多pipeline同步支持

This commit is contained in:
strepsiades
2026-03-23 21:02:52 +08:00
parent 0812489f9d
commit b51ed0175b
49 changed files with 2110 additions and 792 deletions
+2 -1
View File
@@ -95,4 +95,5 @@ run_profiles/*.yml
# Sync System Runtime Data
_runtime/
test_results/
*.code-workspace
*.code-workspace
runtime/
+14 -6
View File
@@ -58,15 +58,21 @@
## 5. 使用方法
运行配置遵循一个约定:
- 跟踪在仓库里的 `run_profiles/*.yaml``run_profiles/preset/*.default.yaml` 只写必要的覆盖项。
- pipeline 启动时会打印并写入一份 **Resolved Full Config**,把省略的默认值全部补齐,便于排障和审阅。
- 字段含义与全量示例见:`docs/runtime_config_guide.md`
### JSONL 同步
- 基线配置(优先)`ecm-sync-run --config_path=run_profiles/preset/jsonl_to_jsonl.default.yaml`
- 兼容旧入口`python run.py --config_path=run_profiles/preset/jsonl_to_jsonl.default.yaml`
- 使用自定义配置:先从 `run_profiles/preset/jsonl_to_jsonl.default.yaml` 复制到 `run_profiles/jsonl_to_jsonl.local.yaml`,按需修改后运行:`ecm-sync-run --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
- 已安装命令行入口`ecm-sync-run --config_path=run_profiles/preset/jsonl_to_jsonl.default.yaml`
- 仓库内直接运行`python run.py --config_path=run_profiles/preset/jsonl_to_jsonl.default.yaml`
- 多项目 JSONL 示例:`python run.py --config_path=run_profiles/preset/jsonl_to_jsonl.multi_project.default.yaml`
- 自定义配置:从 `run_profiles/preset/` 复制一份到 `run_profiles/*.local.yaml`,只改有差异的字段即可。
### API 同步
- 基线配置`ecm-sync-run --config_path=run_profiles/preset/jsonl_to_api.default.yaml`
- 兼容旧入口`python run.py --config_path=run_profiles/preset/jsonl_to_api.default.yaml`
- 使用自定义配置:先从 `run_profiles/preset/jsonl_to_api.default.yaml` 复制到 `run_profiles/jsonl_to_api.local.yaml`,按需修改后运行:`ecm-sync-run --config_path=run_profiles/jsonl_to_api.local.yaml`
- 已安装命令行入口`ecm-sync-run --config_path=run_profiles/preset/jsonl_to_api.default.yaml`
- 仓库内直接运行`python run.py --config_path=run_profiles/preset/jsonl_to_api.default.yaml`
- 如果是单项目联调,优先参考仓库内现成示例:`run_profiles/jsonl_to_api.yaml`
### 库模式使用
- 本地可编辑安装:`pip install -e .`
@@ -75,6 +81,7 @@
### 配置与校验
- 模板配置:`run_profiles/preset/*.default.yaml`
- 建议先跑 default,再在 `run_profiles/*.local.yaml` 做覆盖(该目录 YAML 已默认忽略)
- 跟踪中的模板与示例配置都采用“只写必要字段”的风格;启动日志里的 `Resolved Full Config` 才是最终生效配置。
- 状态机配置校验:`python tools/validate_config.py --config sync_state_machine/config/node_state_machine.yaml`
- 状态机图生成:`python tools/render_graph.py --format mermaid --out docs/state_machine.mmd`
@@ -88,6 +95,7 @@
- UI 调试:`python -m ui.main --host 127.0.0.1 --port 8765`
- 运维与排障:`docs/operations.md`
- 远程测试环境:`tools/remote_env.py`
- 脚本化回归与数据集构建:`scripts/README.md`
## 文档导航
+115 -3
View File
@@ -2,6 +2,91 @@
本文档从业务视角说明配置字段含义、对阶段行为的影响,以及推荐的覆盖方式。
## 0. 配置约定
当前运行配置分成两层:
1. 输入配置
- 你写在 `run_profiles/*.yaml``run_profiles/preset/*.default.yaml` 里的内容。
- 只建议写必要覆盖项,不要把默认值全部展开。
2. Resolved Full Config
- pipeline 启动时打印并写入日志的全量配置。
- 其中已经补齐 datasource 默认值、策略默认值和日志/持久化默认值。
也就是说:
- 模板应当尽量短。
- 排障时看启动日志里的 **Resolved Full Config**
### 0.1 一个最小输入示例
```yaml
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/remote_datasource/datasource
persist:
db_path: ${PROJECT_ROOT}/test_results/sync_state_machine/demo.db
strategies:
project:
config:
auto_bind: true
auto_bind_fields:
- name
```
### 0.2 对应的全量 resolved 结果骨架
```yaml
node_types:
- company
- supplier
- user
- project
...
local_datasource:
type: jsonl
jsonl_dir: /abs/path/filtered_datasource/datasource/filtered
read_only: false
handler_configs: {}
remote_datasource:
type: jsonl
jsonl_dir: /abs/path/remote_datasource/datasource
read_only: false
handler_configs: {}
strategies:
- node_type: project
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- name
pre_bind_data_id: []
depend_fields:
company_id: company
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: []
persist:
backend: sqlite
db_path: /abs/path/test_results/sync_state_machine/demo.db
backend_options: {}
enable: true
wipe_on_start: false
logging:
initialize: true
file_path: null
file_dir: null
file_name_pattern: null
level: 20
suppress_node_logs: true
```
## 1. 推荐使用方式(先 default,再覆盖)
1. 先使用基线配置运行:
@@ -48,7 +133,7 @@
## 4. 策略字段(Strategy
每个 node_type 都建议显式写出以下字段,便于审阅与排障
每个 node_type **Resolved Full Config** 都会包含以下字段,但输入配置不需要全部显式写出
### 4.1 `update_direction`
@@ -97,6 +182,32 @@
- `remote_datasource.handler_configs.supplier.load_mode: persisted_only`
- `persisted_only` 表示只使用已持久化数据,不主动从外部拉新数据(具体以对应 handler 实现为准)。
### 4.7 `field_direction_key` / `field_direction_overrides`
- 这两个字段只在少数 node_type 上需要,典型例子是 `project_detail_data`
- `field_direction_key`
- 指定从节点 data 里取哪个字段作为“方向查表键”。
- 例如 `key`,表示用 `data["key"]` 决定这条记录是 push 还是 pull。
- `field_direction_overrides`
- 指定“字段值 -> 更新方向”的映射。
- 没命中的值继续使用默认 `update_direction`
示例:
```yaml
project_detail_data:
config:
field_direction_key: key
field_direction_overrides:
ensure_production: pull
climb_production: pull
challenge_production: pull
```
含义:
- `key=ensure_production/climb_production/challenge_production` 时,从远端拉回本地。
- 其余 key 继续按默认 `update_direction=push` 处理。
## 5. 阶段行为与配置关系
- bind 阶段
@@ -108,8 +219,9 @@
- update 阶段
- `update_direction` 决定是否和向哪侧更新。
- 写入后 reloadPipeline 内置)
- 当某个 node_type 的 create 或 update 发生实际写入成功后,pipeline 会自动对该 node_type 重新执行两侧 `load`
- 目的:让后续 update 判断基于最新远端/本地数据,支持“create 后再补一次 update”的链路
- 对 API / 需要重新拉取权威状态的数据源,create 或 update 成功后仍会按 node_type reload。
- 对纯 JSONL -> JSONL pipelinereload 会跳过,直接复用当前内存状态
- 目的:避免对离线 JSONL 数据做无意义重复扫描,同时保留 API 数据源的权威状态刷新。
- 同步后一致性检查(Pipeline 内置)
- 每个 node_type 在本轮同步后会基于绑定对执行数据一致性校验(比较时忽略 `id/_id` 字段)。
- 若发现差异,会在日志中打印差异样本,并在汇总表新增 `Consistent` 列展示 `Y` / `N(差异数)`
-1
View File
@@ -10,4 +10,3 @@ filterwarnings =
ignore:'asyncio.set_event_loop_policy' is deprecated and slated for removal in Python 3.16:DeprecationWarning:pytest_asyncio\.plugin
# 也可以设置异步测试的默认 loop scope
asyncio_default_fixture_loop_scope = function
addopts = --ignore=scripts/test_api_sync.py --ignore=scripts/test_api_sync_top10.py --ignore=scripts/test_api_write_real_sample.py
+4 -8
View File
@@ -11,7 +11,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from sync_state_machine.config import load_overrides_from_file
from sync_state_machine.pipeline import build_config, run_pipeline_from_config
from sync_state_machine.pipeline import run_profile_from_file
def _resolve_config_path(raw_path: str) -> Path:
@@ -34,14 +34,10 @@ async def _main() -> int:
if not cfg_path.exists() or not cfg_path.is_file():
raise FileNotFoundError(f"Config file not found: {cfg_path}")
overrides = load_overrides_from_file(cfg_path, PROJECT_ROOT)
config = build_config(project_root=PROJECT_ROOT, overrides=overrides)
mode = f"{config.local_datasource.type}->{config.remote_datasource.type}"
print(f"🧩 Loaded config file: {cfg_path}")
await run_pipeline_from_config(
config,
title=f"sync_state_machine pipeline ({mode})",
await run_profile_from_file(
project_root=PROJECT_ROOT,
config_path=cfg_path,
print_summary=True,
)
return 0
+19 -3
View File
@@ -3,19 +3,35 @@
- 路径:项目根目录 `run_profiles/`
- 命名:建议使用 `*.local.yaml`(该目录下 YAML 默认会被 git 忽略)
当前约定:
- 仓库里跟踪的配置文件只写必要字段,不重复抄默认值。
- pipeline 启动时会打印 **Resolved Full Config**,把省略字段补齐。
- 如果你要看完整字段和字段含义,不看模板,直接看 `docs/runtime_config_guide.md`
模板来源:
- `run_profiles/preset/jsonl_to_jsonl.default.yaml`
- `run_profiles/preset/jsonl_to_api.default.yaml`
- `run_profiles/preset/jsonl_to_jsonl.multi_project.default.yaml`
推荐流程:
1. 从模板复制到本目录,例如:
- `run_profiles/jsonl_to_jsonl.local.yaml`
- `run_profiles/jsonl_to_api.local.yaml`
- `run_profiles/jsonl_to_jsonl.local.yaml`
- `run_profiles/jsonl_to_api.local.yaml`
2. 按需修改后运行:
- `ecm-sync-run --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
- `ecm-sync-run --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
兼容旧入口:
- `python run.py --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
多项目示例:
- `run_profiles/jsonl_to_jsonl.multi_project.yaml`
- `run_profiles/jsonl_to_jsonl.multi_project.global.yaml`
- `run_profiles/jsonl_to_jsonl.multi_project.project.yaml`
支持占位符:
- `${PROJECT_ROOT}`:运行时替换为项目根目录绝对路径。
现成示例:
- `run_profiles/jsonl_to_api.yaml`:单项目 JSONL -> API 联调示例
- `run_profiles/jsonl_to_api.test.yaml`auto-test 使用的 JSONL -> API 示例
- `run_profiles/jsonl_to_jsonl.multi_project.yaml`:多项目 JSONL -> JSONL 编排入口
+17 -2
View File
@@ -2,20 +2,35 @@
该目录存放版本管理的运行配置模板(default)。
模板约定:
- 这里只保留必要覆盖项。
- 省略的字段会在加载时从默认 datasource 配置和各 domain 的 `default_config` 自动补齐。
- 真正生效的全量配置,以启动日志里的 **Resolved Full Config** 为准。
当前模板:
- `jsonl_to_jsonl.default.yaml`
- `jsonl_to_api.default.yaml`
- `jsonl_to_jsonl.multi_project.default.yaml`
- `jsonl_to_jsonl.multi_project.global.default.yaml`
- `jsonl_to_jsonl.multi_project.project.default.yaml`
使用方式:
1. 复制模板到项目根目录 `run_profiles/`,并重命名为你自己的文件(如 `*.local.yaml`)。
2. 修改后通过入口运行:
2. 只改和当前环境/业务差异相关的字段。
3. 修改后通过入口运行:
- `ecm-sync-run --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
兼容旧入口:
- `python run.py --config_path=run_profiles/jsonl_to_jsonl.local.yaml`
多项目配置说明:
- `jsonl_to_jsonl.multi_project.default.yaml` 是多 pipeline 编排入口。
- `jsonl_to_jsonl.multi_project.global.default.yaml` 定义全局公共 domain 的 pipeline。
- `jsonl_to_jsonl.multi_project.project.default.yaml` 定义单项目 pipeline 默认配置。
- `project_bootstrap_node_types` 用于显式声明项目级 pipeline 需要从 global scope 继承到项目 scope 的公共节点类型,例如 `company``supplier`
说明:
- `run_profiles/*.yaml` / `run_profiles/*.yml` 默认被 `.gitignore` 忽略,用于本地私有配置。
- 模板内可使用 `${PROJECT_ROOT}` 占位符。
- 模板已显式展开 datasource 与 strategies 关键字段,便于集中审查
- 完整字段定义与字段含义见 `docs/runtime_config_guide.md`
- API 数据源支持全局限流配置:`api_rate_limit_max_requests``api_rate_limit_window_seconds`
+32 -254
View File
@@ -1,32 +1,33 @@
node_types:
- company
- supplier
- project
- contract
- contract_change
- construction_task
- construction_task_detail
- material
- material_detail
- contract_settlement
- contract_settlement_detail
- personnel
- personnel_detail
- preparation
- production
- lar
- lar_change
- project_detail_data
- units
- company
- supplier
- project
- contract
- contract_change
- construction_task
- construction_task_detail
- material
- material_detail
- contract_settlement
- contract_settlement_detail
- personnel
- personnel_detail
- preparation
- production
- lar
- lar_change
- project_detail_data
- units
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
read_only: false
handler_configs:
project:
data_id_filter:
- "0f9c3e22-f4bb-4803-825a-4f238eeb06d5"
- "f3e02e84-e81c-4aa6-88d4-f5aa108c8227"
remote_datasource:
type: api
api_base_url: http://localhost:8000/api/third/v2
@@ -36,257 +37,34 @@ remote_datasource:
api_rate_limit_max_requests: 30
api_rate_limit_window_seconds: 10.0
poll_max_retries: 1
poll_interval: 0.5
handler_configs:
project:
data_id_filter:
# 请替换为实际的项目ID,至少需要一个
- "0f9c3e22-f4bb-4803-825a-4f238eeb06d5"
- "f3e02e84-e81c-4aa6-88d4-f5aa108c8227"
supplier: {}
persist:
backend: sqlite
db_path: ${PROJECT_ROOT}/test_results/sync_state_machine/simple_pipeline_sm.db
enable: true
wipe_on_start: false
logging:
initialize: true
file_dir: ${PROJECT_ROOT}/test_results/sync_state_machine
file_name_pattern: simple_pipeline_sm_{timestamp}.log
level: 20
strategies:
company:
skip_sync: false
preset: pull_only
config:
auto_bind: true
auto_bind_fields:
- credit_code
depend_fields: {}
local_orphan_action: none
remote_orphan_action: create_local
update_direction: pull
- credit_code
supplier:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- credit_code
depend_fields: {}
local_orphan_action: none
remote_orphan_action: create_local
update_direction: pull
preset: pull_only
project:
skip_sync: false
config:
auto_bind: false
auto_bind_fields: []
pre_bind_data_id: []
depend_fields:
company_id: company
local_orphan_action: none
remote_orphan_action: none
update_direction: push
contract:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- company_id
- code
depend_fields:
project_id: project
company_id: supplier
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_change:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- change_time
- title
depend_fields:
project_id: project
contract_id: contract
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
construction_task:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- task_type
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
construction_task_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: construction_task
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
material:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- material_type
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
material_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: material
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_settlement:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_settlement_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: contract_settlement
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
personnel:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
contract_id: contract
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
personnel_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: personnel
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
preparation:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
production:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
lar:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
lar_change:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- change_time
- title
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
project_detail_data:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- key
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push # 未在 field_direction_overrides 中列出的 key 默认 push
field_direction_key: key # 以节点 data.key 字段值作为方向查表键
field_direction_overrides:
ensure_production: pull # 远程 → 本地
climb_production: pull # 远程 → 本地
challenge_production: pull # 远程 → 本地
units:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- serial_number
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
post_check_ignore_fields:
- is_exist
+2 -217
View File
@@ -22,261 +22,46 @@ node_types:
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
read_only: false
handler_configs: {}
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/remote_datasource/datasource
read_only: false
handler_configs: {}
persist:
backend: sqlite
db_path: ${PROJECT_ROOT}/test_results/sync_state_machine/full_sync_pipeline_jsonl_sm.db
enable: true
wipe_on_start: false
logging:
initialize: true
file_dir: ${PROJECT_ROOT}/test_results/sync_state_machine
file_name_pattern: jsonl_pipeline_sm_{timestamp}.log
level: 20
strategies:
company:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- credit_code
depend_fields: {}
- credit_code
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
user:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- username
depend_fields: {}
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
supplier:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- credit_code
depend_fields: {}
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
project:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- name
depend_fields:
company_id: company
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- company_id
- code
depend_fields:
project_id: project
company_id: supplier
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_change:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- change_time
- title
depend_fields:
project_id: project
contract_id: contract
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
construction_task:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- task_type
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
construction_task_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: construction_task
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
material:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
- material_type
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
material_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: material
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_settlement:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- contract_id
depend_fields:
contract_id: contract
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
contract_settlement_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: contract_settlement
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
personnel:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
contract_id: contract
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
personnel_detail:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- parent_id
- date
depend_fields:
parent_id: personnel
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
preparation:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
production:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- code
depend_fields:
project_id: project
local_orphan_action: none
remote_orphan_action: none
update_direction: push
- name
lar:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
depend_fields:
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
lar_change:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- change_time
- title
depend_fields:
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
project_detail_data:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- key
depend_fields:
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
units:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
- serial_number
depend_fields:
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
@@ -0,0 +1,12 @@
global_config: run_profiles/preset/jsonl_to_jsonl.multi_project.global.default.yaml
default_project_config: run_profiles/preset/jsonl_to_jsonl.multi_project.project.default.yaml
project_bootstrap_node_types:
- company
- supplier
projects:
- local_project_id: f3e02e84-e81c-4aa6-88d4-f5aa108c8227
remote_project_id: f3e02e84-e81c-4aa6-88d4-f5aa108c8227
- local_project_id: 0f9c3e22-f4bb-4803-825a-4f238eeb06d5
remote_project_id: 0f9c3e22-f4bb-4803-825a-4f238eeb06d5
@@ -0,0 +1,29 @@
node_types:
- company
- supplier
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/working_local_datasource/20260302-020003
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/runtime/tmp/multi_project_jsonl_remote
persist:
db_path: ${PROJECT_ROOT}/test_results/sync_state_machine/multi_project_jsonl.db
wipe_on_start: true
logging:
file_dir: ${PROJECT_ROOT}/test_results/sync_state_machine
file_name_pattern: multi_project_global_{timestamp}.log
strategies:
company:
config:
auto_bind_fields:
- code
- name
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
supplier:
config:
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
@@ -0,0 +1,47 @@
node_types:
- project
- contract
- contract_change
- construction_task
- construction_task_detail
- material
- material_detail
- contract_settlement
- contract_settlement_detail
- personnel
- personnel_detail
- preparation
- production
- lar
- lar_change
- project_detail_data
- units
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/working_local_datasource/20260302-020003
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/runtime/tmp/multi_project_jsonl_remote
persist:
db_path: ${PROJECT_ROOT}/test_results/sync_state_machine/multi_project_jsonl.db
logging:
file_dir: ${PROJECT_ROOT}/test_results/sync_state_machine
file_name_pattern: multi_project_project_{timestamp}.log
strategies:
project:
config:
auto_bind: true
auto_bind_fields:
- name
lar:
config:
local_orphan_action: create_remote
lar_change:
config:
local_orphan_action: create_remote
project_detail_data:
config:
local_orphan_action: create_remote
units:
config:
local_orphan_action: create_remote
+32 -35
View File
@@ -1,44 +1,41 @@
# 脚本目录
## 文件说明
本目录当前主要放两类脚本:
- 自动化测试编排脚本
- 本地数据集构建/分析脚本
### test_api_sync.py
主要的API数据同步测试脚本。
旧的 `scripts/test_api_sync.py``API_SYNC_GUIDE.md` 已不存在,不再作为当前入口。
**功能:**
- 从远程API获取所有GET/LIST接口数据
- 自动分页获取全量数据
- 保存为JSONL格式到 `remote_datasource` 目录
- 支持单项目模式或全量同步
## 当前常用入口
### 自动化测试
- `scripts/auto_test_init.py`
- 初始化 auto-test 运行环境,负责清理状态、恢复测试数据、拉起后端。
- `scripts/auto_test_clear.py`
- 清理 auto-test 运行环境。
- `scripts/run_auto_test.py`
- 运行指定 domain 的多轮自动回归并输出报告。
- `scripts/run_auto_test_all_domains.py`
- 聚合运行多个 domain 的自动回归。
- `scripts/run_large_scale_auto_test.py`
- 大规模自动回归入口。
### 数据集与分析
- `scripts/build_filtered_datasource_for_projects.py`
- 从完整 datasource 中抽取指定项目,生成可复现的精简 JSONL 数据集。
- `scripts/analyze_state_signatures.py`
- 分析状态签名与差异。
## 推荐命令
这些命令目前可直接使用:
**运行方式:**
```bash
python scripts/test_api_sync.py
python scripts/build_filtered_datasource_for_projects.py --help
python scripts/run_auto_test.py --help
```
**数据类型覆盖:**
- 基础数据: company, user, supplier, project
- 项目数据: contract, construction_task, material, contract_settlement, personnel
- 项目扩展: preparation, production, lar, units (从all_info获取)
- 明细数据: construction_task_detail, material_detail, contract_settlement_detail, personnel_detail
## 进一步说明
**配置说明:**
在文件中修改以下配置:
```python
BASE_URL = "http://localhost:8000/api/third/v2"
UID = "your-uid"
SECRET_KEY = "your-secret-key"
TARGET_PROJECT_ID = "xxx-xxx-xxx" # 单项目模式,设为None则全量同步
```
## 输出结果
数据保存在 `remote_datasource/datasource/` 目录下:
- `company_1.jsonl` - 公司数据
- `user_1.jsonl` - 用户数据
- `project_1.jsonl` - 项目数据
- ... 其他数据类型
## 更多文档
详细文档请查看项目根目录的 [API_SYNC_GUIDE.md](../API_SYNC_GUIDE.md)
- 自动化测试总览:`docs/testing_guide.md`
- 大规模自动回归方法:`docs/auto_test_spec/agent_auto_test_method.md`
+9 -1
View File
@@ -25,12 +25,15 @@ from .config import (
list_registered_datasource_types,
load_named_preset_overrides,
load_overrides_from_file,
MultiProjectRunConfig,
build_multi_project_config_from_file,
is_multi_project_payload,
register_datasource_config_model,
register_datasource_factory,
register_datasource_type,
)
from .datasource import ApiClient, ApiDataSource, BaseApiHandler, BaseJsonlHandler, JsonlDataSource
from .pipeline import FullSyncPipeline, create_pipeline_from_config, run_pipeline_from_config
from .pipeline import FullSyncPipeline, MultiProjectPipeline, create_pipeline_from_config, run_pipeline_from_config, run_profile_from_file
try:
__version__ = version("ecm-sync-system")
@@ -59,6 +62,9 @@ __all__ = [
"list_registered_datasource_types",
"load_overrides_from_file",
"load_named_preset_overrides",
"MultiProjectRunConfig",
"build_multi_project_config_from_file",
"is_multi_project_payload",
"register_datasource_config_model",
"register_datasource_factory",
"register_datasource_type",
@@ -68,6 +74,8 @@ __all__ = [
"BaseApiHandler",
"BaseJsonlHandler",
"FullSyncPipeline",
"MultiProjectPipeline",
"create_pipeline_from_config",
"run_pipeline_from_config",
"run_profile_from_file",
]
+4 -9
View File
@@ -5,8 +5,7 @@ import asyncio
import logging
from pathlib import Path
from .config import build_config, load_overrides_from_file
from .pipeline import run_pipeline_from_config
from .pipeline import run_profile_from_file
def package_project_root() -> Path:
@@ -35,14 +34,10 @@ async def _main() -> int:
if not cfg_path.exists() or not cfg_path.is_file():
raise FileNotFoundError(f"Config file not found: {cfg_path}")
overrides = load_overrides_from_file(cfg_path, project_root)
config = build_config(project_root=project_root, overrides=overrides)
mode = f"{config.local_datasource.type}->{config.remote_datasource.type}"
print(f"🧩 Loaded config file: {cfg_path}")
await run_pipeline_from_config(
config,
title=f"sync_state_machine pipeline ({mode})",
await run_profile_from_file(
project_root=project_root,
config_path=cfg_path,
print_summary=True,
)
return 0
+55 -8
View File
@@ -12,9 +12,10 @@ class BindingManager:
BindingManager 负责维护不同 DataCollection 间节点的绑定关系。
所有查询均在内存中进行,持久化仅作为备份。
"""
def __init__(self, persistence: PersistenceBackend, auto_persist: bool = True):
def __init__(self, persistence: PersistenceBackend, auto_persist: bool = True, scope: str = "global"):
self.persistence = persistence
self.auto_persist = auto_persist
self.scope = scope
# { node_type: { local_id: remote_id } }
self._bindings: Dict[str, Dict[str, Optional[str]]] = {}
self._deleted: Dict[str, set[str]] = {}
@@ -55,7 +56,7 @@ class BindingManager:
logger.debug(log_line)
if self.auto_persist:
await self.persistence.save_binding(node_type, local_id, {"remote_id": remote_id})
await self.persistence.save_binding(self.scope, node_type, local_id, {"remote_id": remote_id})
async def unbind(self, node_type: str, local_id: str, manual: bool = False):
"""
@@ -80,7 +81,7 @@ class BindingManager:
)
if self.auto_persist:
await self.persistence.delete_binding(node_type, local_id)
await self.persistence.delete_binding(self.scope, node_type, local_id)
else:
self._get_deleted(node_type).add(local_id)
@@ -119,25 +120,71 @@ class BindingManager:
async def load_from_persistence(self, node_type: str):
"""从持久化层加载特定类型的绑定关系到内存"""
raw_bindings = await self.persistence.load_bindings(node_type)
raw_bindings = await self.persistence.load_bindings(self.scope, node_type)
bindings = self._get_type_bindings(node_type)
bindings.clear()
self._get_deleted(node_type).clear()
for rb in raw_bindings:
payload = rb.get("payload") or {}
bindings[rb["local_id"]] = payload.get("remote_id")
async def persist(self) -> None:
def import_bindings(self, node_type: str, bindings: Dict[str, Optional[str]]) -> None:
"""导入既有绑定到当前 manager,仅写内存,不触发持久化。"""
target = self._get_type_bindings(node_type)
target.clear()
target.update(dict(bindings))
self._get_deleted(node_type).clear()
async def prune_missing_nodes(
self,
*,
local_collection,
remote_collection,
node_types: Optional[List[str]] = None,
) -> int:
target_types = list(node_types) if node_types is not None else list(self._bindings.keys())
deleted_count = 0
for node_type in target_types:
bindings = self._get_type_bindings(node_type)
if not bindings:
continue
valid_local_ids = {node.node_id for node in local_collection.filter(node_type=node_type)}
valid_remote_ids = {node.node_id for node in remote_collection.filter(node_type=node_type)}
stale_local_ids = [
local_id
for local_id, remote_id in bindings.items()
if local_id not in valid_local_ids or (remote_id is not None and remote_id not in valid_remote_ids)
]
for local_id in stale_local_ids:
del bindings[local_id]
deleted_count += 1
if self.auto_persist:
await self.persistence.delete_binding(self.scope, node_type, local_id)
else:
self._get_deleted(node_type).add(local_id)
return deleted_count
async def persist(self, *, exclude_node_types: Optional[List[str]] = None) -> None:
"""将所有绑定关系写回持久化层"""
if not self._bindings and not self._deleted:
logger.info("🔍 [BindingManager.persist] 跳过:无绑定变更")
return
excluded = set(node_type for node_type in (exclude_node_types or []) if node_type)
type_count = 0
saved_total = 0
deleted_total = 0
touched_types: List[str] = []
for node_type, bindings in self._bindings.items():
if node_type in excluded:
continue
deleted = list(self._get_deleted(node_type))
binding_count = len(bindings)
deleted_count = len(deleted)
@@ -149,13 +196,13 @@ class BindingManager:
touched_types.append(node_type)
if deleted:
await self.persistence.delete_bindings_bulk(node_type, deleted)
await self.persistence.delete_bindings_bulk(self.scope, node_type, deleted)
self._get_deleted(node_type).clear()
if bindings:
await self.persistence.save_bindings_bulk(node_type, bindings)
await self.persistence.save_bindings_bulk(self.scope, node_type, bindings)
touched_text = ",".join(touched_types) if touched_types else "none"
logger.info(
f"🔍 [BindingManager.persist] 完成: types={type_count}, saved={saved_total}, deleted={deleted_total}, touched=[{touched_text}]"
f"🔍 [BindingManager.persist] 完成: scope={self.scope}, types={type_count}, saved={saved_total}, deleted={deleted_total}, touched=[{touched_text}]"
)
+160 -8
View File
@@ -1,4 +1,5 @@
import uuid
import copy
from enum import Enum
from typing import Dict, List, Optional, Any, Set, Callable, Literal
from .sync_node import SyncNode
@@ -22,11 +23,13 @@ class DataCollection:
def __init__(
self,
collection_id: str,
scope: str = "global",
persistence: Optional[PersistenceBackend] = None,
auto_persist: bool = False,
sm_runtime: Optional[Any] = None,
):
self.collection_id = collection_id
self.scope = scope
self.persistence = persistence
self.auto_persist = auto_persist
self._sm_runtime = sm_runtime
@@ -67,7 +70,7 @@ class DataCollection:
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
await self.persistence.save_node(self.collection_id, self.scope, node)
async def update(self, node: SyncNode):
old_node = self._nodes.get(node.node_id)
@@ -85,7 +88,7 @@ class DataCollection:
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
if self.persistence and self.auto_persist:
await self.persistence.save_node(self.collection_id, node)
await self.persistence.save_node(self.collection_id, self.scope, node)
async def delete(self, node_id: str):
old_node = self._nodes.get(node_id)
@@ -96,7 +99,7 @@ class DataCollection:
del self._nodes[node_id]
if self.persistence:
if self.auto_persist:
await self.persistence.delete_node(self.collection_id, node_id)
await self.persistence.delete_node(self.collection_id, self.scope, node_id)
else:
self._deleted_node_ids.add(node_id)
@@ -370,7 +373,7 @@ class DataCollection:
self._data_id_to_node_id = {}
self._deleted_node_ids.clear()
node_dicts = await self.persistence.load_nodes(self.collection_id)
node_dicts = await self.persistence.load_nodes(self.collection_id, self.scope)
from .types import SyncAction, SyncStatus, BindingStatus
from .registry import DomainRegistry
@@ -491,7 +494,7 @@ class DataCollection:
node_types = {node.node_type for node in self._nodes.values()}
for node_type in node_types:
override_map = await self.persistence.load_bind_data_id_overrides(node_type)
override_map = await self.persistence.load_bind_data_id_overrides(node_type, self.scope)
for node in self.filter(node_type=node_type):
bind_data_id = override_map.get(node.node_id)
if bind_data_id:
@@ -499,20 +502,169 @@ class DataCollection:
else:
node.context.pop("bind_data_id", None)
async def persist(self) -> None:
async def load_from_persistence_excluding(self, node_types: Optional[List[str]] = None):
"""从持久化恢复数据,但跳过指定 node_type。"""
if not self.persistence:
return
self._nodes = {}
self._data_id_to_node_id = {}
self._deleted_node_ids.clear()
node_dicts = await self.persistence.load_nodes(
self.collection_id,
self.scope,
exclude_node_types=node_types,
)
from .types import SyncAction, SyncStatus, BindingStatus
from .registry import DomainRegistry
for node_dict in node_dicts:
node_type = node_dict["node_type"]
node_class = DomainRegistry.get_node_class(node_type)
assert issubclass(node_class, SyncNode)
parse_errors: list[str] = []
raw_action = node_dict.get("action", SyncAction.NONE)
if isinstance(raw_action, str):
try:
action_value = SyncAction(raw_action)
except ValueError:
parse_errors.append(f"invalid action={raw_action}")
action_value = SyncAction.NONE
elif isinstance(raw_action, SyncAction):
action_value = raw_action
else:
if raw_action is None:
action_value = SyncAction.NONE
else:
parse_errors.append(f"invalid action type={type(raw_action).__name__}")
action_value = SyncAction.NONE
raw_status = node_dict.get("status", SyncStatus.PENDING)
if isinstance(raw_status, str):
try:
status_value = SyncStatus(raw_status)
except ValueError:
parse_errors.append(f"invalid status={raw_status}")
status_value = SyncStatus.FAILED
elif isinstance(raw_status, SyncStatus):
status_value = raw_status
else:
if raw_status is None:
status_value = SyncStatus.PENDING
else:
parse_errors.append(f"invalid status type={type(raw_status).__name__}")
status_value = SyncStatus.FAILED
raw_binding_status = node_dict.get("binding_status", BindingStatus.UNCHECKED)
if isinstance(raw_binding_status, str):
try:
binding_status_value = BindingStatus(raw_binding_status)
except ValueError:
parse_errors.append(f"invalid binding_status={raw_binding_status}")
binding_status_value = BindingStatus.ABNORMAL
elif isinstance(raw_binding_status, BindingStatus):
binding_status_value = raw_binding_status
else:
if raw_binding_status is None:
binding_status_value = BindingStatus.UNCHECKED
else:
parse_errors.append(
f"invalid binding_status type={type(raw_binding_status).__name__}"
)
binding_status_value = BindingStatus.ABNORMAL
raw_context = node_dict.get("context", {})
context_value = raw_context if isinstance(raw_context, dict) else {}
error_value = node_dict.get("error")
if parse_errors:
context_value = dict(context_value)
context_value["bootstrap_blocked"] = True
context_value["bootstrap_block_reason"] = "invalid_persisted_enum"
context_value["bootstrap_block_errors"] = parse_errors
binding_status_value = BindingStatus.ABNORMAL
action_value = SyncAction.NONE
status_value = SyncStatus.FAILED
details = "; ".join(parse_errors)
error_value = f"LOAD_INVALID_ENUM: {details}"
node = node_class(
node_id=node_dict["node_id"],
data_id=node_dict.get("data_id", ""),
depend_ids=node_dict.get("depend_ids", []),
data=node_dict.get("data"),
origin_data=node_dict.get("origin_data"),
action=action_value,
status=status_value,
binding_status=binding_status_value,
error=error_value,
sync_log=None,
context=context_value,
)
node.context["_loaded_from_persistence"] = True
self._nodes[node.node_id] = node
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected while loading: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
loaded_node_types = {node.node_type for node in self._nodes.values()}
for node_type in loaded_node_types:
override_map = await self.persistence.load_bind_data_id_overrides(node_type, self.scope)
for node in self.filter(node_type=node_type):
bind_data_id = override_map.get(node.node_id)
if bind_data_id:
node.context["bind_data_id"] = bind_data_id
else:
node.context.pop("bind_data_id", None)
async def import_nodes(self, nodes: List[SyncNode]) -> None:
"""导入现有节点到当前 collection,仅写内存,不触发持久化。"""
for source_node in nodes:
node = copy.deepcopy(source_node)
if node.node_id in self._nodes:
continue
if node.data_id and node.data_id != "":
existing_node_id = self._data_id_to_node_id.get(node.data_id)
if existing_node_id is not None and existing_node_id != node.node_id:
raise ValueError(
f"Duplicate data_id detected: {node.data_id} already bound to node_id={existing_node_id}"
)
self._data_id_to_node_id[node.data_id] = node.node_id
self._nodes[node.node_id] = node
self._deleted_node_ids.discard(node.node_id)
async def persist(self, *, exclude_node_types: Optional[List[str]] = None) -> None:
"""将当前 collection 全量持久化到后端"""
if not self.persistence:
return
# 先落库删除(用于 auto_persist=False 的场景)
if self._deleted_node_ids:
await self.persistence.delete_nodes_bulk(self.collection_id, list(self._deleted_node_ids))
await self.persistence.delete_nodes_bulk(self.collection_id, self.scope, list(self._deleted_node_ids))
self._deleted_node_ids.clear()
if not self._nodes:
return
await self.persistence.save_nodes_bulk(self.collection_id, list(self._nodes.values()))
excluded = set(node_type for node_type in (exclude_node_types or []) if node_type)
nodes_to_persist = [
node for node in self._nodes.values() if node.node_type not in excluded
]
if not nodes_to_persist:
return
await self.persistence.save_nodes_bulk(self.collection_id, self.scope, nodes_to_persist)
async def filter_by_project_ids(self, data_id_filter: List[str]) -> int:
"""按目标项目 data_id 集合过滤当前 collection,返回删除数量。"""
+194 -47
View File
@@ -114,6 +114,7 @@ class PersistenceBackend:
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS nodes (
collection_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'global',
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
data_id TEXT,
@@ -126,7 +127,7 @@ class PersistenceBackend:
error TEXT,
sync_log TEXT,
context TEXT,
PRIMARY KEY (collection_id, node_id)
PRIMARY KEY (collection_id, scope, node_id)
)
""")
await self._ensure_nodes_schema()
@@ -140,8 +141,49 @@ class PersistenceBackend:
cursor = await self.conn.execute("PRAGMA table_info(nodes)")
rows = await cursor.fetchall()
columns = [r[1] for r in rows] if rows else []
if "scope" not in columns:
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS nodes_new (
collection_id TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'global',
node_id TEXT NOT NULL,
node_type TEXT NOT NULL,
data_id TEXT,
bind_data_id TEXT,
data TEXT,
origin_data TEXT,
action TEXT,
status TEXT,
binding_status TEXT,
error TEXT,
sync_log TEXT,
context TEXT,
PRIMARY KEY (collection_id, scope, node_id)
)
""")
await self.conn.execute("""
INSERT INTO nodes_new (
collection_id, scope, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
SELECT
collection_id, 'global', node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
FROM nodes
""")
await self.conn.execute("DROP TABLE nodes")
await self.conn.execute("ALTER TABLE nodes_new RENAME TO nodes")
cursor = await self.conn.execute("PRAGMA table_info(nodes)")
rows = await cursor.fetchall()
columns = [r[1] for r in rows] if rows else []
if "bind_data_id" not in columns:
await self.conn.execute("ALTER TABLE nodes ADD COLUMN bind_data_id TEXT")
await self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_nodes_collection_scope_type ON nodes (collection_id, scope, node_type)"
)
await self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_nodes_collection_scope_data_id ON nodes (collection_id, scope, data_id)"
)
async def _ensure_bindings_schema(self) -> None:
"""确保 bindings 表为 (node_type, local_id, remote_id) 结构。
@@ -155,46 +197,60 @@ class PersistenceBackend:
if not columns:
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS bindings (
scope TEXT NOT NULL DEFAULT 'global',
node_type TEXT NOT NULL,
local_id TEXT NOT NULL,
remote_id TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (node_type, local_id)
PRIMARY KEY (scope, node_type, local_id)
)
""")
return
if "payload" in columns and "remote_id" not in columns:
# 迁移旧结构 payload -> remote_id
if "scope" not in columns or ("payload" in columns and "remote_id" not in columns):
# 迁移旧结构到 scope + remote_id 新结构
await self.conn.execute("""
CREATE TABLE IF NOT EXISTS bindings_new (
scope TEXT NOT NULL DEFAULT 'global',
node_type TEXT NOT NULL,
local_id TEXT NOT NULL,
remote_id TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (node_type, local_id)
PRIMARY KEY (scope, node_type, local_id)
)
""")
async with self.conn.execute("SELECT node_type, local_id, payload FROM bindings") as cur:
old_rows = await cur.fetchall()
for row in old_rows:
try:
payload_dict = json.loads(row[2]) if row[2] else {}
except json.JSONDecodeError:
payload_dict = {}
remote_id = payload_dict.get("remote_id")
await self.conn.execute(
"INSERT OR REPLACE INTO bindings_new (node_type, local_id, remote_id) VALUES (?, ?, ?)",
(row[0], row[1], remote_id),
)
if "payload" in columns and "remote_id" not in columns:
async with self.conn.execute("SELECT node_type, local_id, payload FROM bindings") as cur:
old_rows = await cur.fetchall()
for row in old_rows:
try:
payload_dict = json.loads(row[2]) if row[2] else {}
except json.JSONDecodeError:
payload_dict = {}
remote_id = payload_dict.get("remote_id")
await self.conn.execute(
"INSERT OR REPLACE INTO bindings_new (scope, node_type, local_id, remote_id) VALUES (?, ?, ?, ?)",
("global", row[0], row[1], remote_id),
)
else:
async with self.conn.execute("SELECT node_type, local_id, remote_id FROM bindings") as cur:
old_rows = await cur.fetchall()
for row in old_rows:
await self.conn.execute(
"INSERT OR REPLACE INTO bindings_new (scope, node_type, local_id, remote_id) VALUES (?, ?, ?, ?)",
("global", row[0], row[1], row[2]),
)
await self.conn.execute("DROP TABLE bindings")
await self.conn.execute("ALTER TABLE bindings_new RENAME TO bindings")
await self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_bindings_scope_type_remote ON bindings (scope, node_type, remote_id)"
)
# --- DataCollection 支持 ---
async def save_node(self, collection_id: str, node: "SyncNode"):
async def save_node(self, collection_id: str, scope: str, node: "SyncNode"):
"""保存或更新单个节点(depend_ids不持久化,运行时从data实时计算)"""
bind_data_id = None
if isinstance(node.context, dict):
@@ -203,12 +259,13 @@ class PersistenceBackend:
bind_data_id = str(value)
await self.conn.execute("""
INSERT OR REPLACE INTO nodes (
collection_id, node_id, node_type, data_id, bind_data_id,
collection_id, scope, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
collection_id,
scope,
node.node_id,
node.node_type,
node.data_id,
@@ -224,7 +281,7 @@ class PersistenceBackend:
))
await self.conn.commit()
async def save_nodes_bulk(self, collection_id: str, nodes: List["SyncNode"]):
async def save_nodes_bulk(self, collection_id: str, scope: str, nodes: List["SyncNode"]):
"""批量保存或更新节点 (单次提交,depend_ids不持久化)"""
if not nodes:
return
@@ -237,6 +294,7 @@ class PersistenceBackend:
bind_data_id = str(value)
rows.append((
collection_id,
scope,
node.node_id,
node.node_type,
node.data_id,
@@ -253,32 +311,49 @@ class PersistenceBackend:
await self.conn.executemany("""
INSERT OR REPLACE INTO nodes (
collection_id, node_id, node_type, data_id, bind_data_id,
collection_id, scope, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", rows)
await self.conn.commit()
async def delete_node(self, collection_id: str, node_id: str):
async def delete_node(self, collection_id: str, scope: str, node_id: str):
"""删除单个节点"""
await self.conn.execute("DELETE FROM nodes WHERE collection_id = ? AND node_id = ?", (collection_id, node_id))
await self.conn.execute(
"DELETE FROM nodes WHERE collection_id = ? AND scope = ? AND node_id = ?",
(collection_id, scope, node_id),
)
await self.conn.commit()
async def delete_nodes_bulk(self, collection_id: str, node_ids: List[str]):
async def delete_nodes_bulk(self, collection_id: str, scope: str, node_ids: List[str]):
"""批量删除节点 (单次提交)"""
if not node_ids:
return
rows = [(collection_id, node_id) for node_id in node_ids]
rows = [(collection_id, scope, node_id) for node_id in node_ids]
await self.conn.executemany(
"DELETE FROM nodes WHERE collection_id = ? AND node_id = ?",
"DELETE FROM nodes WHERE collection_id = ? AND scope = ? AND node_id = ?",
rows,
)
await self.conn.commit()
async def load_nodes(self, collection_id: str) -> List[Dict[str, Any]]:
async def load_nodes(
self,
collection_id: str,
scope: str,
*,
exclude_node_types: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""从特定 collection 中加载所有节点(depend_ids初始化为空,稍后从data实时计算)"""
async with self.conn.execute("SELECT * FROM nodes WHERE collection_id = ?", (collection_id,)) as cursor:
sql = "SELECT * FROM nodes WHERE collection_id = ? AND scope = ?"
params: List[Any] = [collection_id, scope]
filtered_types = [node_type for node_type in (exclude_node_types or []) if node_type]
if filtered_types:
placeholders = ", ".join("?" for _ in filtered_types)
sql += f" AND node_type NOT IN ({placeholders})"
params.extend(filtered_types)
async with self.conn.execute(sql, tuple(params)) as cursor:
rows = await cursor.fetchall()
results = []
for row in rows:
@@ -303,52 +378,52 @@ class PersistenceBackend:
# --- BindingManager 支持 ---
async def save_binding(self, node_type: str, local_id: str, payload_dict: Dict[str, Any]):
async def save_binding(self, scope: str, node_type: str, local_id: str, payload_dict: Dict[str, Any]):
"""保存或更新绑定关系及其元数据"""
remote_id = payload_dict.get("remote_id") if payload_dict else None
await self.conn.execute("""
INSERT OR REPLACE INTO bindings (node_type, local_id, remote_id)
VALUES (?, ?, ?)
""", (node_type, local_id, remote_id))
INSERT OR REPLACE INTO bindings (scope, node_type, local_id, remote_id)
VALUES (?, ?, ?, ?)
""", (scope, node_type, local_id, remote_id))
await self.conn.commit()
async def save_bindings_bulk(self, node_type: str, bindings: Dict[str, Optional[str]]):
async def save_bindings_bulk(self, scope: str, node_type: str, bindings: Dict[str, Optional[str]]):
"""批量保存或更新绑定关系 (单次提交)"""
if not bindings:
return
rows = []
for local_id, remote_id in bindings.items():
rows.append((node_type, local_id, remote_id))
rows.append((scope, node_type, local_id, remote_id))
await self.conn.executemany(
"INSERT OR REPLACE INTO bindings (node_type, local_id, remote_id) VALUES (?, ?, ?)",
"INSERT OR REPLACE INTO bindings (scope, node_type, local_id, remote_id) VALUES (?, ?, ?, ?)",
rows,
)
await self.conn.commit()
async def delete_binding(self, node_type: str, local_id: str):
async def delete_binding(self, scope: str, node_type: str, local_id: str):
"""删除绑定关系"""
await self.conn.execute(
"DELETE FROM bindings WHERE node_type = ? AND local_id = ?",
(node_type, local_id)
"DELETE FROM bindings WHERE scope = ? AND node_type = ? AND local_id = ?",
(scope, node_type, local_id)
)
await self.conn.commit()
async def delete_bindings_bulk(self, node_type: str, local_ids: List[str]):
async def delete_bindings_bulk(self, scope: str, node_type: str, local_ids: List[str]):
"""批量删除绑定关系 (单次提交)"""
if not local_ids:
return
rows = [(node_type, local_id) for local_id in local_ids]
rows = [(scope, node_type, local_id) for local_id in local_ids]
await self.conn.executemany(
"DELETE FROM bindings WHERE node_type = ? AND local_id = ?",
"DELETE FROM bindings WHERE scope = ? AND node_type = ? AND local_id = ?",
rows,
)
await self.conn.commit()
async def load_bindings(self, node_type: str) -> List[Dict[str, Any]]:
async def load_bindings(self, scope: str, node_type: str) -> List[Dict[str, Any]]:
"""回写该类型下的所有绑定关系到内存"""
async with self.conn.execute(
"SELECT local_id, remote_id FROM bindings WHERE node_type = ?",
(node_type,)
"SELECT local_id, remote_id FROM bindings WHERE scope = ? AND node_type = ?",
(scope, node_type)
) as cursor:
rows = await cursor.fetchall()
return [
@@ -356,7 +431,7 @@ class PersistenceBackend:
for r in rows
]
async def load_bind_data_id_overrides(self, node_type: str) -> Dict[str, Optional[str]]:
async def load_bind_data_id_overrides(self, node_type: str, scope: str) -> Dict[str, Optional[str]]:
"""基于 bindings 表计算 node_id -> bind_data_id 覆盖映射。
规则:
@@ -369,7 +444,9 @@ class PersistenceBackend:
LEFT JOIN nodes rn
ON rn.node_id = b.remote_id
AND rn.node_type = b.node_type
AND rn.scope = b.scope
WHERE b.node_type = ?
AND b.scope = ?
UNION ALL
@@ -378,10 +455,12 @@ class PersistenceBackend:
LEFT JOIN nodes ln
ON ln.node_id = b.local_id
AND ln.node_type = b.node_type
AND ln.scope = b.scope
WHERE b.node_type = ?
AND b.scope = ?
AND b.remote_id IS NOT NULL
"""
async with self.conn.execute(sql, (node_type, node_type)) as cursor:
async with self.conn.execute(sql, (node_type, scope, node_type, scope)) as cursor:
rows = await cursor.fetchall()
result: Dict[str, Optional[str]] = {}
for row in rows:
@@ -391,6 +470,74 @@ class PersistenceBackend:
result[str(src_node_id)] = row["bind_data_id"]
return result
async def delete_scope_node_types(
self,
*,
scope: str,
node_types: List[str],
) -> None:
filtered_types = [node_type for node_type in node_types if node_type]
if not filtered_types:
return
placeholders = ", ".join("?" for _ in filtered_types)
await self.conn.execute(
f"DELETE FROM nodes WHERE scope = ? AND node_type IN ({placeholders})",
(scope, *filtered_types),
)
await self.conn.execute(
f"DELETE FROM bindings WHERE scope = ? AND node_type IN ({placeholders})",
(scope, *filtered_types),
)
await self.conn.commit()
async def copy_nodes_between_scopes(
self,
*,
collection_id: str,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
if source_scope == target_scope or not node_types:
return
placeholders = ", ".join("?" for _ in node_types)
sql = f"""
INSERT OR REPLACE INTO nodes (
collection_id, scope, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
)
SELECT
collection_id, ?, node_id, node_type, data_id, bind_data_id,
data, origin_data, action, status, binding_status, error, sync_log, context
FROM nodes
WHERE collection_id = ?
AND scope = ?
AND node_type IN ({placeholders})
"""
await self.conn.execute(sql, (target_scope, collection_id, source_scope, *node_types))
await self.conn.commit()
async def copy_bindings_between_scopes(
self,
*,
source_scope: str,
target_scope: str,
node_types: List[str],
) -> None:
if source_scope == target_scope or not node_types:
return
placeholders = ", ".join("?" for _ in node_types)
sql = f"""
INSERT OR REPLACE INTO bindings (scope, node_type, local_id, remote_id)
SELECT ?, node_type, local_id, remote_id
FROM bindings
WHERE scope = ?
AND node_type IN ({placeholders})
"""
await self.conn.execute(sql, (target_scope, source_scope, *node_types))
await self.conn.commit()
async def dump_to_runtime(self, filename: str):
"""将当前状态转储到 _runtime 文件夹 (使用 VACUUM INTO)"""
runtime_path = os.path.join("_runtime", filename)
+10
View File
@@ -24,6 +24,12 @@ from .strategy_config import (
ConfigPresets,
)
from .pipeline_config import PipelineRunConfig, DEFAULT_NODE_TYPES
from .multi_project_config import (
MultiProjectItemConfig,
MultiProjectRunConfig,
build_multi_project_config_from_file,
is_multi_project_payload,
)
from .builder import (
build_default_config,
build_config,
@@ -58,6 +64,10 @@ __all__ = [
"StrategyRuntimeConfig",
"ConfigPresets",
"DEFAULT_NODE_TYPES",
"MultiProjectItemConfig",
"MultiProjectRunConfig",
"build_multi_project_config_from_file",
"is_multi_project_payload",
"build_default_config",
"build_config",
"build_config_from_file",
+12 -3
View File
@@ -63,13 +63,22 @@ def _apply_strategy_overrides(
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))
override.pop("skip_sync", None)
override.pop("force_sync", None)
override.pop("load_mode", None)
skip_sync = override.pop("skip_sync", None)
if skip_sync is not None:
runtime_cfg.skip_sync = bool(skip_sync)
if "config" in override and isinstance(override["config"], dict):
runtime_cfg.config = runtime_cfg.config.model_validate(
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict
from .run_presets import load_overrides_from_file
class MultiProjectItemConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
local_project_id: str
remote_project_id: str
config_path: Optional[str] = None
@property
def scope(self) -> str:
return f"project_id:{self.local_project_id}"
class MultiProjectRunConfig(BaseModel):
model_config = ConfigDict(extra="forbid", validate_assignment=True)
global_config: str
default_project_config: str
project_bootstrap_node_types: List[str] = []
projects: List[MultiProjectItemConfig]
def is_multi_project_payload(payload: Dict[str, Any]) -> bool:
return all(key in payload for key in ("global_config", "default_project_config", "projects"))
def build_multi_project_config_from_file(*, project_root: Path, file_path: str | Path) -> MultiProjectRunConfig:
path = Path(file_path)
overrides = load_overrides_from_file(path, project_root)
return MultiProjectRunConfig.model_validate(overrides)
@@ -14,6 +14,12 @@ from .strategy_config import (
ConfigPresets,
)
from .pipeline_config import PipelineRunConfig, DEFAULT_NODE_TYPES
from .multi_project_config import (
MultiProjectItemConfig,
MultiProjectRunConfig,
build_multi_project_config_from_file,
is_multi_project_payload,
)
from .builder import (
build_default_config,
build_config,
@@ -35,6 +41,10 @@ __all__ = [
"ConfigPresets",
"PipelineRunConfig",
"DEFAULT_NODE_TYPES",
"MultiProjectItemConfig",
"MultiProjectRunConfig",
"build_multi_project_config_from_file",
"is_multi_project_payload",
"build_default_config",
"build_config",
"build_config_from_file",
+4 -4
View File
@@ -47,10 +47,9 @@ class StrategyConfig(BaseModel):
field_direction_key: Optional[str] = None
field_direction_overrides: Dict[str, UpdateDirection] = Field(default_factory=dict)
# post-check 一致性比较时忽略列表型字段内各元素的特定子键
# 格式: {字段名: [子键, ...]},如 {"plan_node": ["is_audit"]}
# 用于排除后端硬写死、永远与本地不一致的字段,避免误报。
post_check_ignore_list_item_fields: Dict[str, List[str]] = Field(default_factory=dict)
# post-check 一致性比较时忽略的顶层字段
# 只影响最终一致性评估,不影响 create/update 的差异检测
post_check_ignore_fields: List[str] = Field(default_factory=list)
# schema diff / validate 时忽略的顶层字段。
compare_ignore_fields: List[str] = Field(default_factory=list)
@@ -62,6 +61,7 @@ class StrategyRuntimeConfig(BaseModel):
model_config = ConfigDict(validate_assignment=True, extra="forbid")
node_type: str
skip_sync: bool = False
config: StrategyConfig = Field(default_factory=StrategyConfig)
+9 -5
View File
@@ -361,11 +361,15 @@ class BaseNodeHandler(ABC, Generic[T]):
self.handler_config = dict(config)
def get_data_id_filter(self) -> List[str]:
value = self.handler_config.get("data_id_filter", [])
if isinstance(value, str):
return [value] if value else []
if isinstance(value, list):
return [str(item) for item in value if str(item)]
if "data_id_filter" in self.handler_config:
value = self.handler_config.get("data_id_filter", [])
if isinstance(value, str):
return [value] if value else []
if isinstance(value, list):
return [str(item) for item in value if str(item)]
return []
if self.node_type != "project" and self._collection is not None:
return [node.data_id for node in self._collection.filter(node_type="project") if node.data_id]
return []
def should_skip_by_data_id_filter(self, item: Dict[str, Any], item_id: str) -> bool:
@@ -10,8 +10,10 @@ JsonlDataSource - JSONL 文件数据源
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Dict, Any, Set
from typing import Dict, Any, Set, Callable, List, Optional
from ..datasource import BaseDataSource
@@ -46,6 +48,7 @@ class JsonlDataSource(BaseDataSource):
# 用于 create/update/delete 的内存缓存
self._data: Dict[str, Dict[str, Dict[str, Any]]] = {} # {node_type: {id: data}}
self._dirty_types: Set[str] = set()
self._node_items_cache: Dict[str, Dict[str, Any]] = {}
def _on_node_loaded(self, handler, node) -> None:
node.append_log(
@@ -61,6 +64,98 @@ class JsonlDataSource(BaseDataSource):
node.append_log(
f"JSONL链路[poll] task_id={task_id} status={result.status.value}"
)
def invalidate_node_cache(self, node_type: str) -> None:
self._node_items_cache.pop(node_type, None)
def _matching_files(self, node_type: str) -> List[Path]:
if not self.dir_path.exists():
return []
pattern = re.compile(rf"^{re.escape(node_type)}_\d+\.jsonl$")
return sorted(
file_path
for file_path in self.dir_path.iterdir()
if pattern.match(file_path.name)
)
def _build_cache_entry(
self,
*,
node_type: str,
extract_id: Callable[[Dict[str, Any]], str],
) -> Dict[str, Any]:
files = self._matching_files(node_type)
signature = tuple(
(file_path.name, file_path.stat().st_mtime_ns, file_path.stat().st_size)
for file_path in files
)
cached = self._node_items_cache.get(node_type)
if cached and cached.get("signature") == signature:
return cached
items: List[Dict[str, Any]] = []
data_id_index: Dict[str, List[Dict[str, Any]]] = {}
project_index: Dict[str, List[Dict[str, Any]]] = {}
no_project_items: List[Dict[str, Any]] = []
for file_path in files:
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
except json.JSONDecodeError:
print(f"Warning: Invalid JSON in {file_path.name}:{line_num}")
continue
items.append(item)
item_id = extract_id(item)
if item_id:
data_id_index.setdefault(str(item_id), []).append(item)
project_id = item.get("project_id")
if project_id:
project_index.setdefault(str(project_id), []).append(item)
else:
no_project_items.append(item)
entry = {
"signature": signature,
"items": items,
"data_id_index": data_id_index,
"project_index": project_index,
"no_project_items": no_project_items,
}
self._node_items_cache[node_type] = entry
return entry
def get_items_for_load(
self,
*,
node_type: str,
extract_id: Callable[[Dict[str, Any]], str],
data_id_filter: Optional[List[str]] = None,
filter_on_project_id: bool = False,
) -> List[Dict[str, Any]]:
entry = self._build_cache_entry(node_type=node_type, extract_id=extract_id)
filters = [str(item) for item in (data_id_filter or []) if str(item)]
if not filters:
return list(entry["items"])
if not filter_on_project_id:
matched: List[Dict[str, Any]] = []
for data_id in filters:
matched.extend(entry["data_id_index"].get(data_id, []))
return matched
matched = list(entry["no_project_items"])
for project_id in filters:
matched.extend(entry["project_index"].get(project_id, []))
return matched
# load_all 和 sync_all 继承自 BaseDataSource
# 保存逻辑在 BaseJsonlHandler 中实现
+20 -34
View File
@@ -12,7 +12,6 @@ BaseJsonlHandler - JSONL Handler 基类
from __future__ import annotations
import json
import re
from typing import Dict, List, Any, TYPE_CHECKING, Optional, Set
from uuid import uuid4
@@ -64,40 +63,26 @@ class BaseJsonlHandler(BaseNodeHandler):
if not self.datasource.dir_path.exists():
return nodes
# 查找所有匹配的文件:{node_type}_N.jsonl
pattern = re.compile(rf"^{re.escape(self.node_type)}_\d+\.jsonl$")
for file_path in self.datasource.dir_path.iterdir():
if not pattern.match(file_path.name):
continue
# 读取文件
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
item_id = self.extract_id(item)
if self.should_skip_by_data_id_filter(item, item_id):
continue
# 创建节点
node = self.create_node(item)
nodes.append(node)
# 同时加载到缓存
if item_id:
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[item_id] = item
except json.JSONDecodeError as e:
print(f"Warning: Invalid JSON in {file_path.name}:{line_num}: {e}")
# 继续处理下一行
data_id_filter = self.get_data_id_filter()
items = self.datasource.get_items_for_load(
node_type=self.node_type,
extract_id=self.extract_id,
data_id_filter=data_id_filter,
filter_on_project_id=self.node_type != "project",
)
for item in items:
item_id = self.extract_id(item)
if self.should_skip_by_data_id_filter(item, item_id):
continue
node = self.create_node(item)
nodes.append(node)
if item_id:
bucket = self.datasource._data.setdefault(self.node_type, {})
bucket[item_id] = item
return nodes
@@ -276,6 +261,7 @@ class BaseJsonlHandler(BaseNodeHandler):
# 清除该类型的 dirty 标记
self.datasource._dirty_types.discard(self.node_type)
self.datasource.invalidate_node_cache(self.node_type)
except Exception as e:
print(f"Error saving {self.node_type}: {str(e)}")
@@ -18,3 +18,13 @@ class ConstructionTaskSyncStrategy(DefaultSyncStrategy[ConstructionResponse]):
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
def preprocess_compare_payload(self, payload: dict) -> dict:
plan_nodes = payload.get("plan_node")
if not isinstance(plan_nodes, list):
return payload
payload["plan_node"] = [
{key: value for key, value in item.items() if key != "is_audit"} if isinstance(item, dict) else item
for item in plan_nodes
]
return payload
@@ -17,4 +17,14 @@ class ContractSettlementSyncStrategy(DefaultSyncStrategy[ContractSettlementRespo
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
)
def preprocess_compare_payload(self, payload: dict) -> dict:
plan_nodes = payload.get("plan_node")
if not isinstance(plan_nodes, list):
return payload
payload["plan_node"] = [
{key: value for key, value in item.items() if key != "is_audit"} if isinstance(item, dict) else item
for item in plan_nodes
]
return payload
@@ -17,4 +17,14 @@ class MaterialSyncStrategy(DefaultSyncStrategy[MaterialResponse]):
local_orphan_action=OrphanAction.CREATE_REMOTE,
remote_orphan_action=OrphanAction.NONE,
update_direction=UpdateDirection.PUSH,
)
)
def preprocess_compare_payload(self, payload: dict) -> dict:
plan_nodes = payload.get("plan_node")
if not isinstance(plan_nodes, list):
return payload
payload["plan_node"] = [
{key: value for key, value in item.items() if key != "is_audit"} if isinstance(item, dict) else item
for item in plan_nodes
]
return payload
+4
View File
@@ -4,6 +4,7 @@ Pipeline Layer - Pipeline orchestration and workflow management
from .copy_data_pipeline import CopyDataPipeline
from .full_sync_pipeline import FullSyncPipeline
from .multi_project_pipeline import MultiProjectPipeline
from ..config import (
PipelineRunConfig,
ApiDataSourceConfig,
@@ -24,11 +25,13 @@ from .factory import (
create_pipeline_from_config,
run_pipeline_from_file,
run_pipeline_from_config,
run_profile_from_file,
)
__all__ = [
"CopyDataPipeline",
"FullSyncPipeline",
"MultiProjectPipeline",
"create_jsonl_to_api_pipeline",
"create_jsonl_to_jsonl_pipeline",
"create_api_to_jsonl_pipeline",
@@ -36,6 +39,7 @@ __all__ = [
"create_pipeline_from_config",
"run_pipeline_from_file",
"run_pipeline_from_config",
"run_profile_from_file",
"PipelineRunConfig",
"ApiDataSourceConfig",
"JsonlDataSourceConfig",
+111 -24
View File
@@ -27,18 +27,23 @@ from ..datasource import ensure_builtin_datasource_types_registered
from ..datasource.type_registry import get_datasource_factory
from ..config import (
PipelineRunConfig,
MultiProjectRunConfig,
DataSourceConfig,
ApiDataSourceConfig,
PersistConfig,
JsonlDataSourceConfig,
LoggingConfig,
build_config,
StrategyRuntimeConfig,
build_config_from_file,
build_multi_project_config_from_file,
apply_config_overrides,
OrphanAction,
UpdateDirection,
resolve_log_file_path,
config_snapshot,
is_multi_project_payload,
load_overrides_from_file,
)
from ..sync_system.strategy import BaseSyncStrategy
from .run_logger import init_pipeline_logger
@@ -180,6 +185,17 @@ def _register_handlers(
)
def _build_collections_and_bindings(
*,
persistence: PersistenceBackend,
scope: str,
) -> tuple[DataCollection, DataCollection, BindingManager]:
local_collection = DataCollection("local", scope=scope, persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", scope=scope, persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False, scope=scope)
return local_collection, remote_collection, binding_manager
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}
@@ -196,6 +212,28 @@ def _resolve_strategy_map(config: PipelineRunConfig, node_types: List[str]) -> D
return resolved
def _build_pipeline_run_config(
*,
node_types: List[str],
local_datasource: DataSourceConfig,
remote_datasource: DataSourceConfig,
persist: PersistConfig,
logging_config: LoggingConfig,
strategy_overrides: Optional[Dict[str, Dict[str, Any]]] = None,
) -> PipelineRunConfig:
cfg = PipelineRunConfig(
node_types=node_types,
local_datasource=local_datasource,
remote_datasource=remote_datasource,
strategies=[],
persist=persist,
logging=logging_config,
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return cfg
def _build_strategies(
config: PipelineRunConfig,
node_types: List[str],
@@ -225,6 +263,7 @@ def _build_strategies(
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)
strategies.append(strategy)
return strategies
@@ -233,6 +272,10 @@ def _build_strategies(
async def create_pipeline_from_config(
config: PipelineRunConfig,
*,
scope: str = "global",
pipeline_name: str | None = None,
bootstrap_binding_node_types: Optional[List[str]] = None,
ephemeral_node_types: Optional[List[str]] = None,
local_datasource_override=None,
remote_datasource_override=None,
) -> FullSyncPipeline:
@@ -279,9 +322,10 @@ async def create_pipeline_from_config(
await _initialize_datasource(remote_ds, endpoint_name="remote")
_register_handlers(config, local_ds, remote_ds, all_types)
local_collection = DataCollection("local", persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
local_collection, remote_collection, binding_manager = _build_collections_and_bindings(
persistence=persistence,
scope=scope,
)
strategies = _build_strategies(
config,
@@ -301,6 +345,10 @@ async def create_pipeline_from_config(
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
scope=scope,
pipeline_name=pipeline_name,
bootstrap_binding_node_types=bootstrap_binding_node_types,
ephemeral_node_types=ephemeral_node_types,
enable_persistence=config.persist.enable,
)
@@ -325,6 +373,8 @@ async def run_pipeline_from_config(
*,
title: str = "sync_state_machine pipeline",
print_summary: bool = True,
scope: str = "global",
bootstrap_binding_node_types: Optional[List[str]] = None,
local_datasource_override=None,
remote_datasource_override=None,
) -> Dict[str, Any]:
@@ -341,7 +391,7 @@ async def run_pipeline_from_config(
resolved_cfg = config_snapshot(config)
resolved_cfg_text = json.dumps(resolved_cfg, ensure_ascii=False, indent=2)
logger.info(
"Resolved Config Summary: node_types=%d local=%s remote=%s",
"Resolved Full Config Summary: node_types=%d local=%s remote=%s",
len(config.node_types),
config.local_datasource.type,
config.remote_datasource.type,
@@ -349,7 +399,7 @@ async def run_pipeline_from_config(
if config.logging.file_path:
try:
with open(config.logging.file_path, "a", encoding="utf-8") as fp:
fp.write("Resolved Config:\n")
fp.write("Resolved Full Config:\n")
fp.write(resolved_cfg_text)
fp.write("\n")
except Exception as exc:
@@ -362,15 +412,18 @@ async def run_pipeline_from_config(
print(f"🚀 Creating {title}")
print(f"📝 Log file: {config.logging.file_path or '-'}")
print(
f"🔧 Resolved Config Summary: node_types={len(config.node_types)} "
f"🔧 Resolved Full Config Summary: node_types={len(config.node_types)} "
f"local={config.local_datasource.type} "
f"remote={config.remote_datasource.type}"
)
print(f"🔧 Resolved Config:\n{resolved_cfg_text}")
print(f"🔧 Resolved Full Config:\n{resolved_cfg_text}")
print("=" * 80)
pipeline = await create_pipeline_from_config(
config,
scope=scope,
pipeline_name=title,
bootstrap_binding_node_types=bootstrap_binding_node_types,
local_datasource_override=local_datasource_override,
remote_datasource_override=remote_datasource_override,
)
@@ -390,12 +443,20 @@ async def create_pipeline_from_file(
*,
project_root: Path,
config_path: str | Path,
scope: str = "global",
pipeline_name: str | None = None,
bootstrap_binding_node_types: Optional[List[str]] = None,
ephemeral_node_types: Optional[List[str]] = None,
local_datasource_override=None,
remote_datasource_override=None,
) -> FullSyncPipeline:
config = build_config_from_file(project_root=project_root, file_path=config_path)
return await create_pipeline_from_config(
config,
scope=scope,
pipeline_name=pipeline_name,
bootstrap_binding_node_types=bootstrap_binding_node_types,
ephemeral_node_types=ephemeral_node_types,
local_datasource_override=local_datasource_override,
remote_datasource_override=remote_datasource_override,
)
@@ -407,6 +468,8 @@ async def run_pipeline_from_file(
config_path: str | Path,
title: str = "sync_state_machine pipeline",
print_summary: bool = True,
scope: str = "global",
bootstrap_binding_node_types: Optional[List[str]] = None,
local_datasource_override=None,
remote_datasource_override=None,
) -> Dict[str, Any]:
@@ -415,11 +478,41 @@ async def run_pipeline_from_file(
config,
title=title,
print_summary=print_summary,
scope=scope,
bootstrap_binding_node_types=bootstrap_binding_node_types,
local_datasource_override=local_datasource_override,
remote_datasource_override=remote_datasource_override,
)
async def run_profile_from_file(
*,
project_root: Path,
config_path: str | Path,
print_summary: bool = True,
) -> Dict[str, Any]:
cfg_path = Path(config_path)
raw = load_overrides_from_file(cfg_path, project_root)
if is_multi_project_payload(raw):
from .multi_project_pipeline import MultiProjectPipeline
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)
return await pipeline.run()
config = build_config(project_root=project_root, overrides=raw)
mode = f"{config.local_datasource.type}->{config.remote_datasource.type}"
return await run_pipeline_from_config(
config,
title=f"sync_state_machine pipeline ({mode})",
print_summary=print_summary,
)
async def create_jsonl_to_api_pipeline(
*,
# 路径配置
@@ -438,7 +531,7 @@ async def create_jsonl_to_api_pipeline(
# API 配置
api_debug: bool = False,
poll_max_retries: int = 10,
poll_interval: float = 0.5,
poll_interval: float = 0.5,
api_rate_limit_max_requests: int = 30,
api_rate_limit_window_seconds: float = 10.0,
local_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -448,7 +541,7 @@ async def create_jsonl_to_api_pipeline(
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
cfg = _build_pipeline_run_config(
node_types=node_types,
local_datasource=JsonlDataSourceConfig(
type="jsonl",
@@ -468,20 +561,18 @@ async def create_jsonl_to_api_pipeline(
api_rate_limit_window_seconds=api_rate_limit_window_seconds,
handler_configs=remote_handler_configs or {},
),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
logging_config=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
strategy_overrides=strategy_overrides,
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
@@ -503,7 +594,7 @@ async def create_api_to_jsonl_pipeline(
# API 配置
api_debug: bool = False,
poll_max_retries: int = 10,
poll_interval: float = 0.5,
poll_interval: float = 0.5,
api_rate_limit_max_requests: int = 30,
api_rate_limit_window_seconds: float = 10.0,
local_handler_configs: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -513,7 +604,7 @@ async def create_api_to_jsonl_pipeline(
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
cfg = _build_pipeline_run_config(
node_types=node_types,
local_datasource=ApiDataSourceConfig(
type="api",
@@ -533,20 +624,18 @@ async def create_api_to_jsonl_pipeline(
read_only=False,
handler_configs=remote_handler_configs or {},
),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
logging_config=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
strategy_overrides=strategy_overrides,
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
@@ -565,7 +654,7 @@ async def create_jsonl_to_jsonl_pipeline(
logger_file_path: Optional[str] = None,
logger_level: int = logging.INFO,
) -> FullSyncPipeline:
cfg = PipelineRunConfig(
cfg = _build_pipeline_run_config(
node_types=node_types,
local_datasource=JsonlDataSourceConfig(
type="jsonl",
@@ -579,18 +668,16 @@ async def create_jsonl_to_jsonl_pipeline(
read_only=False,
handler_configs=remote_handler_configs or {},
),
strategies=[],
persist=PersistConfig(
db_path=persistence_db,
enable=enable_persistence,
wipe_on_start=wipe_persistence_on_start,
),
logging=LoggingConfig(
logging_config=LoggingConfig(
initialize=initialize_logger,
file_path=logger_file_path,
level=logger_level,
),
strategy_overrides=strategy_overrides,
)
if strategy_overrides:
cfg = apply_config_overrides(cfg, {"strategies": strategy_overrides})
return await create_pipeline_from_config(cfg)
+105 -26
View File
@@ -12,6 +12,7 @@ from pathlib import Path
if TYPE_CHECKING:
from ..datasource.datasource import BaseDataSource
from ..datasource.jsonl import JsonlDataSource
from ..common.collection import DataCollection
from ..common.binding import BindingManager
from ..common.persistence import PersistenceBackend
@@ -47,6 +48,10 @@ class FullSyncPipeline:
local_collection: DataCollection,
remote_collection: DataCollection,
binding_manager: BindingManager,
scope: str = "global",
pipeline_name: str | None = None,
bootstrap_binding_node_types: Optional[List[str]] = None,
ephemeral_node_types: Optional[List[str]] = None,
node_types: Optional[List[str]] = None,
load_order: Optional[List[str]] = None,
sync_order: Optional[List[str]] = None,
@@ -61,7 +66,11 @@ class FullSyncPipeline:
self.local_datasource = local_datasource
self.remote_datasource = remote_datasource
self.strategies = strategies
self.scope = scope
self.pipeline_name = pipeline_name or f"pipeline[{scope}]"
self.node_types = node_types or [s.node_type for s in self.strategies]
self.bootstrap_binding_node_types = list(dict.fromkeys(bootstrap_binding_node_types or []))
self.ephemeral_node_types = list(dict.fromkeys(ephemeral_node_types or []))
self.load_order = load_order or self.node_types
self.sync_order = sync_order or self.node_types
self.persistence = persistence
@@ -91,6 +100,9 @@ class FullSyncPipeline:
self._logger = logger
self._closed = False
self._loaded_node_types: set[str] = set()
self._preloaded_local_nodes: List[Any] = []
self._preloaded_remote_nodes: List[Any] = []
self._preloaded_bindings: Dict[str, Dict[str, Optional[str]]] = {}
def _resolve_pipeline_runtime(self) -> StateMachineRuntime:
if self.strategies:
@@ -104,17 +116,17 @@ class FullSyncPipeline:
async def run(self) -> Dict[str, Any]:
try:
await self.phase_bootstrap()
self._logger.info("✅ Phase bootstrap completed")
self._logger.info("✅ Phase bootstrap completed (%s)", self.pipeline_name)
await self.phase_sync_by_node_type()
self._logger.info("✅ Phase sync-by-node-type completed")
self._logger.info("✅ Phase sync-by-node-type completed (%s)", self.pipeline_name)
post_ok = await self.phase_post_check()
self.stats["post_check_passed"] = post_ok
self._logger.info("✅ Phase post-check completed")
self._logger.info("✅ Phase post-check completed (%s)", self.pipeline_name)
await self.phase_persistence()
self._logger.info("✅ Phase persistence completed")
self._logger.info("✅ Phase persistence completed (%s)", self.pipeline_name)
return self.stats
finally:
await self.close()
@@ -173,9 +185,14 @@ class FullSyncPipeline:
await self._load_persistence_state()
self._logger.info("✅ Bootstrap: persistence loaded")
await self._apply_preloaded_state()
self._logger.info("✅ Bootstrap: shared preload applied")
self._prepare_datasources()
self._logger.info("✅ Bootstrap: datasource prepared")
await self._apply_project_scope_cleanup()
async def phase_bind(self) -> None:
self._prepare_datasources()
strategy_map = {s.node_type: s for s in self.strategies}
@@ -257,10 +274,17 @@ class FullSyncPipeline:
if not self.enable_persistence:
return
excluded_node_types = self.ephemeral_node_types or None
# 加载持久化数据
await self.local_collection.load_from_persistence()
await self.remote_collection.load_from_persistence()
for node_type in self.node_types:
await self.local_collection.load_from_persistence_excluding(excluded_node_types)
await self.remote_collection.load_from_persistence_excluding(excluded_node_types)
binding_node_types = [
node_type
for node_type in dict.fromkeys([*self.node_types, *self.bootstrap_binding_node_types])
if node_type not in set(self.ephemeral_node_types)
]
for node_type in binding_node_types:
await self.binding_manager.load_from_persistence(node_type)
self._logger.info("🔄 Persistence loaded (state preserved; pending E01 normalization)")
@@ -278,11 +302,67 @@ class FullSyncPipeline:
self._logger.info(f"🧹 Reset cleanup: {total_cleaned} CREATE-failed zombies cleaned")
self._logger.info("🔄 Post-load reset cleanup completed")
async def _apply_preloaded_state(self) -> None:
if self._preloaded_local_nodes:
await self.local_collection.import_nodes(self._preloaded_local_nodes)
if self._preloaded_remote_nodes:
await self.remote_collection.import_nodes(self._preloaded_remote_nodes)
for node_type, bindings in self._preloaded_bindings.items():
self.binding_manager.import_bindings(node_type, bindings)
def preload_shared_state(
self,
*,
local_nodes: List[Any],
remote_nodes: List[Any],
bindings_by_type: Dict[str, Dict[str, Optional[str]]],
) -> None:
self._preloaded_local_nodes = list(local_nodes)
self._preloaded_remote_nodes = list(remote_nodes)
self._preloaded_bindings = {
node_type: dict(bindings)
for node_type, bindings in bindings_by_type.items()
}
def _prepare_datasources(self) -> None:
"""为后续按 node_type 加载准备 collection 绑定。"""
self.local_datasource.set_collection(self.local_collection)
self.remote_datasource.set_collection(self.remote_collection)
def _get_project_scope_filter_ids(self, datasource: "BaseDataSource") -> List[str]:
try:
handler = datasource.get_handler("project")
get_data_id_filter = getattr(handler, "get_data_id_filter", None)
if callable(get_data_id_filter):
raw_filter = get_data_id_filter()
if isinstance(raw_filter, list):
return list(dict.fromkeys(raw_filter))
except Exception:
pass
return []
async def _apply_project_scope_cleanup(self) -> None:
local_project_ids = self._get_project_scope_filter_ids(self.local_datasource)
remote_project_ids = self._get_project_scope_filter_ids(self.remote_datasource)
if not local_project_ids and not remote_project_ids:
return
deleted_local = await self.local_collection.filter_by_project_ids(local_project_ids)
deleted_remote = await self.remote_collection.filter_by_project_ids(remote_project_ids)
deleted_bindings = await self.binding_manager.prune_missing_nodes(
local_collection=self.local_collection,
remote_collection=self.remote_collection,
node_types=list(dict.fromkeys([*self.node_types, *self.bootstrap_binding_node_types])),
)
if deleted_local or deleted_remote or deleted_bindings:
self._logger.info(
"🧹 Project-scope cleanup: local_deleted=%d remote_deleted=%d bindings_deleted=%d",
deleted_local,
deleted_remote,
deleted_bindings,
)
async def _load_node_type(self, node_type: str, *, reason: str) -> None:
self._logger.info(f"🔄 Load node_type={node_type}, reason={reason}")
await self.local_datasource.load_all(order=[node_type])
@@ -300,7 +380,13 @@ class FullSyncPipeline:
action_summary = summary.get(action_key, {}) if isinstance(summary, dict) else {}
return int(action_summary.get("success", 0)) if isinstance(action_summary, dict) else 0
def _should_skip_reload(self) -> bool:
return isinstance(self.local_datasource, JsonlDataSource) and isinstance(self.remote_datasource, JsonlDataSource)
async def _reload_node_type(self, node_type: str, *, reason: str) -> None:
if self._should_skip_reload():
self._logger.info("⏭️ Reload skipped for JSONL pipeline: node_type=%s reason=%s", node_type, reason)
return
await self._load_node_type(node_type, reason=reason)
async def _evaluate_consistency_for_node_type(self, node_type: str) -> Dict[str, Any]:
@@ -309,28 +395,18 @@ class FullSyncPipeline:
compare_to_remote = True
if strategy and strategy.config.update_direction == UpdateDirection.PULL:
compare_to_remote = False
ignore_list_item_fields = dict(strategy.config.post_check_ignore_list_item_fields) if strategy else {}
# 从远端 handler 的 update_schemas 提取只读字段过滤白名单
post_check_fields: List[str] | None = None
try:
remote_handler = self.remote_datasource.get_handler(node_type)
fields = remote_handler.get_update_fields()
if fields:
post_check_fields = fields
except Exception:
pass
ignore_fields = list(strategy.config.post_check_ignore_fields) if strategy else []
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,
depend_fields=depend_fields,
compare_to_remote=compare_to_remote,
ignore_list_item_fields=ignore_list_item_fields,
post_check_fields=post_check_fields,
ignore_fields=ignore_fields,
)
self._consistency_by_type[node_type] = result
return result
@@ -430,9 +506,12 @@ class FullSyncPipeline:
所有类型均已同步完毕后,统一做一次全量 reload(触发后端派生字段计算),
再对所有类型做一致性评估,确保 post-check 结果反映最终状态。
"""
self._logger.info("🔄 Post-check: reloading all node types for final consistency evaluation...")
for node_type in self.node_types:
await self._reload_node_type(node_type, reason="post-check final reload")
if self._should_skip_reload():
self._logger.info("⏭️ Post-check reload skipped for JSONL pipeline")
else:
self._logger.info("🔄 Post-check: reloading all node types for final consistency evaluation...")
for node_type in self.node_types:
await self._reload_node_type(node_type, reason="post-check final reload")
for node_type in self.node_types:
await self._evaluate_consistency_for_node_type(node_type)
@@ -454,11 +533,11 @@ class FullSyncPipeline:
return
self._logger.info("🔍 Persisting local collection...")
await self.local_collection.persist()
await self.local_collection.persist(exclude_node_types=self.ephemeral_node_types)
self._logger.info("🔍 Persisting remote collection...")
await self.remote_collection.persist()
await self.remote_collection.persist(exclude_node_types=self.ephemeral_node_types)
self._logger.info("🔍 Persisting binding manager...")
await self.binding_manager.persist()
await self.binding_manager.persist(exclude_node_types=self.ephemeral_node_types)
self._logger.info("✅ All data persisted successfully")
# ========== Summary & Reporting ==========
@@ -0,0 +1,166 @@
from __future__ import annotations
import logging
import copy
from pathlib import Path
from typing import Dict, Optional, List, TypedDict, Any
from ..config import (
MultiProjectItemConfig,
MultiProjectRunConfig,
PipelineRunConfig,
apply_config_overrides,
build_config_from_file,
)
from .factory import create_pipeline_from_config
logger = logging.getLogger(__name__)
class _SharedStateSnapshot(TypedDict):
local_nodes: List[Any]
remote_nodes: List[Any]
bindings_by_type: Dict[str, Dict[str, Optional[str]]]
def _resolve_embedded_config_path(raw_path: str, *, project_root: Path, profile_path: Path) -> Path:
candidate = Path(raw_path)
if candidate.is_absolute():
return candidate
profile_relative = (profile_path.parent / candidate).resolve()
if profile_relative.exists():
return profile_relative
return (project_root / candidate).resolve()
def _project_scope_overrides(item: MultiProjectItemConfig, *, node_types: list[str]) -> Dict[str, object]:
del node_types
local_handler_configs = {
"project": {"data_id_filter": [item.local_project_id]},
}
remote_handler_configs = {
"project": {"data_id_filter": [item.remote_project_id]},
}
return {
"local_datasource": {
"handler_configs": local_handler_configs,
},
"remote_datasource": {
"handler_configs": remote_handler_configs,
},
}
def _resolve_project_bootstrap_node_types(
*,
config: MultiProjectRunConfig,
shared_node_types: list[str],
) -> list[str]:
configured = [node_type for node_type in config.project_bootstrap_node_types if node_type]
if configured:
return list(dict.fromkeys(configured))
return list(dict.fromkeys(shared_node_types))
class MultiProjectPipeline:
def __init__(self, *, project_root: Path, config_path: Path, config: MultiProjectRunConfig):
self.project_root = project_root
self.config_path = config_path
self.config = config
def _load_pipeline_config(self, raw_path: str) -> PipelineRunConfig:
resolved = _resolve_embedded_config_path(raw_path, project_root=self.project_root, profile_path=self.config_path)
return build_config_from_file(project_root=self.project_root, file_path=resolved)
def _prepare_project_config(
self,
*,
shared_persist: Dict[str, object],
item: MultiProjectItemConfig,
base_path: Optional[str],
) -> PipelineRunConfig:
raw_path = base_path or self.config.default_project_config
cfg = self._load_pipeline_config(raw_path)
cfg = apply_config_overrides(cfg, {"persist": {**shared_persist, "wipe_on_start": False}})
return apply_config_overrides(cfg, _project_scope_overrides(item, node_types=list(cfg.node_types)))
async def _snapshot_shared_state(self, *, pipeline, node_types: List[str]) -> _SharedStateSnapshot:
local_nodes = [
copy.deepcopy(node)
for node_type in node_types
for node in pipeline.local_collection.filter(node_type=node_type)
]
remote_nodes = [
copy.deepcopy(node)
for node_type in node_types
for node in pipeline.remote_collection.filter(node_type=node_type)
]
bindings_by_type: Dict[str, Dict[str, Optional[str]]] = {}
for node_type in node_types:
bindings = await pipeline.binding_manager.get_all_bindings(node_type)
bindings_by_type[node_type] = {local_id: remote_id for local_id, remote_id in bindings}
return {
"local_nodes": local_nodes,
"remote_nodes": remote_nodes,
"bindings_by_type": bindings_by_type,
}
async def run(self) -> Dict[str, Dict[str, object]]:
results: Dict[str, Dict[str, object]] = {}
global_config = self._load_pipeline_config(self.config.global_config)
shared_persist = global_config.persist.model_dump(mode="json")
shared_node_types = list(global_config.node_types)
project_bootstrap_node_types = _resolve_project_bootstrap_node_types(
config=self.config,
shared_node_types=shared_node_types,
)
logger.info("🚀 Multi-project pipeline start: projects=%d", len(self.config.projects))
global_pipeline = await create_pipeline_from_config(
global_config,
scope="global",
pipeline_name="multi-project pipeline [global]",
)
try:
results["global"] = await global_pipeline.run()
shared_state = await self._snapshot_shared_state(
pipeline=global_pipeline,
node_types=project_bootstrap_node_types,
)
finally:
await global_pipeline.close()
for item in self.config.projects:
project_config = self._prepare_project_config(
shared_persist=shared_persist,
item=item,
base_path=item.config_path,
)
scope = item.scope
title = f"multi-project pipeline [{scope}]"
logger.info(
"🚀 Project pipeline start: scope=%s local_project_id=%s remote_project_id=%s",
scope,
item.local_project_id,
item.remote_project_id,
)
project_pipeline = await create_pipeline_from_config(
project_config,
scope=scope,
pipeline_name=title,
bootstrap_binding_node_types=project_bootstrap_node_types,
ephemeral_node_types=project_bootstrap_node_types,
)
try:
project_pipeline.preload_shared_state(
local_nodes=shared_state["local_nodes"],
remote_nodes=shared_state["remote_nodes"],
bindings_by_type=shared_state["bindings_by_type"],
)
results[scope] = await project_pipeline.run()
finally:
await project_pipeline.close()
return results
@@ -52,18 +52,17 @@ async def print_pipeline_summary(*, logger, node_types, binding_manager, local_d
logger.info(" pass")
continue
logger.info(" field mismatch/total diff_schemas samples")
logger.info(" ------------------------------------------------------------------------------")
logger.info(" field mismatch/total samples")
logger.info(" -----------------------------------------------------------")
for field_report in differing_fields:
field_name = str(field_report.get("field_name", ""))
field_mismatch = int(field_report.get("mismatch_count", 0))
field_total = int(field_report.get("total_count", 0))
schema_refs = ",".join(str(item) for item in (field_report.get("schema_refs", []) or [])) or "-"
samples = field_report.get("samples", []) or []
sample_text = _format_sample(samples[0]) if samples else "-"
logger.info(
f" {_clip(field_name, 30):<30} {field_mismatch:>4}/{field_total:<4} "
f"{_clip(schema_refs, 28):<28} {sample_text}"
f"{sample_text}"
)
def print_collection_summary(title: str, datasource, collection) -> None:
+18 -1
View File
@@ -20,6 +20,7 @@ from ..engine import (
StateMachineRuntime,
)
from ..validation import SchemaDiffValidator
from .strategy_ops.compare_ops import normalized_data_for_compare
T = TypeVar("T", bound=BaseModel)
logger = logging.getLogger(__name__)
@@ -186,12 +187,28 @@ class BaseSyncStrategy(Generic[T]):
self._validate_config()
logger.info(f"[{self.node_type}] Applied preset: {preset_name}")
def preprocess_compare_payload(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
def normalize_compare_payload(
self,
data: Optional[Dict[str, Any]],
data_id_map: Optional[Dict[str, str]] = None,
*,
ignore_fields: Optional[set[str]] = None,
) -> Dict[str, Any]:
prepared = self.preprocess_compare_payload(dict(data or {}))
return normalized_data_for_compare(
prepared,
data_id_map,
ignore_fields=ignore_fields,
)
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
@@ -2,7 +2,7 @@ from __future__ import annotations
import copy
import inspect
from typing import Any, Dict, Mapping, Optional
from typing import Any, Dict, Optional
async def build_data_id_normalization_map(
@@ -39,42 +39,18 @@ def is_id_like_field(field_name: Optional[str]) -> bool:
return lowered.endswith("_id") or lowered.endswith("_ids")
def _strip_ignored_list_item_fields(
field_name: Optional[str],
value: Any,
ignore_list_item_fields: Mapping[str, list[str]],
) -> Any:
if not isinstance(value, list):
return value
ignored_keys = set(ignore_list_item_fields.get(field_name or "", []))
if not ignored_keys:
return value
normalized_items = []
for item in value:
if isinstance(item, dict):
normalized_items.append({key: nested for key, nested in item.items() if key not in ignored_keys})
else:
normalized_items.append(item)
return normalized_items
def normalize_compare_value(
field_name: Optional[str],
value: Any,
data_id_map: Dict[str, str],
ignore_list_item_fields: Optional[Mapping[str, list[str]]] = None,
) -> Any:
ignored_list_fields = ignore_list_item_fields or {}
if isinstance(value, dict):
return {
key: normalize_compare_value(key, nested, data_id_map, ignored_list_fields)
key: normalize_compare_value(key, nested, data_id_map)
for key, nested in value.items()
}
if isinstance(value, list):
value = _strip_ignored_list_item_fields(field_name, value, ignored_list_fields)
return [normalize_compare_value(field_name, item, data_id_map, ignored_list_fields) for item in value]
return [normalize_compare_value(field_name, item, data_id_map) for item in value]
if value == "":
value = None
@@ -88,7 +64,6 @@ def normalized_data_for_compare(
data: Optional[Dict[str, Any]],
data_id_map: Optional[Dict[str, str]] = None,
ignore_fields: Optional[set[str]] = None,
ignore_list_item_fields: Optional[Mapping[str, list[str]]] = None,
) -> Dict[str, Any]:
payload = copy.deepcopy(data or {})
payload.pop("id", None)
@@ -96,7 +71,7 @@ def normalized_data_for_compare(
ignored = ignore_fields or set()
mapping = data_id_map or {}
return {
key: normalize_compare_value(key, value, mapping, ignore_list_item_fields)
key: normalize_compare_value(key, value, mapping)
for key, value in payload.items()
if key not in ignored
}
@@ -4,34 +4,7 @@ from typing import Any, Dict, List
from ...sync_system.resolve_ids import IDResolver
from ...validation import SchemaDiffValidator
from .compare_ops import (
build_data_id_normalization_map,
normalized_data_for_compare,
)
def _strip_list_item_fields(payload: Dict, ignore_map: Dict[str, List[str]]) -> Dict:
"""从 payload 中列表型字段的每个元素里,剔除指定子键。
例如 ignore_map={"plan_node": ["is_audit"]} 会把 payload["plan_node"][*]["is_audit"] 全部去掉。
payload 本身不会被修改(浅拷贝)。
"""
if not ignore_map:
return payload
result = dict(payload)
for field_name, sub_keys in ignore_map.items():
if field_name not in result:
continue
items = result[field_name]
if not isinstance(items, list):
continue
stripped = []
for item in items:
if isinstance(item, dict):
item = {k: v for k, v in item.items() if k not in sub_keys}
stripped.append(item)
result[field_name] = stripped
return result
from .compare_ops import build_data_id_normalization_map
def _compact_repr(value: Any, *, max_len: int = 80) -> str:
@@ -54,20 +27,23 @@ 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,
depend_fields: Dict[str, str] | None = None,
compare_to_remote: bool = True,
ignore_list_item_fields: Dict[str, List[str]] | None = None,
post_check_fields: List[str] | None = None,
ignore_fields: List[str] | None = None,
) -> Dict[str, Any]:
records = await binding_manager.get_all_records(node_type)
checked_pairs = 0
depend_fields = dict(depend_fields or {})
id_resolver = IDResolver(local_collection, remote_collection, binding_manager)
validator = SchemaDiffValidator(schema_name=node_type)
validator = SchemaDiffValidator(
schema_name=node_type,
ignore_fields=ignore_fields,
)
data_id_map = await build_data_id_normalization_map(
node_type=node_type,
local_collection=local_collection,
@@ -117,14 +93,8 @@ async def evaluate_consistency_for_node_type(
if resolved_remote is not None:
remote_data = resolved_remote
local_payload = normalized_data_for_compare(local_data, data_id_map)
remote_payload = normalized_data_for_compare(remote_data, data_id_map)
if post_check_fields:
local_payload = {k: v for k, v in local_payload.items() if k in post_check_fields}
remote_payload = {k: v for k, v in remote_payload.items() if k in post_check_fields}
if ignore_list_item_fields:
local_payload = _strip_list_item_fields(local_payload, ignore_list_item_fields)
remote_payload = _strip_list_item_fields(remote_payload, ignore_list_item_fields)
local_payload = strategy.normalize_compare_payload(local_data, data_id_map)
remote_payload = strategy.normalize_compare_payload(remote_data, data_id_map)
diff_map = validator.compare_payloads(
local_payload,
remote_payload,
@@ -55,17 +55,15 @@ def default_needs_update(
return False
ignore_fields = set(strategy.config.compare_ignore_fields)
normalized_source = normalized_data_for_compare(
normalized_source = strategy.normalize_compare_payload(
source_data,
data_id_map,
ignore_fields=ignore_fields,
ignore_list_item_fields=strategy.config.post_check_ignore_list_item_fields,
)
normalized_target = normalized_data_for_compare(
normalized_target = strategy.normalize_compare_payload(
target_data,
data_id_map,
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(
normalized_source,
@@ -87,28 +85,26 @@ def resolve_needs_update_check(strategy) -> Callable:
def _collect_diff_descriptions(
strategy,
source_data,
target_data,
*,
data_id_map,
ignore_fields: set[str],
ignore_list_item_fields: dict[str, list[str]],
max_items: int = 8,
) -> List[str]:
if source_data is None or target_data is None:
return []
normalized_source = normalized_data_for_compare(
normalized_source = strategy.normalize_compare_payload(
source_data,
data_id_map,
ignore_fields=ignore_fields,
ignore_list_item_fields=ignore_list_item_fields,
)
normalized_target = normalized_data_for_compare(
normalized_target = strategy.normalize_compare_payload(
target_data,
data_id_map,
ignore_fields=ignore_fields,
ignore_list_item_fields=ignore_list_item_fields,
)
diff_map = collect_payload_diffs(
normalized_source,
@@ -206,11 +202,11 @@ async def add_update_action(
has_diff = bool(should_update)
if has_diff:
diff_descriptions = _collect_diff_descriptions(
strategy,
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:
+1 -5
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping
from typing import Any, Iterable
from ..sync_system.strategy_ops.compare_ops import normalized_data_for_compare
@@ -28,12 +28,10 @@ class SchemaDiffValidator:
*,
schema_name: str,
ignore_fields: Iterable[str] | None = None,
ignore_list_item_fields: Mapping[str, list[str]] | None = None,
sample_limit: int = 3,
) -> None:
self.schema_name = schema_name
self.ignore_fields = {str(field) for field in (ignore_fields or []) if str(field)}
self.ignore_list_item_fields = dict(ignore_list_item_fields or {})
self.sample_limit = max(1, sample_limit)
self.reset()
@@ -56,12 +54,10 @@ class SchemaDiffValidator:
local_normalized = normalized_data_for_compare(
dict(local_payload or {}),
ignore_fields=self.ignore_fields,
ignore_list_item_fields=self.ignore_list_item_fields,
)
remote_normalized = normalized_data_for_compare(
dict(remote_payload or {}),
ignore_fields=self.ignore_fields,
ignore_list_item_fields=self.ignore_list_item_fields,
)
field_names = sorted(set(local_normalized) | set(remote_normalized))
+2 -1
View File
@@ -12,6 +12,7 @@
- 工具链专项:`pytest tests/integration/test_toolchain_config_and_graph.py -q`
- UI 专项:`pytest tests/integration/test_ui_api_smoke.py -q`
- 测试总览与远程环境工具:`docs/testing_guide.md`
- 最近常用的定向回归:`pytest tests/unit/test_update_ops.py tests/unit/test_post_check_dependency_id_normalization.py -q`
## 设计原则
- 不绕过 schema/状态机校验;配置错误必须显式失败。
@@ -26,5 +27,5 @@
- 覆盖核心业务链路(bind/create/update 与 full pipeline)。
## 大规模自动回归
- 按轮迭代的大规模自动测试方法见 [auto_test_spec/agent_auto_test_method.md](auto_test_spec/agent_auto_test_method.md)。
- 按轮迭代的大规模自动测试方法见 [../docs/auto_test_spec/agent_auto_test_method.md](../docs/auto_test_spec/agent_auto_test_method.md)。
- 执行时优先采用“单 domain 复现 -> 修复/分类 -> 全量聚合回归 -> 记录总计变化”的节奏,避免一次携带过多上下文。
@@ -0,0 +1,157 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.pipeline import run_profile_from_file
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
def _write_yaml(path: Path, content: str) -> None:
path.write_text(content.strip() + "\n", encoding="utf-8")
@pytest.mark.asyncio
async def test_multi_project_pipeline_runs_jsonl_to_jsonl_with_shared_persistence(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("yaml")
async def _skip_post_check(self) -> bool:
return True
monkeypatch.setattr(FullSyncPipeline, "phase_post_check", _skip_post_check)
project_root = Path(__file__).resolve().parents[2]
local_dir = project_root / "working_local_datasource" / "20260302-020003"
remote_dir = tmp_path / "remote_jsonl"
db_path = tmp_path / "multi_project.db"
logs_dir = tmp_path / "logs"
remote_dir.mkdir(parents=True, exist_ok=True)
logs_dir.mkdir(parents=True, exist_ok=True)
global_cfg = tmp_path / "global.yaml"
project_cfg = tmp_path / "project.yaml"
multi_cfg = tmp_path / "multi.yaml"
_write_yaml(
global_cfg,
f"""
node_types:
- project
local_datasource:
type: jsonl
jsonl_dir: {local_dir.as_posix()}
read_only: false
remote_datasource:
type: jsonl
jsonl_dir: {remote_dir.as_posix()}
read_only: false
persist:
backend: sqlite
db_path: {db_path.as_posix()}
enable: true
wipe_on_start: true
logging:
initialize: true
file_dir: {logs_dir.as_posix()}
file_name_pattern: multi_global_{{timestamp}}.log
level: 20
strategies:
project:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- name
depend_fields: {{}}
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
""",
)
_write_yaml(
project_cfg,
f"""
node_types:
- lar
local_datasource:
type: jsonl
jsonl_dir: {local_dir.as_posix()}
read_only: false
remote_datasource:
type: jsonl
jsonl_dir: {remote_dir.as_posix()}
read_only: false
persist:
backend: sqlite
db_path: {db_path.as_posix()}
enable: true
wipe_on_start: false
logging:
initialize: true
file_dir: {logs_dir.as_posix()}
file_name_pattern: multi_project_{{timestamp}}.log
level: 20
strategies:
lar:
skip_sync: false
config:
auto_bind: true
auto_bind_fields:
- project_id
depend_fields:
project_id: project
local_orphan_action: create_remote
remote_orphan_action: none
update_direction: push
""",
)
_write_yaml(
multi_cfg,
f"""
global_config: {global_cfg.as_posix()}
default_project_config: {project_cfg.as_posix()}
projects:
- local_project_id: f3e02e84-e81c-4aa6-88d4-f5aa108c8227
remote_project_id: f3e02e84-e81c-4aa6-88d4-f5aa108c8227
- local_project_id: 0f9c3e22-f4bb-4803-825a-4f238eeb06d5
remote_project_id: 0f9c3e22-f4bb-4803-825a-4f238eeb06d5
""",
)
results = await run_profile_from_file(project_root=project_root, config_path=multi_cfg, print_summary=False)
assert "global" in results
assert "project_id:f3e02e84-e81c-4aa6-88d4-f5aa108c8227" in results
assert "project_id:0f9c3e22-f4bb-4803-825a-4f238eeb06d5" in results
_, scope_rows = PersistenceBackend.query_records(
backend="sqlite",
db_path=str(db_path),
sql="SELECT DISTINCT scope FROM nodes ORDER BY scope",
)
scopes = [row["scope"] for row in scope_rows]
assert scopes == [
"global",
"project_id:0f9c3e22-f4bb-4803-825a-4f238eeb06d5",
"project_id:f3e02e84-e81c-4aa6-88d4-f5aa108c8227",
]
_, copied_rows = PersistenceBackend.query_records(
backend="sqlite",
db_path=str(db_path),
sql=(
"SELECT scope, collection_id, node_type, COUNT(1) AS c "
"FROM nodes "
"WHERE scope <> 'global' AND node_type = 'project' "
"GROUP BY scope, collection_id, node_type "
"ORDER BY scope, collection_id, node_type"
),
)
assert copied_rows == []
@@ -1,11 +1,13 @@
from __future__ import annotations
from types import SimpleNamespace
from pathlib import Path
import pytest
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.datasource.jsonl import JsonlDataSource
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
@@ -93,4 +95,52 @@ async def test_phase_sync_loads_each_node_type_before_strategy(monkeypatch: pyte
("project", "create wrote data"),
("preparation", "pre-strategy refresh"),
("contract", "pre-strategy refresh"),
]
@pytest.mark.asyncio
async def test_phase_sync_skips_reload_for_jsonl_datasources(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
project_strategy = _StubStrategy("project")
pipeline = FullSyncPipeline(
local_datasource=JsonlDataSource(tmp_path / "local", read_only=True),
remote_datasource=JsonlDataSource(tmp_path / "remote", read_only=True),
strategies=[project_strategy],
persistence=_FakePersistence(),
local_collection=DataCollection("local"),
remote_collection=DataCollection("remote"),
binding_manager=BindingManager(_FakePersistence(), auto_persist=False),
node_types=["project"],
load_order=["project"],
sync_order=["project"],
enable_persistence=False,
)
reload_calls: list[tuple[str, str]] = []
async def fake_commit_creates(node_type: str) -> bool:
return node_type == "project"
async def fake_commit_updates(node_type: str) -> bool:
del node_type
return False
async def fake_load_node_type(node_type: str, *, reason: str) -> None:
reload_calls.append((node_type, reason))
async def fake_normalize(node_type: str) -> None:
del node_type
monkeypatch.setattr(pipeline, "commit_creates", fake_commit_creates)
monkeypatch.setattr(pipeline, "commit_updates", fake_commit_updates)
monkeypatch.setattr(pipeline, "_load_node_type", fake_load_node_type)
monkeypatch.setattr(pipeline, "normalize_create_success_to_update_entry", fake_normalize)
await pipeline.phase_sync_by_node_type()
assert reload_calls == [
("project", "pre-strategy refresh"),
]
@@ -0,0 +1,123 @@
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from pydantic import BaseModel
from sync_state_machine.config.multi_project_config import build_multi_project_config_from_file
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.common.sync_node import SyncNode
from sync_state_machine.pipeline.multi_project_pipeline import (
_project_scope_overrides,
_resolve_project_bootstrap_node_types,
)
def test_build_multi_project_config_reads_explicit_project_bootstrap_node_types(tmp_path: Path) -> None:
config_path = tmp_path / "multi.yaml"
config_path.write_text(
yaml.safe_dump(
{
"global_config": "run_profiles/global.yaml",
"default_project_config": "run_profiles/project.yaml",
"project_bootstrap_node_types": ["company", "supplier"],
"projects": [
{
"local_project_id": "local-project",
"remote_project_id": "remote-project",
}
],
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
config = build_multi_project_config_from_file(project_root=tmp_path, file_path=config_path)
assert config.project_bootstrap_node_types == ["company", "supplier"]
def test_project_scope_overrides_only_inject_project_handler_filter() -> None:
item = type("Item", (), {"local_project_id": "local-project", "remote_project_id": "remote-project"})()
overrides = _project_scope_overrides(item, node_types=["project", "contract", "user"])
assert overrides == {
"local_datasource": {
"handler_configs": {
"project": {"data_id_filter": ["local-project"]},
}
},
"remote_datasource": {
"handler_configs": {
"project": {"data_id_filter": ["remote-project"]},
}
},
}
def test_resolve_project_bootstrap_node_types_prefers_explicit_config() -> None:
config = type(
"Config",
(),
{"project_bootstrap_node_types": ["company", "supplier", "company"]},
)()
resolved = _resolve_project_bootstrap_node_types(
config=config,
shared_node_types=["company", "supplier", "project"],
)
assert resolved == ["company", "supplier"]
class _ProjectSchema(BaseModel):
id: str
class _ProjectNode(SyncNode[dict]):
node_type = "project"
schema = _ProjectSchema
@pytest.mark.asyncio
async def test_binding_manager_prunes_stale_bindings_after_project_scope_cleanup() -> None:
persistence = PersistenceBackend(":memory:")
await persistence.initialize()
try:
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False, scope="project_id:p1")
local_node = _ProjectNode(node_id="local-project-1", data={"id": "p1"}, data_id="p1")
remote_node = _ProjectNode(node_id="remote-project-1", data={"id": "p1"}, data_id="p1")
stale_local_node = _ProjectNode(node_id="local-project-2", data={"id": "p2"}, data_id="p2")
stale_remote_node = _ProjectNode(node_id="remote-project-2", data={"id": "p2"}, data_id="p2")
await local_collection.add(local_node)
await local_collection.add(stale_local_node)
await remote_collection.add(remote_node)
await remote_collection.add(stale_remote_node)
await binding_manager.bind("project", "local-project-1", "remote-project-1")
await binding_manager.bind("project", "local-project-2", "remote-project-2")
await local_collection.filter_by_project_ids(["p1"])
await remote_collection.filter_by_project_ids(["p1"])
deleted = await binding_manager.prune_missing_nodes(
local_collection=local_collection,
remote_collection=remote_collection,
node_types=["project"],
)
assert deleted == 1
assert await binding_manager.get_remote_id("project", "local-project-1") == "remote-project-1"
assert await binding_manager.get_remote_id("project", "local-project-2") is None
finally:
await persistence.close()
@@ -10,7 +10,9 @@ from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.domain.construction.sync_node import ConstructionTaskSyncNode
from sync_state_machine.domain.construction.sync_strategy import ConstructionTaskSyncStrategy
from sync_state_machine.domain.contract.sync_node import ContractSyncNode
from sync_state_machine.domain.contract.sync_strategy import ContractSyncStrategy
from sync_state_machine.sync_system.strategy_ops.post_check_ops import evaluate_consistency_for_node_type
@@ -138,8 +140,16 @@ async def test_post_check_uses_same_id_resolver_as_create_update_for_depend_ids(
await remote_collection.add(remote_task)
await binding_manager.bind("construction_task", local_task.node_id, remote_task.node_id)
strategy = ConstructionTaskSyncStrategy(
"construction_task",
local_collection,
remote_collection,
binding_manager,
)
result = await evaluate_consistency_for_node_type(
node_type="construction_task",
strategy=strategy,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
@@ -152,3 +162,92 @@ async def test_post_check_uses_same_id_resolver_as_create_update_for_depend_ids(
assert result["mismatch_count"] == 0
await persistence.close()
@pytest.mark.asyncio
async def test_post_check_ignores_only_explicit_configured_fields(tmp_path: Path) -> None:
db_path = tmp_path / "state.db"
persistence = PersistenceBackend(str(db_path))
await persistence.initialize()
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
local_contract = ContractSyncNode(
node_id="local-contract-node",
data_id="local-contract-1",
data={
"id": "local-contract-1",
"project_id": "",
"name": "合同A-LOCAL",
"short_name": "合同A",
"code": "C-LOCAL",
"contract_type": "施工",
"currency": "CNY",
"manager_name": "张三",
"manager_phone": "13800000000",
"sign_company": "示例公司",
"company_id": "company-1",
"sign_date": "2025-01-01",
"credit_code": None,
"total_price": 1.0,
"sub_name": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
remote_contract = ContractSyncNode(
node_id="remote-contract-node",
data_id="remote-contract-1",
data={
"id": "remote-contract-1",
"project_id": "",
"name": "合同A-REMOTE",
"short_name": "合同A",
"code": "C-REMOTE",
"contract_type": "施工",
"currency": "CNY",
"manager_name": "张三",
"manager_phone": "13800000000",
"sign_company": "示例公司",
"company_id": "company-1",
"sign_date": "2025-01-01",
"credit_code": None,
"total_price": 1.0,
"sub_name": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
await local_collection.add(local_contract)
await remote_collection.add(remote_contract)
await binding_manager.bind("contract", local_contract.node_id, remote_contract.node_id)
strategy = ContractSyncStrategy(
"contract",
local_collection,
remote_collection,
binding_manager,
)
result = await evaluate_consistency_for_node_type(
node_type="contract",
strategy=strategy,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
logger=logging.getLogger("sync_state_machine"),
compare_to_remote=True,
ignore_fields=["code"],
)
field_names = {field["field_name"] for field in result["fields"]}
assert "code" not in field_names
assert "name" in field_names
assert result["ignore_fields"] == ["code"]
await persistence.close()
+82 -1
View File
@@ -6,7 +6,7 @@ import pytest
from pydantic import ConfigDict
from sync_state_machine.config import BaseDataSourceConfig, build_config_from_file, register_datasource_type
from sync_state_machine.config import BaseDataSourceConfig, build_config_from_file, config_snapshot, register_datasource_type
from sync_state_machine.config.run_presets import load_overrides_from_file
from sync_state_machine.datasource import BaseDataSource
@@ -123,3 +123,84 @@ logging:
assert config.local_datasource.type == "file_backed"
assert getattr(config.local_datasource, "namespace") == "local-demo"
def test_build_config_from_sparse_profile_resolves_full_strategy_defaults(tmp_path: Path) -> None:
profile = tmp_path / "sparse_profile.yaml"
profile.write_text(
"""
node_types:
- project
- production
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/remote_datasource/datasource
persist:
db_path: ${PROJECT_ROOT}/runtime/test.db
logging:
initialize: false
strategies:
project:
config:
local_orphan_action: none
post_check_ignore_fields:
- code
production:
config:
post_check_ignore_fields:
- is_exist
""".strip(),
encoding="utf-8",
)
config = build_config_from_file(project_root=tmp_path, file_path=profile)
strategies = {item.node_type: item for item in config.strategies}
project = strategies["project"].config
assert project.auto_bind is False
assert project.auto_bind_fields == []
assert project.depend_fields == {"company_id": "company"}
assert project.local_orphan_action == "none"
assert project.remote_orphan_action == "none"
assert project.update_direction == "push"
assert project.post_check_ignore_fields == ["code"]
assert project.compare_ignore_fields == []
production = strategies["production"].config
assert production.auto_bind_fields == ["project_id", "code"]
assert production.depend_fields == {"project_id": "project"}
assert production.post_check_ignore_fields == ["is_exist"]
snapshot = config_snapshot(config)
assert snapshot["strategies"][0]["config"]["compare_ignore_fields"] == []
assert snapshot["strategies"][0]["config"]["field_direction_overrides"] == {}
def test_build_config_from_file_rejects_legacy_strategy_keys(tmp_path: Path) -> None:
profile = tmp_path / "legacy_profile.yaml"
profile.write_text(
"""
node_types:
- supplier
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
remote_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/remote_datasource/datasource
persist:
db_path: ${PROJECT_ROOT}/runtime/test.db
logging:
initialize: false
strategies:
supplier:
load_mode: persisted_only
""".strip(),
encoding="utf-8",
)
with pytest.raises(ValueError, match="unsupported legacy keys: load_mode"):
build_config_from_file(project_root=tmp_path, file_path=profile)
@@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from tests.fixtures.mock_domain import build_project_node, register_test_domain
@pytest.mark.asyncio
async def test_scope_partitioning_and_copy_between_scopes(tmp_path: Path) -> None:
register_test_domain()
db_path = tmp_path / "scope_partitioning.db"
persistence = PersistenceBackend(str(db_path))
await persistence.initialize()
global_collection = DataCollection("local", scope="global", persistence=persistence, auto_persist=False)
global_node = build_project_node("node-global-1", "project-global-1", name="Global Project", code="GP-1")
await global_collection.add(global_node)
await global_collection.persist()
global_bindings = BindingManager(persistence, auto_persist=False, scope="global")
await global_bindings.bind("test_project", global_node.node_id, "remote-node-1")
await global_bindings.persist()
target_scope = "project_id:abc"
await persistence.copy_nodes_between_scopes(
collection_id="local",
source_scope="global",
target_scope=target_scope,
node_types=["test_project"],
)
await persistence.copy_bindings_between_scopes(
source_scope="global",
target_scope=target_scope,
node_types=["test_project"],
)
scoped_collection = DataCollection("local", scope=target_scope, persistence=persistence, auto_persist=False)
await scoped_collection.load_from_persistence()
scoped_nodes = scoped_collection.filter(node_type="test_project")
assert len(scoped_nodes) == 1
assert scoped_nodes[0].data_id == "project-global-1"
scoped_bindings = BindingManager(persistence, auto_persist=False, scope=target_scope)
await scoped_bindings.load_from_persistence("test_project")
assert await scoped_bindings.get_remote_id("test_project", "node-global-1") == "remote-node-1"
reloaded_global = DataCollection("local", scope="global", persistence=persistence, auto_persist=False)
await reloaded_global.load_from_persistence()
assert len(reloaded_global.filter(node_type="test_project")) == 1
await persistence.close()
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import logging
from io import StringIO
import pytest
from sync_state_machine.pipeline.summary_report import print_pipeline_summary
class _FakeBindingManager:
async def get_all_records(self, node_type: str) -> list[dict]:
return []
class _FakeDataSource:
def get_action_summary(self) -> dict:
return {}
class _FakeCollection:
def filter(self, *, node_type: str) -> list:
return []
@pytest.mark.asyncio
async def test_pipeline_summary_omits_diff_schemas_column() -> None:
stream = StringIO()
logger = logging.getLogger("tests.summary_report")
logger.handlers.clear()
logger.setLevel(logging.INFO)
logger.propagate = False
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
try:
await print_pipeline_summary(
logger=logger,
node_types=["project"],
binding_manager=_FakeBindingManager(),
local_datasource=_FakeDataSource(),
remote_datasource=_FakeDataSource(),
local_collection=_FakeCollection(),
remote_collection=_FakeCollection(),
consistency_by_type={
"project": {
"checked_pairs": 1,
"mismatch_count": 1,
"fields": [
{
"field_name": "actual_production_date",
"mismatch_count": 1,
"total_count": 1,
"schema_refs": ["ProjectResponseBase", "ProjectBaseInfoUpdate"],
"samples": [{"local": None, "remote": "2025-11-14"}],
}
],
}
},
stats={
"loaded": 1,
"synced": 0,
"failed": 0,
"post_check_passed": True,
},
)
finally:
logger.removeHandler(handler)
output = stream.getvalue()
assert "diff_schemas" not in output
assert "ProjectResponseBase" not in output
assert "ProjectBaseInfoUpdate" not in output
assert "field mismatch/total samples" in output
assert "actual_production_date" in output
assert "None/'2025-11-14'" in output
@@ -77,3 +77,38 @@ async def test_jsonl_non_project_load_filters_rows_by_project_id(tmp_path):
loaded = datasource._collection.filter(node_type="contract") # type: ignore[union-attr]
assert {node.data_id for node in loaded} == {"c1", "c3"}
@pytest.mark.asyncio
async def test_jsonl_non_project_load_inherits_project_filter_from_loaded_projects(tmp_path):
project_file = tmp_path / "project_1.jsonl"
project_file.write_text(
"\n".join([
json.dumps({"id": "p1"}, ensure_ascii=False),
])
+ "\n",
encoding="utf-8",
)
contract_file = tmp_path / "contract_1.jsonl"
contract_file.write_text(
"\n".join([
json.dumps({"id": "c1", "project_id": "p1"}, ensure_ascii=False),
json.dumps({"id": "c2", "project_id": "p2"}, ensure_ascii=False),
json.dumps({"id": "c3"}, ensure_ascii=False),
])
+ "\n",
encoding="utf-8",
)
datasource = JsonlDataSource(tmp_path)
project_handler = BaseJsonlHandler(datasource, "project", _ProjectNode, _ProjectSchema)
project_handler.set_handler_config({"data_id_filter": ["p1"]})
contract_handler = BaseJsonlHandler(datasource, "contract", _BizNode, _BizSchema)
datasource.register_handler(project_handler)
datasource.register_handler(contract_handler)
datasource.set_collection(DataCollection("local"))
await datasource.load_all(order=["project", "contract"])
loaded = datasource._collection.filter(node_type="contract") # type: ignore[union-attr]
assert {node.data_id for node in loaded} == {"c1", "c3"}
+21
View File
@@ -2,6 +2,7 @@ import pytest
from unittest.mock import AsyncMock, patch, MagicMock
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.domain.construction.sync_strategy import ConstructionTaskSyncStrategy
@pytest.mark.asyncio
async def test_run_update_with_direction_overrides():
@@ -65,3 +66,23 @@ async def test_run_update_with_direction_overrides():
node_empty = FakeNode(None)
assert push_filter(node_empty) is True
assert pull_filter(node_empty) is False
def test_construction_task_compare_payload_ignores_plan_node_is_audit():
strategy = ConstructionTaskSyncStrategy(
"construction_task",
MagicMock(),
MagicMock(),
MagicMock(),
)
local_payload = {
"plan_node": [{"name": "A", "is_audit": True}],
"task_type": "demo",
}
remote_payload = {
"plan_node": [{"name": "A", "is_audit": False}],
"task_type": "demo",
}
assert strategy.normalize_compare_payload(local_payload) == strategy.normalize_compare_payload(remote_payload)