Files
ecm_sync_system/sync_state_machine/engine/events.py
T

691 lines
21 KiB
Python

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,
bootstrap_source_present: bool = True,
has_binding_record: bool = False,
) -> Decision:
context = {
"is_create_zombie": is_create_zombie,
"bootstrap_source_present": bootstrap_source_present,
"has_binding_record": has_binding_record,
}
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 == "S17":
_apply_decision(runtime, node=node, decision=decision, fields={"binding_status", "action", "status"})
reason_text = decision_note(decision) or "<no-reason>"
node.context["bootstrap_blocked"] = True
node.context["bootstrap_block_reason"] = "source_missing_after_persistence_refresh"
node.context["bootstrap_block_errors"] = [reason_text]
node.context["bootstrap_source_present"] = False
node.error = f"BOOTSTRAP_SOURCE_MISSING: {reason_text}"
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