优化sync_system代码质量
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
- 负责组装并驱动整条流程:加载持久化、执行 bind/create/update、调用 datasource 同步、持久化结果。
|
||||
- reset 语义:
|
||||
- 加载阶段仅恢复持久化状态(不做状态重置);
|
||||
- 加载后通过 `BaseSyncStrategy.run_reset()` 触发 `E01`:
|
||||
- 加载后通过 `run_phase2_cleanup()` 触发 `E01`:
|
||||
- `create_zombie=true` -> `S15`(随后删除);
|
||||
- `create_zombie=false` -> `S00`(归并为 `UNCHECKED/NONE/PENDING`,并清空 `error`)。
|
||||
- 若持久化核心枚举非法(`action/status/binding_status`),节点进入加载异常隔离态 `S17`:
|
||||
@@ -30,7 +30,7 @@
|
||||
- `create_ops.py`: `run_create()`
|
||||
- `update_ops.py`: `run_update()`
|
||||
- `delete_ops.py`: `run_delete()`
|
||||
- `reset_ops.py`: `get_phase1_reset_defaults()/run_phase2_cleanup()`
|
||||
- `reset_ops.py`: `run_phase2_cleanup()`
|
||||
- strategy 负责收集上下文并调用状态机事件函数,不直接硬编码状态值。
|
||||
|
||||
### datasource(执行与回写层)
|
||||
|
||||
@@ -70,10 +70,9 @@ strategies:
|
||||
local_orphan_action: create_remote
|
||||
remote_orphan_action: none
|
||||
update_direction: push
|
||||
field_direction_key: null
|
||||
field_direction_overrides: {}
|
||||
post_check_ignore_fields: []
|
||||
compare_ignore_fields: []
|
||||
domain_option: {}
|
||||
persist:
|
||||
backend: sqlite
|
||||
db_path: /abs/path/test_results/sync_state_machine/demo.db
|
||||
@@ -180,7 +179,6 @@ persist:
|
||||
|
||||
- `push`:本地变更推送到远端。
|
||||
- `pull`:远端变更拉取到本地。
|
||||
- `bidirectional`:双向更新(需要明确冲突策略,谨慎使用)。
|
||||
- `none`:关闭 update 阶段。
|
||||
|
||||
### 4.2 `local_orphan_action` / `remote_orphan_action`
|
||||
@@ -228,31 +226,34 @@ persist:
|
||||
- `remote_datasource.handler_configs.supplier.load_mode: persisted_only`
|
||||
- `persisted_only` 表示只使用已持久化数据,不主动从外部拉新数据(具体以对应 handler 实现为准)。
|
||||
|
||||
### 4.7 `field_direction_key` / `field_direction_overrides`
|
||||
### 4.7 `domain_option`
|
||||
|
||||
- 这两个字段只在少数 node_type 上需要,典型例子是 `project_detail_data`。
|
||||
- `field_direction_key`
|
||||
- 指定从节点 data 里取哪个字段作为“方向查表键”。
|
||||
- 例如 `key`,表示用 `data["key"]` 决定这条记录是 push 还是 pull。
|
||||
- `field_direction_overrides`
|
||||
- 指定“字段值 -> 更新方向”的映射。
|
||||
- 没命中的值继续使用默认 `update_direction`。
|
||||
- `domain_option` 用于承载 domain 自己识别的运行时扩展参数。
|
||||
- 通用策略行为仍然放在 `config` 下;只有领域特例才放到 `domain_option`。
|
||||
|
||||
示例:
|
||||
示例 1:`project` 先从远端拉回两个特殊投产日期字段,再执行默认 push
|
||||
|
||||
```yaml
|
||||
project:
|
||||
domain_option:
|
||||
pull_group_plan_production_date: true
|
||||
config:
|
||||
update_direction: push
|
||||
```
|
||||
|
||||
示例 2:`project_detail_data` 用单个开关启用固定业务规则,把集团保供/爬坡/挑战产能计划按 pull 处理
|
||||
|
||||
```yaml
|
||||
project_detail_data:
|
||||
domain_option:
|
||||
pull_group_production_plans: true
|
||||
config:
|
||||
field_direction_key: key
|
||||
field_direction_overrides:
|
||||
ensure_production: pull
|
||||
climb_production: pull
|
||||
challenge_production: pull
|
||||
update_direction: push
|
||||
```
|
||||
|
||||
含义:
|
||||
- `key=ensure_production/climb_production/challenge_production` 时,从远端拉回本地。
|
||||
- 其余 key 继续按默认 `update_direction=push` 处理。
|
||||
- `project.domain_option.pull_group_plan_production_date=true` 时,`ic_plan_first_production_date` 和 `ic_plan_full_production_date` 会先 remote -> local,再执行剩余字段的常规更新。
|
||||
- `project_detail_data.domain_option.pull_group_production_plans=true` 时,固定的 `ensure_production`、`climb_production`、`challenge_production` 会先 remote -> local;其余 key 继续沿用 `config.update_direction`。
|
||||
|
||||
## 5. 阶段行为与配置关系
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- bind 主入口:`sync_state_machine/sync_system/strategy.py::DefaultSyncStrategy.bind()` -> `sync_state_machine/sync_system/strategy_ops/bind_ops.py::run_bind()`
|
||||
- create 主入口:`sync_state_machine/sync_system/strategy.py::DefaultSyncStrategy.create()` -> `sync_state_machine/sync_system/strategy_ops/create_ops.py::run_create()`
|
||||
- update 主入口:`sync_state_machine/sync_system/strategy.py::DefaultSyncStrategy.update()` -> `sync_state_machine/sync_system/strategy_ops/update_ops.py::run_update()`
|
||||
- reset 主入口:`sync_state_machine/sync_system/strategy.py::BaseSyncStrategy.run_reset()` -> `sync_state_machine/sync_system/strategy_ops/reset_ops.py::run_phase2_cleanup()`
|
||||
- reset 主入口:`sync_state_machine/pipeline/full_sync_pipeline.py::_load_persistence_state()` -> `sync_state_machine/sync_system/strategy_ops/reset_ops.py::run_phase2_cleanup()`
|
||||
|
||||
## 5. context 字段(以 YAML 为准)
|
||||
|
||||
|
||||
+3
-3
@@ -11,12 +11,12 @@
|
||||
|
||||
## 2. reset 时序
|
||||
|
||||
### 2.1 加载时 reset(phase1 语义)
|
||||
### 2.1 加载时恢复
|
||||
- 在 `sync_state_machine/common/collection.py::load_from_persistence()` 执行。
|
||||
- 语义:仅恢复持久化状态(不在此阶段做状态归并)。
|
||||
|
||||
### 2.2 加载后 cleanup(phase2 语义)
|
||||
- pipeline 调用 `BaseSyncStrategy.run_reset()`。
|
||||
### 2.2 加载后 cleanup
|
||||
- pipeline 直接调用 `run_phase2_cleanup()`。
|
||||
- 实际实现:`sync_state_machine/sync_system/strategy_ops/reset_ops.py::run_phase2_cleanup()`。
|
||||
- 核心事件:`E01`(`S16 -> S00/S15`)。
|
||||
- `S16` 表示“持久化加载输入态”:本轮从持久化恢复出的节点集合(可包含上轮遗留节点,也可包含上轮已稳定节点)。
|
||||
|
||||
@@ -309,11 +309,11 @@ def build_temp_run_profile_for_project_auto_bind(
|
||||
project_strategy["update_direction"] = project_update_direction or "none"
|
||||
|
||||
if project_detail_push_keys:
|
||||
project_detail_config = profile.setdefault("strategies", {}).setdefault("project_detail_data", {}).setdefault("config", {})
|
||||
overrides = dict(project_detail_config.get("field_direction_overrides", {}))
|
||||
for key in project_detail_push_keys:
|
||||
overrides.pop(key, None)
|
||||
project_detail_config["field_direction_overrides"] = overrides
|
||||
fixed_pull_keys = {"ensure_production", "climb_production", "challenge_production"}
|
||||
if fixed_pull_keys.intersection(project_detail_push_keys):
|
||||
project_detail_runtime = profile.setdefault("strategies", {}).setdefault("project_detail_data", {})
|
||||
domain_option = project_detail_runtime.setdefault("domain_option", {})
|
||||
domain_option["pull_group_production_plans"] = False
|
||||
|
||||
runtime_dir = Path(report_root) / "runtime_configs"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -15,6 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import sync_state_machine.domain # noqa: F401, E402
|
||||
from sync_state_machine.logging import configure_app_logging, get_logger # noqa: E402
|
||||
from sync_state_machine.pipeline import ( # noqa: E402
|
||||
build_config,
|
||||
run_pipeline_from_config,
|
||||
@@ -23,15 +24,17 @@ from sync_state_machine.config import load_named_preset_overrides # noqa: E402
|
||||
|
||||
|
||||
PRESET_NAME = "jsonl_sm"
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
try:
|
||||
overrides, preset_file, used_default = load_named_preset_overrides(PROJECT_ROOT, PRESET_NAME)
|
||||
source_label = "default" if used_default else "custom"
|
||||
print(f"🧩 Loaded preset ({source_label}): {preset_file}")
|
||||
|
||||
config = build_config(project_root=PROJECT_ROOT, overrides=overrides)
|
||||
configure_app_logging(config.logging, replace_handlers=True)
|
||||
logger.info("🧩 Loaded preset (%s): %s", source_label, preset_file)
|
||||
await run_pipeline_from_config(
|
||||
config,
|
||||
title="sync_state_machine JSONL -> JSONL pipeline",
|
||||
@@ -40,10 +43,10 @@ async def main() -> int:
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Interrupted by user")
|
||||
logger.warning("⚠️ Interrupted by user")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"\n❌ Pipeline failed: {type(exc).__name__}: {exc}")
|
||||
logger.exception("❌ Pipeline failed: %s: %s", type(exc).__name__, exc)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
@@ -15,6 +15,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
import sync_state_machine.domain # noqa: F401, E402
|
||||
from sync_state_machine.logging import configure_app_logging, get_logger # noqa: E402
|
||||
from sync_state_machine.pipeline import ( # noqa: E402
|
||||
build_config,
|
||||
run_pipeline_from_config,
|
||||
@@ -23,15 +24,17 @@ from sync_state_machine.config import load_named_preset_overrides # noqa: E402
|
||||
|
||||
|
||||
PRESET_NAME = "simple_sm"
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
try:
|
||||
overrides, preset_file, used_default = load_named_preset_overrides(PROJECT_ROOT, PRESET_NAME)
|
||||
source_label = "default" if used_default else "custom"
|
||||
print(f"🧩 Loaded preset ({source_label}): {preset_file}")
|
||||
|
||||
config = build_config(project_root=PROJECT_ROOT, overrides=overrides)
|
||||
configure_app_logging(config.logging, replace_handlers=True)
|
||||
logger.info("🧩 Loaded preset (%s): %s", source_label, preset_file)
|
||||
await run_pipeline_from_config(
|
||||
config,
|
||||
title="sync_state_machine JSONL -> API pipeline",
|
||||
@@ -40,10 +43,10 @@ async def main() -> int:
|
||||
return 0
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ Interrupted by user")
|
||||
logger.warning("⚠️ Interrupted by user")
|
||||
return 130
|
||||
except Exception as exc:
|
||||
print(f"\n❌ Pipeline failed: {type(exc).__name__}: {exc}")
|
||||
logger.exception("❌ Pipeline failed: %s: %s", type(exc).__name__, exc)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
@@ -362,7 +362,7 @@ class DataCollection:
|
||||
- data/origin_data 保留(由 DataSource 覆盖)
|
||||
|
||||
**初始化归并(E01)**:
|
||||
- 由 Pipeline 在加载后调用 run_reset() 触发 E01
|
||||
- 由 Pipeline 在加载后调用 `run_phase2_cleanup()` 触发 E01
|
||||
- CREATE 僵尸节点进入 S15 并删除
|
||||
- 其余节点进入 S00(UNCHECKED/NONE/PENDING)
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from ..common.registry import DomainRegistry
|
||||
from .datasource_config import DataSourceConfig
|
||||
from .pipeline_config import PipelineRunConfig
|
||||
from .run_presets import load_overrides_from_file
|
||||
from .strategy_config import ConfigPresets, StrategyRuntimeConfig
|
||||
from .strategy_config import ConfigPresets, StrategyRuntimeConfig, resolve_domain_option_config
|
||||
|
||||
|
||||
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
@@ -28,19 +28,76 @@ def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> Dict[st
|
||||
return merged
|
||||
|
||||
|
||||
def _build_default_strategy_runtime_config(node_type: str) -> StrategyRuntimeConfig | None:
|
||||
strategy_class = DomainRegistry.get_strategy_class(node_type)
|
||||
if strategy_class is None:
|
||||
return None
|
||||
|
||||
config = strategy_class.default_config.model_copy(deep=True)
|
||||
config.domain_option = resolve_domain_option_config(
|
||||
getattr(strategy_class, "domain_option_model", None),
|
||||
config.domain_option,
|
||||
)
|
||||
|
||||
return StrategyRuntimeConfig(
|
||||
node_type=node_type,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _default_strategy_map(node_types: list[str]) -> Dict[str, StrategyRuntimeConfig]:
|
||||
strategy_map: Dict[str, StrategyRuntimeConfig] = {}
|
||||
for node_type in node_types:
|
||||
strategy_class = DomainRegistry.get_strategy_class(node_type)
|
||||
if strategy_class is None:
|
||||
runtime_cfg = _build_default_strategy_runtime_config(node_type)
|
||||
if runtime_cfg is None:
|
||||
continue
|
||||
strategy_map[node_type] = StrategyRuntimeConfig(
|
||||
node_type=node_type,
|
||||
config=strategy_class.default_config.model_copy(deep=True),
|
||||
)
|
||||
strategy_map[node_type] = runtime_cfg
|
||||
return strategy_map
|
||||
|
||||
|
||||
def _merge_strategy_runtime_config(
|
||||
runtime_cfg: StrategyRuntimeConfig,
|
||||
raw_override: Any,
|
||||
) -> StrategyRuntimeConfig:
|
||||
override = dict(raw_override or {})
|
||||
override.pop("node_type", None)
|
||||
|
||||
deprecated_keys = [key for key in ("force_sync", "load_mode") if key in override]
|
||||
if deprecated_keys:
|
||||
joined = ", ".join(deprecated_keys)
|
||||
raise ValueError(
|
||||
f"Strategy override for {runtime_cfg.node_type!r} contains unsupported legacy keys: {joined}. "
|
||||
"Use skip_sync under strategies.<node_type> or datasource.handler_configs instead."
|
||||
)
|
||||
|
||||
preset = override.pop("preset", None)
|
||||
if preset:
|
||||
runtime_cfg.config = runtime_cfg.config.model_validate(
|
||||
_deep_merge(
|
||||
runtime_cfg.config.model_dump(),
|
||||
ConfigPresets.get_preset(str(preset)).model_dump(exclude_defaults=True),
|
||||
)
|
||||
)
|
||||
|
||||
if "config" in override and isinstance(override["config"], dict):
|
||||
runtime_cfg.config = runtime_cfg.config.model_validate(
|
||||
_deep_merge(runtime_cfg.config.model_dump(), override.pop("config"))
|
||||
)
|
||||
|
||||
if override:
|
||||
runtime_cfg.config = runtime_cfg.config.model_validate(
|
||||
_deep_merge(runtime_cfg.config.model_dump(), override)
|
||||
)
|
||||
|
||||
strategy_class = DomainRegistry.get_strategy_class(runtime_cfg.node_type)
|
||||
runtime_cfg.config.domain_option = resolve_domain_option_config(
|
||||
getattr(strategy_class, "domain_option_model", None) if strategy_class is not None else None,
|
||||
runtime_cfg.config.domain_option,
|
||||
)
|
||||
|
||||
return runtime_cfg
|
||||
|
||||
|
||||
def _apply_strategy_overrides(
|
||||
node_types: list[str],
|
||||
strategy_overrides: Any,
|
||||
@@ -49,8 +106,16 @@ def _apply_strategy_overrides(
|
||||
|
||||
if isinstance(strategy_overrides, list):
|
||||
for item in strategy_overrides:
|
||||
runtime_cfg = StrategyRuntimeConfig.model_validate(item)
|
||||
strategy_map[runtime_cfg.node_type] = runtime_cfg
|
||||
raw_item = dict(item or {})
|
||||
node_type = str(raw_item.get("node_type") or "")
|
||||
if not node_type:
|
||||
raise ValueError("Strategy override list item missing required node_type")
|
||||
|
||||
runtime_cfg = strategy_map.get(node_type)
|
||||
if runtime_cfg is None:
|
||||
runtime_cfg = _build_default_strategy_runtime_config(node_type) or StrategyRuntimeConfig(node_type=node_type)
|
||||
|
||||
strategy_map[node_type] = _merge_strategy_runtime_config(runtime_cfg, raw_item)
|
||||
return [strategy_map[n] for n in node_types if n in strategy_map]
|
||||
|
||||
if not isinstance(strategy_overrides, dict):
|
||||
@@ -58,41 +123,10 @@ def _apply_strategy_overrides(
|
||||
|
||||
for node_type, raw_override in strategy_overrides.items():
|
||||
if node_type not in strategy_map:
|
||||
strategy_map[node_type] = StrategyRuntimeConfig(node_type=node_type)
|
||||
strategy_map[node_type] = _build_default_strategy_runtime_config(node_type) or StrategyRuntimeConfig(node_type=node_type)
|
||||
|
||||
runtime_cfg = strategy_map[node_type]
|
||||
override = dict(raw_override or {})
|
||||
|
||||
deprecated_keys = [key for key in ("force_sync", "load_mode") if key in override]
|
||||
if deprecated_keys:
|
||||
joined = ", ".join(deprecated_keys)
|
||||
raise ValueError(
|
||||
f"Strategy override for {node_type!r} contains unsupported legacy keys: {joined}. "
|
||||
"Use skip_sync under strategies.<node_type> or datasource.handler_configs instead."
|
||||
)
|
||||
|
||||
preset = override.pop("preset", None)
|
||||
if preset:
|
||||
runtime_cfg.config = ConfigPresets.get_preset(str(preset))
|
||||
|
||||
skip_sync = override.pop("skip_sync", None)
|
||||
skip_post_check = override.pop("skip_post_check", None)
|
||||
|
||||
if skip_sync is not None:
|
||||
runtime_cfg.skip_sync = bool(skip_sync)
|
||||
|
||||
if skip_post_check is not None:
|
||||
runtime_cfg.skip_post_check = bool(skip_post_check)
|
||||
|
||||
if "config" in override and isinstance(override["config"], dict):
|
||||
runtime_cfg.config = runtime_cfg.config.model_validate(
|
||||
_deep_merge(runtime_cfg.config.model_dump(), override.pop("config"))
|
||||
)
|
||||
|
||||
if override:
|
||||
runtime_cfg.config = runtime_cfg.config.model_validate(
|
||||
_deep_merge(runtime_cfg.config.model_dump(), override)
|
||||
)
|
||||
strategy_map[node_type] = _merge_strategy_runtime_config(runtime_cfg, raw_override)
|
||||
|
||||
return [strategy_map[n] for n in node_types if n in strategy_map]
|
||||
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Mapping, Type
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
def resolve_domain_option_config(
|
||||
domain_option_model: Type[BaseModel] | None,
|
||||
domain_option: Mapping[str, Any] | None,
|
||||
) -> Dict[str, Any]:
|
||||
if domain_option_model is None:
|
||||
return dict(domain_option or {})
|
||||
return domain_option_model.model_validate(domain_option or {}).model_dump()
|
||||
|
||||
|
||||
class OrphanAction(str, Enum):
|
||||
CREATE_REMOTE = "create_remote"
|
||||
CREATE_LOCAL = "create_local"
|
||||
@@ -17,7 +27,6 @@ class OrphanAction(str, Enum):
|
||||
class UpdateDirection(str, Enum):
|
||||
PUSH = "push"
|
||||
PULL = "pull"
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
@@ -40,13 +49,6 @@ class StrategyConfig(BaseModel):
|
||||
remote_orphan_action: OrphanAction = OrphanAction.NONE
|
||||
update_direction: UpdateDirection = UpdateDirection.PUSH
|
||||
|
||||
# 字段级同步方向覆盖。
|
||||
# field_direction_key: 节点 data 中用作查表键的字段名,如 "key"。
|
||||
# field_direction_overrides: 字段值 → UpdateDirection 的映射。
|
||||
# 未命中映射的节点沿用 update_direction 作为默认方向。
|
||||
field_direction_key: Optional[str] = None
|
||||
field_direction_overrides: Dict[str, UpdateDirection] = Field(default_factory=dict)
|
||||
|
||||
# post-check 一致性比较时忽略的顶层字段。
|
||||
# 只影响最终一致性评估,不影响 create/update 的差异检测。
|
||||
post_check_ignore_fields: List[str] = Field(default_factory=list)
|
||||
@@ -63,6 +65,60 @@ class StrategyConfig(BaseModel):
|
||||
# 默认关闭,避免在一般场景下引入隐式匹配。
|
||||
allow_many_to_one_auto_bind: bool = False
|
||||
|
||||
# 是否跳过该类型的 create/update 阶段。
|
||||
skip_sync: bool = False
|
||||
|
||||
# 是否跳过该类型的 post-check reload 与一致性校验。
|
||||
skip_post_check: bool = False
|
||||
|
||||
# 领域策略运行参数,由各 strategy 的 domain_option_model 负责解释与校验。
|
||||
domain_option: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def log_logic_warnings(self, *, node_type: str, logger: logging.Logger) -> None:
|
||||
direction = self.update_direction
|
||||
local_action = self.local_orphan_action
|
||||
remote_action = self.remote_orphan_action
|
||||
|
||||
if direction == UpdateDirection.PUSH:
|
||||
if local_action == OrphanAction.CREATE_LOCAL:
|
||||
logger.warning(
|
||||
f"[{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"[{node_type}] Config inconsistency: "
|
||||
f"update_direction=PUSH but remote_orphan_action=CREATE_LOCAL (本地创建)。"
|
||||
f"PUSH 模式应设为 NONE 或 DELETE_REMOTE"
|
||||
)
|
||||
elif direction == UpdateDirection.PULL:
|
||||
if local_action == OrphanAction.CREATE_REMOTE:
|
||||
logger.warning(
|
||||
f"[{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"[{node_type}] Config inconsistency: "
|
||||
f"update_direction=PULL but remote_orphan_action={remote_action}。"
|
||||
f"PULL 模式建议使用 CREATE_LOCAL 或 NONE"
|
||||
)
|
||||
elif direction == UpdateDirection.NONE:
|
||||
if local_action != OrphanAction.NONE:
|
||||
logger.warning(
|
||||
f"[{node_type}] Config inconsistency: "
|
||||
f"update_direction=NONE but local_orphan_action={local_action}。"
|
||||
f"应设为 NONE"
|
||||
)
|
||||
if remote_action != OrphanAction.NONE:
|
||||
logger.warning(
|
||||
f"[{node_type}] Config inconsistency: "
|
||||
f"update_direction=NONE but remote_orphan_action={remote_action}。"
|
||||
f"应设为 NONE"
|
||||
)
|
||||
|
||||
|
||||
class StrategyRuntimeConfig(BaseModel):
|
||||
"""Pipeline 层策略运行配置(按 node_type)。"""
|
||||
@@ -70,8 +126,6 @@ class StrategyRuntimeConfig(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True, extra="forbid")
|
||||
|
||||
node_type: str
|
||||
skip_sync: bool = False
|
||||
skip_post_check: bool = False
|
||||
config: StrategyConfig = Field(default_factory=StrategyConfig)
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ def _build_default_push_steps() -> List[Dict[str, str]]:
|
||||
class ApiClient:
|
||||
"""API HTTP 客户端"""
|
||||
_TRACE_TEXT_MAX_LEN = 1000
|
||||
_REQUEST_STATS_LOG_PREFIX = "📊 ApiClient request stats"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -72,6 +73,9 @@ class ApiClient:
|
||||
self._rate_limit_window_seconds = float(rate_limit_window_seconds)
|
||||
self._rate_limit_lock = asyncio.Lock()
|
||||
self._rate_limit_timestamps: deque[float] = deque()
|
||||
self._request_total = 0
|
||||
self._request_success = 0
|
||||
self._request_failure = 0
|
||||
|
||||
def _is_rate_limit_enabled(self) -> bool:
|
||||
return self._rate_limit_max_requests > 0 and self._rate_limit_window_seconds > 0
|
||||
@@ -105,9 +109,38 @@ class ApiClient:
|
||||
|
||||
async def close(self):
|
||||
"""关闭 HTTP 客户端"""
|
||||
self.log_request_stats()
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def get_request_stats(self) -> Dict[str, int]:
|
||||
"""返回当前客户端生命周期内的请求统计。"""
|
||||
return {
|
||||
"total": self._request_total,
|
||||
"success": self._request_success,
|
||||
"failure": self._request_failure,
|
||||
}
|
||||
|
||||
def format_request_stats(self) -> str:
|
||||
"""格式化当前客户端生命周期内的请求统计。"""
|
||||
stats = self.get_request_stats()
|
||||
return (
|
||||
f"{self._REQUEST_STATS_LOG_PREFIX}: "
|
||||
f"total={stats['total']}, success={stats['success']}, failure={stats['failure']}"
|
||||
)
|
||||
|
||||
def log_request_stats(self, level: str = "INFO") -> None:
|
||||
"""输出当前客户端生命周期内的请求统计。"""
|
||||
self._log(self.format_request_stats(), level=level)
|
||||
|
||||
def _log_request_summary(self, *, method: str, endpoint: str, elapsed_seconds: float) -> None:
|
||||
if not self._debug:
|
||||
return
|
||||
self._log(
|
||||
f"ApiClient request: method={method.upper()} endpoint={endpoint} elapsed={elapsed_seconds:.3f}s",
|
||||
level="DEBUG",
|
||||
)
|
||||
|
||||
def _log(self, message: str, level: str = "DEBUG"):
|
||||
"""
|
||||
@@ -382,6 +415,7 @@ class ApiClient:
|
||||
client = self._get_client()
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
await self._acquire_rate_limit_slot()
|
||||
self._request_total += 1
|
||||
|
||||
# 记录请求信息
|
||||
self._log(f"\n{'='*60}", level="DEBUG")
|
||||
@@ -433,6 +467,7 @@ class ApiClient:
|
||||
|
||||
# 记录响应时间
|
||||
elapsed = time.time() - start_time
|
||||
self._log_request_summary(method=method, endpoint=endpoint, elapsed_seconds=elapsed)
|
||||
self._log(f"Response Time: {elapsed:.3f}s", level="DEBUG")
|
||||
|
||||
# 检查 HTTP 状态
|
||||
@@ -453,14 +488,17 @@ class ApiClient:
|
||||
response=result,
|
||||
elapsed_ms=int(elapsed * 1000),
|
||||
)
|
||||
self._request_success += 1
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
self._request_failure += 1
|
||||
|
||||
# 记录错误
|
||||
elapsed = time.time() - start_time
|
||||
self._log_request_summary(method=method, endpoint=endpoint, elapsed_seconds=elapsed)
|
||||
error_msg = "\n========== [ApiClient HTTP Error] ==========\n"
|
||||
error_msg += f"Request: {method} {url}\n"
|
||||
error_msg += f"Params: {params}\n"
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import ClassVar, Dict, List, Any, Optional, TYPE_CHECKING, TypeVar
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from ...common.type_safety import get_pydantic_model_fields
|
||||
from ...logging import get_logger
|
||||
from ..handler import BaseNodeHandler
|
||||
from ..task_result import TaskResult
|
||||
from ..handler import ValidationResult # Import ValidationResult
|
||||
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
from .client import ApiClient
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseApiHandler(BaseNodeHandler[T]):
|
||||
@@ -67,6 +69,16 @@ class BaseApiHandler(BaseNodeHandler[T]):
|
||||
"""设置 Collection(由 DataSource 调用)"""
|
||||
self._collection = collection
|
||||
|
||||
def ensure_api_client(self) -> 'ApiClient':
|
||||
if self.api_client is None:
|
||||
raise RuntimeError("API client not set. Call set_api_client() before invoking API handler methods.")
|
||||
return self.api_client
|
||||
|
||||
def ensure_collection(self) -> 'DataCollection':
|
||||
if self._collection is None:
|
||||
raise RuntimeError("Collection not set. Call set_collection() before invoking handler methods.")
|
||||
return self._collection
|
||||
|
||||
@property
|
||||
def node_type(self) -> str:
|
||||
"""返回节点类型"""
|
||||
@@ -109,8 +121,7 @@ class BaseApiHandler(BaseNodeHandler[T]):
|
||||
def _create_basic_node(self, data: Dict[str, Any], depend_ids: Optional[List[str]] = None) -> "SyncNode":
|
||||
"""创建或复用基础节点(默认使用 data['id'] 或 data['_id'])"""
|
||||
data_id = str(data.get("id") or data.get("_id") or "")
|
||||
if self._collection is None:
|
||||
raise RuntimeError("Collection not set. Call set_collection() before creating nodes.")
|
||||
self.ensure_collection()
|
||||
|
||||
if data_id:
|
||||
existing_node = self._collection.get_by_data_id(self.node_type, data_id)
|
||||
@@ -329,10 +340,10 @@ class BaseApiHandler(BaseNodeHandler[T]):
|
||||
|
||||
if diff_fields:
|
||||
node_info = f"[{node_id}]" if node_id else ""
|
||||
print(f" [DIFF] {self.node_type}{node_info} schema={schema.__name__}")
|
||||
logger.info(" [DIFF] %s%s schema=%s", self.node_type, node_info, schema.__name__)
|
||||
for k, v in diff_fields.items():
|
||||
orig_v = origin_dict.get(k)
|
||||
print(f" • {k}: {orig_v!r} → {v!r}")
|
||||
logger.info(" • %s: %r → %r", k, orig_v, v)
|
||||
|
||||
return diff_fields if diff_fields else None
|
||||
|
||||
|
||||
@@ -37,9 +37,10 @@ from ..engine import (
|
||||
StateMachineRuntime,
|
||||
e40_sync_execute,
|
||||
)
|
||||
from ..logging import get_logger
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseDataSource(ABC):
|
||||
@@ -460,7 +461,7 @@ class BaseDataSource(ABC):
|
||||
# Handler 加载数据并创建节点
|
||||
nodes = await handler.load()
|
||||
|
||||
print(f"Loaded nodes for {node_type}: {len(nodes)}")
|
||||
logger.info("Loaded nodes for %s: %d", node_type, len(nodes))
|
||||
|
||||
# 将节点写入 Collection:优先按 data_id 命中已有节点并更新
|
||||
await self._upsert_loaded_nodes(handler, node_type, nodes)
|
||||
@@ -468,7 +469,7 @@ class BaseDataSource(ABC):
|
||||
# 统计加载的节点数
|
||||
self._stats[node_type]["loaded"] += len(nodes)
|
||||
except Exception as exc:
|
||||
print(f"❌ [{node_type}] 加载失败: {exc}")
|
||||
logger.error("❌ [%s] 加载失败: %s", node_type, exc)
|
||||
continue
|
||||
|
||||
# ========== 同步执行 ==========
|
||||
@@ -639,7 +640,7 @@ class BaseDataSource(ABC):
|
||||
f"in_progress={create_in_progress}, failed={create_failed}, skipped={create_skipped}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ [{handler.node_type}] create_all 异常: {e}")
|
||||
logger.error("❌ [%s] create_all 异常: %s", handler.node_type, e)
|
||||
for node in create_nodes:
|
||||
ok = self._apply_sync_execute_state(
|
||||
node,
|
||||
@@ -656,7 +657,7 @@ class BaseDataSource(ABC):
|
||||
for result in update_results:
|
||||
await self._handle_task_result(handler, result, async_tasks)
|
||||
except Exception as e:
|
||||
print(f"❌ [{handler.node_type}] update_all 异常: {e}")
|
||||
logger.error("❌ [%s] update_all 异常: %s", handler.node_type, e)
|
||||
for node in update_nodes:
|
||||
ok = self._apply_sync_execute_state(
|
||||
node,
|
||||
|
||||
@@ -19,7 +19,8 @@ class ConstructionTaskSyncStrategy(DefaultSyncStrategy[ConstructionResponse]):
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def preprocess_compare_payload(self, payload: dict) -> dict:
|
||||
def normalize_compare_payload(self, data: dict | None, data_id_map=None) -> dict:
|
||||
payload = super().normalize_compare_payload(data, data_id_map)
|
||||
plan_nodes = payload.get("plan_node")
|
||||
if not isinstance(plan_nodes, list):
|
||||
return payload
|
||||
|
||||
@@ -19,7 +19,8 @@ class ContractSettlementSyncStrategy(DefaultSyncStrategy[ContractSettlementRespo
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def preprocess_compare_payload(self, payload: dict) -> dict:
|
||||
def normalize_compare_payload(self, data: dict | None, data_id_map=None) -> dict:
|
||||
payload = super().normalize_compare_payload(data, data_id_map)
|
||||
plan_nodes = payload.get("plan_node")
|
||||
if not isinstance(plan_nodes, list):
|
||||
return payload
|
||||
|
||||
@@ -19,7 +19,8 @@ class MaterialSyncStrategy(DefaultSyncStrategy[MaterialResponse]):
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def preprocess_compare_payload(self, payload: dict) -> dict:
|
||||
def normalize_compare_payload(self, data: dict | None, data_id_map=None) -> dict:
|
||||
payload = super().normalize_compare_payload(data, data_id_map)
|
||||
plan_nodes = payload.get("plan_node")
|
||||
if not isinstance(plan_nodes, list):
|
||||
return payload
|
||||
|
||||
@@ -20,16 +20,13 @@ class PreparationSyncStrategy(DefaultSyncStrategy[PreparationDetail]):
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def _needs_update(self, source_node, target_node, resolved_data=None, data_id_map=None) -> bool:
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{self.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
target_data = target_node.get_data()
|
||||
def should_update_pair(self, source_node, target_node, *, source_data, target_data, data_id_map=None) -> bool:
|
||||
if target_data is None:
|
||||
return False
|
||||
|
||||
# 这里只按 PostPreparation 可写字段判定是否需要 update,避免 response-only 字段差异触发空更新。
|
||||
update_fields = set(PostPreparation.model_fields.keys())
|
||||
source_payload = {key: value for key, value in resolved_data.items() if key in update_fields}
|
||||
source_payload = {key: value for key, value in source_data.items() if key in update_fields}
|
||||
target_payload = {key: value for key, value in target_data.items() if key in update_fields}
|
||||
|
||||
normalized_source = normalized_data_for_compare(source_payload, data_id_map or {})
|
||||
|
||||
@@ -20,16 +20,13 @@ class ProductionSyncStrategy(DefaultSyncStrategy[ProductionDetail]):
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
def _needs_update(self, source_node, target_node, resolved_data=None, data_id_map=None) -> bool:
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{self.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
target_data = target_node.get_data()
|
||||
def should_update_pair(self, source_node, target_node, *, source_data, target_data, data_id_map=None) -> bool:
|
||||
if target_data is None:
|
||||
return False
|
||||
|
||||
# 这里只按 PostProduction 可写字段判定是否需要 update,避免 response-only 字段差异触发空更新。
|
||||
update_fields = set(PostProduction.model_fields.keys())
|
||||
source_payload = {key: value for key, value in resolved_data.items() if key in update_fields}
|
||||
source_payload = {key: value for key, value in source_data.items() if key in update_fields}
|
||||
target_payload = {key: value for key, value in target_data.items() if key in update_fields}
|
||||
|
||||
normalized_source = normalized_data_for_compare(source_payload, data_id_map or {})
|
||||
|
||||
@@ -107,6 +107,8 @@ class ProjectApiHandler(BaseApiHandler):
|
||||
project_id: 可选,指定项目ID列表则只加载这些项目(优先级高于构造函数的filter_project_id)
|
||||
"""
|
||||
try:
|
||||
self.ensure_api_client()
|
||||
self.ensure_collection()
|
||||
# 清空旧缓存
|
||||
self.clear_cache()
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ class ProjectSyncSchema(ProjectResponseBase):
|
||||
completed_investment: float | None = Field(None, description="已完成投资")
|
||||
total_contract_quantity: float | None = Field(None, description="合同总量")
|
||||
completed_settlement_quantity: float | None = Field(None, description="已结算量")
|
||||
ic_plan_first_production_date: str | None = Field(None, description="集团计划首批投产日期")
|
||||
ic_plan_full_production_date: str | None = Field(None, description="集团计划全容量投产日期")
|
||||
|
||||
|
||||
class ProjectSyncNode(SyncNode[ProjectSyncSchema]):
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from schemas.project.project_base import ProjectResponseBase
|
||||
|
||||
|
||||
class ProjectDomainOption(BaseModel):
|
||||
pull_group_plan_production_date: bool = False
|
||||
|
||||
|
||||
class ProjectSyncStrategy(DefaultSyncStrategy[ProjectResponseBase]):
|
||||
"""
|
||||
Project 同步策略
|
||||
@@ -17,6 +23,12 @@ class ProjectSyncStrategy(DefaultSyncStrategy[ProjectResponseBase]):
|
||||
|
||||
# 类变量:schema 固定为 ProjectResponseBase
|
||||
schema = ProjectResponseBase
|
||||
domain_option_model = ProjectDomainOption
|
||||
|
||||
_PULL_ONLY_FIELDS = [
|
||||
"ic_plan_first_production_date",
|
||||
"ic_plan_full_production_date",
|
||||
]
|
||||
|
||||
# 类变量:默认配置
|
||||
default_config = StrategyConfig(
|
||||
@@ -26,4 +38,60 @@ class ProjectSyncStrategy(DefaultSyncStrategy[ProjectResponseBase]):
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_option(self) -> ProjectDomainOption:
|
||||
return ProjectDomainOption.model_validate(self.config.domain_option)
|
||||
|
||||
def get_node_update_payload(self, node):
|
||||
payload = dict(node.get_data() or {})
|
||||
context = getattr(node, "context", None)
|
||||
if not isinstance(context, dict):
|
||||
return payload
|
||||
|
||||
all_info = context.get("all_info")
|
||||
if not isinstance(all_info, dict):
|
||||
return payload
|
||||
|
||||
for section_name, section_data in all_info.items():
|
||||
if not section_name.endswith("_extension") or not isinstance(section_data, dict):
|
||||
continue
|
||||
for field_name in self._PULL_ONLY_FIELDS:
|
||||
if payload.get(field_name) not in (None, ""):
|
||||
continue
|
||||
if field_name in section_data:
|
||||
payload[field_name] = section_data.get(field_name)
|
||||
|
||||
return payload
|
||||
|
||||
async def update_pair(self, local_node, remote_node, data_id_map=None):
|
||||
updated_by_id = {}
|
||||
|
||||
if self.domain_option.pull_group_plan_production_date:
|
||||
pull_update = await self.prepare_update_for_direction(
|
||||
local_node,
|
||||
remote_node,
|
||||
UpdateDirection.PULL,
|
||||
data_id_map,
|
||||
include_fields=self._PULL_ONLY_FIELDS,
|
||||
)
|
||||
if pull_update is not None:
|
||||
updated_by_id[pull_update.node_id] = pull_update
|
||||
|
||||
generic_exclude_fields = self._PULL_ONLY_FIELDS
|
||||
else:
|
||||
generic_exclude_fields = None
|
||||
|
||||
if self.config.update_direction != UpdateDirection.NONE:
|
||||
generic_update = await self.prepare_update_for_direction(
|
||||
local_node,
|
||||
remote_node,
|
||||
self.config.update_direction,
|
||||
data_id_map,
|
||||
exclude_fields=generic_exclude_fields,
|
||||
)
|
||||
if generic_update is not None:
|
||||
updated_by_id[generic_update.node_id] = generic_update
|
||||
|
||||
return list(updated_by_id.values())
|
||||
@@ -1,8 +1,16 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ...sync_system.strategy import DefaultSyncStrategy
|
||||
from ...sync_system.config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from ...sync_system.strategy_ops.compare_ops import build_data_id_normalization_map
|
||||
from ...sync_system.strategy_ops.update_ops import emit_schema_diff_report, _build_schema_diff_validator
|
||||
from schemas.project_extensions.project_detail import ProjectDetailKey, ProjectDetailProject
|
||||
|
||||
|
||||
class ProjectDetailDomainOption(BaseModel):
|
||||
pull_group_production_plans: bool = Field(True, description="固定将集团保供/爬坡/挑战产能计划按 pull 处理")
|
||||
|
||||
|
||||
class ProjectDetailSyncStrategy(DefaultSyncStrategy[ProjectDetailProject]):
|
||||
"""
|
||||
ProjectDetail 同步策略(项目容量信息)。
|
||||
@@ -13,6 +21,12 @@ class ProjectDetailSyncStrategy(DefaultSyncStrategy[ProjectDetailProject]):
|
||||
"""
|
||||
|
||||
schema = ProjectDetailProject
|
||||
domain_option_model = ProjectDetailDomainOption
|
||||
_GROUP_PRODUCTION_PLAN_KEYS = {
|
||||
"ensure_production",
|
||||
"climb_production",
|
||||
"challenge_production",
|
||||
}
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
@@ -21,16 +35,54 @@ class ProjectDetailSyncStrategy(DefaultSyncStrategy[ProjectDetailProject]):
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
field_direction_key="key",
|
||||
field_direction_overrides={
|
||||
"ensure_production": UpdateDirection.PULL,
|
||||
"climb_production": UpdateDirection.PULL,
|
||||
"challenge_production": UpdateDirection.PULL,
|
||||
},
|
||||
)
|
||||
|
||||
def preprocess_compare_payload(self, payload: dict) -> dict:
|
||||
normalized = dict(payload or {})
|
||||
async def update(self):
|
||||
self.ensure_runtime()
|
||||
validator = _build_schema_diff_validator(self)
|
||||
self._active_schema_diff_validator = validator
|
||||
data_id_map = await build_data_id_normalization_map(
|
||||
node_type=self.node_type,
|
||||
local_collection=self.local_collection,
|
||||
remote_collection=self.remote_collection,
|
||||
binding_manager=self.binding_manager,
|
||||
)
|
||||
|
||||
pull_pairs = []
|
||||
generic_pairs = []
|
||||
for pair in await self.collect_update_pairs():
|
||||
if self._should_pull_pair(pair.local_node, pair.remote_node):
|
||||
pull_pairs.append(pair)
|
||||
else:
|
||||
generic_pairs.append(pair)
|
||||
|
||||
updated_by_id = {}
|
||||
try:
|
||||
for pair in pull_pairs:
|
||||
updated_node = await self.prepare_update_for_direction(
|
||||
pair.local_node,
|
||||
pair.remote_node,
|
||||
UpdateDirection.PULL,
|
||||
data_id_map,
|
||||
)
|
||||
if updated_node is not None:
|
||||
updated_by_id[updated_node.node_id] = updated_node
|
||||
|
||||
for pair in generic_pairs:
|
||||
for updated_node in await self.update_pair(pair.local_node, pair.remote_node, data_id_map):
|
||||
updated_by_id[updated_node.node_id] = updated_node
|
||||
finally:
|
||||
self._active_schema_diff_validator = None
|
||||
|
||||
emit_schema_diff_report(self, validator)
|
||||
return list(updated_by_id.values())
|
||||
|
||||
@property
|
||||
def domain_option(self) -> ProjectDetailDomainOption:
|
||||
return ProjectDetailDomainOption.model_validate(self.config.domain_option)
|
||||
|
||||
def normalize_compare_payload(self, data: dict | None, data_id_map=None) -> dict:
|
||||
normalized = super().normalize_compare_payload(data, data_id_map)
|
||||
key = normalized.get("key")
|
||||
if isinstance(key, ProjectDetailKey):
|
||||
key = key.value
|
||||
@@ -76,3 +128,20 @@ class ProjectDetailSyncStrategy(DefaultSyncStrategy[ProjectDetailProject]):
|
||||
items.append(normalized_item)
|
||||
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _extract_pair_key(local_node, remote_node) -> str | None:
|
||||
local_data = local_node.get_data() or {}
|
||||
remote_data = remote_node.get_data() or {}
|
||||
|
||||
key = local_data.get("key", remote_data.get("key"))
|
||||
if isinstance(key, ProjectDetailKey):
|
||||
return key.value
|
||||
if isinstance(key, str):
|
||||
return key
|
||||
return None
|
||||
|
||||
def _should_pull_pair(self, local_node, remote_node) -> bool:
|
||||
if not self.domain_option.pull_group_production_plans:
|
||||
return False
|
||||
return self._extract_pair_key(local_node, remote_node) in self._GROUP_PRODUCTION_PLAN_KEYS
|
||||
|
||||
@@ -24,19 +24,16 @@ class SupplierSyncStrategy(DefaultSyncStrategy[SupplierDetailResponse]):
|
||||
update_direction=UpdateDirection.PULL,
|
||||
)
|
||||
|
||||
def _needs_update(
|
||||
def should_update_pair(
|
||||
self,
|
||||
source_node,
|
||||
target_node,
|
||||
resolved_data=None,
|
||||
*,
|
||||
source_data,
|
||||
target_data,
|
||||
data_id_map=None,
|
||||
) -> bool:
|
||||
"""Supplier 更新忽略 id 字段差异。"""
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{self.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
source_data = resolved_data
|
||||
target_data = target_node.get_data()
|
||||
if source_data is None or target_data is None:
|
||||
return False
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
APP_LOGGER_NAME = "sync_state_machine"
|
||||
|
||||
|
||||
class _NodeLogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
if "node=" in message:
|
||||
return False
|
||||
if "节点 " in message and "状态" in message:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_app_logger() -> logging.Logger:
|
||||
return logging.getLogger(APP_LOGGER_NAME)
|
||||
|
||||
|
||||
def get_logger(name: Optional[str] = None) -> logging.Logger:
|
||||
if not name or name == "__main__":
|
||||
return get_app_logger()
|
||||
if name == APP_LOGGER_NAME or name.startswith(f"{APP_LOGGER_NAME}."):
|
||||
return logging.getLogger(name)
|
||||
return logging.getLogger(f"{APP_LOGGER_NAME}.{name}")
|
||||
|
||||
|
||||
def clear_app_logger_handlers(logger: Optional[logging.Logger] = None) -> None:
|
||||
target = logger or get_app_logger()
|
||||
handlers = list(target.handlers)
|
||||
for handler in handlers:
|
||||
target.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def init_app_logger(
|
||||
*,
|
||||
log_file_path: Optional[str] = None,
|
||||
level: int = logging.INFO,
|
||||
mirror_console: bool = True,
|
||||
replace_handlers: bool = True,
|
||||
suppress_node_logs: bool = True,
|
||||
) -> logging.Logger:
|
||||
logger = get_app_logger()
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
|
||||
if logger.handlers and not replace_handlers:
|
||||
return logger
|
||||
|
||||
if replace_handlers:
|
||||
clear_app_logger_handlers(logger)
|
||||
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
node_filter = _NodeLogFilter() if suppress_node_logs else None
|
||||
|
||||
if mirror_console:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(formatter)
|
||||
if node_filter is not None:
|
||||
console_handler.addFilter(node_filter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
if log_file_path:
|
||||
path = Path(log_file_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(path, mode="w", encoding="utf-8")
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
if node_filter is not None:
|
||||
file_handler.addFilter(node_filter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def configure_app_logging(config, *, replace_handlers: bool = False, mirror_console: bool = True) -> logging.Logger:
|
||||
if not getattr(config, "initialize", True):
|
||||
return get_app_logger()
|
||||
|
||||
if not getattr(config, "file_path", None):
|
||||
from .config.logging_config import resolve_log_file_path
|
||||
|
||||
config.file_path = resolve_log_file_path(config)
|
||||
|
||||
return init_app_logger(
|
||||
log_file_path=config.file_path,
|
||||
level=config.level,
|
||||
mirror_console=mirror_console,
|
||||
replace_handlers=replace_handlers,
|
||||
suppress_node_logs=config.suppress_node_logs,
|
||||
)
|
||||
|
||||
|
||||
def ensure_app_logging(config, *, mirror_console: bool = True) -> logging.Logger:
|
||||
logger = get_app_logger()
|
||||
if not getattr(config, "initialize", True):
|
||||
return logger
|
||||
if logger.handlers:
|
||||
return logger
|
||||
return configure_app_logging(config, replace_handlers=False, mirror_console=mirror_console)
|
||||
@@ -45,12 +45,14 @@ from ..config import (
|
||||
is_multi_project_payload,
|
||||
load_overrides_from_file,
|
||||
)
|
||||
from ..config.strategy_config import resolve_domain_option_config
|
||||
from ..sync_system.strategy import BaseSyncStrategy
|
||||
from ..logging import configure_app_logging, ensure_app_logging, get_logger
|
||||
from .run_logger import init_pipeline_logger
|
||||
from .full_sync_pipeline import FullSyncPipeline
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
@@ -63,29 +65,6 @@ def _prepare_remote_jsonl_dir(remote_dir: Path, *, project_root: Optional[Path]
|
||||
return prepare_remote_jsonl_dir(remote_dir, project_root=(project_root or _project_root()))
|
||||
|
||||
|
||||
def _ensure_pipeline_logger(
|
||||
*,
|
||||
initialize_logger: bool,
|
||||
logger_file_path: Optional[str],
|
||||
logger_level: int,
|
||||
suppress_node_logs: bool,
|
||||
) -> None:
|
||||
if not initialize_logger:
|
||||
return
|
||||
|
||||
app_logger = logging.getLogger("sync_state_machine")
|
||||
if app_logger.handlers:
|
||||
return
|
||||
|
||||
init_pipeline_logger(
|
||||
log_file_path=logger_file_path,
|
||||
level=logger_level,
|
||||
mirror_console=True,
|
||||
replace_handlers=False,
|
||||
suppress_node_logs=suppress_node_logs,
|
||||
)
|
||||
|
||||
|
||||
def _build_handler_config(
|
||||
node_type: str,
|
||||
ds_config: DataSourceConfig,
|
||||
@@ -198,16 +177,28 @@ def _build_collections_and_bindings(
|
||||
|
||||
def _resolve_strategy_map(config: PipelineRunConfig, node_types: List[str]) -> Dict[str, StrategyRuntimeConfig]:
|
||||
if config.strategies:
|
||||
return {item.node_type: item for item in config.strategies}
|
||||
resolved_from_config = {item.node_type: item for item in config.strategies}
|
||||
for node_type, runtime_cfg in resolved_from_config.items():
|
||||
strategy_class = DomainRegistry.get_strategy_class(node_type)
|
||||
runtime_cfg.config.domain_option = resolve_domain_option_config(
|
||||
getattr(strategy_class, "domain_option_model", None) if strategy_class is not None else None,
|
||||
runtime_cfg.config.domain_option,
|
||||
)
|
||||
return resolved_from_config
|
||||
|
||||
resolved: Dict[str, StrategyRuntimeConfig] = {}
|
||||
for node_type in node_types:
|
||||
strategy_class = DomainRegistry.get_strategy_class(node_type)
|
||||
if strategy_class is None:
|
||||
continue
|
||||
strategy_config = strategy_class.default_config.model_copy(deep=True)
|
||||
strategy_config.domain_option = resolve_domain_option_config(
|
||||
getattr(strategy_class, "domain_option_model", None),
|
||||
strategy_config.domain_option,
|
||||
)
|
||||
resolved[node_type] = StrategyRuntimeConfig(
|
||||
node_type=node_type,
|
||||
config=strategy_class.default_config.model_copy(deep=True),
|
||||
config=strategy_config,
|
||||
)
|
||||
return resolved
|
||||
|
||||
@@ -251,20 +242,18 @@ def _build_strategies(
|
||||
raise RuntimeError(f"Missing strategy for node_type: {node_type}")
|
||||
strategy = strategy_class(node_type, local_collection, remote_collection, binding_manager)
|
||||
|
||||
if both_jsonl:
|
||||
strategy.update_config(
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
if node_type == "contract":
|
||||
strategy.update_config(depend_fields={"project_id": "project"})
|
||||
|
||||
runtime_cfg = strategy_map.get(node_type)
|
||||
if runtime_cfg is not None:
|
||||
strategy.set_config(runtime_cfg.config.model_copy(deep=True))
|
||||
strategy.skip_sync = bool(runtime_cfg.skip_sync)
|
||||
strategy.skip_post_check = bool(runtime_cfg.skip_post_check)
|
||||
strategy.config = runtime_cfg.config.model_copy(deep=True)
|
||||
strategy.config.log_logic_warnings(node_type=strategy.node_type, logger=logger)
|
||||
|
||||
if both_jsonl:
|
||||
strategy.config.local_orphan_action = OrphanAction.CREATE_REMOTE
|
||||
strategy.config.remote_orphan_action = OrphanAction.NONE
|
||||
strategy.config.update_direction = UpdateDirection.PUSH
|
||||
if node_type == "contract":
|
||||
strategy.config.depend_fields = {"project_id": "project"}
|
||||
strategy.config.log_logic_warnings(node_type=strategy.node_type, logger=logger)
|
||||
|
||||
strategies.append(strategy)
|
||||
return strategies
|
||||
@@ -280,15 +269,7 @@ async def create_pipeline_from_config(
|
||||
local_datasource_override=None,
|
||||
remote_datasource_override=None,
|
||||
) -> FullSyncPipeline:
|
||||
if config.logging.initialize and not config.logging.file_path:
|
||||
config.logging.file_path = resolve_log_file_path(config.logging)
|
||||
|
||||
_ensure_pipeline_logger(
|
||||
initialize_logger=config.logging.initialize,
|
||||
logger_file_path=config.logging.file_path,
|
||||
logger_level=config.logging.level,
|
||||
suppress_node_logs=config.logging.suppress_node_logs,
|
||||
)
|
||||
ensure_app_logging(config.logging)
|
||||
|
||||
all_types = list(config.node_types)
|
||||
strategy_map = _resolve_strategy_map(config, all_types)
|
||||
@@ -376,15 +357,7 @@ async def run_pipeline_from_config(
|
||||
local_datasource_override=None,
|
||||
remote_datasource_override=None,
|
||||
) -> Dict[str, Any]:
|
||||
if config.logging.initialize and not config.logging.file_path:
|
||||
config.logging.file_path = resolve_log_file_path(config.logging)
|
||||
|
||||
_ensure_pipeline_logger(
|
||||
initialize_logger=config.logging.initialize,
|
||||
logger_file_path=config.logging.file_path,
|
||||
logger_level=config.logging.level,
|
||||
suppress_node_logs=config.logging.suppress_node_logs,
|
||||
)
|
||||
ensure_app_logging(config.logging)
|
||||
|
||||
resolved_cfg = config_snapshot(config)
|
||||
resolved_cfg_text = json.dumps(resolved_cfg, ensure_ascii=False, indent=2)
|
||||
@@ -394,28 +367,21 @@ async def run_pipeline_from_config(
|
||||
config.local_datasource.type,
|
||||
config.remote_datasource.type,
|
||||
)
|
||||
if config.logging.file_path:
|
||||
try:
|
||||
with open(config.logging.file_path, "a", encoding="utf-8") as fp:
|
||||
fp.write("Resolved Full Config:\n")
|
||||
fp.write(resolved_cfg_text)
|
||||
fp.write("\n")
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to write resolved config into log file: %s", exc)
|
||||
logger.info("Resolved Full Config:\n%s", resolved_cfg_text)
|
||||
|
||||
pipeline: Optional[FullSyncPipeline] = None
|
||||
try:
|
||||
if print_summary:
|
||||
print("\n" + "=" * 80)
|
||||
print(f"🚀 Creating {title}")
|
||||
print(f"📝 Log file: {config.logging.file_path or '-'}")
|
||||
print(
|
||||
f"🔧 Resolved Full Config Summary: node_types={len(config.node_types)} "
|
||||
f"local={config.local_datasource.type} "
|
||||
f"remote={config.remote_datasource.type}"
|
||||
logger.info("\n%s", "=" * 80)
|
||||
logger.info("🚀 Creating %s", title)
|
||||
logger.info("📝 Log file: %s", config.logging.file_path or "-")
|
||||
logger.info(
|
||||
"🔧 Resolved Full Config Summary: node_types=%d local=%s remote=%s",
|
||||
len(config.node_types),
|
||||
config.local_datasource.type,
|
||||
config.remote_datasource.type,
|
||||
)
|
||||
print(f"🔧 Resolved Full Config:\n{resolved_cfg_text}")
|
||||
print("=" * 80)
|
||||
logger.info("=" * 80)
|
||||
|
||||
pipeline = await create_pipeline_from_config(
|
||||
config,
|
||||
@@ -428,8 +394,8 @@ async def run_pipeline_from_config(
|
||||
stats = await pipeline.run()
|
||||
|
||||
if print_summary:
|
||||
print(f"\n✅ Pipeline completed: stats={stats}")
|
||||
print(f"🗃️ Persistence DB: {config.persist.db_path}")
|
||||
logger.info("✅ Pipeline completed: stats=%s", stats)
|
||||
logger.info("🗃️ Persistence DB: %s", config.persist.db_path)
|
||||
|
||||
return stats
|
||||
finally:
|
||||
@@ -497,12 +463,13 @@ async def run_profile_from_file(
|
||||
multi_cfg = MultiProjectRunConfig.model_validate(raw)
|
||||
pipeline = MultiProjectPipeline(project_root=project_root, config_path=cfg_path, config=multi_cfg)
|
||||
if print_summary:
|
||||
print("\n" + "=" * 80)
|
||||
print(f"🚀 Creating multi-project pipeline from: {cfg_path}")
|
||||
print("=" * 80)
|
||||
logger.info("\n%s", "=" * 80)
|
||||
logger.info("🚀 Creating multi-project pipeline from: %s", cfg_path)
|
||||
logger.info("=" * 80)
|
||||
return await pipeline.run()
|
||||
|
||||
config = build_config(project_root=project_root, overrides=raw)
|
||||
configure_app_logging(config.logging, replace_handlers=False)
|
||||
mode = f"{config.local_datasource.type}->{config.remote_datasource.type}"
|
||||
return await run_pipeline_from_config(
|
||||
config,
|
||||
|
||||
@@ -20,7 +20,7 @@ from ..sync_system.strategy import BaseSyncStrategy
|
||||
from ..sync_system.config import OrphanAction, UpdateDirection
|
||||
from ..engine import StateMachineConfig, StateMachineRuntime
|
||||
from ..engine import e41_post_create_ready
|
||||
from ..sync_system.strategy_ops import finalize_peer_creating_sources
|
||||
from ..sync_system.strategy_ops import finalize_peer_creating_sources, run_phase2_cleanup
|
||||
from ..sync_system.strategy_ops.post_check_ops import evaluate_consistency_for_node_type
|
||||
from .summary_report import print_pipeline_summary
|
||||
|
||||
@@ -282,7 +282,7 @@ class FullSyncPipeline:
|
||||
# 加载后 reset 清理:清理 CREATE 失败的僵尸绑定
|
||||
if not self.strategies:
|
||||
raise RuntimeError("no strategies configured: cannot run bootstrap event E01")
|
||||
total_cleaned = await BaseSyncStrategy.run_reset(
|
||||
total_cleaned = await run_phase2_cleanup(
|
||||
node_types=self.node_types,
|
||||
local_collection=self.local_collection,
|
||||
remote_collection=self.remote_collection,
|
||||
@@ -397,11 +397,11 @@ class FullSyncPipeline:
|
||||
|
||||
result = await evaluate_consistency_for_node_type(
|
||||
node_type=node_type,
|
||||
strategy=strategy,
|
||||
local_collection=self.local_collection,
|
||||
remote_collection=self.remote_collection,
|
||||
binding_manager=self.binding_manager,
|
||||
logger=self._logger,
|
||||
normalize_compare_payload=strategy.normalize_compare_payload,
|
||||
depend_fields=depend_fields,
|
||||
compare_to_remote=compare_to_remote,
|
||||
ignore_fields=ignore_fields,
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class _NodeLogFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
message = record.getMessage()
|
||||
if "node=" in message:
|
||||
return False
|
||||
if "节点 " in message and "状态" in message:
|
||||
return False
|
||||
return True
|
||||
from ..logging import clear_app_logger_handlers, init_app_logger
|
||||
|
||||
|
||||
def init_pipeline_logger(
|
||||
@@ -23,43 +14,14 @@ def init_pipeline_logger(
|
||||
replace_handlers: bool = True,
|
||||
suppress_node_logs: bool = True,
|
||||
) -> logging.Logger:
|
||||
logger = logging.getLogger("sync_state_machine")
|
||||
logger.setLevel(level)
|
||||
logger.propagate = False
|
||||
|
||||
if replace_handlers:
|
||||
clear_pipeline_logger_handlers(logger)
|
||||
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
node_filter = _NodeLogFilter() if suppress_node_logs else None
|
||||
|
||||
if mirror_console:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(formatter)
|
||||
if node_filter is not None:
|
||||
console_handler.addFilter(node_filter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
if log_file_path:
|
||||
path = Path(log_file_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(path, mode="w", encoding="utf-8")
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
if node_filter is not None:
|
||||
file_handler.addFilter(node_filter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
return init_app_logger(
|
||||
log_file_path=log_file_path,
|
||||
level=level,
|
||||
mirror_console=mirror_console,
|
||||
replace_handlers=replace_handlers,
|
||||
suppress_node_logs=suppress_node_logs,
|
||||
)
|
||||
|
||||
|
||||
def clear_pipeline_logger_handlers(logger: Optional[logging.Logger] = None) -> None:
|
||||
target = logger or logging.getLogger("sync_state_machine")
|
||||
handlers = list(target.handlers)
|
||||
for handler in handlers:
|
||||
target.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
clear_app_logger_handlers(logger)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import copy
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar, Generic, Type, Dict, List, Optional, Any
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
@@ -7,31 +7,35 @@ 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 .config import StrategyConfig, OrphanAction, UpdateDirection
|
||||
from ..config.strategy_config import resolve_domain_option_config
|
||||
from .strategy_ops import (
|
||||
get_phase1_reset_defaults,
|
||||
run_bind,
|
||||
run_create,
|
||||
run_delete,
|
||||
run_phase2_cleanup,
|
||||
)
|
||||
from .strategy_ops.update_ops import (
|
||||
collect_bound_node_pairs,
|
||||
default_get_node_update_payload,
|
||||
default_needs_update,
|
||||
default_update_pair,
|
||||
prepare_directional_update,
|
||||
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]):
|
||||
class BaseSyncStrategy(ABC, Generic[T]):
|
||||
"""
|
||||
同步策略基类。
|
||||
纯逻辑层,不负责 IO 执行。
|
||||
同步策略抽象接口。
|
||||
|
||||
子类可以通过覆盖 default_config 来设置默认配置:
|
||||
子类可以通过覆盖以下类变量提供静态配置:
|
||||
```python
|
||||
class ProjectStrategy(DefaultSyncStrategy):
|
||||
default_config = StrategyConfig(
|
||||
@@ -47,41 +51,122 @@ class BaseSyncStrategy(Generic[T]):
|
||||
适用于只需要加载数据供其他类型引用的场景(如 supplier)
|
||||
skip_post_check: bool - 如果为 True,则跳过该类型最终 post-check 的 reload 与一致性校验
|
||||
"""
|
||||
# 类变量:默认配置(子类可覆盖)
|
||||
default_config: StrategyConfig = StrategyConfig()
|
||||
# 类变量:schema 类型(子类必须设置)
|
||||
schema: Type[T] = None # type: ignore
|
||||
# 类变量:是否默认跳过同步(子类可覆盖)
|
||||
default_skip_sync: bool = False
|
||||
# 类变量:是否默认跳过 post-check(子类可覆盖)
|
||||
default_skip_post_check: bool = False
|
||||
|
||||
default_config: StrategyConfig
|
||||
domain_option_model: Type[BaseModel] | None
|
||||
schema: Type[T]
|
||||
node_type: str
|
||||
config: StrategyConfig
|
||||
sm_runtime: Optional[StateMachineRuntime]
|
||||
|
||||
@abstractmethod
|
||||
def __init__(
|
||||
self,
|
||||
node_type: str,
|
||||
local_collection: DataCollection,
|
||||
remote_collection: DataCollection,
|
||||
self,
|
||||
node_type: str,
|
||||
local_collection: DataCollection,
|
||||
remote_collection: DataCollection,
|
||||
binding_manager: BindingManager,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def bind(self, **kwargs):
|
||||
"""进行绑定逻辑判定,设置 node.binding_status 和 node.action"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create(self) -> List[SyncNode]:
|
||||
"""准备 CREATE 操作"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update(self) -> List[SyncNode]:
|
||||
"""准备 UPDATE 操作"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self) -> List[SyncNode]:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def skip_sync(self) -> bool:
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def skip_post_check(self) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalize_compare_payload(
|
||||
self,
|
||||
data: Optional[Dict[str, Any]],
|
||||
data_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
|
||||
class DefaultSyncStrategy(BaseSyncStrategy[T]):
|
||||
"""
|
||||
基于 STATE_DEFINITIONS.md 实现的标准化同步策略。
|
||||
采用四阶段管线:核心状态 -> 依赖检查 -> 自动匹配 -> 动作映射。
|
||||
"""
|
||||
|
||||
default_config: StrategyConfig = StrategyConfig()
|
||||
domain_option_model: Type[BaseModel] | None = None
|
||||
schema: Type[T] = None # type: ignore
|
||||
|
||||
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
|
||||
# 是否跳过 post-check(可运行时修改)
|
||||
self.skip_post_check = self.default_skip_post_check
|
||||
# 统一由 Pipeline 注入 runtime;未注入时在执行阶段显式报错
|
||||
self.sm_runtime: Optional[StateMachineRuntime] = None
|
||||
self._schema_diff_validator: Optional[SchemaDiffValidator] = None
|
||||
|
||||
# 配置逻辑校验
|
||||
self._validate_config()
|
||||
self.config.domain_option = resolve_domain_option_config(
|
||||
self.domain_option_model,
|
||||
self.config.domain_option,
|
||||
)
|
||||
|
||||
self.config.log_logic_warnings(node_type=self.node_type, logger=logger)
|
||||
|
||||
@classmethod
|
||||
def get_default_domain_option(cls) -> Dict[str, Any]:
|
||||
return resolve_domain_option_config(cls.domain_option_model, None)
|
||||
|
||||
def _validate_domain_option(self, domain_option: Optional[Dict[str, Any]]) -> BaseModel | Dict[str, Any]:
|
||||
if self.domain_option_model is None:
|
||||
return dict(domain_option or {})
|
||||
return self.domain_option_model.model_validate(domain_option or {})
|
||||
|
||||
@property
|
||||
def domain_option(self) -> BaseModel | Dict[str, Any]:
|
||||
return self._validate_domain_option(self.config.domain_option)
|
||||
|
||||
@property
|
||||
def skip_sync(self) -> bool:
|
||||
return bool(self.config.skip_sync)
|
||||
|
||||
@skip_sync.setter
|
||||
def skip_sync(self, value: bool) -> None:
|
||||
self.config.skip_sync = bool(value)
|
||||
|
||||
@property
|
||||
def skip_post_check(self) -> bool:
|
||||
return bool(self.config.skip_post_check)
|
||||
|
||||
@skip_post_check.setter
|
||||
def skip_post_check(self, value: bool) -> None:
|
||||
self.config.skip_post_check = bool(value)
|
||||
|
||||
def ensure_runtime(self) -> StateMachineRuntime:
|
||||
runtime = self.sm_runtime
|
||||
@@ -93,128 +178,6 @@ class BaseSyncStrategy(Generic[T]):
|
||||
self.remote_collection.set_state_machine_runtime(runtime)
|
||||
return runtime
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""
|
||||
校验配置的逻辑一致性。
|
||||
|
||||
规则:
|
||||
- PUSH 模式:local_orphan_action 应为 CREATE_REMOTE,remote_orphan_action 应为 NONE
|
||||
- PULL 模式:local_orphan_action 应为 NONE,remote_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 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,
|
||||
ignore_list_item_fields=self.config.post_check_ignore_list_item_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}")
|
||||
|
||||
def preprocess_compare_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return payload
|
||||
|
||||
def normalize_compare_payload(
|
||||
self,
|
||||
data: Optional[Dict[str, Any]],
|
||||
@@ -226,103 +189,12 @@ class BaseSyncStrategy(Generic[T]):
|
||||
ignore_fields=set(self.config.compare_ignore_fields),
|
||||
ignore_list_item_fields=self.config.post_check_ignore_list_item_fields,
|
||||
)
|
||||
return self.preprocess_compare_payload(copy.deepcopy(normalized))
|
||||
|
||||
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()` 仅恢复持久化状态;
|
||||
- 这里触发 E01:create_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 实现的标准化同步策略。
|
||||
采用四阶段管线:核心状态 -> 依赖检查 -> 自动匹配 -> 动作映射。
|
||||
"""
|
||||
return normalized
|
||||
|
||||
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)
|
||||
@@ -331,6 +203,51 @@ class DefaultSyncStrategy(BaseSyncStrategy[T]):
|
||||
self.ensure_runtime()
|
||||
return await run_update(self)
|
||||
|
||||
def get_node_update_payload(self, node: SyncNode) -> Dict[str, Any]:
|
||||
return default_get_node_update_payload(node)
|
||||
|
||||
def should_update_pair(
|
||||
self,
|
||||
source_node: SyncNode,
|
||||
target_node: SyncNode,
|
||||
*,
|
||||
source_data: Dict[str, Any],
|
||||
target_data: Dict[str, Any],
|
||||
data_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> bool:
|
||||
return default_needs_update(self, source_data, target_data, data_id_map)
|
||||
|
||||
async def collect_update_pairs(self):
|
||||
return await collect_bound_node_pairs(self)
|
||||
|
||||
async def prepare_update_for_direction(
|
||||
self,
|
||||
local_node: SyncNode,
|
||||
remote_node: SyncNode,
|
||||
direction: UpdateDirection,
|
||||
data_id_map: Optional[Dict[str, str]] = None,
|
||||
*,
|
||||
include_fields: Optional[List[str]] = None,
|
||||
exclude_fields: Optional[List[str]] = None,
|
||||
) -> Optional[SyncNode]:
|
||||
return await prepare_directional_update(
|
||||
self,
|
||||
local_node=local_node,
|
||||
remote_node=remote_node,
|
||||
direction=direction,
|
||||
data_id_map=data_id_map,
|
||||
include_fields=include_fields,
|
||||
exclude_fields=exclude_fields,
|
||||
)
|
||||
|
||||
async def update_pair(
|
||||
self,
|
||||
local_node: SyncNode,
|
||||
remote_node: SyncNode,
|
||||
data_id_map: Optional[Dict[str, str]] = None,
|
||||
) -> List[SyncNode]:
|
||||
return await default_update_pair(self, local_node, remote_node, data_id_map)
|
||||
|
||||
async def delete(self) -> List[SyncNode]:
|
||||
self.ensure_runtime()
|
||||
return await run_delete(self)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from .create_ops import add_create_action, run_create, finalize_peer_creating_sources
|
||||
from .update_ops import add_update_action, run_update
|
||||
from .update_ops import (
|
||||
BoundNodePair,
|
||||
add_update_action,
|
||||
collect_bound_node_pairs,
|
||||
prepare_directional_update,
|
||||
run_update,
|
||||
)
|
||||
from .delete_ops import run_delete
|
||||
from .bind_ops import (
|
||||
run_bind,
|
||||
@@ -10,13 +16,16 @@ from .bind_ops import (
|
||||
check_dependency_satisfied,
|
||||
collect_dependency_errors,
|
||||
)
|
||||
from .reset_ops import get_phase1_reset_defaults, run_phase2_cleanup
|
||||
from .reset_ops import run_phase2_cleanup
|
||||
|
||||
__all__ = [
|
||||
"add_create_action",
|
||||
"run_create",
|
||||
"finalize_peer_creating_sources",
|
||||
"BoundNodePair",
|
||||
"add_update_action",
|
||||
"collect_bound_node_pairs",
|
||||
"prepare_directional_update",
|
||||
"run_update",
|
||||
"run_delete",
|
||||
"run_bind",
|
||||
@@ -26,6 +35,5 @@ __all__ = [
|
||||
"phase_auto_binding",
|
||||
"check_dependency_satisfied",
|
||||
"collect_dependency_errors",
|
||||
"get_phase1_reset_defaults",
|
||||
"run_phase2_cleanup",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,10 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_id_field_hints(strategy) -> Dict[str, str]:
|
||||
return dict(strategy.config.depend_fields) if strategy.config.depend_fields else {}
|
||||
|
||||
|
||||
def _stable_pick_bind_pair(local_candidates: List["SyncNode"], remote_candidates: List["SyncNode"]):
|
||||
local_node = sorted(local_candidates, key=lambda item: ((item.data_id or ""), item.node_id))[0]
|
||||
remote_node = sorted(remote_candidates, key=lambda item: ((item.data_id or ""), item.node_id))[0]
|
||||
@@ -399,7 +403,7 @@ async def phase_auto_binding(strategy, local_nodes: List["SyncNode"], remote_nod
|
||||
resolved_key_dict, resolve_error = await resolve_id_fields_in_key_dict(
|
||||
key_dict,
|
||||
id_resolver,
|
||||
strategy._get_id_field_hints(),
|
||||
get_id_field_hints(strategy),
|
||||
)
|
||||
if not resolved_key_dict:
|
||||
if node.binding_status == BindingStatus.MISSING:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from ...sync_system.resolve_ids import IDResolver
|
||||
from ...validation import SchemaDiffValidator
|
||||
@@ -27,11 +27,11 @@ def _append_post_check_error(node, message: str) -> None:
|
||||
async def evaluate_consistency_for_node_type(
|
||||
*,
|
||||
node_type: str,
|
||||
strategy,
|
||||
local_collection,
|
||||
remote_collection,
|
||||
binding_manager,
|
||||
logger,
|
||||
normalize_compare_payload: Callable[[Dict[str, Any] | None, Dict[str, str] | None], Dict[str, Any]],
|
||||
depend_fields: Dict[str, str] | None = None,
|
||||
compare_to_remote: bool = True,
|
||||
ignore_fields: List[str] | None = None,
|
||||
@@ -44,6 +44,7 @@ async def evaluate_consistency_for_node_type(
|
||||
schema_name=node_type,
|
||||
ignore_fields=ignore_fields,
|
||||
)
|
||||
mismatches: list[str] = []
|
||||
data_id_map = await build_data_id_normalization_map(
|
||||
node_type=node_type,
|
||||
local_collection=local_collection,
|
||||
@@ -93,8 +94,8 @@ async def evaluate_consistency_for_node_type(
|
||||
if resolved_remote is not None:
|
||||
remote_data = resolved_remote
|
||||
|
||||
local_payload = strategy.normalize_compare_payload(local_data, data_id_map)
|
||||
remote_payload = strategy.normalize_compare_payload(remote_data, data_id_map)
|
||||
local_payload = normalize_compare_payload(local_data, data_id_map)
|
||||
remote_payload = normalize_compare_payload(remote_data, data_id_map)
|
||||
diff_map = validator.compare_payloads(
|
||||
local_payload,
|
||||
remote_payload,
|
||||
@@ -129,6 +130,7 @@ async def evaluate_consistency_for_node_type(
|
||||
"fields": report["fields"],
|
||||
"schema_name": report["schema_name"],
|
||||
"ignore_fields": report["ignore_fields"],
|
||||
"mismatches": mismatches,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -14,13 +14,6 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_phase1_reset_defaults() -> Dict[str, Any]:
|
||||
return {
|
||||
"binding_status": BindingStatus.UNCHECKED,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_local_id_for_unbind(
|
||||
*,
|
||||
node_type: str,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional
|
||||
from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Sequence
|
||||
|
||||
from ...common.types import SyncAction
|
||||
from ...sync_system.config import UpdateDirection
|
||||
from ...sync_system.resolve_ids import IDResolver
|
||||
from ...engine import decision_note, e30_update_prepare
|
||||
from ...validation import SchemaDiffValidator
|
||||
from .compare_ops import (
|
||||
build_data_id_normalization_map,
|
||||
normalized_data_for_compare,
|
||||
collect_payload_diffs,
|
||||
normalized_data_for_compare,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -21,6 +23,36 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_schema_diff_validator(strategy) -> SchemaDiffValidator:
|
||||
schema = getattr(strategy, "schema", None)
|
||||
schema_name = getattr(schema, "__name__", None) or str(getattr(strategy, "node_type", "unknown"))
|
||||
return SchemaDiffValidator(
|
||||
schema_name=schema_name,
|
||||
ignore_fields=strategy.config.compare_ignore_fields,
|
||||
ignore_list_item_fields=strategy.config.post_check_ignore_list_item_fields,
|
||||
)
|
||||
|
||||
|
||||
def _get_active_schema_diff_validator(strategy) -> SchemaDiffValidator:
|
||||
validator = getattr(strategy, "_active_schema_diff_validator", None)
|
||||
if validator is not None:
|
||||
return validator
|
||||
return _build_schema_diff_validator(strategy)
|
||||
|
||||
|
||||
def emit_schema_diff_report(strategy, validator: SchemaDiffValidator) -> None:
|
||||
if not validator.has_records():
|
||||
return
|
||||
for line in validator.format_summary_lines():
|
||||
logger.info(f"[{strategy.node_type}] validate {line}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundNodePair:
|
||||
local_node: "SyncNode"
|
||||
remote_node: "SyncNode"
|
||||
|
||||
|
||||
def _extract_snapshot_steps(snapshot) -> List:
|
||||
if not isinstance(snapshot, dict):
|
||||
return []
|
||||
@@ -41,16 +73,10 @@ def _safe_repr(value, *, max_len: int = 120) -> str:
|
||||
|
||||
def default_needs_update(
|
||||
strategy,
|
||||
source_node,
|
||||
target_node,
|
||||
resolved_data=None,
|
||||
source_data,
|
||||
target_data,
|
||||
data_id_map=None,
|
||||
) -> bool:
|
||||
if resolved_data is None:
|
||||
raise ValueError(f"[{strategy.node_type}] resolved_data is required for differential sync.")
|
||||
|
||||
source_data = resolved_data
|
||||
target_data = target_node.get_data()
|
||||
if source_data is None or target_data is None:
|
||||
return False
|
||||
|
||||
@@ -67,10 +93,10 @@ def default_needs_update(
|
||||
ignore_fields=ignore_fields,
|
||||
ignore_list_item_fields=strategy.config.post_check_ignore_list_item_fields,
|
||||
)
|
||||
diff_map = strategy.get_schema_diff_validator().compare_payloads(
|
||||
diff_map = _get_active_schema_diff_validator(strategy).compare_payloads(
|
||||
normalized_source,
|
||||
normalized_target,
|
||||
sample_id=str(target_node.data_id or target_node.node_id),
|
||||
sample_id=str(source_data.get("id") or target_data.get("id") or "unknown"),
|
||||
schema_refs=[strategy.schema.__name__],
|
||||
)
|
||||
is_different = bool(diff_map)
|
||||
@@ -82,8 +108,8 @@ def default_needs_update(
|
||||
return is_different
|
||||
|
||||
|
||||
def resolve_needs_update_check(strategy) -> Callable:
|
||||
return strategy._needs_update
|
||||
def default_get_node_update_payload(node: "SyncNode") -> dict[str, Any]:
|
||||
return node.get_data() or {}
|
||||
|
||||
|
||||
def _collect_diff_descriptions(
|
||||
@@ -125,235 +151,268 @@ def _collect_diff_descriptions(
|
||||
return descriptions
|
||||
|
||||
|
||||
async def collect_bound_node_pairs(strategy) -> List[BoundNodePair]:
|
||||
local_nodes = strategy.local_collection.filter_by_state_ids(
|
||||
node_type=strategy.node_type,
|
||||
state_ids=["S01"],
|
||||
)
|
||||
if not local_nodes:
|
||||
return []
|
||||
|
||||
pairs: List[BoundNodePair] = []
|
||||
for local_node in local_nodes:
|
||||
remote_node_id = await strategy.binding_manager.get_remote_id(strategy.node_type, local_node.node_id)
|
||||
if not remote_node_id:
|
||||
continue
|
||||
|
||||
remote_node = strategy.remote_collection.get_by_node_id(remote_node_id)
|
||||
if remote_node is None:
|
||||
continue
|
||||
|
||||
pairs.append(BoundNodePair(local_node=local_node, remote_node=remote_node))
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
async def default_update_pair(
|
||||
strategy,
|
||||
local_node: "SyncNode",
|
||||
remote_node: "SyncNode",
|
||||
data_id_map: Optional[dict[str, str]] = None,
|
||||
) -> List["SyncNode"]:
|
||||
direction = strategy.config.update_direction
|
||||
if direction == UpdateDirection.NONE:
|
||||
return []
|
||||
|
||||
updated_node = await prepare_directional_update(
|
||||
strategy,
|
||||
local_node=local_node,
|
||||
remote_node=remote_node,
|
||||
direction=direction,
|
||||
data_id_map=data_id_map,
|
||||
)
|
||||
return [updated_node] if updated_node is not None else []
|
||||
|
||||
|
||||
def _sync_pending_approval_snapshot(source_node: "SyncNode", target_node: "SyncNode") -> bool:
|
||||
source_snapshot = source_node.context.get("approval_snapshot") if isinstance(source_node.context, dict) else None
|
||||
target_snapshot = target_node.context.get("approval_snapshot") if isinstance(target_node.context, dict) else None
|
||||
if isinstance(source_snapshot, dict):
|
||||
steps_changed = _steps_payload_changed(source_snapshot, target_snapshot)
|
||||
pending_snapshot = copy.deepcopy(source_snapshot)
|
||||
pending_snapshot["steps_changed"] = steps_changed
|
||||
target_node.context["pending_approval_snapshot"] = pending_snapshot
|
||||
return steps_changed
|
||||
|
||||
target_node.context.pop("pending_approval_snapshot", None)
|
||||
return False
|
||||
|
||||
|
||||
def _select_fields(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_fields: Optional[Sequence[str]] = None,
|
||||
exclude_fields: Optional[Sequence[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
if include_fields:
|
||||
include_set = set(include_fields)
|
||||
return {field_name: value for field_name, value in payload.items() if field_name in include_set}
|
||||
if exclude_fields:
|
||||
exclude_set = set(exclude_fields)
|
||||
return {field_name: value for field_name, value in payload.items() if field_name not in exclude_set}
|
||||
return dict(payload)
|
||||
|
||||
|
||||
def build_merged_update_payload(
|
||||
*,
|
||||
source_data: dict[str, Any],
|
||||
target_data: dict[str, Any],
|
||||
include_fields: Optional[Sequence[str]] = None,
|
||||
exclude_fields: Optional[Sequence[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
if include_fields:
|
||||
merged_payload = dict(target_data)
|
||||
for field_name, value in _select_fields(source_data, include_fields=include_fields).items():
|
||||
merged_payload[field_name] = value
|
||||
return merged_payload
|
||||
|
||||
if exclude_fields:
|
||||
exclude_set = set(exclude_fields)
|
||||
merged_payload = dict(source_data)
|
||||
for field_name, value in target_data.items():
|
||||
if field_name in exclude_set:
|
||||
merged_payload[field_name] = value
|
||||
return merged_payload
|
||||
|
||||
return dict(source_data)
|
||||
|
||||
|
||||
async def _resolve_update_source_payload(
|
||||
strategy,
|
||||
*,
|
||||
source_node: "SyncNode",
|
||||
source_is_local: bool,
|
||||
) -> tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
source_payload = strategy.get_node_update_payload(source_node)
|
||||
if not source_payload:
|
||||
return None, "Node data is missing or invalid"
|
||||
|
||||
id_resolver = IDResolver(strategy.local_collection, strategy.remote_collection, strategy.binding_manager)
|
||||
return await id_resolver.resolve_and_validate(
|
||||
data=source_payload,
|
||||
node_type=strategy.node_type,
|
||||
depend_fields=dict(strategy.config.depend_fields) if strategy.config.depend_fields else {},
|
||||
to_remote=source_is_local,
|
||||
)
|
||||
|
||||
|
||||
async def prepare_directional_update(
|
||||
strategy,
|
||||
*,
|
||||
local_node: "SyncNode",
|
||||
remote_node: "SyncNode",
|
||||
direction: UpdateDirection,
|
||||
data_id_map: Optional[dict] = None,
|
||||
include_fields: Optional[Sequence[str]] = None,
|
||||
exclude_fields: Optional[Sequence[str]] = None,
|
||||
) -> Optional["SyncNode"]:
|
||||
if direction == UpdateDirection.NONE:
|
||||
return None
|
||||
|
||||
source_is_local = direction == UpdateDirection.PUSH
|
||||
source_node = local_node if source_is_local else remote_node
|
||||
target_node = remote_node if source_is_local else local_node
|
||||
|
||||
if target_node.action == SyncAction.CREATE:
|
||||
return None
|
||||
|
||||
target_has_data_id = bool(target_node.data_id)
|
||||
resolved_source_data = None
|
||||
resolve_error = None
|
||||
if target_has_data_id:
|
||||
resolved_source_data, resolve_error = await _resolve_update_source_payload(
|
||||
strategy,
|
||||
source_node=source_node,
|
||||
source_is_local=source_is_local,
|
||||
)
|
||||
|
||||
update_id_resolve_ok = resolved_source_data is not None
|
||||
target_payload = strategy.get_node_update_payload(target_node)
|
||||
prepared_payload: dict[str, Any] | None = None
|
||||
has_diff = False
|
||||
diff_descriptions: List[str] = []
|
||||
|
||||
steps_changed = _sync_pending_approval_snapshot(source_node, target_node)
|
||||
|
||||
if update_id_resolve_ok and resolved_source_data is not None:
|
||||
prepared_payload = build_merged_update_payload(
|
||||
source_data=resolved_source_data,
|
||||
target_data=target_payload,
|
||||
include_fields=include_fields,
|
||||
exclude_fields=exclude_fields,
|
||||
)
|
||||
has_diff = bool(
|
||||
strategy.should_update_pair(
|
||||
source_node,
|
||||
target_node,
|
||||
source_data=prepared_payload,
|
||||
target_data=target_payload,
|
||||
data_id_map=data_id_map,
|
||||
)
|
||||
)
|
||||
if has_diff:
|
||||
diff_descriptions = _collect_diff_descriptions(
|
||||
prepared_payload,
|
||||
target_payload,
|
||||
data_id_map=data_id_map or {},
|
||||
ignore_fields=set(strategy.config.compare_ignore_fields),
|
||||
ignore_list_item_fields=dict(strategy.config.post_check_ignore_list_item_fields),
|
||||
)
|
||||
|
||||
if steps_changed:
|
||||
has_diff = True
|
||||
diff_descriptions.append("steps: approval flow changed")
|
||||
|
||||
if target_has_data_id and update_id_resolve_ok and not has_diff:
|
||||
return None
|
||||
|
||||
decision = e30_update_prepare(
|
||||
strategy.ensure_runtime(),
|
||||
source_node=source_node,
|
||||
target_node=target_node,
|
||||
update_enabled=True,
|
||||
target_has_data_id=target_has_data_id,
|
||||
update_id_resolve_ok=update_id_resolve_ok,
|
||||
has_diff=has_diff,
|
||||
id_resolve_detail=resolve_error,
|
||||
)
|
||||
reason = decision_note(decision)
|
||||
if decision.to_state == "S08":
|
||||
target_node.error = reason
|
||||
logger.debug(f"[{strategy.node_type}] 更新准备阻断: target={target_node.node_id}, reason={reason}")
|
||||
return None
|
||||
if decision.to_state != "S07":
|
||||
raise RuntimeError(f"[{strategy.node_type}] unexpected update_prepare state: {decision.to_state}")
|
||||
if prepared_payload is None:
|
||||
raise RuntimeError(f"[{strategy.node_type}] E30(S07) requires resolved update data")
|
||||
|
||||
target_node.set_data(prepared_payload)
|
||||
target_node.error = None
|
||||
diff_text = "; ".join(diff_descriptions) if diff_descriptions else "(未生成差异明细)"
|
||||
target_node.append_log(f"检测到差异: 从节点 {source_node.node_id} 更新数据 | fields={diff_text}")
|
||||
logger.info(f"[{strategy.node_type}] 更新差异字段: target={target_node.node_id}, {diff_text}")
|
||||
logger.debug(f"[{strategy.node_type}] 更新准备完成: target={target_node.node_id}, reason={reason}")
|
||||
return target_node
|
||||
|
||||
|
||||
async def add_update_action(
|
||||
strategy,
|
||||
*,
|
||||
from_collection: "DataCollection",
|
||||
to_collection: "DataCollection",
|
||||
needs_update_check,
|
||||
source_is_local: bool,
|
||||
data_id_map: Optional[dict] = None,
|
||||
node_filter: Optional[Callable[["SyncNode"], bool]] = None,
|
||||
include_fields: Optional[Iterable[str]] = None,
|
||||
exclude_fields: Optional[Iterable[str]] = None,
|
||||
) -> List["SyncNode"]:
|
||||
nodes_to_update = from_collection.filter_by_state_ids(
|
||||
node_type=strategy.node_type,
|
||||
state_ids=["S01"],
|
||||
)
|
||||
if not nodes_to_update:
|
||||
return []
|
||||
del from_collection
|
||||
del to_collection
|
||||
|
||||
if node_filter is not None:
|
||||
nodes_to_update = [n for n in nodes_to_update if node_filter(n)]
|
||||
if not nodes_to_update:
|
||||
return []
|
||||
|
||||
id_resolver = IDResolver(strategy.local_collection, strategy.remote_collection, strategy.binding_manager)
|
||||
updated_nodes: List["SyncNode"] = []
|
||||
total_nodes = len(nodes_to_update)
|
||||
|
||||
for idx, src_node in enumerate(nodes_to_update, start=1):
|
||||
if idx % 100 == 0 or idx == total_nodes:
|
||||
logger.debug(f"[{strategy.node_type}] 更新检查 {idx}/{total_nodes}")
|
||||
|
||||
src_data = src_node.get_data()
|
||||
if not src_data:
|
||||
continue
|
||||
|
||||
if source_is_local:
|
||||
target_node_id = await strategy.binding_manager.get_remote_id(strategy.node_type, src_node.node_id)
|
||||
else:
|
||||
target_node_id = await strategy.binding_manager.get_local_id(strategy.node_type, src_node.node_id)
|
||||
if not target_node_id:
|
||||
continue
|
||||
|
||||
target_node = to_collection.get_by_node_id(target_node_id)
|
||||
if not target_node or target_node.action == SyncAction.CREATE:
|
||||
continue
|
||||
|
||||
target_has_data_id = bool(target_node.data_id)
|
||||
data = None
|
||||
resolve_error = None
|
||||
if target_has_data_id:
|
||||
data, resolve_error = await id_resolver.resolve_and_validate(
|
||||
data=src_data,
|
||||
node_type=strategy.node_type,
|
||||
depend_fields=dict(strategy.config.depend_fields) if strategy.config.depend_fields else {},
|
||||
to_remote=source_is_local,
|
||||
)
|
||||
|
||||
update_id_resolve_ok = data is not None
|
||||
has_diff = False
|
||||
diff_descriptions: List[str] = []
|
||||
source_snapshot = src_node.context.get("approval_snapshot") if isinstance(src_node.context, dict) else None
|
||||
target_snapshot = target_node.context.get("approval_snapshot") if isinstance(target_node.context, dict) else None
|
||||
steps_changed = False
|
||||
if isinstance(source_snapshot, dict):
|
||||
steps_changed = _steps_payload_changed(source_snapshot, target_snapshot)
|
||||
pending_snapshot = copy.deepcopy(source_snapshot)
|
||||
pending_snapshot["steps_changed"] = steps_changed
|
||||
target_node.context["pending_approval_snapshot"] = pending_snapshot
|
||||
else:
|
||||
target_node.context.pop("pending_approval_snapshot", None)
|
||||
|
||||
if update_id_resolve_ok:
|
||||
try:
|
||||
should_update = needs_update_check(src_node, target_node, resolved_data=data, data_id_map=data_id_map)
|
||||
except TypeError:
|
||||
try:
|
||||
should_update = needs_update_check(src_node, target_node)
|
||||
except TypeError:
|
||||
should_update = needs_update_check(src_node)
|
||||
has_diff = bool(should_update)
|
||||
if has_diff:
|
||||
diff_descriptions = _collect_diff_descriptions(
|
||||
data,
|
||||
target_node.get_data(),
|
||||
data_id_map=data_id_map or {},
|
||||
ignore_fields=set(strategy.config.compare_ignore_fields),
|
||||
ignore_list_item_fields=dict(strategy.config.post_check_ignore_list_item_fields),
|
||||
)
|
||||
|
||||
if steps_changed:
|
||||
has_diff = True
|
||||
diff_descriptions.append("steps: approval flow changed")
|
||||
|
||||
if target_has_data_id and update_id_resolve_ok and not has_diff:
|
||||
continue
|
||||
|
||||
decision = e30_update_prepare(
|
||||
strategy.ensure_runtime(),
|
||||
source_node=src_node,
|
||||
target_node=target_node,
|
||||
update_enabled=True,
|
||||
target_has_data_id=target_has_data_id,
|
||||
update_id_resolve_ok=update_id_resolve_ok,
|
||||
has_diff=has_diff,
|
||||
id_resolve_detail=resolve_error,
|
||||
direction = UpdateDirection.PUSH if source_is_local else UpdateDirection.PULL
|
||||
for pair in await collect_bound_node_pairs(strategy):
|
||||
updated_node = await prepare_directional_update(
|
||||
strategy,
|
||||
local_node=pair.local_node,
|
||||
remote_node=pair.remote_node,
|
||||
direction=direction,
|
||||
data_id_map=data_id_map,
|
||||
include_fields=list(include_fields) if include_fields is not None else None,
|
||||
exclude_fields=list(exclude_fields) if exclude_fields is not None else None,
|
||||
)
|
||||
reason = decision_note(decision)
|
||||
if decision.to_state == "S08":
|
||||
target_node.error = reason
|
||||
logger.debug(f"[{strategy.node_type}] 更新准备阻断: target={target_node.node_id}, reason={reason}")
|
||||
continue
|
||||
if decision.to_state != "S07":
|
||||
raise RuntimeError(f"[{strategy.node_type}] unexpected update_prepare state: {decision.to_state}")
|
||||
if data is None:
|
||||
raise RuntimeError(f"[{strategy.node_type}] E30(S07) requires resolved update data")
|
||||
|
||||
target_node.set_data(data)
|
||||
target_node.error = None
|
||||
diff_text = "; ".join(diff_descriptions) if diff_descriptions else "(未生成差异明细)"
|
||||
target_node.append_log(f"检测到差异: 从节点 {src_node.node_id} 更新数据 | fields={diff_text}")
|
||||
logger.info(f"[{strategy.node_type}] 更新差异字段: target={target_node.node_id}, {diff_text}")
|
||||
logger.debug(f"[{strategy.node_type}] 更新准备完成: target={target_node.node_id}, reason={reason}")
|
||||
updated_nodes.append(target_node)
|
||||
if updated_node is not None:
|
||||
updated_nodes.append(updated_node)
|
||||
|
||||
return updated_nodes
|
||||
|
||||
|
||||
async def run_update(strategy) -> List["SyncNode"]:
|
||||
updated_nodes: List["SyncNode"] = []
|
||||
strategy.reset_schema_diff_validator()
|
||||
needs_update_check = resolve_needs_update_check(strategy)
|
||||
updated_by_id: dict[str, "SyncNode"] = {}
|
||||
validator = _build_schema_diff_validator(strategy)
|
||||
strategy._active_schema_diff_validator = validator
|
||||
data_id_map = await build_data_id_normalization_map(
|
||||
node_type=strategy.node_type,
|
||||
local_collection=strategy.local_collection,
|
||||
remote_collection=strategy.remote_collection,
|
||||
binding_manager=strategy.binding_manager,
|
||||
)
|
||||
try:
|
||||
for pair in await collect_bound_node_pairs(strategy):
|
||||
nodes = await strategy.update_pair(pair.local_node, pair.remote_node, data_id_map)
|
||||
for node in nodes:
|
||||
updated_by_id[node.node_id] = node
|
||||
finally:
|
||||
strategy._active_schema_diff_validator = None
|
||||
|
||||
field_key = strategy.config.field_direction_key
|
||||
overrides: dict = strategy.config.field_direction_overrides
|
||||
|
||||
if field_key and overrides:
|
||||
# 按方向归组:对每种出现的方向各调一次 add_update_action,传入过滤器。
|
||||
# 覆盖表中未命中的节点沿用 update_direction 作为默认方向。
|
||||
default_direction = strategy.config.update_direction
|
||||
active_directions = set(overrides.values()) | {default_direction}
|
||||
|
||||
for direction in (UpdateDirection.PUSH, UpdateDirection.PULL):
|
||||
if direction not in active_directions:
|
||||
continue
|
||||
|
||||
captured_direction = direction # 避免闭包捕获循环变量
|
||||
captured_overrides = overrides
|
||||
captured_default = default_direction
|
||||
captured_field_key = field_key
|
||||
|
||||
def make_filter(d=captured_direction, fk=captured_field_key, ov=captured_overrides, dflt=captured_default):
|
||||
def _filter(node: "SyncNode") -> bool:
|
||||
node_data = node.get_data() or {}
|
||||
key_val = node_data.get(fk)
|
||||
effective = ov.get(key_val, dflt)
|
||||
return effective == d
|
||||
return _filter
|
||||
|
||||
node_filter = make_filter()
|
||||
|
||||
if direction == UpdateDirection.PUSH:
|
||||
nodes = await add_update_action(
|
||||
strategy,
|
||||
from_collection=strategy.local_collection,
|
||||
to_collection=strategy.remote_collection,
|
||||
needs_update_check=needs_update_check,
|
||||
source_is_local=True,
|
||||
data_id_map=data_id_map,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
updated_nodes.extend(nodes)
|
||||
logger.info(
|
||||
f"[{strategy.node_type}] Updated {len(nodes)} nodes in remote collection"
|
||||
f" (Push, field_direction_key={field_key})"
|
||||
)
|
||||
|
||||
elif direction == UpdateDirection.PULL:
|
||||
nodes = await add_update_action(
|
||||
strategy,
|
||||
from_collection=strategy.remote_collection,
|
||||
to_collection=strategy.local_collection,
|
||||
needs_update_check=needs_update_check,
|
||||
source_is_local=False,
|
||||
data_id_map=data_id_map,
|
||||
node_filter=node_filter,
|
||||
)
|
||||
updated_nodes.extend(nodes)
|
||||
logger.info(
|
||||
f"[{strategy.node_type}] Updated {len(nodes)} nodes in local collection"
|
||||
f" (Pull, field_direction_key={field_key})"
|
||||
)
|
||||
|
||||
strategy.emit_schema_diff_report()
|
||||
return updated_nodes
|
||||
|
||||
# ── 无字段级覆盖:原有逻辑不变 ──
|
||||
if strategy.config.update_direction == UpdateDirection.PUSH:
|
||||
nodes = await add_update_action(
|
||||
strategy,
|
||||
from_collection=strategy.local_collection,
|
||||
to_collection=strategy.remote_collection,
|
||||
needs_update_check=needs_update_check,
|
||||
source_is_local=True,
|
||||
data_id_map=data_id_map,
|
||||
)
|
||||
updated_nodes.extend(nodes)
|
||||
logger.info(f"[{strategy.node_type}] Updated {len(nodes)} nodes in remote collection (Push)")
|
||||
|
||||
elif strategy.config.update_direction == UpdateDirection.PULL:
|
||||
nodes = await add_update_action(
|
||||
strategy,
|
||||
from_collection=strategy.remote_collection,
|
||||
to_collection=strategy.local_collection,
|
||||
needs_update_check=needs_update_check,
|
||||
source_is_local=False,
|
||||
data_id_map=data_id_map,
|
||||
)
|
||||
updated_nodes.extend(nodes)
|
||||
logger.info(f"[{strategy.node_type}] Updated {len(nodes)} nodes in local collection (Pull)")
|
||||
|
||||
elif strategy.config.update_direction == UpdateDirection.BIDIRECTIONAL:
|
||||
raise NotImplementedError(
|
||||
f"[{strategy.node_type}] update_direction=BIDIRECTIONAL requires custom implementation"
|
||||
)
|
||||
|
||||
strategy.emit_schema_diff_report()
|
||||
return updated_nodes
|
||||
emit_schema_diff_report(strategy, validator)
|
||||
return list(updated_by_id.values())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import copy
|
||||
import json
|
||||
@@ -35,6 +36,12 @@ from tests.fixtures.mock_domain import (
|
||||
)
|
||||
|
||||
|
||||
def _configure_strategy(strategy, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(strategy.config, key, value)
|
||||
strategy.config.log_logic_warnings(node_type=strategy.node_type, logger=logging.getLogger(__name__))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def pipeline_bundle():
|
||||
register_test_domain()
|
||||
@@ -166,7 +173,8 @@ async def pipeline_bundle():
|
||||
await binding_manager.persist()
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local_collection, remote_collection, binding_manager)
|
||||
project_strategy.update_config(
|
||||
_configure_strategy(
|
||||
project_strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
@@ -176,7 +184,8 @@ async def pipeline_bundle():
|
||||
)
|
||||
|
||||
contract_strategy = TestContractStrategy("test_contract", local_collection, remote_collection, binding_manager)
|
||||
contract_strategy.update_config(
|
||||
_configure_strategy(
|
||||
contract_strategy,
|
||||
auto_bind=False,
|
||||
depend_fields={"project_ref": "test_project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
@@ -468,7 +477,8 @@ async def test_pipeline_create_then_update_completes_in_single_run_with_reload()
|
||||
remote_ds.add_handler(remote_handler)
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local_collection, remote_collection, binding_manager)
|
||||
project_strategy.update_config(
|
||||
_configure_strategy(
|
||||
project_strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,6 +27,12 @@ from tests.fixtures.mock_domain import (
|
||||
)
|
||||
|
||||
|
||||
def _configure_strategy(strategy, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(strategy.config, key, value)
|
||||
strategy.config.log_logic_warnings(node_type=strategy.node_type, logger=logging.getLogger(__name__))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pipeline_multinode_mock_persisted_db_snapshot() -> None:
|
||||
register_test_domain()
|
||||
@@ -127,7 +134,8 @@ async def test_full_pipeline_multinode_mock_persisted_db_snapshot() -> None:
|
||||
await binding_manager.persist()
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local_collection, remote_collection, binding_manager)
|
||||
project_strategy.update_config(
|
||||
_configure_strategy(
|
||||
project_strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
@@ -137,7 +145,8 @@ async def test_full_pipeline_multinode_mock_persisted_db_snapshot() -> None:
|
||||
)
|
||||
|
||||
contract_strategy = TestContractStrategy("test_contract", local_collection, remote_collection, binding_manager)
|
||||
contract_strategy.update_config(
|
||||
_configure_strategy(
|
||||
contract_strategy,
|
||||
auto_bind=False,
|
||||
depend_fields={"project_ref": "test_project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,6 +30,12 @@ from tests.fixtures.mock_domain import (
|
||||
)
|
||||
|
||||
|
||||
def _configure_strategy(strategy, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(strategy.config, key, value)
|
||||
strategy.config.log_logic_warnings(node_type=strategy.node_type, logger=logging.getLogger(__name__))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def setup_env():
|
||||
register_test_domain()
|
||||
@@ -109,7 +116,7 @@ async def test_dependency_resolution_migrated(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
project_strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
project_strategy.update_config(depend_fields={})
|
||||
_configure_strategy(project_strategy, depend_fields={})
|
||||
|
||||
await local.add(build_project_node("proj_l", "P-1", name="Project A", code="P-A"))
|
||||
await remote.add(build_project_node("proj_r", "P-1-R", name="Project A", code="P-A"))
|
||||
@@ -123,7 +130,8 @@ async def test_dependency_resolution_migrated(setup_env):
|
||||
assert local.get("proj_l").binding_status == BindingStatus.NORMAL
|
||||
|
||||
contract_strategy = TestContractStrategy("test_contract", local, remote, bm)
|
||||
contract_strategy.update_config(
|
||||
_configure_strategy(
|
||||
contract_strategy,
|
||||
depend_fields={"project_ref": "test_project"},
|
||||
local_orphan_action=OrphanAction.CREATE_REMOTE,
|
||||
remote_orphan_action=OrphanAction.NONE,
|
||||
@@ -164,7 +172,7 @@ async def test_auto_binding_migrated(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
||||
_configure_strategy(strategy, auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
||||
|
||||
await local.add(build_project_node("l1", "P1", name="A", code="AUTO-1"))
|
||||
await remote.add(build_project_node("r1", "RP1", name="A", code="AUTO-1"))
|
||||
@@ -180,7 +188,7 @@ async def test_auto_binding_skip_on_ambiguous_migrated(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
||||
_configure_strategy(strategy, auto_bind=True, auto_bind_fields=["code"], depend_fields={})
|
||||
|
||||
await local.add(build_project_node("l1", "P1", name="A", code="AMB-1"))
|
||||
await local.add(build_project_node("l2", "P2", name="A", code="AMB-1"))
|
||||
@@ -198,7 +206,8 @@ async def test_auto_binding_many_to_one_can_bind_stable_pick_and_leave_rest_for_
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(
|
||||
_configure_strategy(
|
||||
strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={},
|
||||
@@ -222,7 +231,8 @@ async def test_pre_bind_data_id_works_when_auto_bind_disabled(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(
|
||||
_configure_strategy(
|
||||
strategy,
|
||||
auto_bind=False,
|
||||
auto_bind_fields=[],
|
||||
pre_bind_data_id=[{"local_data_id": "PRE-DID-1", "remote_data_id": "PRE-DID-R-1"}],
|
||||
@@ -245,7 +255,7 @@ async def test_dependency_check_data_missing_goes_abnormal(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestContractStrategy("test_contract", local, remote, bm)
|
||||
strategy.update_config(depend_fields={"project_ref": "test_project"})
|
||||
_configure_strategy(strategy, depend_fields={"project_ref": "test_project"})
|
||||
|
||||
await local.add(
|
||||
build_contract_node(
|
||||
@@ -270,7 +280,7 @@ async def test_auto_binding_key_missing_migrated(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(auto_bind=True, auto_bind_fields=["code", "never_exists"], depend_fields={})
|
||||
_configure_strategy(strategy, auto_bind=True, auto_bind_fields=["code", "never_exists"], depend_fields={})
|
||||
|
||||
await local.add(build_project_node("km_l", "KM-L", name="KM", code="KM-1"))
|
||||
|
||||
@@ -286,7 +296,8 @@ async def test_auto_binding_key_id_resolve_fail_migrated(setup_env):
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(
|
||||
_configure_strategy(
|
||||
strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["id", "code"],
|
||||
depend_fields={"id": "test_project"},
|
||||
@@ -308,7 +319,8 @@ async def test_create_prepare_e21_on_id_resolve_fail(setup_env, monkeypatch: pyt
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(
|
||||
_configure_strategy(
|
||||
strategy,
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
depend_fields={"dummy_id": "test_project"},
|
||||
@@ -347,7 +359,7 @@ async def test_update_prepare_target_missing_data_id_goes_precondition_failed(se
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(update_direction=UpdateDirection.PUSH, depend_fields={})
|
||||
_configure_strategy(strategy, update_direction=UpdateDirection.PUSH, depend_fields={})
|
||||
|
||||
local_src = TestProjectNode(
|
||||
node_id="upd_src",
|
||||
@@ -382,7 +394,8 @@ async def test_update_prepare_e30b_on_id_resolve_fail(setup_env, monkeypatch: py
|
||||
local, remote, bm, _ = setup_env
|
||||
|
||||
strategy = TestProjectStrategy("test_project", local, remote, bm)
|
||||
strategy.update_config(
|
||||
_configure_strategy(
|
||||
strategy,
|
||||
depend_fields={"dummy_id": "test_project"},
|
||||
update_direction=UpdateDirection.PUSH,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from sync_state_machine.datasource.api.client import ApiClient
|
||||
|
||||
@@ -21,11 +23,28 @@ class _FakeResponse:
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self) -> None:
|
||||
self.call_times: list[float] = []
|
||||
self.closed = False
|
||||
|
||||
async def request(self, **kwargs):
|
||||
self.call_times.append(time.monotonic())
|
||||
return _FakeResponse()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FailingAsyncClient:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
|
||||
async def request(self, **kwargs):
|
||||
request = httpx.Request(kwargs["method"], kwargs["url"])
|
||||
response = httpx.Response(500, request=request, text="boom")
|
||||
raise httpx.HTTPStatusError("server error", request=request, response=response)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_global_rate_limit_applies_to_concurrent_requests() -> None:
|
||||
@@ -48,3 +67,50 @@ async def test_api_client_global_rate_limit_applies_to_concurrent_requests() ->
|
||||
assert len(fake_client.call_times) == 3
|
||||
third_delay = fake_client.call_times[2] - fake_client.call_times[0]
|
||||
assert third_delay >= 0.15
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_request_stats_track_success_and_failure(caplog: pytest.LogCaptureFixture) -> None:
|
||||
client = ApiClient(
|
||||
base_url="http://example.com",
|
||||
uid="uid",
|
||||
secret="secret",
|
||||
)
|
||||
logger_name = "sync_state_machine.datasource.api.client"
|
||||
success_client = _FakeAsyncClient()
|
||||
client._client = success_client
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=logger_name):
|
||||
await client.request("GET", "/ping")
|
||||
|
||||
failing_client = _FailingAsyncClient()
|
||||
client._client = failing_client
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.request("GET", "/ping")
|
||||
|
||||
stats = client.get_request_stats()
|
||||
assert stats == {"total": 2, "success": 1, "failure": 1}
|
||||
assert client.format_request_stats().endswith("total=2, success=1, failure=1")
|
||||
|
||||
await client.close()
|
||||
|
||||
assert failing_client.closed is True
|
||||
assert "📊 ApiClient request stats: total=2, success=1, failure=1" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_client_logs_one_line_request_summary_in_debug_mode(caplog: pytest.LogCaptureFixture) -> None:
|
||||
client = ApiClient(
|
||||
base_url="http://example.com",
|
||||
uid="uid",
|
||||
secret="secret",
|
||||
debug=True,
|
||||
)
|
||||
logger_name = "sync_state_machine.datasource.api.client"
|
||||
fake_client = _FakeAsyncClient()
|
||||
client._client = fake_client
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger=logger_name):
|
||||
await client.request("GET", "/ping")
|
||||
|
||||
assert "ApiClient request: method=GET endpoint=/ping elapsed=" in caplog.text
|
||||
|
||||
@@ -10,14 +10,14 @@ from sync_state_machine.common.persistence import PersistenceBackend
|
||||
from sync_state_machine.common.sqlite_backend import SQLitePersistenceBackend
|
||||
from sync_state_machine.datasource.datasource import BaseDataSource
|
||||
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
|
||||
from sync_state_machine.sync_system.strategy import BaseSyncStrategy
|
||||
from sync_state_machine.sync_system.strategy import DefaultSyncStrategy
|
||||
|
||||
|
||||
class _DS(BaseDataSource):
|
||||
pass
|
||||
|
||||
|
||||
class _FailStrategy(BaseSyncStrategy):
|
||||
class _FailStrategy(DefaultSyncStrategy):
|
||||
schema = object # type: ignore
|
||||
|
||||
async def bind(self, **kwargs):
|
||||
@@ -30,7 +30,7 @@ class _FailStrategy(BaseSyncStrategy):
|
||||
return []
|
||||
|
||||
|
||||
class _OkStrategy(BaseSyncStrategy):
|
||||
class _OkStrategy(DefaultSyncStrategy):
|
||||
schema = object # type: ignore
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sync_state_machine.common.collection import DataCollection
|
||||
from sync_state_machine.domain.project.api_handler import ProjectApiHandler
|
||||
|
||||
|
||||
class _FakeProjectResponse:
|
||||
def __init__(self, project_id: str):
|
||||
self.id = project_id
|
||||
|
||||
def model_dump(self) -> dict[str, str]:
|
||||
return {"id": self.id, "name": f"project-{self.id}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_handler_purge_project_data_requires_data_id_filter() -> None:
|
||||
handler = ProjectApiHandler()
|
||||
handler.set_handler_config({"purge_project_data": True})
|
||||
|
||||
with pytest.raises(ValueError, match="requires non-empty data_id_filter"):
|
||||
await handler.load()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_handler_purge_project_data_calls_remote_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
handler = ProjectApiHandler()
|
||||
handler.set_handler_config(
|
||||
{
|
||||
"purge_project_data": True,
|
||||
"data_id_filter": ["project-1"],
|
||||
}
|
||||
)
|
||||
handler.api_client = object()
|
||||
handler.set_collection(DataCollection("remote"))
|
||||
|
||||
purged: list[str] = []
|
||||
|
||||
async def fake_purge(client, project_id: str):
|
||||
purged.append(project_id)
|
||||
return {"project_id": project_id}
|
||||
|
||||
async def fake_get_project(client, project_id: str):
|
||||
return _FakeProjectResponse(project_id)
|
||||
|
||||
async def fake_get_all_info(cls, client, project_id: str):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("sync_state_machine.domain.project.api_handler.api_purge_project_data", fake_purge)
|
||||
monkeypatch.setattr("sync_state_machine.domain.project.api_handler.api_get_project", fake_get_project)
|
||||
monkeypatch.setattr(ProjectApiHandler, "get_all_info", classmethod(fake_get_all_info))
|
||||
|
||||
nodes = await handler.load()
|
||||
|
||||
assert purged == ["project-1"]
|
||||
assert [node.data_id for node in nodes] == ["project-1"]
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from sync_state_machine.common.collection import DataCollection
|
||||
from sync_state_machine.domain.project.api_handler import ProjectApiHandler
|
||||
from sync_state_machine.domain.project.api_handler import _merge_project_all_info_fields
|
||||
from sync_state_machine.domain.project.sync_node import ProjectSyncNode
|
||||
|
||||
@@ -82,3 +85,12 @@ def test_project_sync_node_preserves_extension_backed_fields() -> None:
|
||||
assert normalized["implementation_estimate"] == 120.0
|
||||
assert normalized["static_total_investment"] == 110.0
|
||||
assert normalized["completed_investment"] == 70.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_api_handler_load_requires_api_client() -> None:
|
||||
handler = ProjectApiHandler()
|
||||
handler.set_collection(DataCollection("remote"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="API client not set"):
|
||||
await handler.load()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sync_state_machine.domain.project_detail.sync_strategy import ProjectDetailSyncStrategy
|
||||
from sync_state_machine.sync_system.config import UpdateDirection
|
||||
from sync_state_machine.sync_system.strategy_ops import BoundNodePair
|
||||
|
||||
|
||||
def _fake_node(*, node_id: str, key: str):
|
||||
data = {"id": node_id, "key": key}
|
||||
return SimpleNamespace(
|
||||
node_id=node_id,
|
||||
data_id=node_id,
|
||||
action=None,
|
||||
context={},
|
||||
get_data=lambda: data,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_detail_update_partitions_group_production_plans_before_generic_logic():
|
||||
strategy = ProjectDetailSyncStrategy("project_detail_data", MagicMock(), MagicMock(), MagicMock())
|
||||
|
||||
pull_pair = BoundNodePair(
|
||||
local_node=_fake_node(node_id="local-pull", key="ensure_production"),
|
||||
remote_node=_fake_node(node_id="remote-pull", key="ensure_production"),
|
||||
)
|
||||
generic_pair = BoundNodePair(
|
||||
local_node=_fake_node(node_id="local-generic", key="production"),
|
||||
remote_node=_fake_node(node_id="remote-generic", key="production"),
|
||||
)
|
||||
pull_node = SimpleNamespace(node_id="pull-update")
|
||||
generic_node = SimpleNamespace(node_id="generic-update")
|
||||
|
||||
with patch.object(
|
||||
strategy,
|
||||
"collect_update_pairs",
|
||||
new=AsyncMock(return_value=[pull_pair, generic_pair]),
|
||||
), patch(
|
||||
"sync_state_machine.domain.project_detail.sync_strategy.build_data_id_normalization_map",
|
||||
new=AsyncMock(return_value={}),
|
||||
), patch.object(
|
||||
strategy,
|
||||
"prepare_update_for_direction",
|
||||
new=AsyncMock(return_value=pull_node),
|
||||
) as mock_prepare, patch.object(
|
||||
strategy,
|
||||
"update_pair",
|
||||
new=AsyncMock(return_value=[generic_node]),
|
||||
) as mock_update_pair:
|
||||
updated_nodes = await strategy.update()
|
||||
|
||||
assert updated_nodes == [pull_node, generic_node]
|
||||
mock_prepare.assert_awaited_once_with(
|
||||
pull_pair.local_node,
|
||||
pull_pair.remote_node,
|
||||
UpdateDirection.PULL,
|
||||
{},
|
||||
)
|
||||
mock_update_pair.assert_awaited_once_with(generic_pair.local_node, generic_pair.remote_node, {})
|
||||
|
||||
|
||||
def test_project_detail_domain_option_is_typed_schema() -> None:
|
||||
strategy = ProjectDetailSyncStrategy("project_detail_data", MagicMock(), MagicMock(), MagicMock())
|
||||
|
||||
assert strategy.domain_option.pull_group_production_plans is True
|
||||
@@ -0,0 +1,68 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from sync_state_machine.domain.project.sync_strategy import ProjectSyncStrategy
|
||||
from sync_state_machine.sync_system.config import UpdateDirection
|
||||
|
||||
|
||||
def _fake_node(*, node_id: str, data_id: str, data: dict, context=None):
|
||||
return SimpleNamespace(
|
||||
node_id=node_id,
|
||||
data_id=data_id,
|
||||
data=data,
|
||||
context=context or {},
|
||||
action=None,
|
||||
get_data=lambda: data,
|
||||
)
|
||||
|
||||
|
||||
def test_project_get_node_update_payload_enriches_group_plan_dates_from_all_info():
|
||||
strategy = ProjectSyncStrategy("project", MagicMock(), MagicMock(), MagicMock())
|
||||
node = _fake_node(
|
||||
node_id="node-1",
|
||||
data_id="project-1",
|
||||
data={"id": "project-1", "project_name": "demo"},
|
||||
context={
|
||||
"all_info": {
|
||||
"wind_extension": {
|
||||
"ic_plan_first_production_date": "2026-01-01",
|
||||
"ic_plan_full_production_date": "2026-06-01",
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
payload = strategy.get_node_update_payload(node)
|
||||
|
||||
assert payload["ic_plan_first_production_date"] == "2026-01-01"
|
||||
assert payload["ic_plan_full_production_date"] == "2026-06-01"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_update_pair_runs_special_pull_then_generic_update():
|
||||
strategy = ProjectSyncStrategy("project", MagicMock(), MagicMock(), MagicMock())
|
||||
strategy.config.domain_option = {"pull_group_plan_production_date": True}
|
||||
|
||||
local_node = _fake_node(node_id="local", data_id="local-1", data={"id": "local-1"})
|
||||
remote_node = _fake_node(node_id="remote", data_id="remote-1", data={"id": "remote-1"})
|
||||
pull_node = SimpleNamespace(node_id="pull-node")
|
||||
push_node = SimpleNamespace(node_id="push-node")
|
||||
|
||||
with patch.object(
|
||||
strategy,
|
||||
"prepare_update_for_direction",
|
||||
new=AsyncMock(side_effect=[pull_node, push_node]),
|
||||
) as mock_prepare:
|
||||
updated_nodes = await strategy.update_pair(local_node, remote_node, {"project": "remote-project"})
|
||||
|
||||
assert updated_nodes == [pull_node, push_node]
|
||||
|
||||
pull_call = mock_prepare.await_args_list[0]
|
||||
assert pull_call.args == (local_node, remote_node, UpdateDirection.PULL, {"project": "remote-project"})
|
||||
assert pull_call.kwargs["include_fields"] == strategy._PULL_ONLY_FIELDS
|
||||
|
||||
generic_call = mock_prepare.await_args_list[1]
|
||||
assert generic_call.args == (local_node, remote_node, UpdateDirection.PUSH, {"project": "remote-project"})
|
||||
assert generic_call.kwargs["exclude_fields"] == strategy._PULL_ONLY_FIELDS
|
||||
@@ -176,7 +176,9 @@ strategies:
|
||||
|
||||
snapshot = config_snapshot(config)
|
||||
assert snapshot["strategies"][0]["config"]["compare_ignore_fields"] == []
|
||||
assert snapshot["strategies"][0]["config"]["field_direction_overrides"] == {}
|
||||
assert snapshot["strategies"][0]["config"]["domain_option"] == {
|
||||
"pull_group_plan_production_date": False
|
||||
}
|
||||
|
||||
|
||||
def test_build_config_from_file_rejects_legacy_strategy_keys(tmp_path: Path) -> None:
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sync_state_machine.config.builder import _apply_strategy_overrides
|
||||
from sync_state_machine.config.strategy_config import StrategyConfig, StrategyRuntimeConfig
|
||||
from sync_state_machine.config.strategy_config import (
|
||||
StrategyConfig,
|
||||
StrategyRuntimeConfig,
|
||||
resolve_domain_option_config,
|
||||
)
|
||||
|
||||
|
||||
def test_strategy_runtime_config_skip_sync_defaults_false() -> None:
|
||||
runtime_config = StrategyRuntimeConfig(node_type="project")
|
||||
|
||||
assert runtime_config.skip_sync is False
|
||||
assert runtime_config.skip_post_check is False
|
||||
assert runtime_config.config.skip_sync is False
|
||||
assert runtime_config.config.skip_post_check is False
|
||||
assert runtime_config.config.domain_option == {}
|
||||
|
||||
|
||||
def test_apply_strategy_overrides_allows_skip_sync_override() -> None:
|
||||
@@ -24,8 +31,34 @@ def test_apply_strategy_overrides_allows_skip_sync_override() -> None:
|
||||
|
||||
assert len(resolved) == 1
|
||||
assert resolved[0].node_type == "project"
|
||||
assert resolved[0].skip_sync is True
|
||||
assert resolved[0].skip_post_check is True
|
||||
assert resolved[0].config.skip_sync is True
|
||||
assert resolved[0].config.skip_post_check is True
|
||||
|
||||
|
||||
def test_apply_strategy_overrides_allows_domain_option_override() -> None:
|
||||
resolved = _apply_strategy_overrides(
|
||||
node_types=["project"],
|
||||
strategy_overrides={
|
||||
"project": {
|
||||
"domain_option": {
|
||||
"pull_group_plan_production_date": True,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resolved[0].config.domain_option == {"pull_group_plan_production_date": True}
|
||||
|
||||
|
||||
def test_apply_strategy_overrides_preserves_project_detail_default_domain_option() -> None:
|
||||
resolved = _apply_strategy_overrides(
|
||||
node_types=["project_detail_data"],
|
||||
strategy_overrides={},
|
||||
)
|
||||
|
||||
assert resolved[0].config.domain_option == {
|
||||
"pull_group_production_plans": True,
|
||||
}
|
||||
|
||||
|
||||
def test_strategy_config_supports_both_post_check_ignore_fields() -> None:
|
||||
@@ -39,4 +72,20 @@ def test_strategy_config_supports_both_post_check_ignore_fields() -> None:
|
||||
|
||||
assert config.post_check_ignore_fields == ["code"]
|
||||
assert config.post_check_ignore_list_item_fields == {"plan_node": ["is_audit"]}
|
||||
assert config.allow_many_to_one_auto_bind is True
|
||||
assert config.allow_many_to_one_auto_bind is True
|
||||
|
||||
|
||||
def test_resolve_domain_option_config_fills_schema_defaults() -> None:
|
||||
class ExampleDomainOption(BaseModel):
|
||||
enabled: bool = True
|
||||
mode: str = "strict"
|
||||
|
||||
resolved = resolve_domain_option_config(
|
||||
ExampleDomainOption,
|
||||
{"enabled": False},
|
||||
)
|
||||
|
||||
assert resolved == {
|
||||
"enabled": False,
|
||||
"mode": "strict",
|
||||
}
|
||||
@@ -1,74 +1,78 @@
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from sync_state_machine.domain.company.sync_strategy import CompanySyncStrategy
|
||||
from sync_state_machine.sync_system.config import UpdateDirection
|
||||
from sync_state_machine.sync_system.strategy_ops.update_ops import run_update
|
||||
from sync_state_machine.sync_system.strategy_ops.update_ops import BoundNodePair, build_merged_update_payload, run_update
|
||||
from sync_state_machine.domain.construction.sync_strategy import ConstructionTaskSyncStrategy
|
||||
from sync_state_machine.domain.project_detail.sync_strategy import ProjectDetailSyncStrategy
|
||||
from schemas.project_extensions.project_detail import ProjectDetailKey
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_update_with_direction_overrides():
|
||||
class FakeNode:
|
||||
def __init__(self, data):
|
||||
self._data = data
|
||||
async def test_run_update_delegates_to_strategy_pair_planner():
|
||||
local_node = SimpleNamespace(node_id="local-1")
|
||||
remote_node = SimpleNamespace(node_id="remote-1")
|
||||
|
||||
def get_data(self):
|
||||
return self._data
|
||||
|
||||
# Setup mock strategy
|
||||
strategy = MagicMock()
|
||||
strategy.node_type = "TestNode"
|
||||
strategy.config.field_direction_key = "sync_direction"
|
||||
strategy.config.field_direction_overrides = {
|
||||
"local_only": UpdateDirection.PUSH,
|
||||
"remote_only": UpdateDirection.PULL,
|
||||
"ignore": UpdateDirection.BIDIRECTIONAL # Just to check filtering
|
||||
}
|
||||
strategy.config.update_direction = UpdateDirection.PUSH
|
||||
strategy.local_collection = MagicMock()
|
||||
strategy.remote_collection = MagicMock()
|
||||
strategy.binding_manager = MagicMock()
|
||||
strategy.reset_schema_diff_validator = MagicMock()
|
||||
strategy.emit_schema_diff_report = MagicMock()
|
||||
updated_node = SimpleNamespace(node_id="target-1")
|
||||
strategy.update_pair = AsyncMock(return_value=[updated_node])
|
||||
|
||||
with patch('sync_state_machine.sync_system.strategy_ops.update_ops.add_update_action', new_callable=AsyncMock) as mock_add_action:
|
||||
mock_add_action.return_value = []
|
||||
|
||||
await run_update(strategy)
|
||||
|
||||
# In the active directions, PUSH and PULL should be handled because of overrides.
|
||||
# Check PUSH call
|
||||
assert mock_add_action.call_count == 2
|
||||
|
||||
push_call = mock_add_action.call_args_list[0]
|
||||
assert push_call.kwargs['source_is_local'] is True
|
||||
assert push_call.kwargs['from_collection'] == strategy.local_collection
|
||||
|
||||
pull_call = mock_add_action.call_args_list[1]
|
||||
assert pull_call.kwargs['source_is_local'] is False
|
||||
assert pull_call.kwargs['from_collection'] == strategy.remote_collection
|
||||
|
||||
# Test the node filters
|
||||
push_filter = push_call.kwargs['node_filter']
|
||||
pull_filter = pull_call.kwargs['node_filter']
|
||||
with patch(
|
||||
'sync_state_machine.sync_system.strategy_ops.update_ops.build_data_id_normalization_map',
|
||||
new=AsyncMock(return_value={"project": "remote-project"}),
|
||||
), patch(
|
||||
'sync_state_machine.sync_system.strategy_ops.update_ops.collect_bound_node_pairs',
|
||||
new=AsyncMock(return_value=[BoundNodePair(local_node=local_node, remote_node=remote_node)]),
|
||||
):
|
||||
updated = await run_update(strategy)
|
||||
|
||||
# Node that has 'local_only' -> PUSH
|
||||
node_push = FakeNode({"sync_direction": "local_only"})
|
||||
assert push_filter(node_push) is True
|
||||
assert pull_filter(node_push) is False
|
||||
assert updated == [updated_node]
|
||||
|
||||
# Node that has 'remote_only' -> PULL
|
||||
node_pull = FakeNode({"sync_direction": "remote_only"})
|
||||
assert push_filter(node_pull) is False
|
||||
assert pull_filter(node_pull) is True
|
||||
strategy.update_pair.assert_awaited_once_with(
|
||||
local_node,
|
||||
remote_node,
|
||||
{"project": "remote-project"},
|
||||
)
|
||||
|
||||
# Node with unknown key -> default direction (PUSH)
|
||||
node_default = FakeNode({"sync_direction": "unknown"})
|
||||
assert push_filter(node_default) is True
|
||||
assert pull_filter(node_default) is False
|
||||
|
||||
# Node with empty data -> default direction (PUSH)
|
||||
node_empty = FakeNode(None)
|
||||
assert push_filter(node_empty) is True
|
||||
assert pull_filter(node_empty) is False
|
||||
def test_build_merged_update_payload_supports_include_and_exclude_fields():
|
||||
source_data = {
|
||||
"id": "remote-1",
|
||||
"project_name": "new-name",
|
||||
"special": "from-source",
|
||||
}
|
||||
target_data = {
|
||||
"id": "remote-1",
|
||||
"project_name": "old-name",
|
||||
"special": "from-target",
|
||||
}
|
||||
|
||||
include_only = build_merged_update_payload(
|
||||
source_data=source_data,
|
||||
target_data=target_data,
|
||||
include_fields=["special"],
|
||||
)
|
||||
exclude_special = build_merged_update_payload(
|
||||
source_data=source_data,
|
||||
target_data=target_data,
|
||||
exclude_fields=["special"],
|
||||
)
|
||||
|
||||
assert include_only == {
|
||||
"id": "remote-1",
|
||||
"project_name": "old-name",
|
||||
"special": "from-source",
|
||||
}
|
||||
assert exclude_special == {
|
||||
"id": "remote-1",
|
||||
"project_name": "new-name",
|
||||
"special": "from-target",
|
||||
}
|
||||
|
||||
|
||||
def test_construction_task_compare_payload_ignores_plan_node_is_audit():
|
||||
|
||||
+5
-10
@@ -18,9 +18,8 @@ from sync_state_machine.config import (
|
||||
JsonlDataSourceConfig,
|
||||
ApiDataSourceConfig,
|
||||
apply_config_overrides,
|
||||
resolve_log_file_path,
|
||||
)
|
||||
from sync_state_machine.pipeline.run_logger import clear_pipeline_logger_handlers, init_pipeline_logger
|
||||
from sync_state_machine.logging import clear_app_logger_handlers, configure_app_logging
|
||||
|
||||
from .config_models import PipelineUiConfig, default_ui_config
|
||||
|
||||
@@ -128,15 +127,11 @@ class PipelineRunnerService:
|
||||
app_logger = None
|
||||
|
||||
log_cfg = cfg.logging.model_copy(deep=True)
|
||||
log_path = resolve_log_file_path(log_cfg)
|
||||
self._status.current_log_file = str(log_path or "")
|
||||
app_logger = None
|
||||
|
||||
try:
|
||||
app_logger = init_pipeline_logger(
|
||||
log_file_path=log_path,
|
||||
mirror_console=True,
|
||||
replace_handlers=True,
|
||||
)
|
||||
app_logger = configure_app_logging(log_cfg, replace_handlers=True)
|
||||
self._status.current_log_file = str(log_cfg.file_path or "")
|
||||
ui_handler = _UiLogHandler(self._append_log_callback)
|
||||
ui_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
app_logger.addHandler(ui_handler)
|
||||
@@ -165,7 +160,7 @@ class PipelineRunnerService:
|
||||
ui_handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
clear_pipeline_logger_handlers(app_logger)
|
||||
clear_app_logger_handlers(app_logger)
|
||||
self._status.running = False
|
||||
self._status.ended_at = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user