first commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# 同步系统整体框架
|
||||
|
||||
## 1. 分层职责(pipeline + strategy + datasource + domain)
|
||||
|
||||
### pipeline(编排层)
|
||||
- 入口:`pipeline/full_sync_pipeline.py`
|
||||
- 负责组装并驱动整条流程:加载持久化、执行 bind/create/update、调用 datasource 同步、持久化结果。
|
||||
- reset 语义:
|
||||
- 加载阶段仅恢复持久化状态(不做状态重置);
|
||||
- 加载后通过 `BaseSyncStrategy.run_reset()` 触发 `E01`:
|
||||
- `create_zombie=true` -> `S15`(随后删除);
|
||||
- `create_zombie=false` -> `S00`(归并为 `UNCHECKED/NONE/PENDING`,并清空 `error`)。
|
||||
- 若持久化核心枚举非法(`action/status/binding_status`),节点进入加载异常隔离态 `S17`:
|
||||
- 保留错误信息供人工介入;
|
||||
- 不参与 `E01` 与后续 bind/create/update/sync 自动流程。
|
||||
|
||||
### pipeline 运行语境(人工介入 + 可变配置)
|
||||
- 运行前可按批次调整策略配置(例如“首轮先拉取、后续只推送”),属于 pipeline 的一次性运行参数。
|
||||
- 人工介入分两段:
|
||||
- **运行前**:可手工修复数据、手工绑定、调整策略参数;
|
||||
- **运行后**:根据节点 `status/error/sync_log` 处理异常(为未来 GUI 介入提供依据)。
|
||||
- 若上轮异常未人工处理,本轮在 `E01` 先做归并/清理:
|
||||
- CREATE 僵尸节点自动删除并解除绑定残留;
|
||||
- 非僵尸节点统一进入 `S00`,再进入本轮 bind/create/update 判定。
|
||||
|
||||
### strategy(决策组织层)
|
||||
- 外部入口:`sync_system/strategy.py`
|
||||
- 具体实现下沉:`sync_system/strategy_ops/`
|
||||
- `bind_ops.py`: `run_bind()` + `phase_core_state()/phase_dependency_check()/phase_auto_binding()`
|
||||
- `create_ops.py`: `run_create()`
|
||||
- `update_ops.py`: `run_update()`
|
||||
- `delete_ops.py`: `run_delete()`
|
||||
- `reset_ops.py`: `get_phase1_reset_defaults()/run_phase2_cleanup()`
|
||||
- strategy 负责收集上下文并调用状态机事件函数,不直接硬编码状态值。
|
||||
|
||||
### datasource(执行与回写层)
|
||||
- 入口:`datasource/datasource.py`
|
||||
- 执行 handler(create/update/delete),并结合 `E40` 将执行结果落地到节点状态。
|
||||
|
||||
### domain(业务适配层)
|
||||
- 目录:`domain/*`
|
||||
- 负责业务字段映射、handler 细节与 schema 约束,不承担状态迁移判定。
|
||||
|
||||
## 2. 运行时契约(Runtime Contract)
|
||||
|
||||
### 真源
|
||||
- 状态机配置真源:`config/node_state_machine.yaml`
|
||||
- 事件执行入口:`engine/events.py`
|
||||
- 状态落地入口:`engine/state_apply.py`
|
||||
|
||||
### 执行规则
|
||||
1. 业务层构造 `context`。
|
||||
2. 调用 `StateMachineRuntime.dispatch(current_state, event_id, context)`。
|
||||
3. 依据 `Decision.to_state` 从 YAML `states[Sxx]` 落地 `binding_status/action/status`。
|
||||
4. `Decision.emit` 只用于状态外副作用(spawn、绑定、调用 handler 等)。
|
||||
5. 每次事件落地后,`events._apply_decision` 调用 `runtime.check_invariants()`;违反直接抛错。
|
||||
|
||||
## 3. 边界与约束
|
||||
- `strategy/datasource` 不应绕过状态机直接写最终状态。
|
||||
- `PRECONDITION_FAILED` 必须满足 `action=UPDATE`(YAML invariant 强约束)。
|
||||
- 依赖语义由 strategy 计算(如 `dep_satisfied/dep_errors`),状态机只消费语义结果并迁移。
|
||||
|
||||
## 4. 相关文档
|
||||
- 框架与流程:`docs/sync_flow.md`
|
||||
- 节点状态定义:`docs/node_state_definition.md`
|
||||
- 状态机形式化说明:`docs/state_machine.md`
|
||||
@@ -0,0 +1,11 @@
|
||||
# 文档归档说明
|
||||
|
||||
本目录存放历史文档,不作为当前实现规范。
|
||||
|
||||
- `from_root_docs/`:从旧位置迁移的历史文档快照。
|
||||
- `conflict_log.md` / `goals.md` / `migration_map.md`:重构过程文档。
|
||||
|
||||
遇到冲突时,请以 `docs/` 根目录下现行文档和代码真源为准:
|
||||
- `config/node_state_machine.yaml`
|
||||
- `engine/events.py`
|
||||
- `sync_system/strategy_ops/*`
|
||||
@@ -0,0 +1,10 @@
|
||||
# 文档/实现冲突记录
|
||||
|
||||
## 已确认冲突
|
||||
- 旧文档中存在 E11x 编号残留,与当前 YAML(E08/E09/E10/E12..E20) 不一致。
|
||||
- `PRECONDITION_FAILED` 在旧实现中可能出现 `action=NONE`,重构目标改为强制 `action=UPDATE`。
|
||||
|
||||
## 处理原则
|
||||
1. 以 `config/node_state_machine.yaml` 为唯一真源。
|
||||
2. 文档必须由配置和测试结果反向校验。
|
||||
3. 冲突先记录,再通过测试保护修复。
|
||||
@@ -0,0 +1,706 @@
|
||||
# API DataSource 架构设计
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Pipeline │
|
||||
│ (同步流程编排) │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ApiDataSource │
|
||||
│ (数据源协调层) │
|
||||
│ │
|
||||
│ - 持有 ApiClient 实例 │
|
||||
│ - 注册和管理 Handler │
|
||||
│ - 实现 load_all / execute_batch │
|
||||
│ - 注入 api_client 到 Handler │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Handler │ │ Handler │ │ Handler │
|
||||
│ Project │ │ Contract │ │ Material │
|
||||
│ │ │ │ │ │
|
||||
│ 业务逻辑层 │ │ 业务逻辑层 │ │ 业务逻辑层 │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│ 使用 self.api_client
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ ApiClient │
|
||||
│ (HTTP 通信层) │
|
||||
│ │
|
||||
│ - 签名生成 │
|
||||
│ - HTTP 请求 │
|
||||
│ - Push Log │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**调用流程**:
|
||||
1. Pipeline 创建 ApiDataSource,传入 base_url, uid, secret
|
||||
2. ApiDataSource 内部创建 ApiClient 实例
|
||||
3. ApiDataSource 注册各业务 Handler,并注入 api_client
|
||||
4. Handler 使用 `self.api_client.get/post/put()` 发送请求
|
||||
5. ApiClient 自动处理签名、push_log 查询、错误处理
|
||||
|
||||
**优势**:
|
||||
- ✅ **职责清晰**:ApiClient(通信)、DataSource(协调)、Handler(业务)
|
||||
- ✅ **避免循环依赖**:Handler → ApiClient(单向依赖)
|
||||
- ✅ **易于测试**:ApiClient 可 mock,Handler 独立测试
|
||||
- ✅ **易于扩展**:新业务只需实现 Handler
|
||||
|
||||
## 1. 问题分析:旧方案的复杂性
|
||||
|
||||
旧方案 `sync_system/datasource/api` 存在的问题:
|
||||
|
||||
1. **过度抽象**:引入了 `ApiDataSource` → `Repository` → `ResponseHandle` → `Cache` 多层封装,每个业务需要独立 Repository 类,Protocol 定义繁琐
|
||||
2. **状态管理混乱**:缓存策略不清晰,ResponseHandle 封装异步结果但使用困难,缺少统一的 push_log 查询机制
|
||||
3. **依赖关系困难**:Repository 之间互相传递引用,难以从 collection 获取已有数据
|
||||
4. **API 路由分散**:每个 Repository 手动实现多个 update 方法,缺少自动路由机制
|
||||
|
||||
## 2. 新方案设计原则
|
||||
|
||||
- **简单直接**:Handler 直接调用 HTTP API,不过度封装
|
||||
- **状态透明**:DataSource 统一管理 session、签名、push_log 查询
|
||||
- **依赖清晰**:通过 collection 获取依赖数据,使用 context 存储额外信息
|
||||
- **智能路由**:根据数据字段自动选择 API endpoint
|
||||
|
||||
## 3. 核心组件职责
|
||||
|
||||
### 3.1 ApiClient - HTTP 通信层
|
||||
|
||||
**职责**:
|
||||
- 封装所有 HTTP 通信逻辑
|
||||
- 管理 httpx.AsyncClient,维护连接池和 session
|
||||
- 生成请求签名(timestamp, nonce, sign)
|
||||
- 统一请求入口,自动注入 uid
|
||||
- 批量 Push Log 查询
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str, uid: str, secret: str):
|
||||
self._client = httpx.AsyncClient()
|
||||
self._base_url = base_url
|
||||
self._uid = uid
|
||||
self._secret = secret
|
||||
|
||||
async def request(self, method: str, url: str, **kwargs) -> httpx.Response
|
||||
"""统一请求入口,自动签名"""
|
||||
|
||||
async def get(self, url: str, params: dict = None) -> httpx.Response
|
||||
"""GET 请求"""
|
||||
|
||||
async def post(self, url: str, data: dict) -> httpx.Response
|
||||
"""POST 请求,data 中已包含 push_id/biz_id(由 Handler 生成)"""
|
||||
|
||||
async def put(self, url: str, data: dict) -> httpx.Response
|
||||
"""PUT 请求,data 中已包含 push_id/biz_id"""
|
||||
|
||||
async def delete(self, url: str, data: dict) -> httpx.Response
|
||||
"""DELETE 请求,data 中已包含 push_id/biz_id"""
|
||||
|
||||
async def get_push_logs(self, push_ids: List[str]) -> Dict[str, dict]
|
||||
"""批量查询 push_log 状态,返回 {push_id: push_log_data}
|
||||
注意:单个查询也传单元素列表
|
||||
当前实现:用 for 循环模拟,后续等后端实现批量接口
|
||||
"""
|
||||
|
||||
def _generate_signature(self, params: dict) -> dict
|
||||
"""生成签名参数(timestamp, nonce, sign)"""
|
||||
```
|
||||
|
||||
### 3.2 ApiDataSource - 数据源协调层
|
||||
|
||||
**职责**:
|
||||
- 持有 ApiClient 实例,提供给 Handler 使用
|
||||
- 实现 DataSource 接口(load_all, sync_all)
|
||||
- 协调多个 Handler 的执行顺序(通过 sync_all)
|
||||
- Handler 内部负责:生成 push_id/biz_id、调用 poll 轮询、提取 created_id
|
||||
- 统一异常处理和日志记录
|
||||
|
||||
**核心属性和方法**:
|
||||
```python
|
||||
class ApiDataSource(DataSource):
|
||||
def __init__(self, base_url: str, uid: str, secret: str):
|
||||
self.client = ApiClient(base_url, uid, secret)
|
||||
self._handlers: Dict[str, BaseApiHandler] = {}
|
||||
|
||||
def register_handler(self, node_type: str, handler: BaseApiHandler):
|
||||
"""注册业务 Handler"""
|
||||
handler.api_client = self.client # 注入 client
|
||||
self._handlers[node_type] = handler
|
||||
|
||||
async def load_all(self, collection: DataCollection, order: List[str]) -> None:
|
||||
"""调用基类的 load_all,按顺序加载数据"""
|
||||
|
||||
async def sync_all(self, collection: DataCollection, order: List[str]) -> None:
|
||||
"""调用基类的 sync_all,按顺序执行同步"""
|
||||
```
|
||||
|
||||
### 3.3 BaseApiHandler - 业务逻辑基类
|
||||
|
||||
**职责**:
|
||||
- 实现 Handler 接口(load, create, update, delete, poll)
|
||||
- 使用 self.api_client 发送 HTTP 请求
|
||||
- 生成 push_id 和 biz_id(业务标识)
|
||||
- 从 collection 获取依赖节点数据
|
||||
- 管理节点的 context(存储 project_id, contract_id 等)
|
||||
- 实现 poll 方法,调用 api_client.get_push_logs 批量查询
|
||||
- 提取 created_id(因为不同业务的 id 字段名和位置可能不同)
|
||||
- 返回 TaskResult(SUCCESS/FAILED/IN_PROGRESS)
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
class BaseApiHandler(NodeHandler):
|
||||
def __init__(self):
|
||||
self.api_client: ApiClient = None # 由 DataSource 注入
|
||||
self.collection: DataCollection = None # 由 DataSource 注入
|
||||
|
||||
async def load(self, collection: DataCollection) -> List[dict]
|
||||
async def create(self, node: SyncNode) -> TaskResult
|
||||
async def update(self, node: SyncNode) -> TaskResult
|
||||
async def delete(self, node: SyncNode) -> TaskResult
|
||||
async def poll(self, node: SyncNode, task_id: str) -> TaskResult
|
||||
|
||||
def extract_created_id(self, push_log: dict) -> str
|
||||
"""从 push_log 提取 created_id,子类可重写"""
|
||||
|
||||
# 重试逻辑(可选,子类可重写)
|
||||
async def _execute_with_retry(self, operation, max_retries: int = 3) -> Any
|
||||
"""通用重试逻辑,子类可自定义重试策略"""
|
||||
```
|
||||
|
||||
**重试策略说明**:
|
||||
- **重试在 Handler 内部**:create/update/delete 内部可调用 _execute_with_retry
|
||||
- **返回给 DataSource**:最终只返回 SUCCESS/FAILED/IN_PROGRESS
|
||||
- **灵活控制**:不同业务可重写 _execute_with_retry 实现自定义策略
|
||||
- **默认策略**:网络错误/5xx 重试 3 次,4xx 不重试
|
||||
|
||||
**未实现 API 的处理**:
|
||||
- 部分业务可能没有实现 create 或 delete API
|
||||
- 此时 Handler 应在对应方法中直接返回 FAILED 状态,并在 error 中说明原因
|
||||
- 示例:`return TaskResult(status=TaskStatus.FAILED, error="Create API not implemented for this business")`
|
||||
|
||||
### 3.4 具体业务 Handler
|
||||
|
||||
**职责**:
|
||||
- 实现具体业务的 load 逻辑(调用 self.api_client.get())
|
||||
- 创建节点时设置 context
|
||||
- 重写 API endpoint 选择逻辑(可选)
|
||||
- 定制 created_id 提取逻辑(可选)
|
||||
|
||||
每个业务 Handler 位于 `sync_system_new/domain/{业务}/api_handler.py`
|
||||
|
||||
## 4. HTTP 请求状态管理
|
||||
|
||||
### 4.0 状态系统概述
|
||||
|
||||
**通用状态枚举**(适用于所有 DataSource):
|
||||
|
||||
```python
|
||||
class TaskStatus(Enum):
|
||||
"""Handler 返回的任务执行状态"""
|
||||
SUCCESS = "success" # 同步完成,立即成功
|
||||
FAILED = "failed" # 同步失败
|
||||
IN_PROGRESS = "in_progress" # 异步任务进行中
|
||||
|
||||
class SyncStatus(Enum):
|
||||
"""SyncNode 的同步执行状态"""
|
||||
PENDING = "PENDING"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
SUCCESS = "SUCCESS"
|
||||
FAILED = "FAILED"
|
||||
SKIPPED = "SKIPPED"
|
||||
PRECONDITION_FAILED = "PRECONDITION_FAILED"
|
||||
```
|
||||
|
||||
**状态映射规则**:
|
||||
- TaskStatus 是 Handler 执行操作后的返回值
|
||||
- SyncStatus 是 SyncNode 的最终状态,由 DataSource 根据 TaskStatus 更新
|
||||
- 普通 DataSource(FileDataSource)和 API DataSource 使用相同的状态枚举
|
||||
- 区别在于:**API DataSource 的网络操作如何转化为 TaskStatus**
|
||||
|
||||
**重要说明:Load 阶段 vs 执行阶段的状态**:
|
||||
- **Load 阶段**(Handler.load()):从 API 拉取数据创建 SyncNode
|
||||
- 此时节点状态为 `PENDING`(等待后续 bind/create/update)
|
||||
- GET 请求失败不影响节点创建,只是该节点不存在于 collection
|
||||
- **执行阶段**(Handler.create/update/delete):执行同步操作
|
||||
- DataSource 在调用前设置 `node.status = IN_PROGRESS`
|
||||
- Handler 返回 TaskResult,DataSource 据此更新 node.status
|
||||
- POST/PUT/DELETE 成功后才设置为 SUCCESS
|
||||
|
||||
**核心映射逻辑**(在 DataSource 中):
|
||||
```python
|
||||
# 执行前:DataSource 设置状态
|
||||
node.status = SyncStatus.IN_PROGRESS
|
||||
|
||||
# 调用 Handler(Handler 内部可能重试多次)
|
||||
result = await handler.create(node)
|
||||
|
||||
# 执行后:DataSource 根据最终结果更新状态
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
node.data_id = result.data_id
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
elif result.status == TaskStatus.IN_PROGRESS:
|
||||
# 记录 task_id 等待轮询
|
||||
async_tasks[node.node_id] = result.task_id
|
||||
```
|
||||
|
||||
**重要**:Handler 负责重试,最终只返回确定的结果(SUCCESS/FAILED/IN_PROGRESS)
|
||||
|
||||
### 4.1 请求类型与状态流转
|
||||
|
||||
**API DataSource 的特殊性**:
|
||||
- **Load 阶段**:GET 请求拉取数据,创建 PENDING 节点(失败则不创建节点)
|
||||
- **执行阶段**:POST/PUT/DELETE 先设置 IN_PROGRESS,通过 poll() 轮询变为 SUCCESS/FAILED
|
||||
|
||||
#### Load 阶段 - 数据拉取
|
||||
|
||||
**目的**:从 API 获取数据,创建 SyncNode 放入 collection
|
||||
|
||||
| 阶段 | HTTP 操作 | 成功行为 | 失败行为 | 节点状态 |
|
||||
|------|----------|---------|---------|---------|
|
||||
| 1. 调用 load() | GET /api/list | 返回数据列表 | 返回空列表或抛异常 | - |
|
||||
| 2. 创建节点 | - | 为每条数据创建 SyncNode | 不创建节点 | PENDING |
|
||||
|
||||
**代码示例**:
|
||||
```python
|
||||
async def load(self, collection: DataCollection) -> List[dict]:
|
||||
"""加载数据 - GET 阶段不涉及状态转换"""
|
||||
try:
|
||||
response = await self.api_client.get("project/list")
|
||||
if response.status_code == 200:
|
||||
data_list = response.json().get("list", [])
|
||||
# 返回数据,由 DataSource 创建 PENDING 节点
|
||||
return data_list
|
||||
else:
|
||||
# 失败:返回空列表,不创建节点
|
||||
logger.error(f"Load failed: {response.status_code}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Load exception: {e}")
|
||||
return [] # 或者抛出异常,由调用方处理
|
||||
```
|
||||
|
||||
#### Mutation 请求(POST/PUT/DELETE) - 执行阶段
|
||||
|
||||
**状态变化时机**:
|
||||
1. **执行前**:DataSource 设置 `node.status = IN_PROGRESS`
|
||||
2. **Handler 执行**:内部可能重试,最终返回 SUCCESS/FAILED/IN_PROGRESS
|
||||
3. **轮询完成**:poll() 返回 SUCCESS/FAILED
|
||||
|
||||
| 阶段 | Push Log | TaskStatus | SyncStatus | 说明 |
|
||||
|------|---------|-----------|-----------|------|
|
||||
| 执行前 | - | - | PENDING → IN_PROGRESS | DataSource 设置 |
|
||||
| 提交 | pending | IN_PROGRESS | IN_PROGRESS | 任务已提交 |
|
||||
| 轮询 | processing | IN_PROGRESS | IN_PROGRESS | 后端处理中 |
|
||||
| 成功 | success | SUCCESS | SUCCESS | 同步完成 |
|
||||
| 失败 | failed | FAILED | FAILED | 同步失败 |
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
async def create(self, node: SyncNode) -> TaskResult:
|
||||
"""Handler 内部可重试,最终返回确定结果"""
|
||||
response = await self.api_client.post(url, node.data)
|
||||
if response.status_code == 202:
|
||||
return TaskResult.in_progress(task_id=response.json()["push_id"])
|
||||
else:
|
||||
return TaskResult.failed(error=f"HTTP {response.status_code}")
|
||||
|
||||
async def poll(self, node: SyncNode, task_id: str) -> TaskResult:
|
||||
push_logs = await self.api_client.get_push_logs([task_id])
|
||||
status = push_logs.get(task_id, {}).get("status")
|
||||
if status == "success":
|
||||
return TaskResult.success(data_id=self.extract_created_id(push_logs[task_id]))
|
||||
elif status == "failed":
|
||||
return TaskResult.failed(error="Backend processing failed")
|
||||
else:
|
||||
return TaskResult.in_progress(task_id=task_id)
|
||||
```
|
||||
|
||||
### 4.2 状态转换完整流程
|
||||
|
||||
```
|
||||
Load 阶段:
|
||||
Handler.load() → 返回数据列表 → DataSource 创建 PENDING 节点
|
||||
|
||||
执行阶段(同步操作):
|
||||
DataSource: node.status = IN_PROGRESS
|
||||
→ Handler.create() → TaskResult.success/failed()
|
||||
→ DataSource: node.status = SUCCESS/FAILED
|
||||
|
||||
执行阶段(异步操作):
|
||||
DataSource: node.status = IN_PROGRESS
|
||||
→ Handler.create() → TaskResult.in_progress(push_id)
|
||||
→ DataSource 轮询: Handler.poll(push_id)
|
||||
→ TaskResult.success/failed()
|
||||
→ DataSource: node.status = SUCCESS/FAILED
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- Handler 负责重试,DataSource 只处理最终结果
|
||||
- 不同业务可在 BaseApiHandler._execute_with_retry 中自定义重试策略
|
||||
- FileDataSource 和 ApiDataSource 使用相同的状态枚举和转换逻辑
|
||||
|
||||
### 4.3 Push Log 状态机
|
||||
|
||||
```
|
||||
pending → processing → success/failed
|
||||
```
|
||||
|
||||
- 轮询间隔:0.5秒
|
||||
- 超时:30秒
|
||||
- 成功:提取 created_id
|
||||
- 失败:获取 error_message
|
||||
|
||||
### 4.4 状态总结表
|
||||
|
||||
**Load 阶段(数据拉取)**:
|
||||
|
||||
| HTTP 操作 | 响应码 | 行为 | 创建节点 | 节点状态 | 说明 |
|
||||
|---------|-------|-----|---------|---------|------|
|
||||
| GET /list | 200 | 返回数据列表 | ✅ 是 | PENDING | 等待后续 bind/create/update |
|
||||
| GET /list | 4xx/5xx | 返回空列表 | ❌ 否 | - | 不创建节点 |
|
||||
|
||||
**执行阶段(POST/PUT/DELETE)**:
|
||||
|
||||
| HTTP 操作 | 响应码 | Push Log | TaskStatus | SyncStatus | 说明 |
|
||||
|---------|-------|---------|-----------|-----------|------|
|
||||
| POST/PUT | 202 | pending | IN_PROGRESS | IN_PROGRESS | 异步开始 |
|
||||
| - | - | processing | IN_PROGRESS | IN_PROGRESS | 异步处理中 |
|
||||
| - | - | success | SUCCESS | SUCCESS | 异步成功 |
|
||||
| - | - | failed | FAILED | FAILED | 异步失败 |
|
||||
| - | - | timeout | FAILED | FAILED | 轮询超时 |
|
||||
|
||||
## 5. Context 机制
|
||||
|
||||
### 5.1 Context 的作用
|
||||
|
||||
Context 是 SyncNode 的字典字段,用于存储业务数据之外的上下文信息,例如:
|
||||
- `project_id`: 当前对象所属的项目ID
|
||||
- `contract_id`: 合同变更所属的合同ID
|
||||
- `parent_id`: 父对象ID
|
||||
- 其他 API 调用需要但 schema 中没有的信息
|
||||
|
||||
### 5.2 Context 使用流程
|
||||
|
||||
**场景:加载合同数据**
|
||||
|
||||
1. **Handler.load()** 从 self._collection 获取所有 project
|
||||
2. 遍历每个 project,调用 `GET contract/list?project_id=xxx`
|
||||
3. 为每个返回的 contract 数据添加临时标记 `_context = {"project_id": xxx}`
|
||||
4. **Handler.create_node()** 创建 SyncNode 时,提取 `_context` 并设置到 `node.context`
|
||||
5. **Handler.update()** 更新时,从 `node.context["project_id"]` 获取 project_id 并添加到请求数据
|
||||
|
||||
### 5.3 Context 传递规则
|
||||
|
||||
- 父 → 子:从依赖的父节点继承 context
|
||||
- 横向传递:同级节点不共享 context
|
||||
- 更新时使用:从 context 提取必要信息添加到请求 payload
|
||||
|
||||
## 6. 智能 API 路由
|
||||
|
||||
### 6.1 路由策略
|
||||
|
||||
根据节点数据的字段,自动选择正确的 API endpoint:
|
||||
|
||||
**方案1:字段匹配**
|
||||
- 检查 node.data 中的字段集合
|
||||
- 匹配预定义的 endpoint → required_fields 映射表
|
||||
- 选择第一个匹配的 endpoint
|
||||
|
||||
**方案2:Schema 类型**
|
||||
- 根据 node.schema 的类型
|
||||
- 查找 schema_type → endpoint 映射表
|
||||
|
||||
**默认规则**:
|
||||
- CREATE: `{node_type}`
|
||||
- UPDATE: 子类重写 `_select_update_endpoint()`
|
||||
- DELETE: `{node_type}`
|
||||
|
||||
### 6.2 Project 示例
|
||||
|
||||
Project 有多个更新接口:
|
||||
- `project/key_constraints` - 需要字段:key_constraints, key_constraints_remark
|
||||
- `project/complete_investment` - 需要字段:complete_investment
|
||||
- `project/dynamic_investment` - 需要字段:dynamic_investment
|
||||
- `project/plan_construction` - 需要字段:plan_construction_capacity, plan_construction_date
|
||||
- ... 等
|
||||
|
||||
Handler 定义映射表,根据实际数据字段自动选择。
|
||||
|
||||
## 7. 依赖数据获取
|
||||
|
||||
### 7.1 获取模式
|
||||
|
||||
Handler 通过 `collection.filter(node_type="xxx")` 获取依赖数据:
|
||||
|
||||
**加载顺序**(按依赖关系):
|
||||
1. Project(无依赖)
|
||||
2. Contract(依赖 Project)
|
||||
3. Contract Change(依赖 Project + Contract)
|
||||
|
||||
### 7.2 数据流
|
||||
|
||||
```
|
||||
Pipeline
|
||||
→ 按拓扑序加载
|
||||
→ ProjectHandler.load()
|
||||
→ GET project/list
|
||||
→ 创建 ProjectSyncNode
|
||||
→ ContractHandler.load()
|
||||
→ collection.filter(node_type="project")
|
||||
→ 遍历 project,GET contract/list?project_id=xxx
|
||||
→ 创建 ContractSyncNode,设置 context["project_id"]
|
||||
→ ContractChangeHandler.load()
|
||||
→ collection.filter(node_type="contract")
|
||||
→ 遍历 contract,从 context 获取 project_id
|
||||
→ GET contract_change/list?project_id=xxx&contract_id=yyy
|
||||
→ 创建节点,设置 context
|
||||
```
|
||||
|
||||
## 8. 实现计划
|
||||
|
||||
### Phase 1: HTTP 通信层
|
||||
- 实现 **ApiClient** 类
|
||||
- HTTP Client 封装(httpx.AsyncClient)
|
||||
- 签名生成(_generate_signature)
|
||||
- 统一请求方法(get/post/put/delete)
|
||||
- **批量 Push Log 查询**(get_push_logs,单个也传列表)
|
||||
- ~~wait_for_push_log~~(不实现,由 DataSource 调用 poll)
|
||||
|
||||
### Phase 2: DataSource 协调层
|
||||
- 实现 **ApiDataSource** 类
|
||||
- 持有 ApiClient 实例
|
||||
- Handler 注册和管理
|
||||
- 实现 DataSource 接口(load_all, execute_batch)
|
||||
- 注入 api_client 到 Handler
|
||||
- 实现轮询逻辑(_poll_async_tasks)
|
||||
|
||||
### Phase 3: Handler 基类
|
||||
- 实现 **BaseApiHandler** 类
|
||||
- load/create/update/delete/poll 方法
|
||||
- 使用 self.api_client 发送请求
|
||||
- **extract_created_id**(子类可重写)
|
||||
- **_execute_with_retry**(可选,子类可重写重试策略)
|
||||
- Context 管理
|
||||
|
||||
### Phase 4: 业务验证
|
||||
- 实现 **ProjectApiHandler**(无依赖,简单场景)
|
||||
- 实现 load:GET project/list
|
||||
- 实现 create:POST project
|
||||
- 实现 poll:查询 push_log
|
||||
- 实现 **ContractApiHandler**(依赖 Project,验证 context)
|
||||
- 实现 load:遍历 project,GET contract/list?project_id=xxx
|
||||
- 设置 context["project_id"]
|
||||
- 编写测试验证设计
|
||||
|
||||
### Phase 5: 完善功能
|
||||
- 批量 Push Log 查询优化(等待后端实现批量接口)
|
||||
- 超时控制(可配置)
|
||||
- 请求日志和调试
|
||||
|
||||
### Phase 6: 推广
|
||||
- 实现剩余业务 Handler
|
||||
- 性能优化(并发控制)
|
||||
- 文档和示例
|
||||
|
||||
## 9. 与现有系统集成
|
||||
|
||||
**调用关系**:
|
||||
```
|
||||
Pipeline
|
||||
↓
|
||||
ApiDataSource (持有 ApiClient)
|
||||
↓
|
||||
BaseApiHandler (使用 api_client)
|
||||
↓
|
||||
ApiClient (发送 HTTP 请求)
|
||||
```
|
||||
|
||||
**职责分离**:
|
||||
- **ApiClient**: 纯粹的 HTTP 通信,无业务逻辑
|
||||
- **ApiDataSource**: 协调 Handler,管理执行流程
|
||||
- **BaseApiHandler**: 业务逻辑,调用 ApiClient 完成网络操作
|
||||
- **具体 Handler**: 实现特定业务的 load/create/update/delete
|
||||
|
||||
**优点**:
|
||||
- ApiClient 和 Handler 解耦,避免循环依赖
|
||||
- Handler 通过 self.api_client 访问 HTTP 功能
|
||||
- DataSource 统一管理 ApiClient 生命周期
|
||||
- Handler 内部灵活控制重试策略
|
||||
|
||||
## 10. 关键优势
|
||||
|
||||
1. **简化架构**:去掉 Repository 层,代码量减少 50%
|
||||
2. **职责清晰**:
|
||||
- ApiClient:HTTP 通信
|
||||
- DataSource:协调和轮询
|
||||
- Handler:业务逻辑和重试
|
||||
3. **重试灵活**:Handler 内部实现,不同业务可自定义策略
|
||||
4. **状态清晰**:Load 阶段创建 PENDING 节点,执行阶段明确状态转换
|
||||
5. **批量优化**:Push Log 批量查询,减少 HTTP 请求
|
||||
6. **易于扩展**:新业务只需实现 Handler,复用 Client 和 DataSource
|
||||
7. **易于测试**:ApiClient 可 mock,Handler 独立测试
|
||||
|
||||
## 11. 设计改进总结
|
||||
|
||||
| 改进点 | 初始设计 | 最终设计 | 理由 |
|
||||
|-------|---------|---------|------|
|
||||
| Push Log 查询 | get_push_log(id) | get_push_logs([ids]) | 批量查询 |
|
||||
| 轮询逻辑 | wait_for_push_log 在 Client | 移除,DataSource 调用 poll | 职责清晰 |
|
||||
| created_id 提取 | 在 Client | 在 Handler 基类 | 业务差异 |
|
||||
| 重试逻辑 | 在 DataSource | **在 Handler** | 业务灵活性 |
|
||||
| Load 状态 | GET 成功 → SUCCESS | GET 成功 → PENDING | Load ≠ 同步完成 |
|
||||
| 执行状态 | 不明确 | 执行前设置 IN_PROGRESS | 状态清晰 |
|
||||
7. **易于测试**:ApiClient 可以 mock,Handler 单独测试业务逻辑
|
||||
|
||||
## 12. 使用示例
|
||||
|
||||
### 12.1 基本使用流程
|
||||
|
||||
```python
|
||||
# 1. 创建 DataSource
|
||||
datasource = ApiDataSource(
|
||||
base_url="https://api.example.com",
|
||||
uid="user123",
|
||||
secret="secret_key"
|
||||
)
|
||||
|
||||
# 2. 初始化(必须调用)
|
||||
await datasource.initialize()
|
||||
|
||||
# 3. 注册 Handler
|
||||
datasource.register_handler(ProjectApiHandler())
|
||||
datasource.register_handler(ContractApiHandler())
|
||||
|
||||
# 4. 创建 Collection
|
||||
collection = DataCollection()
|
||||
|
||||
# 5. 加载数据(自动处理异常,不会抛出)
|
||||
await datasource.load_all(collection, order=["project", "contract"])
|
||||
|
||||
# 6. 执行同步(自动处理异常,不会抛出)
|
||||
await datasource.sync_all(collection, order=["project", "contract"])
|
||||
|
||||
# 7. 关闭连接(必须调用)
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
### 12.2 异常处理说明
|
||||
|
||||
**设计原则**:
|
||||
- ✅ **load_all 和 sync_all 不会抛出异常**
|
||||
- ✅ **Handler 的 create/update/delete 捕获所有 HTTP 异常**
|
||||
- ✅ **所有错误转换为状态(SyncStatus.FAILED)和错误信息(node.error)**
|
||||
|
||||
**为什么不需要 try-finally?**
|
||||
|
||||
```python
|
||||
# ❌ 不需要这样做
|
||||
try:
|
||||
await datasource.load_all(collection, order=["project"])
|
||||
finally:
|
||||
await datasource.close()
|
||||
|
||||
# ✅ 直接调用即可
|
||||
await datasource.initialize()
|
||||
await datasource.load_all(collection, order=["project"]) # 内部已处理异常
|
||||
await datasource.sync_all(collection, order=["project"]) # 内部已处理异常
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
**异常处理机制**:
|
||||
|
||||
1. **load_all 异常处理**:
|
||||
```python
|
||||
# 加载某个类型失败,记录错误但继续处理其他类型
|
||||
for node_type in order:
|
||||
try:
|
||||
raw_items = await handler.load(collection)
|
||||
# ... 创建节点
|
||||
except Exception as e:
|
||||
print(f"Error loading {node_type}: {e}")
|
||||
# 继续处理下一个类型
|
||||
```
|
||||
|
||||
2. **sync_all 异常处理**:
|
||||
```python
|
||||
# Handler 返回的异常已被捕获转为 TaskResult
|
||||
for node in nodes:
|
||||
try:
|
||||
result = await handler.create(node) # Handler 内部捕获异常
|
||||
# 根据 result.status 更新节点状态
|
||||
except Exception as e:
|
||||
# DataSource 的最后保护
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = str(e)
|
||||
```
|
||||
|
||||
3. **Handler 异常处理**:
|
||||
```python
|
||||
async def create(self, node: SyncNode) -> TaskResult:
|
||||
try:
|
||||
# 生成参数
|
||||
push_id = self._generate_push_id()
|
||||
data = {..., "push_id": push_id, "biz_id": self._generate_biz_id(node)}
|
||||
|
||||
# 调用 API
|
||||
response = await self.api_client.post("/api/v2/project", data)
|
||||
|
||||
# 返回成功
|
||||
return TaskResult(status=TaskStatus.SUCCESS, data_id=response['data']['id'])
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# HTTP 错误(4xx, 5xx)
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"HTTP {e.response.status_code}: {e}")
|
||||
except httpx.NetworkError as e:
|
||||
# 网络错误
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"Network error: {e}")
|
||||
except Exception as e:
|
||||
# 其他异常
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"Unexpected error: {e}")
|
||||
```
|
||||
|
||||
### 12.3 Pipeline 集成示例
|
||||
|
||||
```python
|
||||
async def sync_pipeline(datasource: ApiDataSource):
|
||||
"""同步流程编排"""
|
||||
|
||||
# 初始化
|
||||
await datasource.initialize()
|
||||
|
||||
# 创建 Collection
|
||||
collection = DataCollection()
|
||||
|
||||
# 执行同步流程(所有错误都被内部处理)
|
||||
await datasource.load_all(collection, order=["project", "contract", "material"])
|
||||
await datasource.sync_all(collection, order=["project", "contract", "material"])
|
||||
|
||||
# 检查结果
|
||||
failed_nodes = collection.filter(status=SyncStatus.FAILED)
|
||||
if failed_nodes:
|
||||
print(f"Failed nodes: {len(failed_nodes)}")
|
||||
for node in failed_nodes:
|
||||
print(f" - {node.node_type}:{node.node_id}: {node.error}")
|
||||
|
||||
# 清理
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 不需要 try-except 包裹 load_all/sync_all
|
||||
- ✅ 通过检查节点状态判断成功/失败
|
||||
- ✅ 错误信息保存在 node.error 中
|
||||
- ✅ Pipeline 专注于流程编排,不处理异常细节
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
# DataSource + Handler 架构设计 V2
|
||||
|
||||
## 核心原则
|
||||
|
||||
### **职责分离**
|
||||
|
||||
```text
|
||||
Strategy (业务逻辑层)
|
||||
↓ 设置 action, 完成 ID 映射
|
||||
DataSource (状态管理 + 编排层)
|
||||
↓ 调用 Handler, 管理状态流转
|
||||
Handler (纯执行层)
|
||||
↓ 调用后端 API, 返回结果
|
||||
Backend API
|
||||
```
|
||||
|
||||
### **关键设计决策**
|
||||
|
||||
1. **ID 映射在 Strategy 完成** ✅
|
||||
- `Strategy.create()` / `Strategy.update()` 时,已将依赖字段的本地 ID 替换为远程 ID
|
||||
- DataSource 拿到的 `node.data` 已经是映射好的数据
|
||||
- Handler 直接使用 `node.data`,不需要关心 ID 映射
|
||||
|
||||
2. **状态管理在 DataSource** ✅
|
||||
- 所有 `node.status` 的转换由 DataSource 负责
|
||||
- Handler 只返回 `TaskResult`(成功/失败/进行中)
|
||||
- DataSource 根据 `TaskResult` 更新 `node.status`
|
||||
|
||||
3. **Handler 只负责执行** ✅
|
||||
- 调用后端 API(load/create/update/delete)
|
||||
- 返回执行结果(TaskResult)
|
||||
- 提供异步任务轮询(poll_tasks)
|
||||
- 提供数据质量检查(validate)
|
||||
|
||||
---
|
||||
|
||||
## Handler 接口设计
|
||||
|
||||
### **核心方法**
|
||||
|
||||
```python
|
||||
class NodeHandler(Protocol[T]):
|
||||
# ===== 数据加载 =====
|
||||
async def load(self, collection: Optional[DataCollection] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从后端加载全部数据
|
||||
|
||||
Args:
|
||||
collection: Collection 上下文(可选,用于依赖查询)
|
||||
|
||||
Returns:
|
||||
原始数据列表
|
||||
|
||||
示例:
|
||||
# Contract Handler(依赖 Project)
|
||||
async def load(self, collection=None):
|
||||
# 从 collection 获取 project 列表
|
||||
if collection:
|
||||
projects = collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
else:
|
||||
project_ids = []
|
||||
|
||||
# 只加载这些项目的合同
|
||||
response = await self.api.get_contracts(project_ids=project_ids)
|
||||
return response["data"]
|
||||
"""
|
||||
|
||||
# ===== 数据操作 =====
|
||||
async def create(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""
|
||||
创建节点
|
||||
|
||||
注意:node.data 已经过 ID 映射,直接使用即可
|
||||
|
||||
Returns:
|
||||
TaskResult:
|
||||
- SUCCESS: 同步完成,返回 data_id
|
||||
- IN_PROGRESS: 异步任务,返回 task_id
|
||||
- FAILED: 失败,返回 error
|
||||
|
||||
示例:
|
||||
# 同步接口
|
||||
async def create(self, node):
|
||||
try:
|
||||
response = await self.api.create_contract(node.data)
|
||||
return TaskResult.success(data_id=response["id"])
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
# 异步接口
|
||||
async def create(self, node):
|
||||
try:
|
||||
response = await self.api.submit_contract_async(node.data)
|
||||
return TaskResult.in_progress(task_id=response["task_id"])
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
"""
|
||||
|
||||
async def update(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""更新节点(同 create)"""
|
||||
|
||||
async def delete(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""删除节点(同 create)"""
|
||||
|
||||
# ===== 异步任务轮询 =====
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""
|
||||
批量轮询异步任务状态
|
||||
|
||||
Args:
|
||||
task_ids: 任务 ID 列表
|
||||
|
||||
Returns:
|
||||
{task_id: TaskResult} 字典
|
||||
|
||||
示例:
|
||||
async def poll_tasks(self, task_ids):
|
||||
response = await self.api.batch_query_tasks(task_ids)
|
||||
results = {}
|
||||
for task in response["tasks"]:
|
||||
if task["status"] == "completed":
|
||||
results[task["id"]] = TaskResult.success(
|
||||
data_id=task["result"]["contract_id"]
|
||||
)
|
||||
elif task["status"] == "failed":
|
||||
results[task["id"]] = TaskResult.failed(
|
||||
error=task["error"]
|
||||
)
|
||||
else: # running
|
||||
results[task["id"]] = TaskResult.in_progress(
|
||||
task_id=task["id"]
|
||||
)
|
||||
return results
|
||||
"""
|
||||
|
||||
# ===== 数据质量检查 =====
|
||||
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
数据质量检查
|
||||
|
||||
Args:
|
||||
data: 原始数据
|
||||
|
||||
Returns:
|
||||
ValidationResult(是否有效 + 错误列表)
|
||||
|
||||
示例:
|
||||
async def validate(self, data):
|
||||
errors = []
|
||||
if not data.get("code"):
|
||||
errors.append("合同编码为空")
|
||||
if not data.get("project_id"):
|
||||
errors.append("项目 ID 为空")
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors
|
||||
)
|
||||
"""
|
||||
|
||||
# ===== 辅助方法 =====
|
||||
def extract_id(self, data: Dict[str, Any]) -> str:
|
||||
"""从原始数据提取 ID"""
|
||||
|
||||
def create_node(self, data: Dict[str, Any]) -> SyncNode[T]:
|
||||
"""从原始数据创建 SyncNode"""
|
||||
```
|
||||
|
||||
### **TaskResult 设计**
|
||||
|
||||
```python
|
||||
class TaskStatus(Enum):
|
||||
SUCCESS = "success" # 同步完成
|
||||
FAILED = "failed" # 失败
|
||||
IN_PROGRESS = "in_progress" # 异步任务进行中
|
||||
|
||||
|
||||
class TaskResult:
|
||||
def __init__(
|
||||
self,
|
||||
status: TaskStatus,
|
||||
data_id: Optional[str] = None, # 成功时返回的 ID
|
||||
task_id: Optional[str] = None, # 异步任务 ID
|
||||
error: Optional[str] = None, # 失败原因
|
||||
metadata: Optional[Dict] = None # 其他元数据
|
||||
):
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def success(cls, data_id: Optional[str] = None) -> TaskResult:
|
||||
"""创建成功结果"""
|
||||
|
||||
@classmethod
|
||||
def failed(cls, error: str) -> TaskResult:
|
||||
"""创建失败结果"""
|
||||
|
||||
@classmethod
|
||||
def in_progress(cls, task_id: str) -> TaskResult:
|
||||
"""创建进行中结果"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataSource 接口设计
|
||||
|
||||
### **接口方法**
|
||||
|
||||
```python
|
||||
class BaseDataSource(ABC):
|
||||
# ===== Handler 管理 =====
|
||||
def register_handler(self, handler: NodeHandler) -> None:
|
||||
"""注册 Handler"""
|
||||
|
||||
def get_handler(self, node_type: str) -> NodeHandler:
|
||||
"""获取 Handler"""
|
||||
|
||||
# ===== 数据加载 =====
|
||||
async def load_all(
|
||||
self,
|
||||
order: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
按依赖顺序加载所有数据
|
||||
|
||||
注意:调用前需先 set_collection()
|
||||
|
||||
Args:
|
||||
order: 节点类型顺序(如 ["project", "contract", "construction"])
|
||||
|
||||
工作流程:
|
||||
1. 按顺序遍历每个节点类型
|
||||
2. 调用 Handler.load()
|
||||
3. Handler 可以从 self._collection 查询依赖节点
|
||||
4. 使用 Handler.create_node() 创建 SyncNode
|
||||
5. 添加到 Collection
|
||||
"""
|
||||
|
||||
# ===== 同步执行 =====
|
||||
async def sync_all(
|
||||
self,
|
||||
order: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
按依赖顺序执行同步
|
||||
|
||||
流程:
|
||||
1. 按顺序遍历每个节点类型
|
||||
2. 筛选需要同步的节点(status=PENDING, action!=NONE)
|
||||
3. 调用 Handler 的 create/update/delete
|
||||
4. 根据 TaskResult 更新节点状态
|
||||
5. 处理异步任务轮询
|
||||
"""
|
||||
|
||||
# ===== 统计信息 =====
|
||||
def get_sync_summary(self, collection: DataCollection) -> Dict[str, Any]:
|
||||
"""获取同步统计信息"""
|
||||
```
|
||||
|
||||
### **状态流转逻辑**
|
||||
|
||||
```python
|
||||
async def _sync_nodes(self, handler, nodes):
|
||||
async_tasks = {} # {node_id: task_id}
|
||||
|
||||
for node in nodes:
|
||||
# 1. 跳过不需要处理的节点
|
||||
if node.action == SyncAction.NONE:
|
||||
continue
|
||||
|
||||
# 2. 跳过异常状态节点
|
||||
if node.binding_status in [DEPENDENCY_ERROR, ABNORMAL, WARNING]:
|
||||
node.status = SyncStatus.SKIPPED
|
||||
continue
|
||||
|
||||
# 3. 只处理 PENDING 节点
|
||||
if node.status != SyncStatus.PENDING:
|
||||
continue
|
||||
|
||||
# 4. 执行操作
|
||||
try:
|
||||
node.status = SyncStatus.IN_PROGRESS
|
||||
|
||||
if node.action == SyncAction.CREATE:
|
||||
result = await handler.create(node)
|
||||
elif node.action == SyncAction.UPDATE:
|
||||
result = await handler.update(node)
|
||||
elif node.action == SyncAction.DELETE:
|
||||
result = await handler.delete(node)
|
||||
|
||||
# 5. 处理结果
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
if result.data_id:
|
||||
node.data_id = result.data_id # 回填 ID
|
||||
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
|
||||
elif result.status == TaskStatus.IN_PROGRESS:
|
||||
async_tasks[node.node_id] = result.task_id
|
||||
|
||||
except Exception as e:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = str(e)
|
||||
|
||||
# 6. 轮询异步任务
|
||||
if async_tasks:
|
||||
await self._poll_async_tasks(handler, nodes, async_tasks)
|
||||
```
|
||||
|
||||
### **异步任务轮询**
|
||||
|
||||
```python
|
||||
async def _poll_async_tasks(self, handler, nodes, async_tasks):
|
||||
node_map = {n.node_id: n for n in nodes}
|
||||
|
||||
max_retries = poll_max_retries # 默认轮询 1 次,可配置
|
||||
retry_count = 0
|
||||
|
||||
while async_tasks and retry_count < max_retries:
|
||||
# 批量查询
|
||||
task_ids = list(async_tasks.values())
|
||||
results = await handler.poll_tasks(task_ids)
|
||||
|
||||
# 处理结果
|
||||
for node_id, task_id in list(async_tasks.items()):
|
||||
if task_id not in results:
|
||||
continue
|
||||
|
||||
result = results[task_id]
|
||||
node = node_map[node_id]
|
||||
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
if result.data_id:
|
||||
node.data_id = result.data_id
|
||||
async_tasks.pop(node_id)
|
||||
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
async_tasks.pop(node_id)
|
||||
|
||||
# 还有未完成任务,等待后重试
|
||||
if async_tasks:
|
||||
await asyncio.sleep(2)
|
||||
retry_count += 1
|
||||
|
||||
# 超时未完成的任务标记为失败
|
||||
for node_id in async_tasks:
|
||||
node = node_map[node_id]
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = "Async task timeout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流程示例
|
||||
|
||||
### **Pipeline 编排**
|
||||
|
||||
```python
|
||||
# Stage 0: 初始化
|
||||
collection = DataCollection()
|
||||
datasource = ApiDataSource(api_client)
|
||||
|
||||
# 注册 Handler
|
||||
datasource.register_handler(ProjectNodeHandler(api_client))
|
||||
datasource.register_handler(ContractNodeHandler(api_client))
|
||||
|
||||
# Stage 1: 加载数据
|
||||
datasource.set_collection(collection)
|
||||
await datasource.load_all(
|
||||
order=["project", "contract"] # 依赖顺序
|
||||
)
|
||||
|
||||
# Stage 2: Strategy 执行
|
||||
for strategy in [project_strategy, contract_strategy]:
|
||||
await strategy.bind() # 设置 binding_status
|
||||
await strategy.create() # 设置 action=CREATE + ID 映射
|
||||
await strategy.update() # 设置 action=UPDATE + ID 映射
|
||||
await strategy.pre_sync_check()
|
||||
|
||||
# Stage 3: 物理同步
|
||||
await datasource.sync_all(
|
||||
order=["project", "contract"]
|
||||
)
|
||||
|
||||
# Stage 4: 检查结果
|
||||
summary = datasource.get_sync_summary(collection)
|
||||
print(f"Success: {summary['success']}, Failed: {summary['failed']}")
|
||||
```
|
||||
|
||||
### **Contract Handler 实现**
|
||||
|
||||
```python
|
||||
class ContractNodeHandler(BaseNodeHandler[ContractResponse]):
|
||||
def __init__(self, api_client):
|
||||
super().__init__(
|
||||
node_type="contract",
|
||||
node_class=ContractSyncNode,
|
||||
schema=ContractResponse
|
||||
)
|
||||
self.api = api_client
|
||||
|
||||
async def load(self):
|
||||
"""加载合同(依赖 Project)"""
|
||||
if self._collection:
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
else:
|
||||
project_ids = []
|
||||
|
||||
response = await self.api.get_contracts(project_ids=project_ids)
|
||||
return response["data"]
|
||||
|
||||
async def create(self, node):
|
||||
"""创建合同(异步接口)"""
|
||||
try:
|
||||
# node.data 已经过 ID 映射,直接使用
|
||||
response = await self.api.create_contract_async(node.data)
|
||||
|
||||
if response.get("async"):
|
||||
return TaskResult.in_progress(task_id=response["task_id"])
|
||||
else:
|
||||
return TaskResult.success(data_id=response["id"])
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
async def update(self, node):
|
||||
"""更新合同(同步接口)"""
|
||||
try:
|
||||
response = await self.api.update_contract(node.data_id, node.data)
|
||||
return TaskResult.success()
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
async def poll_tasks(self, task_ids):
|
||||
"""轮询异步任务"""
|
||||
response = await self.api.batch_query_tasks(task_ids)
|
||||
results = {}
|
||||
for task in response["tasks"]:
|
||||
if task["status"] == "completed":
|
||||
results[task["id"]] = TaskResult.success(
|
||||
data_id=task["result"]["contract_id"]
|
||||
)
|
||||
elif task["status"] == "failed":
|
||||
results[task["id"]] = TaskResult.failed(error=task["error"])
|
||||
else:
|
||||
results[task["id"]] = TaskResult.in_progress(task_id=task["id"])
|
||||
return results
|
||||
|
||||
async def validate(self, data):
|
||||
"""数据质量检查"""
|
||||
errors = []
|
||||
if not data.get("code"):
|
||||
errors.append("合同编码为空")
|
||||
if not data.get("project_id"):
|
||||
errors.append("项目 ID 为空")
|
||||
|
||||
return ValidationResult(is_valid=len(errors) == 0, errors=errors)
|
||||
|
||||
def extract_id(self, data):
|
||||
return data["id"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心优势
|
||||
|
||||
### **1. 职责清晰**
|
||||
|
||||
- **Strategy**: 业务逻辑 + ID 映射
|
||||
- **DataSource**: 状态管理 + 编排
|
||||
- **Handler**: 纯执行(后端 API 调用)
|
||||
|
||||
### **2. ID 映射前置**
|
||||
|
||||
- ID 映射在 Strategy 完成,DataSource 和 Handler 无需关心
|
||||
- `node.data` 已经是映射好的数据,直接使用
|
||||
|
||||
### **3. 状态统一管理**
|
||||
|
||||
- 所有状态流转由 DataSource 负责
|
||||
- Handler 只返回结果,不改变节点状态
|
||||
|
||||
### **4. 异步任务支持**
|
||||
|
||||
- Handler 返回 `TaskResult.in_progress(task_id)`
|
||||
- DataSource 自动轮询直到完成
|
||||
|
||||
### **5. 数据质量保障**
|
||||
|
||||
|
||||
- Handler 提供 `validate()` 方法
|
||||
- DataSource 在加载时检查数据质量
|
||||
|
||||
### **6. 易于测试**
|
||||
|
||||
- Handler 可以 Mock(返回假 TaskResult)
|
||||
- DataSource 可以单独测试状态流转逻辑
|
||||
- Strategy 可以单独测试 ID 映射逻辑
|
||||
|
||||
---
|
||||
|
||||
## 与现有架构的兼容性
|
||||
|
||||
完全符合 [架构分层设计.md](./架构分层设计.md) 的四层模型:
|
||||
|
||||
1. **Common 层**: SyncNode, DataCollection, BindingManager
|
||||
2. **Sync System 层**: SyncStrategy(负责 bind/create/update + ID 映射)
|
||||
3. **DataSource 层**: BaseDataSource + NodeHandler(状态管理 + 执行)
|
||||
4. **Pipeline 层**: 流程编排 + 依赖顺序
|
||||
|
||||
符合 [状态定义规范.md](./状态定义规范.md) 的状态流转逻辑。
|
||||
@@ -0,0 +1,150 @@
|
||||
# node_id / data_id / 持久化与生命周期说明
|
||||
|
||||
本文档说明同步系统中 `node_id` 与 `data_id` 的职责、生成时机、持久化行为,以及同步流程中“创建/绑定/回填”的完整生命周期。目标是避免“重复生成 node_id 导致绑定失效”的问题,并明确各层职责。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础概念
|
||||
|
||||
- **data_id**
|
||||
- 业务主键(来自数据源/远端系统的资源 ID)。
|
||||
- 在加载阶段通常已存在;在 CREATE 场景下初始为空,成功后由 DataSource 回填。
|
||||
|
||||
- **node_id**
|
||||
- 同步系统内部的**稳定标识符**(绑定与持久化的主键)。
|
||||
- 用于绑定表、持久化节点、依赖追踪等。
|
||||
- 设计目标:**跨同步周期稳定**,不能随每次加载而变化。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心结论(必须遵守)
|
||||
|
||||
1. **node_id 一旦建立,不应再被数据源“重新生成”。**
|
||||
- 绑定关系与持久化状态依赖 `node_id`;若每次加载都重新生成,会导致历史绑定失效。
|
||||
2. **加载阶段应先恢复 Collection(持久化节点),再加载 DataSource 数据。**
|
||||
- 数据源加载出的 `data_id` 应优先匹配已持久化的节点;匹配到则复用旧 `node_id`。
|
||||
3. **CREATE 场景允许临时/新建 node_id**,但必须通过后续回填与绑定更新保持一致性。
|
||||
|
||||
---
|
||||
|
||||
## 3. 正确的初始化/加载顺序
|
||||
|
||||
### 推荐流程(Pipeline 前半段)
|
||||
|
||||
1. **持久化恢复阶段**
|
||||
- 必须同时恢复 `node_id` 与 `data_id`(以及状态字段),否则无法建立稳定的映射关系。
|
||||
- Collection 可能为空,这属于合法情况。
|
||||
|
||||
2. **数据源加载阶段(DataSource.load_all)**
|
||||
- 对每一条业务数据(含 `data_id`):
|
||||
- **若 Collection 内已存在该 `data_id` 对应节点**:将数据写回原节点(保留旧 `node_id`)。
|
||||
- **若不存在**:由 **Collection** 生成新的 `node_id`,并创建新节点。
|
||||
|
||||
> 这样可避免“第二次同步时重建节点,导致旧绑定丢失”。
|
||||
|
||||
---
|
||||
|
||||
## 4. node_id 的来源与稳定性
|
||||
|
||||
### A. 持久化恢复阶段
|
||||
|
||||
- **恢复时必须包含 `node_id` 与 `data_id`**:
|
||||
- 两者成对恢复,才能保证后续 load 阶段按 `data_id` 命中旧节点。
|
||||
|
||||
### B. 数据源加载阶段
|
||||
|
||||
- **优先复用持久化 node_id**:
|
||||
- 通过 `data_id` 在 Collection 中查找并更新已有节点。
|
||||
- **仅在未知 data_id 时新建 node_id**:
|
||||
- 新节点 `node_id` 必须由 **Collection** 生成,而非 DataSource/Handler 直接拼接。
|
||||
|
||||
### C. 创建阶段(Strategy.create)
|
||||
|
||||
- 新建“目标端”节点时,**必须有 node_id**:
|
||||
- 用于写入 Collection、记录状态、建立临时绑定。
|
||||
- 成功后回填 `data_id`,**node_id 不变**。
|
||||
|
||||
---
|
||||
|
||||
## 5. data_id 的来源与回填
|
||||
|
||||
本节按阶段拆分说明 `data_id` 的来源与写回。
|
||||
|
||||
### A. 持久化恢复阶段
|
||||
|
||||
- `data_id` 从持久化数据中恢复(与 `node_id` 一起恢复)。
|
||||
|
||||
### B. 数据源加载阶段
|
||||
|
||||
- `data_id` 来自数据源原始数据(`id` / `_id`)。
|
||||
- 若命中已有节点,则仅更新数据与状态,不改变 `node_id`。
|
||||
|
||||
### C. 同步创建与轮询阶段
|
||||
|
||||
- CREATE 提交后,DataSource 从响应/轮询结果提取 `biz_id`。
|
||||
- 成功时回填到 `node.data_id`,并回写 `node.data` 的主键字段(仅 `id` / `_id`)。
|
||||
|
||||
### D. 持久化阶段
|
||||
|
||||
- 将更新后的 `node_id` / `data_id` / 状态字段持久化,供下次同步恢复使用。
|
||||
|
||||
---
|
||||
|
||||
## 6. 绑定与 node_id 的关系
|
||||
|
||||
- 绑定表的 key 为 `node_id`,绑定记录是 `local_node_id <-> remote_node_id`。
|
||||
- 因此:**node_id 必须稳定**,才能保证绑定长期有效。
|
||||
- 绑定可在创建前建立“临时关系”,但成功后不应更换 node_id。
|
||||
|
||||
---
|
||||
|
||||
## 7. 同步周期中的典型场景
|
||||
|
||||
### 场景 1:第一次同步(无持久化)
|
||||
- Collection 为空 → DataSource load 新节点 → 生成新 `node_id`。
|
||||
- Strategy bind / create / update 执行。
|
||||
- 持久化后,下次同步可复用 `node_id`。
|
||||
|
||||
### 场景 2:第二次同步(已有持久化)
|
||||
- 先恢复 Collection(已有 `node_id`)。
|
||||
- DataSource load 时按 `data_id` 匹配旧节点并更新数据。
|
||||
- 绑定关系仍有效。
|
||||
|
||||
### 场景 3:创建新数据(CREATE)
|
||||
- Strategy 产生目标端节点(`node_id` 新生成)。
|
||||
- DataSource 执行创建,回填 `data_id`。
|
||||
- 绑定记录更新为真实 `data_id`(但 node_id 保持不变)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键规则总结
|
||||
|
||||
- **node_id 是“同步系统内的稳定 ID”,必须持久化与复用。**
|
||||
- **data_id 是“业务系统的资源 ID”,由数据源加载或创建后回填。**
|
||||
- **加载顺序:先恢复持久化节点 → 再 load 数据源。**
|
||||
- **遇到已存在 data_id:复用 node_id;否则创建新节点。**
|
||||
- **CREATE 成功后只更新 data_id,不更换 node_id。**
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- 绑定与状态定义:见 [docs/状态定义规范.md](状态定义规范.md)
|
||||
- 编排流程:见 [docs/编排流程规范.md](编排流程规范.md)
|
||||
- BaseDataSource:见 [sync_system_new/datasource/datasource.py](../sync_system_new/datasource/datasource.py)
|
||||
- BaseApiHandler:见 [sync_system_new/datasource/api/handler.py](../sync_system_new/datasource/api/handler.py)
|
||||
|
||||
---
|
||||
|
||||
## 10. 明确规则与建议实现
|
||||
|
||||
- **DataSource.load_all 必须先按 `data_id` 命中已有节点并更新**,未命中时再由 Collection 创建新节点并分配新的 `node_id`。
|
||||
- **推荐实现方式**:先 `find_by_data_id`,命中走通用更新;未命中再创建新节点(无需强制 `upsert_by_data_id` 接口)。
|
||||
- **创建流程中的临时绑定/成功更新规则**:
|
||||
- CREATE 节点创建时:`binding_status = NORMAL`,`action = CREATE`,`status = PENDING`,`data_id = ""`。
|
||||
- 执行提交前置为 `status = IN_PROGRESS`(异步)或保持 `PENDING`(同步接口待执行)。
|
||||
- 成功回填后:`status = SUCCESS`,`data_id` 回填并写回主键字段,绑定记录保持 `node_id` 不变。
|
||||
- 过程中出现错误:`status = FAILED`,`error` 记录原因。
|
||||
- **异常节点持久化/恢复策略**:
|
||||
- 持久化时保留异常节点与错误信息。
|
||||
- 恢复时是否保留/清理异常节点应通过策略配置控制(默认建议保留以便人工修复)。
|
||||
@@ -0,0 +1,232 @@
|
||||
# Skip Sync 行为修复说明
|
||||
|
||||
## 问题分析
|
||||
|
||||
### 1. **绑定失败的根本原因**
|
||||
|
||||
**问题现象**:
|
||||
- Company: 1/39 绑定
|
||||
- Supplier: 0/65202 绑定(完全没绑定!)
|
||||
- 导致依赖supplier的业务(contract, material等)也绑不上
|
||||
- 远程异常创建了74条数据
|
||||
|
||||
**根本原因有两个**:
|
||||
|
||||
#### 原因1:skip_sync 跳过了整个策略(包括 bind)
|
||||
|
||||
```python
|
||||
# 旧逻辑:skip_sync=True 时跳过整个策略
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
continue # ← 跳过了 bind/create/update 全部!
|
||||
```
|
||||
|
||||
修复后:
|
||||
```python
|
||||
# 新逻辑:bind() 始终执行,skip_sync 只跳过 create/update/sync
|
||||
await strategy.bind() # ← 始终执行
|
||||
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
continue # ← 只跳过 create/update
|
||||
```
|
||||
|
||||
#### 原因2:自动绑定ID解析失败没有设置DEPENDENCY_ERROR
|
||||
|
||||
```python
|
||||
# 旧逻辑:ID解析失败只是跳过
|
||||
if not resolved_key_dict:
|
||||
continue # ← 节点保持 MISSING,会被误创建!
|
||||
```
|
||||
|
||||
修复后:
|
||||
```python
|
||||
# 新逻辑:ID解析失败设置DEPENDENCY_ERROR
|
||||
if not resolved_key_dict:
|
||||
node.set_binding_status(BindingStatus.DEPENDENCY_ERROR, "业务键中的ID字段解析失败")
|
||||
continue # ← 节点为 DEPENDENCY_ERROR,不会被创建
|
||||
```
|
||||
|
||||
### 2. **状态与创建的关系**
|
||||
|
||||
| 状态 | 来源 | 允许创建 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| **UNCHECKED** | 加载后初始状态 | ❌ | bind() 未执行 |
|
||||
| **NORMAL** | 有绑定记录 | ❌ | 已绑定 |
|
||||
| **MISSING** | 无绑定记录 | ✅ | 真正的孤儿 |
|
||||
| **WARNING** | 业务键字段缺失 | ❌ | 数据不完整 |
|
||||
| **DEPENDENCY_ERROR** | 依赖不满足 | ❌ | 父节点未就绪 |
|
||||
| **ABNORMAL** | 数据损坏 | ❌ | 需人工处理 |
|
||||
|
||||
**关键原则**:
|
||||
- `create()` 只查找 `binding_status == MISSING` 的节点
|
||||
- 如果 bind() 没执行,状态是 UNCHECKED → 不会创建
|
||||
- 如果 bind() 执行了但依赖不满足 → DEPENDENCY_ERROR → 不会创建
|
||||
|
||||
### 3. **Supplier 默认行为变更**
|
||||
|
||||
**旧行为**:
|
||||
- `default_skip_sync = True` → 默认跳过同步
|
||||
- 需要在调用层设置 `force_sync_types=["supplier"]`
|
||||
|
||||
**新行为**:
|
||||
- `default_skip_sync` 已移除(使用基类默认值 False)
|
||||
- Supplier 默认会执行完整同步(bind/create/update)
|
||||
- 如需跳过,在调用层配置 `skip_sync_types=["supplier"]`
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 修改 1:分离 bind() 和 skip_sync
|
||||
|
||||
**新逻辑**:
|
||||
```python
|
||||
# sync_system_new/pipeline/full_sync_pipeline.py
|
||||
|
||||
# Step 1a: 绑定(始终执行)
|
||||
await strategy.bind()
|
||||
|
||||
# Step 1b: 检查是否跳过 create/update/sync
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
print(f"⏭️ Skipping create/update/sync for {node_type}")
|
||||
# 继续执行 pre_sync_check,但跳过 create/update/sync
|
||||
if hasattr(strategy, "pre_sync_check"):
|
||||
ok = await strategy.pre_sync_check()
|
||||
...
|
||||
continue
|
||||
|
||||
# Step 1c: 正常的 create/update
|
||||
await strategy.create()
|
||||
await strategy.update()
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ **bind() 始终执行**(建立绑定关系)
|
||||
- ✅ **skip_sync=True 只跳过 create/update/sync**(避免昂贵操作)
|
||||
- ✅ **pre_sync_check 仍然执行**(依赖检查)
|
||||
|
||||
### 修改 2:添加 force_sync_types 配置
|
||||
|
||||
**配置示例**:
|
||||
```python
|
||||
# tests/run_full_sync_pipeline_simple.py
|
||||
CONFIG = {
|
||||
# 第一次运行:从 API 加载 + 绑定 + 创建
|
||||
"force_sync_types": ["supplier"],
|
||||
"wipe_persistence_on_start": True,
|
||||
|
||||
# 第二次运行:从持久化加载 + 跳过同步
|
||||
# "handler_configs": {"supplier": {"load_mode": "persisted_only"}},
|
||||
# "skip_sync_types": ["supplier"],
|
||||
# "wipe_persistence_on_start": False,
|
||||
}
|
||||
```
|
||||
|
||||
**优先级**:
|
||||
1. **force_sync_types**(最高)→ `skip_sync = False`
|
||||
2. **skip_sync_types** → `skip_sync = True`
|
||||
3. **策略默认值** (default_skip_sync)
|
||||
|
||||
### 修改 3:添加测试日志保存
|
||||
|
||||
**功能**:
|
||||
```python
|
||||
# tests/run_full_sync_pipeline_simple.py
|
||||
log_path = log_dir / f"simple_pipeline_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
sys.stdout = Tee(original_stdout, log_file)
|
||||
```
|
||||
|
||||
**输出位置**:
|
||||
```
|
||||
test_results/simple_pipeline_20260204_162345.log
|
||||
```
|
||||
|
||||
## 使用指南
|
||||
|
||||
### Phase 1: 首次运行(API + 绑定 + 创建)
|
||||
|
||||
```python
|
||||
CONFIG = {
|
||||
# 不需要特殊配置,所有类型默认都会同步
|
||||
"wipe_persistence_on_start": True, # 清空数据库
|
||||
}
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- Supplier: 从API加载60000条 → 自动绑定(credit_code) → 拉取到本地 → 持久化
|
||||
- Company: 自动绑定 → 拉取
|
||||
- Contract等: 通过supplier_id/project_id绑定 → 创建
|
||||
|
||||
### Phase 2: 后续运行(持久化 + 跳过同步)
|
||||
|
||||
```python
|
||||
CONFIG = {
|
||||
"handler_configs": {
|
||||
"supplier": {"load_mode": "persisted_only"} # 从DB加载
|
||||
},
|
||||
"skip_sync_types": ["supplier"], # 跳过create/update(但仍执行bind)
|
||||
"wipe_persistence_on_start": False, # 保留数据
|
||||
}
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- Supplier: 从DB加载(带绑定关系) → 执行bind(验证状态) → 跳过create/update
|
||||
- 其他业务: 正常同步
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 1. 删除远程异常创建的数据
|
||||
|
||||
```bash
|
||||
# TODO: 通过API删除远程的74条测试数据
|
||||
```
|
||||
|
||||
### 2. 清空数据库重新测试
|
||||
|
||||
```bash
|
||||
rm _runtime/test_simple.db
|
||||
python scripts/legacy/run_full_sync_pipeline_simple.py
|
||||
```
|
||||
|
||||
### 3. 验证绑定统计
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
Local Summary:
|
||||
company: 39/39 bindings ← 全部绑定
|
||||
supplier: 60000+/65202 bindings ← 大量绑定
|
||||
contract: XX/34 bindings ← 通过supplier_id绑定
|
||||
|
||||
Remote Summary:
|
||||
company: 39/39 bindings
|
||||
supplier: 60000+/60000 bindings
|
||||
contract: XX/155 bindings (XX creates)
|
||||
```
|
||||
|
||||
### 4. 检查日志文件
|
||||
|
||||
```bash
|
||||
cat test_results/simple_pipeline_*.log | grep "Skipping"
|
||||
```
|
||||
|
||||
**期望**:
|
||||
- 第一次运行:**不应该有** "Skipping" 输出
|
||||
- 第二次运行:`⏭️ Skipping create/update/sync for supplier`
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 影响范围
|
||||
- ✅ 修复了 supplier/company 绑定失败问题
|
||||
- ✅ 防止未绑定节点异常创建
|
||||
- ✅ 保持了两阶段工作流(首次API+绑定,后续持久化)
|
||||
- ⚠️ 可能影响其他使用 skip_sync 的场景(需要测试)
|
||||
|
||||
### 向后兼容性
|
||||
- ✅ 原始脚本 (run_full_sync_pipeline_api.py) 不受影响
|
||||
- ✅ 没有 skip_sync 属性的策略行为不变
|
||||
- ✅ 新增的 force_sync_types 是可选参数
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [ ] 测试所有业务类型的绑定统计
|
||||
- [ ] 验证依赖关系链(project → contract → material → detail)
|
||||
- [ ] 检查 construction_task 为什么没绑定(0/32)
|
||||
- [ ] 测试第二次运行的持久化加载逻辑
|
||||
- [ ] 更新文档说明 skip_sync 的新语义
|
||||
@@ -0,0 +1,72 @@
|
||||
# 业务流程与依赖关系
|
||||
|
||||
本文档描述推送系统涉及的业务实体及其依赖关系,用于指导同步顺序编排。
|
||||
|
||||
---
|
||||
|
||||
## 业务实体分类
|
||||
|
||||
### 1. 基础数据(仅拉取)
|
||||
|
||||
- **用户(User)**:拉取集团管理员、区域公司管理员及特定用户信息,用于跨平台穿透。其他用户需手动创建。
|
||||
- **公司(Company)**:仅拉取,不推送。
|
||||
- **供应商(Supplier)**:仅拉取,不推送。
|
||||
|
||||
### 2. 项目及扩展数据
|
||||
|
||||
**项目(Project)**
|
||||
- **绑定方式**:必须手动绑定(根节点)
|
||||
- **同步操作**:允许更新和拉取
|
||||
- **依赖项**:公司、附件(Attachment)
|
||||
|
||||
**项目扩展业务**
|
||||
- 通过 `(project_id, key)` 或 `(project_id, code)` 自动绑定
|
||||
- 允许创建、更新、拉取
|
||||
- 依赖项目
|
||||
|
||||
### 3. 合同及子业务
|
||||
|
||||
**合同(Contract)**
|
||||
- **绑定方式**:通过业务键自动绑定(需与 ECP 平台数据交叉检验)
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **依赖项**:项目
|
||||
|
||||
**合同子业务**(施工任务、物资、结算、力能)
|
||||
- **绑定方式**:通过 `(contract_id, code)` 自动绑定
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **特殊规则**:首次绑定后,项目侧不会同步创建远程已有的数据
|
||||
- **依赖项**:合同
|
||||
|
||||
**合同子业务明细**(施工明细、物资明细、结算明细、力能明细)
|
||||
- **绑定方式**:通过 `(parent_id, date)` 自动绑定
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **特殊规则**:首次绑定后,项目侧不会同步创建远程已有的数据
|
||||
- **依赖项**:对应的合同子业务
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系图
|
||||
|
||||
```
|
||||
Company (基础)
|
||||
↓
|
||||
Project (手动绑定)
|
||||
↓
|
||||
Contract (自动绑定)
|
||||
↓
|
||||
Contract Sub-Business (施工/物资/结算/力能)
|
||||
↓
|
||||
Details (明细)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 同步顺序原则
|
||||
|
||||
1. **依赖优先**:父节点必须先于子节点同步
|
||||
2. **绑定传递**:子节点的业务键构建依赖父节点的 ID 映射
|
||||
3. **故障隔离**:父节点同步失败时,子节点标记为 `DEPENDENCY_ERROR` 并跳过
|
||||
|
||||
详细状态定义参见:[sync_system_new/sync_system/STATE_DEFINITIONS.md](../sync_system_new/sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
详细架构设计参见:[sync_system_new/architecture_layers.md](../sync_system_new/architecture_layers.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
# 节点状态持久化与人工介入规范
|
||||
|
||||
> **代码实现**:`sync_system_new/common/state_reset.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 持久化规则
|
||||
|
||||
### 1.1 持久化目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| **可恢复性** | 系统重启后能从上次状态继续 |
|
||||
| **可观测性** | 失败和异常状态要保留,供人工查看 |
|
||||
| **可追溯性** | 失败信息持久化,不自动重试,需人工确认 |
|
||||
| **绑定稳定性** | 手工绑定一旦建立,永不自动改变 |
|
||||
|
||||
### 1.2 分阶段重置设计
|
||||
|
||||
状态重置分为两个阶段执行:
|
||||
|
||||
#### 阶段1:加载时重置(Collection.load_from_persistence)
|
||||
|
||||
| 字段 | 性质 | 持久化 | 阶段1重置 |
|
||||
|------|------|--------|-----------|
|
||||
| `node_id` | 恒定标识 | ✅ 必须 | ❌ 不重置 |
|
||||
| `data_id` | 业务主键 | ✅ 必须 | ❌ 不重置 |
|
||||
| `data` | 业务数据 | ✅ 必须 | ✅ **重置**(每次重新取数) |
|
||||
| `origin_data` | 原始快照 | ✅ 必须 | ✅ **重置**(每次重新取数) |
|
||||
| `depend_ids` | 依赖列表 | ❌ **不持久化** | ✅ **初始化为[]**(bind 阶段从 data 实时计算) |
|
||||
| `binding_status` | 绑定状态 | ✅ 必须 | ✅ **重置为 UNCHECKED** |
|
||||
| `action` | 同步意图 | ✅ 保存 | ❌ **保留**(用于阶段2判断) |
|
||||
| `status` | 执行状态 | ✅ 保存 | ❌ **保留**(全部保留,不重置) |
|
||||
| `error` | 错误信息 | ✅ 保存 | ✅ **重置为None**(清空上次错误) |
|
||||
| `context` | 扩展上下文 | ✅ 保存 | ❌ 不重置 |
|
||||
|
||||
#### 阶段2:数据加载后清理(Pipeline._cleanup_create_failed_zombies)
|
||||
|
||||
在数据加载完成后、策略执行前,执行CREATE失败节点清理:
|
||||
|
||||
1. **检查条件**:
|
||||
- 本地节点 `action == CREATE`(上次尝试创建)
|
||||
- 本地节点 `status == FAILED`(创建失败)
|
||||
|
||||
2. **清理动作**:
|
||||
- **删除本地失败节点**(从 collection 中移除)
|
||||
- 如果有绑定记录:
|
||||
- 解除绑定关系
|
||||
- 删除远程 collection 中的僵尸节点
|
||||
|
||||
3. **最后重置 action**:
|
||||
- 所有剩余节点的 `action` 重置为 NONE
|
||||
- 准备进入策略阶段
|
||||
|
||||
4. **下次同步的自动恢复**:
|
||||
- 被删除的节点会从数据源重新加载
|
||||
- 如果远程实际已创建成功(网络波动导致未获取到结果)→ 绑定阶段会自动绑定上
|
||||
- 如果远程实际也失败了 → 重新尝试创建
|
||||
|
||||
5. **UPDATE 阶段保护**:
|
||||
- UPDATE 操作会跳过所有 `status == FAILED` 的节点
|
||||
- 防止 UPDATE 阶段覆盖 CREATE/UPDATE 失败的状态和错误信息
|
||||
- 确保失败节点的 action 和 error 信息保持不变,直到下次同步清理
|
||||
|
||||
> **说明**:
|
||||
> - `data` / `origin_data`:每次运行从数据源重新获取,保证数据最新
|
||||
> - `depend_ids`:**不持久化**,每次 bind 阶段从 data 的业务字段(如 contract_id, project_id)实时提取
|
||||
> - `binding_status`:持久化用于审计,加载时重置为 `UNCHECKED`,由 bind() 重新判定
|
||||
> - `error`:**阶段1清空**,避免上次错误影响本次运行,新错误会在各阶段重新生成
|
||||
> - `action`:**阶段1保留**用于识别 CREATE 失败的节点,**阶段2清理后重置**
|
||||
> - `status`:**阶段1全部保留**(包括 FAILED、PRECONDITION_FAILED 等),不自动重置
|
||||
> - **CREATE 失败节点会被自动删除并重新加载**,UPDATE/DELETE 失败保留需人工处理
|
||||
> - **UPDATE 操作会跳过 FAILED 节点**,避免覆盖失败状态和错误信息
|
||||
|
||||
### 1.3 绑定记录 vs binding_status
|
||||
|
||||
| 概念 | 存储位置 | 持久性 | 如何改变 |
|
||||
|------|---------|--------|---------|
|
||||
| **绑定记录** | `bindings` 表 | **永久保留** | 只能手工 bind/unbind,或 CREATE 失败自动清理 |
|
||||
| **binding_status** | `nodes` 表 | 保存但可重判 | 每次 bind() 重新判定 |
|
||||
|
||||
### 1.4 CREATE 失败的自动恢复
|
||||
|
||||
当 CREATE 操作失败时,会产生"僵尸绑定":
|
||||
- 绑定记录已建立(local_id → remote_node_id)
|
||||
- 但远程节点的 `data_id` 为空(API 未返回真实 ID)
|
||||
|
||||
**下次同步时的自动恢复流程**:
|
||||
1. 阶段1加载:保留 `action=CREATE`
|
||||
2. 阶段2清理:检测到僵尸绑定,自动清理
|
||||
3. 本地节点恢复为 MISSING 状态
|
||||
4. 策略阶段重新判定,再次尝试 CREATE
|
||||
|
||||
---
|
||||
|
||||
## 2. 人工介入规范
|
||||
|
||||
### 2.1 介入时机
|
||||
|
||||
| 时机 | 场景 | 操作 |
|
||||
|------|------|------|
|
||||
| **运行前** | 查看上次失败记录 | 查看持久化的 error,决定是否修复后重试 |
|
||||
| **bind() 后** | 出现 ABNORMAL/WARNING | 检查数据一致性,手工修复或确认 |
|
||||
| **bind() 后** | 1:N / N:N 映射 | 手工绑定指定远程记录 |
|
||||
| **sync() 后** | 批量失败 | 分析失败原因,决定重试策略 |
|
||||
|
||||
### 2.2 binding_status 与介入需求
|
||||
|
||||
| binding_status | 含义 | 需要介入 | 说明 |
|
||||
|----------------|------|---------|------|
|
||||
| `UNCHECKED` | 未检查 | ❌ | 等待 bind() 判定 |
|
||||
| `NORMAL` | 绑定正常 | ❌ | 自动处理 |
|
||||
| `MISSING` | 无绑定记录 | ❌ | 自动创建 |
|
||||
| `WARNING` | 本地有绑定但远程无数据 | ⚠️ 可选 | 可能是远程删除,需确认 |
|
||||
| `ABNORMAL` | 状态不一致 | ✅ **必须** | 需人工排查 |
|
||||
| `DEPENDENCY_ERROR` | 依赖未就绪 | ❌ | 等依赖解决后自动重试 |
|
||||
|
||||
### 2.3 人工绑定操作
|
||||
|
||||
```python
|
||||
# 手工绑定(1:N 场景)
|
||||
binding_manager.bind(node_id="local_xxx", data_id="remote_xxx")
|
||||
|
||||
# 手工解绑
|
||||
binding_manager.unbind(node_id="local_xxx")
|
||||
```
|
||||
|
||||
绑定记录一旦建立,永不自动改变,只能手工操作。
|
||||
|
||||
### 2.4 职责分工
|
||||
|
||||
| 职责 | 系统自动 | 人工介入 |
|
||||
|------|---------|---------|
|
||||
| 1:1 自动绑定 | ✅ | - |
|
||||
| 1:N / N:N 绑定 | ❌ | ✅ |
|
||||
| NORMAL 节点同步 | ✅ | - |
|
||||
| MISSING 节点创建 | ✅ | - |
|
||||
| CREATE 失败重试 | ✅(自动清理僵尸) | ⚠️(需确认后手动重置) |
|
||||
| UPDATE 失败重试 | ❌(保留FAILED状态) | ✅(需确认后手动重置) |
|
||||
| ABNORMAL 处理 | ❌ | ✅ |
|
||||
| WARNING 确认 | ❌ | ✅(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总结
|
||||
|
||||
1. **分阶段重置**:
|
||||
- 阶段1(加载时):只重置 binding_status,**保留 action/status/error**
|
||||
- 阶段2(数据加载后):清理 CREATE 失败僵尸,然后重置 action
|
||||
2. **失败状态持久化**:FAILED 状态和 error 信息保留,不自动重试
|
||||
3. **绑定记录**:永久保留,除非 CREATE 失败自动清理或手工操作
|
||||
4. **人工介入**:失败需确认后手动重置,ABNORMAL/WARNING 需人工处理,1:N 绑定需手工指定
|
||||
@@ -0,0 +1,112 @@
|
||||
# 架构分层设计:四层模型
|
||||
|
||||
本架构旨在将同步系统的**数据容器**、**同步逻辑**、**执行层**与**编排流程**完全解耦,提供极简且类型安全的开发体验。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
系统分为四层,依赖关系严格从上到下:
|
||||
|
||||
1. **通用层 (Common)**: 定义纯数据结构(Data Container)和基础工具。
|
||||
2. **同步系统层 (Sync System)**: 定义同步行为接口(SyncStrategy)与业务规则。
|
||||
3. **数据源层 (DataSource)**: 实现数据的加载与持久化,属于盲目执行层。
|
||||
4. **编排层 (Pipeline)**: 应用层逻辑,硬编码同步顺序,协调 ID 映射与错误传播。
|
||||
|
||||
---
|
||||
|
||||
## 2. 通用层 (Layer 0: Common)
|
||||
|
||||
本层不包含业务逻辑,仅定义数据契约。
|
||||
|
||||
- **SyncNode[T]**: 核心状态机容器。
|
||||
- `origin_data`: 原始数据(对应 `data_id`)。
|
||||
- `data`: 计算后的最新目标数据。
|
||||
- `data_id`: 业务主键ID。加载时必定有值,CREATE 节点初始为空字符串 `""`(布尔值为 False,可通过校验)。
|
||||
- `action`: 同步意图 (NONE, CREATE, UPDATE, DELETE)。
|
||||
- `status`: 执行状态 (PENDING, IN_PROGRESS, SUCCESS, FAILED, SKIPPED, PRECONDITION_FAILED)。
|
||||
- 详见 [状态定义规范 § 5.A](./状态定义规范.md#5-执行同步状态转换-physical-sync-state-transitions)
|
||||
- `binding_status`: 绑定状态 (UNCHECKED, NORMAL, MISSING, DEPENDENCY_ERROR, ABNORMAL, WARNING)。
|
||||
- 详见 [状态定义规范 § 1](./状态定义规范.md#1-核心状态判定表-core-binding-matrix)
|
||||
- `error`: 异常详情。
|
||||
- **DataCollection**: 节点的集合,提供类型安全的查询和过滤方法。
|
||||
- **BindingManager**:
|
||||
- **ID 映射**: 维护 `local_node_id` <-> `remote_node_id` 的双向映射。
|
||||
- **绑定键**: 使用 `node_id` 作为绑定记录的键,**一致不变**。CREATE 成功后只更新节点的 `data_id`,绑定记录不变。
|
||||
- **持久化**: 将映射关系持久化到存储(如 JSON/数据库)。
|
||||
- **Enums**: `SyncAction`, `SyncStatus`, `BindingStatus` 等。
|
||||
|
||||
---
|
||||
|
||||
## 3. 同步系统层 (Layer 1: Sync System)
|
||||
|
||||
核心业务逻辑层。通过实现 `SyncStrategy` 接口来定义特定实体的同步逻辑。
|
||||
|
||||
- **SyncStrategy[T] 接口**:
|
||||
- `bind()`:
|
||||
- 职责:判定 `binding_status` (NORMAL/MISSING/ABNORMAL/WARNING/DEPENDENCY_ERROR)
|
||||
- 详见 [状态定义规范 § 1-3](./状态定义规范.md)
|
||||
- `create()`:
|
||||
- 职责:处理孤儿节点(MISSING),在对方侧创建新节点并设置 `action=CREATE`
|
||||
- **ID 替换**: 创建新节点时,复制数据并将依赖字段的本地 ID 替换为远程 ID
|
||||
- **内联检查**: 通过 `IDResolver.resolve_and_validate()` 验证依赖 ID 解析
|
||||
- 详见 [状态定义规范 § 4.C](./状态定义规范.md)
|
||||
- `update()`:
|
||||
- 职责:处理差异节点(NORMAL 且有 diff),设置 `action=UPDATE`
|
||||
- **ID 替换**: 准备更新数据时,替换数据中的依赖 ID
|
||||
- **内联检查**: 验证目标节点 data_id 完整性,失败则设置 `PRECONDITION_FAILED`
|
||||
- **FAILED 保护**: 自动跳过 `status=FAILED` 的节点
|
||||
- 详见 [状态定义规范 § 4.A](./状态定义规范.md)
|
||||
---
|
||||
|
||||
## 4. 数据源层 (Layer 2: DataSource)
|
||||
|
||||
作为各个后端的适配器,负责数据加载与持久化。
|
||||
|
||||
- **职责**:
|
||||
- `load_nodes(node_type)`: 从后端加载并返回 `SyncNode` 集合,自动添加到 `DataCollection`。
|
||||
- `save_nodes(nodes)`:
|
||||
- 遍历节点,仅处理 `status == PENDING` 且 `action != NONE` 的节点。
|
||||
- 根据 `action` 执行相应的 CREATE/UPDATE/DELETE 操作。
|
||||
- 执行成功后,更新节点的 `status` 和相关信息(如新生成的 remote_id)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 编排层 (Layer 3: Pipeline)
|
||||
|
||||
最高层级,负责流程编排。
|
||||
|
||||
- **职责**:
|
||||
- **显式顺序**: 直接在代码中按顺序调用同步逻辑(例如:先同步项目,再同步合同)。
|
||||
- **故障传播**: 若前置实体某节点同步失败(`FAILED`),编排器在处理后续依赖节点时可将其标记为 `DEPENDENCY_ERROR`。
|
||||
- **生命周期管理**: 初始化 `BindingManager` 和 `DataCollection`,同步结束后调用持久化方法。
|
||||
|
||||
---
|
||||
|
||||
## 6. ID 映射与对齐:核心流程
|
||||
|
||||
为了解决本地数据引用本地 ID 与远程数据引用远程 ID 的冲突,系统采用以下映射逻辑:
|
||||
|
||||
### 绑定阶段 (Binding)
|
||||
- **时机**: `Strategy.bind`
|
||||
- **目标**: 在 `BindingManager` 中确定 local_id ↔ remote_id 映射
|
||||
- **手段**: 业务键匹配(如 Code 匹配)、历史记录恢复等
|
||||
|
||||
### 执行阶段 (Execution)
|
||||
- **时机**: `Strategy.create()` / `Strategy.update()` 阶段
|
||||
- **目标**: 在创建新节点或准备更新时,将依赖字段的本地 ID 替换为远程 ID
|
||||
- **手段**:
|
||||
- `create()`: 创建对方节点时,复制数据并替换所有依赖 ID
|
||||
- `update()`: 准备更新数据时,替换数据中的依赖 ID
|
||||
- **验证**: `_pre_commit_check` 在 create/update 最后检查,确保所有依赖节点都已有远程 ID
|
||||
|
||||
**注意**: ID 替换在 Strategy 的 create/update 阶段完成,DataSource 只负责盲目执行已准备好的数据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键设计原则
|
||||
|
||||
- **删除类型依赖配置**: 依赖关系通过 `Pipeline` 中的代码调用顺序自然表达。
|
||||
- **无状态 DataSource**: 数据源不了解绑定逻辑,只看到 `action`、`status` 和 `binding_status`。
|
||||
- **BindingManager 为核心**: 负责 ID 空间映射和持久化。
|
||||
- **配置驱动**: 通过 `StrategyConfig` 和预设配置(ConfigPresets)灵活控制同步行为。
|
||||
@@ -0,0 +1,342 @@
|
||||
# 同步系统状态定义 (Final Spec)
|
||||
|
||||
本文档定义了同步系统的状态机逻辑。逻辑流转遵循以下顺序:
|
||||
1. **核心状态判定** (Core State)
|
||||
2. **依赖检查与ID解析** (Dependency)
|
||||
3. **自动绑定/发现** (Discovery)
|
||||
4. **策略执行** (Action)
|
||||
|
||||
---
|
||||
|
||||
## 0. 绑定状态枚举 (BindingStatus)
|
||||
|
||||
| 状态值 | 含义 | 来源 | 允许创建 |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **UNCHECKED** | 未执行绑定检查 | 加载后的初始状态 | ❌ |
|
||||
| **NORMAL** | 绑定正常 | 核心状态判定 | ❌ |
|
||||
| **MISSING** | 无绑定记录(孤儿) | 核心状态判定 | ✅ |
|
||||
| **WARNING** | 业务键不完整 | 自动绑定阶段 | ❌ |
|
||||
| **ABNORMAL** | 数据损坏 | 核心状态判定 | ❌ |
|
||||
| **DEPENDENCY_ERROR** | 依赖未就绪 | 依赖检查或自动绑定 | ❌ |
|
||||
|
||||
**关键原则**:
|
||||
- **UNCHECKED 阻止创建**:加载后节点状态为 UNCHECKED,只有执行了 `bind()` 后才会转为其他状态
|
||||
- **只有 MISSING 状态才能触发 CREATE**:`create()` 方法只查找 `binding_status == MISSING` 的节点
|
||||
- **如果 bind() 被跳过**(如 skip_sync=True),节点保持 UNCHECKED,不会被创建
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心状态判定表 (Core Binding Matrix)
|
||||
|
||||
**说明**:
|
||||
* **Binding Record Pair**: 数据库中的绑定记录 (必须成对出现,已排除单边绑定)。
|
||||
* **Data Valid**: 指数据是否可读、Schema校验是否通过。
|
||||
* **Warning vs Abnormal**:
|
||||
* `ABNORMAL`: 自身数据损坏。
|
||||
* `WARNING`: 自身健康,但因为对方损坏导致**连接不可用**。
|
||||
|
||||
| Bind Pair (Record) | Local Data (Valid?) | Remote Data (Valid?) | -> | **Local Status** | **Remote Status** | **说明** |
|
||||
| :---: | :---: | :---: | :--- | :--- | :--- | :--- |
|
||||
| **YES** | **YES** | **YES** | -> | **NORMAL** | **NORMAL** | **稳态**。链路完整,数据健康,可进行 Diff/Update。 |
|
||||
| **YES** | **YES** | **NO** | -> | **WARNING** | **ABNORMAL** | **远程损坏**。本地虽好,但链路已断 (Link Degraded)。禁止同步。 |
|
||||
| **YES** | **NO** | **YES** | -> | **ABNORMAL** | **WARNING** | **本地损坏**。远程虽好,但链路已断。禁止同步。 |
|
||||
| **YES** | **NO** | **NO** | -> | **ABNORMAL** | **ABNORMAL** | **双向损坏**。 |
|
||||
| **NO** | **YES** | N/A | -> | **MISSING** | N/A | **本地孤儿**。进入“依赖与发现”流程。 |
|
||||
| **NO** | N/A | **YES** | -> | N/A | **MISSING** | **远程孤儿**。进入“依赖与发现”流程。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 依赖与业务键解析 (Dependency & ID Resolution)
|
||||
|
||||
**适用范围**: 所有涉及外键依赖 (Foreign Key) 的节点。
|
||||
**前置条件**: 核心状态 = `MISSING` (准备进行自动绑定或新建)。
|
||||
|
||||
在进行 `Auto-Bind` 或 `Create` 之前,必须先尝试构建**远程业务键**。
|
||||
例如:本地 `Contract(proj_id="P1", code="C01")` 想找远程对象,必须先查询 `P1` 对应的远程 ID `RP_99`,转换成 `(RP_99, "C01")` 才能去远程搜索。
|
||||
|
||||
| 依赖项 (Dependency) | 依赖项绑定状态 | -> | **能否构建业务键** | **节点状态** | **说明** |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **无依赖** | N/A | -> | ✅ Yes | **MISSING** | 根节点 (如 Project),直接进入下一阶段。 |
|
||||
| **有依赖** | **NORMAL** 且 `data_id` 非空(`status` 任意) | -> | ✅ Yes | **MISSING** | 依赖项已获得远程ID,可进入下一阶段。 |
|
||||
| **有依赖** | **NORMAL** 且 `data_id` 为空("",含 `status = FAILED`) | -> | ❌ No | **DEPENDENCY_ERROR** | 依赖项未回填ID,阻断绑定与同步。 |
|
||||
| **有依赖** | **MISSING / ABNORMAL** | -> | ❌ No | **DEPENDENCY_ERROR** | **依赖阻断**。父节点未同步或损坏,无法计算远程键,无法绑定,无法创建。 |
|
||||
| **有依赖** | **WARNING** | -> | ❌ No | **DEPENDENCY_ERROR** | 父节点状态不确定(如连接降级),禁止基于此建立新关系。 |
|
||||
|
||||
> **处理策略**: 处于 `DEPENDENCY_ERROR` 状态的节点,其动作强制为 **SKIP** (或 PENDING)。必须先修复父节点。
|
||||
>
|
||||
> **错误信息格式**: `DEPENDENCY_ERROR` 节点的 `error` 字段会包含详细的依赖失败信息:
|
||||
> - `dep_err: not_found: field_name(node_type)=value` - 依赖节点未找到
|
||||
> - `dep_err: field_name: status=FAILED` - 依赖节点状态为 FAILED
|
||||
> - 多个依赖失败时用分号分隔:`dep_err: not_found: project_id(project)=P1; contract_id: status=FAILED`
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动绑定状态处理 (Auto-Binding State Handling)
|
||||
|
||||
此逻辑遵循“去噪后 1:1”原则:只有在排除掉所有健康同步对后,若剩余节点形成绝对的 1:1 关系,才允许自动绑定。
|
||||
|
||||
**配置说明与触发机制**:
|
||||
- **初始化场景**: 通常在系统第一次初始化或首次接入新实体时,配置 `auto_bind = True` 来对齐历史数据。
|
||||
- **后续同步**: 系统在日常同步中不建议开启全局自动绑定。自动绑定仅作为“发现”手段,一旦建立过关系的节点,其后续绑定操作通常仅在“自动创建”逻辑中伴随产生(详见第 4 节孤儿处理)。
|
||||
- **执行前提**: 只有当节点状态为 `MISSING` 且依赖解析成功(`DEPENDENCY_ERROR = False`)时,才会尝试以下探测逻辑。
|
||||
- **业务键完整性**: 若业务键字段缺失(如 `serial_number = null`),直接标记 `binding_status = WARNING`,并在 `node.error` 记录原因(`Auto-bind skipped: missing fields ...`),不参与自动绑定与孤儿创建判定。- **ID字段解析失败**: 若业务键包含ID字段(如 `supplier_id`)且依赖节点未绑定,ID解析失败,标记 `binding_status = DEPENDENCY_ERROR`,阻止孤儿创建。
|
||||
**核心算法**:
|
||||
1. **全集搜索**: 寻找本地/远端所有共享同一个业务键(Business Key)的记录。
|
||||
2. **排除稳态对**: 识别并移除**成对且互绑**的 `NORMAL` 记录。
|
||||
- 只有当本地 A 与远端 B 互为绑定对象,且状态皆为 `NORMAL` 时,才将这一对记录从待判定集合中剔除。
|
||||
- 若出现单边 `NORMAL` 记录(即其配对项不在本次搜索范围内或状态不属于另一端的 `NORMAL`),则不予剔除,视为环境污染项。
|
||||
3. **判定剩余**: 检查排除上述稳态对后的剩余集合(包含 `MISSING`, `ABNORMAL`, `WARNING` 及单边已占用的 `NORMAL` 记录)。
|
||||
|
||||
> **术语**:
|
||||
> - `L_remain` = 本地记录中,排除“成对稳态”后剩余的数量。
|
||||
> - `R_remain` = 远端记录中,排除“成对稳态”后剩余的数量。
|
||||
|
||||
| `L_remain` | `R_remain` | -> | **动作** | **说明** |
|
||||
| :---: | :---: | :--- | :--- | :--- |
|
||||
| **1** | **1** | -> | **BIND (Link)** | **1对1 自动对齐**:仅当两端剩余记录皆为 `MISSING` 且绝对 1:1 时,执行自动绑定。 |
|
||||
| **1** | **1** | -> | **SKIP** | **带障不处理**:若剩余记录中包含 `ABNORMAL` 等异常状态,说明存在历史冲突,不执行自动动作。 |
|
||||
| **N** | **0** | -> | **CREATE** | **可判定缺失**:业务键完整且唯一,本地存在而远端不存在(N:0)时,才允许进入孤儿创建策略。 |
|
||||
| 其他 | 其他 | -> | **SKIP** | **非 1:1 不处理**:存在多对多、多对一或无候选情况,系统保持原状。 |
|
||||
|
||||
**设计优势**:
|
||||
1. **对故障极端敏感**:环境中存在任何 `ABNORMAL` 记录都会阻断自动绑定,强制人工介入处理异常。
|
||||
2. **状态纯净**:失败时不产生任何中间态(如已废弃的 `AMBIGUOUS`),保持语义清晰。
|
||||
3. **静默自愈**: 当外界清理了环境且满足 1:1 条件后,系统下次同步会自动完成绑定。
|
||||
|
||||
### 3.1 空数据处理策略
|
||||
|
||||
**原则**:**空数据不推送**。
|
||||
|
||||
**空数据定义**:
|
||||
- 初始化时创建但未编辑的业务记录(如空合同、空施工任务)
|
||||
- 业务键字段为空或关键字段缺失的记录
|
||||
|
||||
**处理方式**:
|
||||
- 在自动绑定阶段,若业务键字段缺失,标记 `binding_status = WARNING`,记录原因
|
||||
- 不参与自动绑定与孤儿创建判定
|
||||
- 不推送到远程系统
|
||||
|
||||
**设计理由**:
|
||||
1. **远程系统简化**:远程只需处理完整数据,无需兼容空数据校验逻辑
|
||||
2. **避免垃圾数据**:空数据通常会被本地删除,若已推送则清理复杂
|
||||
3. **数据质量保障**:远程系统只能看到本地已正确编辑保存的业务
|
||||
|
||||
**配置方式**(Strategy 层):
|
||||
```python
|
||||
# 在 Strategy 配置中指定必填字段
|
||||
default_config = StrategyConfig(
|
||||
auto_bind_fields=["contract_id", "task_type"], # 业务键字段
|
||||
# 如果这些字段为空,节点将被标记为 WARNING 而非 MISSING
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 动作策略表 (Action Policy Matrix)
|
||||
|
||||
此表定义了在特定状态下,`execute` 阶段允许产生的 **意图行动 (`SyncAction`)**。
|
||||
|
||||
#### A. 稳态更新 (NORMAL)
|
||||
适用于已有绑定关系且链路健康的节点。
|
||||
|
||||
| 状态 | 动作条件 | 执行动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **NORMAL** | `compute_diff` 判定存在差异 | **UPDATE** |
|
||||
| **NORMAL** | `compute_diff` 判定无差异 | **NONE** |
|
||||
|
||||
#### B. 异常处理 (阻断态)
|
||||
处于这些状态的节点,引擎会自动中止后续的读写操作。
|
||||
|
||||
| 状态 | 说明 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **DEPENDENCY_ERROR** | 父节点缺失/异常同步导致阻断 | **NONE** |
|
||||
| **ABNORMAL / WARNING** | 数据损坏或链路降级,需人工修复 | **NONE** |
|
||||
|
||||
#### C. 孤儿处理 (Create Only)
|
||||
适用于最终探测状态为 **MISSING** 且无法/不满足自动绑定的节点。
|
||||
|
||||
**本地孤儿 (Local Orphan)**: 本地有,远程无。
|
||||
| 策略配置 (`local_orphan_action`) | 对应的同步模式 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **"create_remote"** | Push (本地推远端) | **CREATE** |
|
||||
| **"delete_local"** | Pull (远端拉本地) | **报错 (未实现)** |
|
||||
| **"none"** | Any | **NONE** |
|
||||
|
||||
**远程孤儿 (Remote Orphan)**: 远程有,本地无。
|
||||
| 策略配置 (`remote_orphan_action`) | 对应的同步模式 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **"create_local"** | Pull (远端拉本地) | **CREATE** |
|
||||
| **"delete_remote"** | Push (本地推远端) | **报错 (未实现)** |
|
||||
| **"none"** | Any | **NONE** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 执行同步状态转换 (Physical Sync State Transitions)
|
||||
|
||||
在策略执行完成、设置好 `action` 后,DataSource 层负责执行实际的同步操作并更新 `status` 字段。
|
||||
|
||||
### A. 执行状态枚举 (SyncStatus)
|
||||
|
||||
| 状态值 | 含义 | 适用场景 |
|
||||
| :--- | :--- | :--- |
|
||||
| **PENDING** | 待执行 | 初始状态、无动作的节点 |
|
||||
| **IN_PROGRESS** | 执行中 | 异步接口已提交,等待结果回调 |
|
||||
| **SUCCESS** | 执行成功 | 同步操作完成且成功 |
|
||||
| **FAILED** | 执行失败 | 同步操作失败,`error` 字段记录原因 |
|
||||
| **SKIPPED** | 已跳过 | 节点被主动跳过(如依赖未满足) |
|
||||
| **PRECONDITION_FAILED** | 前置条件失败 | create/update 内部验证发现问题 |
|
||||
|
||||
### B. 执行转换矩阵
|
||||
|
||||
#### 同步接口(一步式)
|
||||
|
||||
适用于同步 API(调用后立即返回成功/失败)。
|
||||
|
||||
| 输入: action | API 调用结果 | → | 输出: status | 副作用 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **CREATE** | 成功返回 (200/201) | → | **SUCCESS** | `data_id` 设置为远程返回的ID |
|
||||
| **CREATE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **UPDATE** | 成功返回 (200) | → | **SUCCESS** | - |
|
||||
| **UPDATE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **DELETE** | 成功返回 (200/204) | → | **SUCCESS** | 节点可能被移除 |
|
||||
| **DELETE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **NONE** | - | → | **PENDING** | 不执行,保持原状 |
|
||||
|
||||
#### 异步接口(两步式)
|
||||
|
||||
适用于异步 API(提交后需要轮询或回调获取结果)。
|
||||
|
||||
| 输入: action | API 提交结果 | → | 输出: status | 后续操作 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **CREATE** | 提交成功 (202 Accepted) | → | **IN_PROGRESS** | 记录 task_id,等待轮询 |
|
||||
| **CREATE** | 提交失败 (4xx/5xx) | → | **FAILED** | `error` 记录错误 |
|
||||
| **IN_PROGRESS** | 轮询返回成功 | → | **SUCCESS** | `data_id` 设置为远程ID |
|
||||
| **IN_PROGRESS** | 轮询返回失败 | → | **FAILED** | `error` 记录错误 |
|
||||
| **IN_PROGRESS** | 超时未完成 | → | **FAILED** | `error` 记录 "Timeout" |
|
||||
|
||||
### B+. Handler 降级(action 取消)
|
||||
|
||||
> **代码实现**:各 `domain/*/api_handler.py` 中的 `update()` 方法。
|
||||
|
||||
当 Handler 在构建 API 请求数据时,发现实际无需更新的字段(如 schema 校验后所有字段与远程一致),
|
||||
Handler 会主动将节点的 `action` 从 `UPDATE` 降级为 `NONE`:
|
||||
|
||||
```
|
||||
action=UPDATE → Handler 校验 → 无实际变更字段 → action=NONE(不提交 API)
|
||||
```
|
||||
|
||||
此机制确保 DataSource 不会向远程发送无意义的空更新请求。
|
||||
|
||||
### C. 关键副作用
|
||||
|
||||
| 动作 | 成功时的副作用 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| **CREATE** | 临时绑定已建立 | 绑定关系可在提交前创建,用于推送与追踪;是否成功不影响该绑定存在 |
|
||||
| **CREATE** | 设置 `data_id` | 成功后从API响应中提取远程分配的ID,更新到节点 |
|
||||
| **CREATE** | 回写 `data` 主键 | DataSource 将远程返回的ID写回 `SyncNode.data`(如 `id`/`*_id` 字段) |
|
||||
| **CREATE** | 更新绑定记录 | 若使用临时ID,成功后替换为真实的远程ID |
|
||||
| **UPDATE** | 更新 `origin_data` | 将当前 `data` 保存为新的 `origin_data` |
|
||||
| **DELETE** | 移除绑定记录 | 删除成功后,清除对应的绑定关系 |
|
||||
|
||||
### D. 错误处理
|
||||
|
||||
| 错误类型 | 记录方式 | 重试策略 |
|
||||
| :--- | :--- | :--- |
|
||||
| **网络错误** | `error = "Network timeout"` | 可重试 |
|
||||
| **权限错误** | `error = "403 Forbidden"` | 不可重试,需人工介入 |
|
||||
| **数据校验错误** | `error = "Validation failed: ..."` | 不可重试,需修复数据 |
|
||||
| **依赖缺失** | `status = PRECONDITION_FAILED` | 不执行,等待依赖修复 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 同步检查规范 (Sync Check Specification)
|
||||
|
||||
在执行同步前后,需要进行状态检查以确保操作的合法性和完整性。
|
||||
|
||||
### A. 内联前置检查(create/update 阶段)
|
||||
|
||||
> **实现说明**:原 `_pre_commit_check` 方法已移除(保留空壳兼容接口)。
|
||||
> 所有有效检查已内联到 `_add_create_action()` 和 `_add_update_action()` 中。
|
||||
|
||||
**create() 阶段内联检查**:
|
||||
|
||||
| 检查类别 | 检查条件 | 不通过时 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **数据完整性** | `src_node.get_data()` 不为空 | 跳过该节点,记录日志 | MISSING 但数据为空 |
|
||||
| **依赖 ID 解析** | `id_resolver.resolve_and_validate()` 成功 | `binding_status → DEPENDENCY_ERROR` | 依赖字段无法解析到目标系统 ID |
|
||||
|
||||
**update() 阶段内联检查**:
|
||||
|
||||
| 检查类别 | 检查条件 | 不通过时 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **FAILED 保护** | `src_node.status != FAILED` | 跳过该节点 | 不覆盖上次失败的状态和错误 |
|
||||
| **数据完整性** | `src_node.get_data()` 不为空 | 跳过该节点 | NORMAL 但数据为空 |
|
||||
| **目标节点存在** | 通过绑定找到目标节点 | 跳过该节点 | 绑定记录指向的节点不在集合中 |
|
||||
| **data_id 完整性** | `target_node.data_id` 非空 | `status → PRECONDITION_FAILED` | UPDATE 必须有业务主键 |
|
||||
| **依赖 ID 解析** | `id_resolver.resolve_and_validate()` 成功 | `status → PRECONDITION_FAILED` | 依赖字段无法解析到目标系统 ID |
|
||||
| **差异检测** | `_needs_update()` 返回 True | 跳过该节点 | 无差异则不更新 |
|
||||
|
||||
> **注意**:以下检查项在当前实现中 **未覆盖**【未实现】:
|
||||
> - 状态合法性:`binding_status` 必须是已知状态
|
||||
> - 动作合法性:`CREATE/UPDATE` 只能用于 `NORMAL` 节点
|
||||
> - 绑定一致性:`NORMAL` 节点必须有绑定记录
|
||||
|
||||
### B. 同步后检查 (Post-Sync Check)
|
||||
|
||||
**目标**: 验证同步结果的完整性,发现残留问题。
|
||||
|
||||
> **实现说明**:当前 `_stage4_post_sync_check` 只打印摘要表格,不做异常检测。以下检查项均为【未实现】。
|
||||
|
||||
#### 检查项清单【未实现】
|
||||
|
||||
| 检查类别 | 检查条件 | 异常情况 | 严重程度 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **CREATE 完成性** | CREATE 节点应该有 `data_id` | `action = CREATE, status = SUCCESS` 但 `data_id = ""` | 🔴 ERROR |
|
||||
| **CREATE 绑定** | CREATE 节点应该有绑定记录 | CREATE 成功但无绑定 | 🔴 ERROR |
|
||||
| **UPDATE 完成性** | UPDATE 节点应该成功 | `action = UPDATE` 但 `status = FAILED` | ⚠️ WARNING |
|
||||
| **绑定保持** | UPDATE 节点的绑定应该保持 | UPDATE 后绑定记录丢失 | 🔴 ERROR |
|
||||
| **残留异常** | 不应有 ABNORMAL 节点 | 同步后仍有 `binding_status = ABNORMAL` | ⚠️ WARNING |
|
||||
| **残留依赖错误** | 不应有 DEPENDENCY_ERROR | 同步后仍有依赖阻断 | ⚠️ WARNING |
|
||||
|
||||
#### 统计指标
|
||||
|
||||
| 指标 | 计算方式 | 用途 |
|
||||
| :--- | :--- | :--- |
|
||||
| **成功率** | `SUCCESS / (CREATE + UPDATE)` | 评估同步质量 |
|
||||
| **异常率** | `(ABNORMAL + WARNING) / total` | 发现数据质量问题 |
|
||||
| **阻断率** | `DEPENDENCY_ERROR / total` | 发现依赖关系问题 |
|
||||
|
||||
#### 决策规则
|
||||
|
||||
| 检查结果 | ERROR 数量 | 决策 |
|
||||
| :--- | :--- | :--- |
|
||||
| ✅ 通过 | 0 | **继续持久化**,正常结束 |
|
||||
| ⚠️ 有警告 | 0, WARNING ≥1 | **继续持久化**,打印警告报告 |
|
||||
| ❌ 有错误 | ≥1 | **继续持久化**(已同步无法回滚),打印错误报告 |
|
||||
|
||||
> **注意**: Post-Sync Check 即使有 ERROR 也不会终止流程,因为同步已经执行,无法回滚。检查目的是发现问题并记录,供后续修复。
|
||||
|
||||
---
|
||||
|
||||
## 附录: 状态字段说明
|
||||
|
||||
### SyncNode 核心字段
|
||||
|
||||
| 字段 | 类型 | 设置阶段 | 含义 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `binding_status` | BindingStatus | Bind 阶段 | 绑定健康状态 |
|
||||
| `action` | SyncAction | Create/Update 阶段 | 待执行动作 |
|
||||
| `status` | SyncStatus | Physical Sync 阶段 | 执行结果状态 |
|
||||
| `data_id` | str | 数据加载 / CREATE 后 | 业务主键ID(CREATE 前为 "") |
|
||||
| `depend_ids` | List[str] | 数据加载 | 依赖节点的 node_id 列表 |
|
||||
| `error` | str \| None | Physical Sync 阶段 | 错误信息(失败时) |
|
||||
|
||||
### 状态流转时序
|
||||
|
||||
```
|
||||
[数据加载] [Bind] [Create/Update] [Physical Sync]
|
||||
binding_status: UNCHECKED → NORMAL/MISSING/... → (保持) → (保持)
|
||||
action: NONE → NONE → CREATE/UPDATE → (保持)
|
||||
status: PENDING → PENDING → PENDING → SUCCESS/FAILED
|
||||
data_id: 从源加载 → (保持) → 可能为 "" → CREATE 后设置
|
||||
```
|
||||
@@ -0,0 +1,498 @@
|
||||
# 状态机形式化规范
|
||||
|
||||
本文档用 **Guard-Event-Action (GEA)** 形式严格定义节点状态机。
|
||||
所有转换以代码实现为准,模糊之处在文档中显式标注。
|
||||
|
||||
> **代码基线**
|
||||
> - 枚举: `sync_system_new/common/types.py`
|
||||
> - 决策引擎: `sync_system_new/sync_system/decision.py`
|
||||
> - 重置策略: `sync_system_new/common/state_reset.py`
|
||||
> - 同步策略: `sync_system_new/sync_system/strategy.py`
|
||||
> - 物理同步: `sync_system_new/datasource/datasource.py`
|
||||
|
||||
> **配置基线(状态机真源)**
|
||||
> - `sync_system_new/state_machine/node_state_machine.yaml`
|
||||
|
||||
### 2026-02 对齐说明(重构前必读)
|
||||
|
||||
以下语义以 `node_state_machine.yaml` 为准,用于修正文档中易混淆点:
|
||||
|
||||
1. **E12a 为什么从 S01 出发?**
|
||||
- `S01` 只约束**源节点**是 NORMAL/NONE/PENDING 且有 `data_id`。
|
||||
- `E12a` 的 guard 是 `target_has_data_id=false`,检查对象是**目标节点**。
|
||||
- 所以“源节点在 S01”与“E12a 触发前置失败”并不冲突。
|
||||
|
||||
2. **S01 会不会触发创建?**
|
||||
- 当前配置中,create 的入口状态是 `S02(MISSING)`,不是 `S01`。
|
||||
- `S01` 进入的是 update_prepare(E12/E13/E14)。
|
||||
|
||||
3. **N:0 + create_enabled=true 的当前行为**
|
||||
- 自动绑定分支 `E08f`:`S02 -> S01`,并 `emit spawn_target_node_state: S06`。
|
||||
- create_prepare 分支 `E09b` 也可在检查成功后 `S02 -> S01 + spawn S06`。
|
||||
- 即:源节点不会停在“等待创建结果”的专用状态;创建执行结果体现在目标创建节点链路 `S06 -> S10/S11/S12/S13`。
|
||||
|
||||
4. **旧章节中的事件编号偏差**
|
||||
- 本文后续仍有部分“E11a/E11b/…”旧编号描述,已与当前 YAML 不一致。
|
||||
- 进行代码重构时,优先依据 YAML 的 `E08/E09/E10/E12/E13/E14/E15..E20`。
|
||||
|
||||
---
|
||||
|
||||
## §1 状态空间定义
|
||||
|
||||
### §1.1 状态维度
|
||||
|
||||
节点携带 **三个独立的状态字段** 和 **两个辅助字段**:
|
||||
|
||||
| 字段 | 枚举 | 值域 | 语义 |
|
||||
|------|------|------|------|
|
||||
| **B** – `binding_status` | `BindingStatus` | `UNCHECKED` · `NORMAL` · `MISSING` · `ABNORMAL` · `WARNING` · `DEPENDENCY_ERROR` | 绑定健康度 |
|
||||
| **A** – `action` | `SyncAction` | `NONE` · `CREATE` · `UPDATE` · `DELETE` | 待执行动作 |
|
||||
| **S** – `status` | `SyncStatus` | `PENDING` · `IN_PROGRESS` · `SUCCESS` · `FAILED` · `SKIPPED` · `PRECONDITION_FAILED` | 执行结果 |
|
||||
|
||||
辅助字段(影响 Guard 但不是状态维度):
|
||||
|
||||
| 字段 | 类型 | 语义 |
|
||||
|------|------|------|
|
||||
| `data_id` | `str` | 业务主键(空串 `""` = 尚未从远程获取) |
|
||||
| `error` | `str \| None` | 最近一次错误信息 |
|
||||
|
||||
理论组合 = 6 × 4 × 6 = **144**,实际可达 ≈ **23** 种。
|
||||
|
||||
### §1.2 可达状态枚举
|
||||
|
||||
以下列出所有在正常流程中可以到达的 `(binding_status, action, status)` 三元组。
|
||||
|
||||
| # | binding_status | action | status | 语义 | 产生阶段 |
|
||||
|---|---|---|---|------|---------|
|
||||
| R1 | UNCHECKED | NONE | PENDING | 首次加载 | 数据加载 |
|
||||
| R2 | UNCHECKED | NONE | SUCCESS | 从持久化恢复(上轮成功)后重置 | 阶段1/2重置 |
|
||||
| R3 | UNCHECKED | NONE | FAILED | 从持久化恢复(上轮失败)后重置 | 阶段1/2重置 |
|
||||
| R4 | UNCHECKED | NONE | SKIPPED | 从持久化恢复(上轮跳过)后重置 | 阶段1/2重置 |
|
||||
| R5 | UNCHECKED | NONE | PRECONDITION_FAILED | 从持久化恢复(上轮前置失败)后重置 | 阶段1/2重置 |
|
||||
| R6 | NORMAL | NONE | PENDING | 绑定正常,无需操作(首次) | bind.p1 |
|
||||
| R7 | NORMAL | NONE | SUCCESS | 绑定正常,无需操作(历史成功) | bind.p1 |
|
||||
| R8 | NORMAL | NONE | FAILED | 绑定正常但上次失败,被 FAILED 保护 | bind.p1 |
|
||||
| R9 | MISSING | NONE | PENDING | 孤儿,等待自动绑定或创建(首次) | bind.p1 |
|
||||
| R10 | MISSING | NONE | SUCCESS | 孤儿(历史成功但绑定丢失?) | bind.p1 |
|
||||
| R11 | ABNORMAL | NONE | * | 数据损坏,需人工 | bind.p1 |
|
||||
| R12 | WARNING | NONE | * | 多候选/对方损坏,需人工 | bind.p1/p3 |
|
||||
| R13 | DEPENDENCY_ERROR | NONE | PENDING | 依赖不满足 | bind.p2/p3/create |
|
||||
| R14 | NORMAL | CREATE | PENDING | 准备创建(目标节点) | create |
|
||||
| R15 | NORMAL | CREATE | IN_PROGRESS | 异步创建进行中 | sync |
|
||||
| R16 | NORMAL | CREATE | SUCCESS | 创建成功 | sync |
|
||||
| R17 | NORMAL | CREATE | FAILED | 创建失败 | sync |
|
||||
| R18 | NORMAL | UPDATE | PENDING | 准备更新(目标节点) | update |
|
||||
| R19 | NORMAL | UPDATE | IN_PROGRESS | 异步更新进行中 | sync |
|
||||
| R20 | NORMAL | UPDATE | SUCCESS | 更新成功 | sync |
|
||||
| R21 | NORMAL | UPDATE | FAILED | 更新失败 | sync |
|
||||
| R22 | NORMAL | UPDATE | PRECONDITION_FAILED | 更新前置条件失败 | update |
|
||||
| R23 | NORMAL | CREATE | SKIPPED | 创建被 Handler 跳过 | sync |
|
||||
|
||||
> **不可达组合(示例)**:
|
||||
> - `(MISSING, UPDATE, *)` — MISSING 节点不可能被安排 UPDATE
|
||||
> - `(ABNORMAL, CREATE, *)` — ABNORMAL 节点不可能被安排 CREATE
|
||||
> - `(*, DELETE, *)` — DELETE 尚未实现
|
||||
> - `(UNCHECKED, CREATE, *)` — UNCHECKED 是瞬态,在 bind 之前不会有 CREATE
|
||||
> - `(DEPENDENCY_ERROR, UPDATE, *)` — DEPENDENCY_ERROR 节点不可能进入 update 流程
|
||||
|
||||
### §1.3 辅助字段约束
|
||||
|
||||
| 对应状态 | binding_status | action | status | data_id 约束 | error 约束 |
|
||||
|---------|---|---|---|-------------|-----------|
|
||||
| R14 | NORMAL | CREATE | PENDING | `""` (待远程返回) | None |
|
||||
| R16 | NORMAL | CREATE | SUCCESS | 非空 (远程回填) | None |
|
||||
| R17 | NORMAL | CREATE | FAILED | `""` (未回填) | 非空 |
|
||||
| R22 | NORMAL | UPDATE | PRECONDITION_FAILED | * | 非空 |
|
||||
| R13 | DEPENDENCY_ERROR | NONE | PENDING | * | 非空 |
|
||||
|
||||
---
|
||||
|
||||
## §2 不变量 (Invariants)
|
||||
|
||||
以下约束在**任何事件执行后**都必须成立。违反即为 Bug。
|
||||
|
||||
```
|
||||
INV-1 B = UNCHECKED ⟹ A = NONE
|
||||
(UNCHECKED 是瞬态,在 bind 之前不允许有动作)
|
||||
|
||||
INV-2 B ∈ {ABNORMAL, WARNING, DEPENDENCY_ERROR} ⟹ A = NONE
|
||||
(阻断态节点不允许执行任何同步动作)
|
||||
|
||||
INV-3 B = MISSING ⟹ A ∈ {NONE, CREATE}
|
||||
(MISSING 节点只能等待创建或保持不动)
|
||||
|
||||
INV-4 A = CREATE ⟹ B = NORMAL
|
||||
(CREATE 动作只出现在目标节点上,其 binding_status 已被设为 NORMAL)
|
||||
|
||||
INV-5 A = UPDATE ⟹ B = NORMAL
|
||||
(UPDATE 动作只对 NORMAL 节点执行)
|
||||
|
||||
INV-6 S = IN_PROGRESS ⟹ A ∈ {CREATE, UPDATE, DELETE}
|
||||
(只有提交过 API 的节点才可能处于 IN_PROGRESS)
|
||||
|
||||
INV-7 S = PRECONDITION_FAILED ⟹ A = UPDATE
|
||||
(PRECONDITION_FAILED 只在 update 阶段产生)
|
||||
|
||||
INV-8 A = CREATE ∧ S = SUCCESS ⟹ data_id ≠ ""
|
||||
(创建成功必须回填 data_id)
|
||||
|
||||
INV-9 S = FAILED ⟹ error ≠ None
|
||||
(失败必须有错误信息)
|
||||
|
||||
INV-10 B = DEPENDENCY_ERROR ⟹ error ≠ None
|
||||
(依赖阻断必须有错误信息)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3 事件与转换
|
||||
|
||||
### §3.1 事件总览图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 节点生命周期事件流 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
持久化恢复 策略层 (Strategy) 执行层 (DataSource)
|
||||
═══════════ ══════════════════════════════════ ══════════════════════════
|
||||
┌──────────────────────────────┐
|
||||
┌──────┐ │ bind() │ ┌──────────────────────┐
|
||||
│E01 │──────────▶│ E04 ─┐ │ │ sync_all() │
|
||||
│RESET1│ │ E05 ├─ phase1: core_state │ │ │
|
||||
└──┬───┘ │ E06 ─┘ │ │ E12 SYNC_OK │
|
||||
│ │ E07 ── phase2: dep_check │ ┌───▶│ E13 SYNC_FAIL │
|
||||
┌──▼───┐ │ E08 ─┐ │ │ │ E14 SYNC_ASYNC │
|
||||
│E02 │ │ E09 ├─ phase3: auto_bind │ │ │ E15 SYNC_TIMEOUT │
|
||||
│ZOMBIE│ │ E10 ─┘ │ │ │ E16 HANDLER_SKIP │
|
||||
└──┬───┘ └──────────────────────────────┘ │ │ E17 HANDLER_DOWN │
|
||||
│ ┌──────────────────────────────┐ │ └──────────────────────┘
|
||||
┌──▼───┐ │ create() / update() │ │
|
||||
│E03 │──────────▶│ E11a CREATE_OK │────┘
|
||||
│RESET2│ │ E11b CREATE_DEP_FAIL │
|
||||
└──────┘ │ E11c UPDATE_DIFF │
|
||||
│ E11d UPDATE_PRECOND_FAIL │
|
||||
│ E11e UPDATE_SKIP_FAILED │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
### §3.2 状态转换详解
|
||||
|
||||
本节按 Pipeline 执行阶段组织。每个阶段包含:
|
||||
1. **状态转换图**:状态(R 编号)为节点,事件(E 编号)为有向边
|
||||
2. **转换表**:每个事件的精确规格——起始状态、判断条件、结束状态、副作用
|
||||
|
||||
---
|
||||
|
||||
#### 🔄 阶段 0:重置
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════╗
|
||||
║ 任意持久化终态 (R6 ~ R23) ║
|
||||
╚═══════════════════╤═══════════════════╝
|
||||
│ E01
|
||||
▼
|
||||
┌────────────────────────────────────────┐ E02
|
||||
│ 瞬态: UNCHECKED │────────────▶ [节点删除 + 解除绑定]
|
||||
│ (action 保留, status 保留, error=None) │
|
||||
└────────────────────┬──────────────────┘
|
||||
│ E03 (存活节点)
|
||||
▼
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ [R1] UNCHECKED, NONE, PENDING ← 首次加载 ║
|
||||
║ [R2] UNCHECKED, NONE, SUCCESS ← 上轮成功 ║
|
||||
║ [R3] UNCHECKED, NONE, FAILED ← 上轮失败 ║
|
||||
║ [R4] UNCHECKED, NONE, SKIPPED ← 上轮跳过 ║
|
||||
║ [R5] UNCHECKED, NONE, PRECONDITION_FAILED ← 上轮前置 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E01**: PHASE1_RESET | R6~R23 (任意持久化终态) | 节点从持久化加载 | 瞬态 (UNCHECKED, action 保留, status 保留) | error → None; data/origin_data → None |
|
||||
| **E02**: ZOMBIE_KILL | 瞬态 | action = CREATE ∧ (status = FAILED ∨ data_id = "") | **节点删除** | 解除绑定关系; 对 local/remote 两侧都扫描 |
|
||||
| **E03**: ACTION_RESET | 瞬态 (存活节点) | E02 完成后所有存活节点 | R1 / R2 / R3 / R4 / R5 (按原 status 分) | action → NONE |
|
||||
|
||||
> **代码**: `NodeStateReset.get_phase1_reset_defaults()` · `cleanup_create_failed_zombies()` · `reset_action_for_nodes()`
|
||||
|
||||
---
|
||||
|
||||
#### 🔗 阶段 1:Bind
|
||||
|
||||
Bind 由三个子阶段串行执行。后一阶段只处理前一阶段的遗留 MISSING 节点。
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
[R1~R5] UNCHECKED, NONE, *
|
||||
│
|
||||
│ ══════════ Phase 1: 核心状态判定 ══════════
|
||||
│
|
||||
├── E04 (有绑定, 双方数据 VALID) ───────────▶ [R6/R7/R8] NORMAL ──────▶ 出口 ✅
|
||||
├── E04 (有绑定, 自身数据缺失/损坏) ────────▶ [R11] ABNORMAL ─────────▶ 出口 🔒
|
||||
├── E04 (有绑定, 对方数据缺失/损坏) ────────▶ [R12] WARNING ──────────▶ 出口 🔒
|
||||
│
|
||||
└── E05 (无绑定记录) ───────────────────────▶ [R9] MISSING
|
||||
│
|
||||
══════════ Phase 2: 依赖检查 ══════════ │
|
||||
│
|
||||
├── E06 (data = None) ──────────────────────▶ [R11] ABNORMAL ──────▶ 出口 🔒
|
||||
├── E07 (依赖节点不满足) ───────────────────▶ [R13] DEP_ERROR ─────▶ 出口 🔒
|
||||
│ (无依赖配置 或 依赖全部满足 → 继续 ▼)
|
||||
│
|
||||
══════════ Phase 3: 自动绑定 ══════════
|
||||
│
|
||||
├── E08 (auto_bind 关闭) ──────────────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E09 (去噪后 1:1 匹配成功) ─────────────▶ [R6] NORMAL ─────────▶ 出口 ✅
|
||||
├── E10a (去噪后非 1:1, 多候选) ───────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E10b (业务键字段缺失) ─────────────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E10c (业务键中 ID 字段解析失败) ───────▶ [R13] DEP_ERROR ─────▶ 出口 🔒
|
||||
│
|
||||
└── (1:0 无远程匹配)
|
||||
├── create_enabled = True → [R9] MISSING ──────────────▶ 进入 create
|
||||
└── create_enabled = False → [R12] WARNING ─────────────▶ 出口 🔒
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E04**: BIND_PAIRED | R1~R5 | 有绑定记录 ∧ 双方数据 VALID | R6 (PENDING) / R7 (SUCCESS) / R8 (FAILED) — status 保持 | 远程节点同步设置 binding_status |
|
||||
| **E04**: BIND_PAIRED (自身异常) | R1~R5 | 有绑定记录 ∧ 本方数据缺失或损坏 | R11 (ABNORMAL) | 对方节点设为 WARNING |
|
||||
| **E04**: BIND_PAIRED (对方异常) | R1~R5 | 有绑定记录 ∧ 对方数据缺失或损坏 | R12 (WARNING) | 对方节点设为 ABNORMAL |
|
||||
| **E05**: BIND_ORPHAN | R1~R5 | 无绑定记录 | R9 (MISSING, NONE, status 保持) | — |
|
||||
| **E06**: DATA_NULL | R9 | binding_status = MISSING ∧ data = None | R11 (ABNORMAL) | error = "数据损坏: 节点数据为空或不存在" |
|
||||
| **E07**: DEP_CHECK_FAIL | R9 | depend_fields 配置非空 ∧ ∃依赖节点: 不存在 ∨ status ≠ NORMAL ∨ data_id 为空 | R13 (DEPENDENCY_ERROR) | error = "依赖项不满足: ..."; depend_ids 填充 |
|
||||
| **E08**: AUTOBIND_DISABLED | R9 | auto_bind = False 或 auto_bind_fields 为空 | R12 (WARNING) | — |
|
||||
| **E09**: AUTOBIND_OK | R9 | 去噪后 local:remote = 1:1 ∧ 双方都是 MISSING | R6 (NORMAL) | 建立绑定记录; 双方都设为 NORMAL |
|
||||
| **E10a**: AUTOBIND_AMBIGUOUS | R9 | 去噪后非 1:1 (多候选) | R12 (WARNING) | error += "存在多个候选节点" |
|
||||
| **E10b**: AUTOBIND_KEY_MISSING | R9 | auto_bind_fields 中有字段不存在于 data | R12 (WARNING) | error += "缺少业务键字段: ..." |
|
||||
| **E10c**: AUTOBIND_ID_FAIL | R9 | 业务键包含 ID 字段 ∧ ID 解析失败 | R13 (DEPENDENCY_ERROR) | error = "业务键中的ID字段解析失败: ..." |
|
||||
| **E10d**: ORPHAN_CREATE_DISABLED | R9 | 去噪后 1:0 ∧ create_enabled = False | R12 (WARNING) | error += "孤儿且不允许自动创建" |
|
||||
|
||||
> **E04 核心状态矩阵** (完整查表):
|
||||
>
|
||||
> | | 远程: VALID | 远程: ABSENT | 远程: INVALID |
|
||||
> |---|---|---|---|
|
||||
> | **本地: VALID** | 本:NORMAL 远:NORMAL | 本:WARNING 远:ABNORMAL | 本:WARNING 远:ABNORMAL |
|
||||
> | **本地: ABSENT** | 本:ABNORMAL 远:WARNING | 本:ABNORMAL 远:ABNORMAL | — |
|
||||
> | **本地: INVALID** | 本:ABNORMAL 远:WARNING | — | 本:ABNORMAL 远:ABNORMAL |
|
||||
>
|
||||
> 不在矩阵中的组合 → 默认 (ABNORMAL, ABNORMAL)
|
||||
|
||||
> **代码**: `SyncDecisionEngine.determine_core_status()` · `_phase_core_state()` · `_check_dependencies_for_nodes()` · `_phase_auto_binding()`
|
||||
|
||||
---
|
||||
|
||||
#### ⚡ 阶段 2:Create / Update
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
═══════════════ Create ═══════════════
|
||||
|
||||
[R9] MISSING, NONE, PENDING (源节点, data ≠ None)
|
||||
│
|
||||
├── E11a (ID 解析成功) ────▶ 源节点: [R9] → [R6] NORMAL
|
||||
│ 新建目标节点: → [R14] NORMAL, CREATE, PENDING
|
||||
│ + 建立绑定记录, 目标 data_id = ""
|
||||
│
|
||||
└── E11b (ID 解析失败) ────▶ 源节点: [R9] → [R13] DEPENDENCY_ERROR
|
||||
|
||||
|
||||
═══════════════ Update ═══════════════
|
||||
|
||||
[R6/R7] NORMAL, NONE, PENDING/SUCCESS (源节点, status ≠ FAILED)
|
||||
│
|
||||
├── E11c (差异检测通过) ────▶ 目标节点 → [R18] NORMAL, UPDATE, PENDING
|
||||
├── E11d.1 (目标 data_id="") ▶ 目标节点 → [R22] status = PRECONDITION_FAILED
|
||||
├── E11d.2 (ID 解析失败) ───▶ 目标节点 → [R22] action = UPDATE, status = PRECOND_FAIL
|
||||
└── (无差异) ──────────────▶ 保持不变
|
||||
|
||||
|
||||
═══════════════ FAILED 保护 ═══════════════
|
||||
|
||||
[R8] NORMAL, NONE, FAILED (源节点)
|
||||
│
|
||||
└── E11e ──────────────────▶ 跳过,保持 [R8] 不变
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 (源节点) | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E11a**: CREATE_OK | R9 (MISSING) | data ≠ None ∧ IDResolver.resolve_and_validate() = OK | 源 → R6 (NORMAL); **新建**目标 → R14 | 建立绑定; 新节点 data = 已替换 ID 的副本, data_id = "" |
|
||||
| **E11b**: CREATE_DEP_FAIL | R9 (MISSING) | data ≠ None ∧ IDResolver.resolve_and_validate() = FAIL | R13 (DEPENDENCY_ERROR) | error = "创建时依赖 ID 未解析: ..." |
|
||||
| **E11c**: UPDATE_DIFF | R6/R7 (NORMAL, status≠FAILED) | 目标存在 ∧ 目标 data_id ≠ "" ∧ ID 解析 OK ∧ _needs_update() = True | 目标 → R18 (UPDATE, PENDING) | 目标: data 替换, error = None |
|
||||
| **E11d**: UPDATE_PRECOND_FAIL | R6/R7 (NORMAL, status≠FAILED) | 目标 data_id = "" (d1) 或 ID 解析失败 (d2) | 目标 → R22 (UPDATE, PRECONDITION_FAILED) | error = "目标缺少 data_id" 或 "依赖 ID 未解析" |
|
||||
| **E11e**: UPDATE_SKIP_FAILED | R8 (NORMAL, FAILED) | status = FAILED | R8 不变 | 保护上次的 error 和 FAILED 状态,不做任何修改 |
|
||||
|
||||
> **代码**: `_add_create_action()` · `_add_update_action()`
|
||||
|
||||
---
|
||||
|
||||
#### 📡 阶段 3:Sync(物理同步)
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
[R14] NORMAL, CREATE, PENDING [R18] NORMAL, UPDATE, PENDING
|
||||
│ │
|
||||
└───────────────┬───────────────────┘
|
||||
│
|
||||
├── E12 (API 成功) ──────▶ [R16] CREATE,SUCCESS / [R20] UPDATE,SUCCESS
|
||||
├── E13 (API 失败) ──────▶ [R17] CREATE,FAILED / [R21] UPDATE,FAILED
|
||||
├── E16 (Handler 跳过) ──▶ [R23] CREATE, SKIPPED
|
||||
├── E17 (Handler 降级) ──▶ [R6] NORMAL, NONE, PENDING (仅 UPDATE)
|
||||
│
|
||||
└── E14 (异步提交) ──────▶ [R15] CREATE,IN_PROGRESS / [R19] UPDATE,INP
|
||||
│
|
||||
├── (轮询成功) ──▶ [R16] / [R20] SUCCESS
|
||||
└── E15 (超时) ──▶ [R17] / [R21] FAILED
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E12**: SYNC_OK | R14 或 R18 | Handler API 返回 SUCCESS | R16 (CREATE) 或 R20 (UPDATE) | error → None; data_id 回填 (CREATE); origin_data 更新 (UPDATE) |
|
||||
| **E13**: SYNC_FAIL | R14 或 R18 | Handler API 返回 FAILED | R17 (CREATE) 或 R21 (UPDATE) | error = Handler 返回的错误信息 |
|
||||
| **E14**: SYNC_ASYNC | R14 或 R18 | Handler API 返回 IN_PROGRESS | R15 (CREATE) 或 R19 (UPDATE) | 记录 task_id, 进入轮询队列 |
|
||||
| **E15**: SYNC_TIMEOUT | R15 或 R19 | 轮询次数 > max_retries | R17 (CREATE) 或 R21 (UPDATE) | error = "Async task timeout after N seconds" |
|
||||
| **E16**: HANDLER_SKIP | R14 或 R18 | Handler API 返回 SKIPPED | R23 (CREATE,SKIPPED) | error → None |
|
||||
| **E17**: HANDLER_DOWNGRADE | R18 (仅 UPDATE) | Handler 运行时发现无实际变更字段 | R6 (NORMAL, NONE, PENDING) | action: UPDATE → NONE; 不提交 API; 统计列从 update 移入 none |
|
||||
|
||||
> **代码**: `BaseDataSource._handle_task_result()` · `_poll_async_tasks()` · `domain/*/api_handler.py`
|
||||
|
||||
---
|
||||
|
||||
## §4 终态分类与死胡同
|
||||
|
||||
### §4.1 终态分类
|
||||
|
||||
| 分类 | 状态 | (binding_status, action, status) | 语义 | 下一轮行为 |
|
||||
|------|------|------|------|---------|
|
||||
| ✅ 成功 | R16 | (NORMAL, CREATE, SUCCESS) | 创建成功 | →R2→bind→R7→update 检查 |
|
||||
| ✅ 成功 | R20 | (NORMAL, UPDATE, SUCCESS) | 更新成功 | →R2→bind→R7→update 检查 |
|
||||
| ❌ 失败 | R17 | (NORMAL, CREATE, FAILED) | 创建失败 | E02 zombie 判定→删除节点→对方回到 R9 重建 |
|
||||
| ❌ 失败 | R21 | (NORMAL, UPDATE, FAILED) | 更新失败 | →R3→bind→R8→FAILED 保护(E11e)→保持 |
|
||||
| 🔒 阻断 | R11 | (ABNORMAL, NONE, \*) | 数据损坏 | 每轮重新 bind 检查; 需人工修复数据 |
|
||||
| 🔒 阻断 | R12 | (WARNING, NONE, \*) | 多候选/对方异常 | 每轮重新 bind 检查; 需人工绑定或修复 |
|
||||
| 🔒 阻断 | R13 | (DEPENDENCY_ERROR, NONE, PENDING) | 依赖不满足 | 每轮重新检查依赖; 依赖修复后自动恢复 |
|
||||
| ⏭ 跳过 | R23 | (NORMAL, CREATE, SKIPPED) | Handler 跳过创建 | →R4→bind→R6→update 检查(无绑定则再 create) |
|
||||
| 🔄 异步 | R15 | (NORMAL, CREATE, IN\_PROGRESS) | 创建异步中 | 轮询直到 SUCCESS 或超时→FAILED |
|
||||
| 🔄 异步 | R19 | (NORMAL, UPDATE, IN\_PROGRESS) | 更新异步中 | 轮询直到 SUCCESS 或超时→FAILED |
|
||||
| 🔄 前置失败 | R22 | (NORMAL, UPDATE, PRECONDITION\_FAILED) | 前置条件不满足 | →R5→bind→R6/R7→update 重试 |
|
||||
|
||||
### §4.2 恢复路径
|
||||
|
||||
```
|
||||
R17 (CREATE, FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, CREATE, FAILED)
|
||||
→ E02: action=CREATE ∧ (status=FAILED ∨ data_id="") → 满足 zombie 条件
|
||||
→ 删除节点 + 解除绑定
|
||||
→ 对方节点回到 R9 (MISSING) → 重新进入 create 流程
|
||||
|
||||
R21 (UPDATE, FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, UPDATE, FAILED)
|
||||
→ E02: action=UPDATE ≠ CREATE → 非 zombie,存活
|
||||
→ E03: action → NONE → R3 (UNCHECKED, NONE, FAILED)
|
||||
→ bind → R8 (NORMAL, NONE, FAILED)
|
||||
→ E11e: FAILED 保护 → 跳过 update → 保持 R8
|
||||
⚠ 需人工清除 FAILED 才能恢复
|
||||
|
||||
R22 (UPDATE, PRECONDITION_FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, UPDATE, PRECONDITION_FAILED)
|
||||
→ E02: action=UPDATE ≠ CREATE → 非 zombie,存活
|
||||
→ E03: action → NONE → R5 (UNCHECKED, NONE, PRECONDITION_FAILED)
|
||||
→ bind → R6/R7 (NORMAL) → update 流程重试
|
||||
|
||||
R11 (ABNORMAL):
|
||||
每轮 E01 重置 → bind 重新检查
|
||||
→ 若数据被人工修复 → E04 → R6/R7/R8 (NORMAL)
|
||||
→ 若数据仍损坏 → 再次 R11
|
||||
|
||||
R12 (WARNING):
|
||||
每轮 E01 重置 → bind 重新检查
|
||||
→ 若人工建立了绑定 → E04 → R6/R7 (NORMAL)
|
||||
→ 若对方数据被修复 → E04 → R6/R7 (NORMAL)
|
||||
|
||||
R13 (DEPENDENCY_ERROR):
|
||||
每轮 E01 重置 → bind 重新检查依赖
|
||||
→ 依赖节点达到 NORMAL 且 data_id 非空后
|
||||
→ E07 通过 → 继续 auto_bind 或 create 流程
|
||||
```
|
||||
|
||||
### §4.3 FAILED 保护机制详解
|
||||
|
||||
FAILED 保护确保失败节点不会被后续流程静默覆盖,保留 error 信息供人工调查。
|
||||
|
||||
```
|
||||
进入条件:
|
||||
节点经过 bind 后为 R8 (NORMAL, NONE, FAILED)
|
||||
即: 绑定关系正常,但上一轮执行失败
|
||||
|
||||
保护行为:
|
||||
strategy._add_update_action() 中:
|
||||
if source.status == SyncStatus.FAILED:
|
||||
skip (→ 事件 E11e)
|
||||
结果: R8 原样保持 → 持久化 → 下一轮依然 R8
|
||||
|
||||
解除条件 (需人工介入):
|
||||
1. 清除 error 字段 (→ None)
|
||||
2. 将 status 改为 PENDING 或 SUCCESS
|
||||
3. 下轮 bind 后 → R6 或 R7 → 正常进入 update 流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5 完备性分析
|
||||
|
||||
### §5.1 状态 × 事件矩阵
|
||||
|
||||
行 = 当前状态,列 = 可能触发的事件。`→Rx` = 转换到目标状态,`—` = 不可能触发。
|
||||
|
||||
| 状态 | E01 | E02 | E03 | E04/E05 | E06~E10 | E11a/b | E11c/d/e | E12~E17 |
|
||||
|------|-----|-----|-----|---------|---------|--------|----------|---------|
|
||||
| R1~R5 | — | — | — | →R6~R13 | — | — | — | — |
|
||||
| R6/R7 | →瞬态 | — | — | — | — | — | →R18/R22 | — |
|
||||
| R8 | →瞬态 | — | — | — | — | — | →R8(skip) | — |
|
||||
| R9 | →瞬态 | — | — | — | →R6/R11~R13 | →R6+R14/R13 | — | — |
|
||||
| R11~R13 | →瞬态 | — | — | — | — | — | — | — |
|
||||
| R14 | →瞬态 | — | — | — | — | — | — | →R15~R17/R23 |
|
||||
| R15 | →瞬态 | — | — | — | — | — | — | →R16/R17 |
|
||||
| R18 | →瞬态 | — | — | — | — | — | — | →R6/R19~R21/R23 |
|
||||
| R19 | →瞬态 | — | — | — | — | — | — | →R20/R21 |
|
||||
| R16/R17 | →瞬态 | — | — | — | — | — | — | — |
|
||||
| R20~R23 | →瞬态 | — | — | — | — | — | — | — |
|
||||
|
||||
> **读表方式**: 找到当前状态行和事件列组的交叉点,即可得到目标状态。`—` 表示该状态不可能触发该事件。
|
||||
|
||||
### §5.2 已知 GAP
|
||||
|
||||
| GAP | 描述 | 风险 | 建议 |
|
||||
|-----|------|------|------|
|
||||
| GAP-1 | `set_binding_status()` / `set_action()` / `set_status()` 是纯 setter,不验证转换合法性 | 中 | 加入前置断言:检查 (当前状态, 新状态) 是否匹配某条已知转换 |
|
||||
| GAP-2 | INV-1 ~ INV-10 未在代码中 assert | 中 | 每次 setter 调用后或阶段结束时断言所有不变量 |
|
||||
| GAP-3 | R22 (PRECONDITION\_FAILED) 节点下一轮行为路径需验证 | 低 | 已补充恢复路径: R22→E01→E03→R5→bind→R6/R7→update 重试 |
|
||||
| GAP-4 | Handler 降级 (E17) 后 status 残留为 PENDING 但 action 已清零 | 低 | 可考虑降级时同时重置 status 为 SUCCESS(当前保持 PENDING) |
|
||||
| GAP-5 | 物理同步后缺少整体一致性检查 (post-sync invariant check) | 低 | 在 `sync_all()` 结束后遍历所有节点验证 INV-1 ~ INV-10 |
|
||||
|
||||
---
|
||||
|
||||
## §6 引用
|
||||
|
||||
| 文档 | 路径 |
|
||||
|------|------|
|
||||
| 枚举定义 | `sync_system_new/common/types.py` |
|
||||
| 决策引擎 | `sync_system_new/sync_system/decision.py` |
|
||||
| 重置策略 | `sync_system_new/common/state_reset.py` |
|
||||
| 同步策略 | `sync_system_new/sync_system/strategy.py` |
|
||||
| 物理同步 | `sync_system_new/datasource/datasource.py` |
|
||||
| ID 解析 | `sync_system_new/sync_system/resolve_ids.py` |
|
||||
| 节点生命周期(叙事) | [节点生命周期状态转移](./节点生命周期状态转移.md) |
|
||||
@@ -0,0 +1,534 @@
|
||||
# 同步编排流程规范 (Pipeline Specification)
|
||||
|
||||
本文档定义了完整的同步编排流程,包括各阶段的职责、节点状态转换和检查点。
|
||||
|
||||
> **详细的状态定义和转换规则,请参考**: [STATE_DEFINITIONS.md](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
---
|
||||
|
||||
## 流程概览
|
||||
|
||||
同步系统的完整执行流程分为 5 个阶段:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 0: 初始化 (Initialization) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - 创建 Collection (Local/Remote) │
|
||||
│ - 创建 BindingManager │
|
||||
│ - 注册 SyncStrategy (各业务类型) │
|
||||
│ - 创建 DataSource (Local/Remote) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 1: 数据加载 (Data Loading) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 节点状态: binding_status=UNCHECKED, action=NONE, status=PENDING │
|
||||
│ - Persistence.load_nodes() → 从持久化恢复 │
|
||||
│ - DataSource.load_all() → 从数据源加载 │
|
||||
│ - BindingManager.load_bindings() → 加载绑定关系 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 2 & 3: 策略执行与同步 (按 node_type 顺序) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ For each node_type in sync_order: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 2.1 bind() → 绑定判定 │ │
|
||||
│ │ 2.2 create() → 设置 CREATE action │ │
|
||||
│ │ └─ 内联 ID 解析 + 前置检查 │ │
|
||||
│ │ 2.3 Commit Creates → 立即同步 CREATE 节点 │ │
|
||||
│ │ └─ DataSource.sync_all(poll_async=True) │ │
|
||||
│ │ 2.4 update() → 设置 UPDATE action │ │
|
||||
│ │ └─ 内联 ID 解析 + 前置检查 │ │
|
||||
│ │ 2.5 Commit Updates → 立即同步 UPDATE 节点 │ │
|
||||
│ │ └─ DataSource.sync_all(poll_async=True) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 说明: │
|
||||
│ - 每个 node_type 完整同步后才处理下一个(保证依赖顺序) │
|
||||
│ - commit 操作内部包含异步轮询,无需独立轮询阶段 │
|
||||
│ - sync_all() 自动过滤 action=CREATE/UPDATE 的节点 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 4: 同步结果报告 (Summary Report) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - 打印同步结果摘要 │
|
||||
│ * 节点统计(按 action/status 分类) │
|
||||
│ * 绑定关系统计 │
|
||||
│ * 异常节点检查(data_id 缺失、绑定缺失等) │
|
||||
│ - 收集统计信息(成功/失败/残留问题) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 5: 状态持久化 (State Persistence) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - Collection.persist() → 保存节点状态 │
|
||||
│ - BindingManager.persist() → 保存绑定关系 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
【流程完成】
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 各阶段节点状态
|
||||
|
||||
### Stage 0: 初始化
|
||||
|
||||
**输出**: 所有组件实例化完成。
|
||||
|
||||
---
|
||||
|
||||
### Stage 1: 数据加载
|
||||
|
||||
**节点状态**:
|
||||
```python
|
||||
binding_status = BindingStatus.UNCHECKED # 未判定
|
||||
action = SyncAction.NONE # 无动作
|
||||
status = SyncStatus.PENDING # 待处理
|
||||
data_id = <从数据源加载> # 已有业务ID
|
||||
origin_data = <从数据源加载> # 原始数据快照
|
||||
data = <从数据源加载> # 当前数据
|
||||
depend_ids = <从数据源加载> # 依赖节点列表
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `data_id` 在加载时**必定有值**(来自数据源)
|
||||
- 只有待创建的新节点 `data_id` 才可能为 ""(空字符串)
|
||||
- 从持久化恢复时应重置所有 `action` 为 `NONE`
|
||||
- `binding_status` 初始值是 `UNCHECKED`,不是 `None`
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.1: Bind (状态判定)
|
||||
|
||||
**状态转换**: `binding_status: UNCHECKED → NORMAL/MISSING/ABNORMAL/WARNING/DEPENDENCY_ERROR`
|
||||
|
||||
**可能的结果状态**:
|
||||
|
||||
| 结果状态 | 含义 | 占比 |
|
||||
|---------|------|-----|
|
||||
| **NORMAL** | 有绑定,数据健康 | 大部分 |
|
||||
| **MISSING** | 无绑定,孤儿节点 | 少量 |
|
||||
| **ABNORMAL** | 有绑定,自身数据损坏 | 极少 |
|
||||
| **WARNING** | 有绑定,对方数据损坏 | 极少 |
|
||||
| **DEPENDENCY_ERROR** | 依赖未满足 | 少量 |
|
||||
|
||||
**详细规则**: 参考 [STATE_DEFINITIONS.md § 1-3](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
**补充约束**:
|
||||
- 若依赖节点 `binding_status = NORMAL` 但 `data_id` 为空(含 `status = FAILED`),视为依赖阻断。
|
||||
- 只要依赖节点已回填 `data_id`,即使其 `status = FAILED`,后续类型仍允许进入 Create/Update。
|
||||
- 自动绑定只在**业务键完整且唯一**时才尝试:
|
||||
- 若业务键字段缺失(如 `serial_number = null`),设置 `binding_status = WARNING`,并在 `node.error` 记录原因:`Auto-bind skipped: missing fields ...`。
|
||||
- 若同一业务键出现多对多/多对一,设置 `binding_status = WARNING`,并在 `node.error` 记录原因:`Auto-bind skipped: ambiguous candidates`。
|
||||
- 以上情况仅记录 **Warning**,不进行自动绑定。
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.2: Create (孤儿处理)
|
||||
|
||||
**状态转换**: `action: NONE → CREATE` (对 MISSING 节点)
|
||||
|
||||
**补充约束**:
|
||||
- `binding_status = WARNING` 的节点不会进入 CREATE(`create()` 只查找 MISSING 节点)
|
||||
- 创建时会通过 `IDResolver.resolve_and_validate()` 二次验证依赖 ID,失败则设置 `DEPENDENCY_ERROR`
|
||||
|
||||
**副作用**:
|
||||
- 在对方 Collection 创建新节点
|
||||
- 新节点的 `binding_status = NORMAL, action = CREATE, data_id = ""`
|
||||
- 建立绑定记录
|
||||
- 原 MISSING 节点也变为 `NORMAL`
|
||||
|
||||
**示例**:
|
||||
```
|
||||
Before:
|
||||
local_node: binding_status=MISSING, action=NONE
|
||||
|
||||
After Create:
|
||||
local_node: binding_status=NORMAL, action=NONE
|
||||
remote_node (新建): binding_status=NORMAL, action=CREATE, data_id=""
|
||||
绑定记录: local_id ↔ remote_temp_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.3: Update (差异同步)
|
||||
|
||||
**状态转换**: `action: NONE → UPDATE` (对 NORMAL 且有差异的节点)
|
||||
|
||||
**前提条件**:
|
||||
- `binding_status = NORMAL`
|
||||
- `status != FAILED`(保护上次失败的状态和错误信息)
|
||||
- `compute_diff()` 判定有差异
|
||||
- 配置的 `update_direction` 决定哪一方节点设置 UPDATE
|
||||
|
||||
**内联检查**:
|
||||
- 目标节点 `data_id` 为空 → `status = PRECONDITION_FAILED`
|
||||
- `IDResolver.resolve_and_validate()` 失败 → `status = PRECONDITION_FAILED`
|
||||
- Handler 校验后无实际变更字段 → `action` 降级为 `NONE`(不提交)
|
||||
|
||||
---
|
||||
|
||||
### Stage 2 & 3: 策略执行与同步
|
||||
|
||||
**执行顺序**: 按 `sync_order` 顺序逐个处理 node_type
|
||||
|
||||
**单个 node_type 的完整流程**:
|
||||
|
||||
```
|
||||
For node_type in sync_order:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.1 Bind - 绑定判定 │
|
||||
│ - 设置 binding_status: NORMAL/MISSING/... │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.2 Create - 创建策略 │
|
||||
│ - 处理 MISSING 节点,设置 action=CREATE │
|
||||
│ - 内联 ID 解析验证 (resolve_and_validate) │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.3 Commit Creates - 立即同步 CREATE │
|
||||
│ - DataSource.sync_all(poll_async=True) │
|
||||
│ - 自动过滤 action=CREATE 的节点 │
|
||||
│ - 内部包含异步轮询(IN_PROGRESS→SUCCESS)│
|
||||
│ - ⭐ 执行 data_id 回填和绑定更新 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.4 Update - 更新策略 │
|
||||
│ - 处理差异节点,设置 action=UPDATE │
|
||||
│ - 内联 ID 解析验证 + data_id 检查 │
|
||||
│ - FAILED 节点自动跳过 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.5 Commit Updates - 立即同步 UPDATE │
|
||||
│ - DataSource.sync_all(poll_async=True) │
|
||||
│ - 自动过滤 action=UPDATE 的节点 │
|
||||
│ - 内部包含异步轮询(IN_PROGRESS→SUCCESS)│
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
下一个 node_type
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
1. **按类型顺序**: 每个 node_type 完全同步后才处理下一个(保证依赖顺序)
|
||||
2. **立即提交**: create/update 后立即同步,不等待所有策略执行完
|
||||
3. **内置轮询**: `sync_all(poll_async=True)` 内部自动轮询异步任务
|
||||
4. **data_id 回填**: CREATE 成功后立即回填 data_id,下游依赖可用
|
||||
|
||||
**同步状态转换**:
|
||||
|
||||
**同步接口(一步式)**:
|
||||
```
|
||||
action=CREATE, API 成功 → status=SUCCESS, data_id=<远程ID>
|
||||
action=CREATE, API 失败 → status=FAILED, error=<错误信息>
|
||||
action=UPDATE, API 成功 → status=SUCCESS
|
||||
action=UPDATE, API 失败 → status=FAILED, error=<错误信息>
|
||||
action=NONE → status=PENDING (不执行)
|
||||
```
|
||||
|
||||
**异步接口(两步式,内置轮询)**:
|
||||
```
|
||||
action=CREATE, 提交成功 → status=IN_PROGRESS, task_id=<任务ID>
|
||||
status=IN_PROGRESS, 轮询中... → status=IN_PROGRESS (保持)
|
||||
status=IN_PROGRESS, 轮询成功 → status=SUCCESS, data_id=<远程ID>
|
||||
status=IN_PROGRESS, 轮询失败 → status=FAILED, error=<错误信息>
|
||||
status=IN_PROGRESS, 超时 → status=FAILED, error="Timeout"
|
||||
```
|
||||
|
||||
**关键副作用**(由 DataSource 在 sync_all 中执行):
|
||||
- CREATE 成功: `data_id` 从 "" 更新为远程分配的 ID,并回写 `data` 主键字段
|
||||
- CREATE 成功: 绑定记录从临时ID更新为真实ID
|
||||
- UPDATE 成功: `origin_data` 更新为当前 `data`
|
||||
- DELETE 成功: 移除绑定记录
|
||||
|
||||
**skip_sync 处理**:
|
||||
- 如果 `strategy.skip_sync = True`,跳过 create/update/commit 步骤
|
||||
- bind 步骤始终执行(用于状态判定)
|
||||
|
||||
**详细规则**: 参考 [STATE_DEFINITIONS.md § 5](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: 同步结果报告
|
||||
|
||||
**目标**: 打印同步结果摘要,帮助用户了解同步情况
|
||||
|
||||
**报告内容**:
|
||||
1. **节点统计**:按 node_type 统计各种 action/status 的节点数量
|
||||
2. **绑定关系**:每个 node_type 的绑定记录数
|
||||
3. **异常检查**:扫描并报告异常情况:
|
||||
- CREATE 成功但 data_id 为空(DataSource 回填失败)
|
||||
- CREATE 成功但绑定记录缺失
|
||||
- UPDATE/CREATE 失败的节点及错误原因
|
||||
|
||||
**输出示例**:
|
||||
```
|
||||
================================================================================
|
||||
📊 Local Collection Summary
|
||||
================================================================================
|
||||
Node Type Bindings Create Update Delete None Total
|
||||
--------------------------------------------------------------------------------
|
||||
project 5/5 0(0/0/0) 2(2/0/0) 0(0/0/0) 3 5(5/0/0)
|
||||
contract 10/12 2(2/0/0) 5(5/0/0) 0(0/0/0) 5 12(12/0/0)
|
||||
```
|
||||
|
||||
**注意**: 此阶段不修改任何状态,仅作为信息展示。
|
||||
|
||||
---
|
||||
|
||||
### Stage 5: 状态持久化
|
||||
|
||||
**持久化内容**:
|
||||
- `binding_status`, `action`, `status` 的最终值
|
||||
- `data_id` (可能在 CREATE 后更新)
|
||||
- `error` (如果失败)
|
||||
- 绑定关系的完整映射
|
||||
|
||||
---
|
||||
|
||||
## 错误处理策略
|
||||
|
||||
| 阶段 | 失败类型 | 处理策略 |
|
||||
|------|---------|---------|
|
||||
| Stage 0 | 初始化失败 | **终止流程**,抛出异常 |
|
||||
| Stage 1 | 数据加载失败 | **终止流程**,抛出异常 |
|
||||
| Stage 2 & 3 | 代码异常 | **终止流程**,抛出异常 |
|
||||
| Stage 2: create/update 内联检查 | 依赖ID未解析、data_id缺失 | **继续流程**,节点标记为 DEPENDENCY_ERROR 或 PRECONDITION_FAILED |
|
||||
| Stage 2 & 3 | API 调用失败 | **继续流程**,标记节点 FAILED |
|
||||
| Stage 4 | - | 总是继续,仅作为报告 |
|
||||
| Stage 5 | 持久化失败 | **记录错误**,不影响内存状态 |
|
||||
|
||||
---
|
||||
|
||||
## 节点状态完整时间线
|
||||
|
||||
```
|
||||
┌──────────────┬─────────────┬────────┬────────┬─────────┐
|
||||
│ 阶段 │ binding_ │ action │ status │ data_id │
|
||||
│ │ status │ │ │ │
|
||||
├──────────────┼─────────────┼────────┼────────┼─────────┤
|
||||
│ 数据加载 │ UNCHECKED │ NONE │PENDING │ 已加载 │
|
||||
│ Bind │ NORMAL/... │ NONE │PENDING │ 已加载 │
|
||||
│ Create │ NORMAL │ CREATE │PENDING │ ""* │
|
||||
│ Commit Create│ NORMAL │ CREATE │SUCCESS │ 远程ID** │
|
||||
│ Update │ NORMAL │ UPDATE │PENDING │ 已加载 │
|
||||
│ Commit Update│ NORMAL │ UPDATE │SUCCESS │ 已加载 │
|
||||
└──────────────┴─────────────┴────────┴────────┴─────────┘
|
||||
|
||||
* 新创建的节点 data_id 初始为 ""
|
||||
** CREATE 成功后 data_id 立即更新为远程分配的ID,下游依赖可用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置驱动的流程控制
|
||||
|
||||
### 跳过某些阶段
|
||||
|
||||
```python
|
||||
pipeline_config = {
|
||||
"skip_bind": False, # 是否跳过 bind
|
||||
"skip_create": False, # 是否跳过 create
|
||||
"skip_update": False, # 是否跳过 update
|
||||
"skip_post_check": False, # 是否跳过同步后检查
|
||||
"fail_on_warning": False, # Pre-Check 时是否将 WARNING 视为失败
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`skip_pre_check` 已移除,原 `_pre_commit_check` 逻辑已内联到 create/update 方法中。
|
||||
```
|
||||
|
||||
### 策略预设应用
|
||||
|
||||
```python
|
||||
# 首次同步:启用自动绑定,双向创建
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("first_sync")
|
||||
|
||||
# 日常同步:仅推送更新
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("push_only")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 幂等性保证
|
||||
|
||||
**要求**: 同一个 Collection 的多次同步应该是幂等的。
|
||||
|
||||
**设计保证**:
|
||||
1. **Bind 阶段**: 基于绑定记录判定,已绑定节点不会重复绑定
|
||||
2. **Create 阶段**: 已有绑定的节点 (NORMAL) 不会重复创建
|
||||
3. **Update 阶段**: 基于 `compute_diff()` 判定,无差异则不执行
|
||||
4. **Sync 阶段**: `action=NONE` 的节点跳过执行
|
||||
|
||||
**前提条件**:
|
||||
- 绑定记录正确持久化
|
||||
- 节点的 `binding_status` 正确保存和加载
|
||||
- `compute_diff()` 逻辑准确
|
||||
|
||||
---
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 关键指标
|
||||
|
||||
| 指标 | 含义 | 阶段 |
|
||||
|------|------|-----|
|
||||
| `nodes_loaded` | 加载的节点总数 | Stage 1 |
|
||||
| `nodes_normal` | NORMAL 状态节点数 | Stage 2.1 |
|
||||
| `nodes_missing` | MISSING 状态节点数 | Stage 2.1 |
|
||||
| `nodes_abnormal` | ABNORMAL+WARNING 节点数 | Stage 2.1 |
|
||||
| `nodes_dep_error` | DEPENDENCY_ERROR 节点数 | Stage 2.1 |
|
||||
| `actions_create` | CREATE 动作节点数 | Stage 2.2 |
|
||||
| `actions_update` | UPDATE 动作节点数 | Stage 2.3 |
|
||||
| `sync_success` | 同步成功节点数 | Stage 3 |
|
||||
| `sync_failed` | 同步失败节点数 | Stage 3 |
|
||||
| `success_rate` | 成功率 (success/total) | Stage 4 |
|
||||
|
||||
### 日志级别
|
||||
|
||||
```
|
||||
INFO: 流程阶段切换、统计信息
|
||||
DEBUG: 节点级别的详细操作
|
||||
WARN: 检查发现的警告、同步失败
|
||||
ERROR: 检查发现的错误、致命异常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [STATE_DEFINITIONS.md](../sync_system/STATE_DEFINITIONS.md) - 详细的状态定义和转换规则
|
||||
- [CHECK_SPEC.md](./CHECK_SPEC.md) - 检查点的实现细节和代码示例(可选)
|
||||
# 1. 持久化 Collection
|
||||
await local_collection.persist()
|
||||
await remote_collection.persist()
|
||||
|
||||
# 2. 持久化 Binding
|
||||
await binding_manager.persist()
|
||||
```
|
||||
|
||||
**持久化内容**:
|
||||
- 节点的 `binding_status`, `action`, `status`, `error`
|
||||
- 节点的 `data_id`(可能在 CREATE 后更新)
|
||||
- 绑定关系的完整映射
|
||||
|
||||
---
|
||||
|
||||
## 错误处理策略
|
||||
|
||||
### 各阶段的失败处理
|
||||
|
||||
| 阶段 | 失败原因示例 | 处理策略 |
|
||||
|------|------------|---------|
|
||||
| Stage 1: 数据加载 | 网络错误、文件缺失 | **终止流程**,抛出异常 |
|
||||
| Stage 2.1: Bind | 代码bug | **终止流程**,抛出异常 |
|
||||
| Stage 2.2-2.3: Create/Update | 代码bug | **终止流程**,抛出异常 |
|
||||
| Stage 2: create/update 内联检查 | 依赖ID未解析、data_id缺失 | **继续流程**,节点标记为 DEPENDENCY_ERROR 或 PRECONDITION_FAILED |
|
||||
| Stage 3: Physical Sync | API错误、权限不足 | **继续流程**,标记节点为 FAILED |
|
||||
| Stage 4: Post-Sync Check | 部分节点同步失败 | **继续流程**,打印警告 |
|
||||
| Stage 5: Persistence | 磁盘满、权限错误 | **记录错误**,但不影响内存状态 |
|
||||
|
||||
### 部分成功的处理
|
||||
|
||||
在 Stage 3 中,允许部分节点成功、部分失败:
|
||||
- 成功的节点: `status = SUCCESS`
|
||||
- 失败的节点: `status = FAILED`, `error = "错误信息"`
|
||||
|
||||
Post-Sync Check 会统计成功率并给出警告。
|
||||
|
||||
---
|
||||
|
||||
## 配置驱动的流程控制
|
||||
|
||||
### 跳过某些阶段
|
||||
|
||||
通过配置控制哪些阶段执行:
|
||||
|
||||
```python
|
||||
pipeline_config = {
|
||||
"skip_bind": False, # 是否跳过 bind
|
||||
"skip_create": False, # 是否跳过 create
|
||||
"skip_update": False, # 是否跳过 update
|
||||
"skip_post_check": False, # 是否跳过同步后检查
|
||||
"fail_on_warning": False, # 是否将 WARNING 也视为失败
|
||||
}
|
||||
```
|
||||
|
||||
### 策略预设应用
|
||||
|
||||
在 Stage 2 之前,可以应用策略预设:
|
||||
|
||||
```python
|
||||
# 首次同步:启用自动绑定,双向创建
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("first_sync")
|
||||
|
||||
# 日常同步:仅推送更新
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("push_only")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 关键指标
|
||||
|
||||
在各阶段收集以下指标:
|
||||
|
||||
| 指标 | 含义 |
|
||||
|------|------|
|
||||
| `nodes_loaded` | 加载的节点总数 |
|
||||
| `nodes_bound` | 成功绑定的节点数 |
|
||||
| `nodes_missing` | MISSING 状态的节点数 |
|
||||
| `nodes_abnormal` | ABNORMAL/WARNING 状态的节点数 |
|
||||
| `nodes_created` | CREATE 动作的节点数 |
|
||||
| `nodes_updated` | UPDATE 动作的节点数 |
|
||||
| `sync_success` | 同步成功的节点数 |
|
||||
| `sync_failed` | 同步失败的节点数 |
|
||||
| `errors_count` | 检查发现的 ERROR 数量 |
|
||||
| `warnings_count` | 检查发现的 WARNING 数量 |
|
||||
|
||||
### 日志级别
|
||||
|
||||
```
|
||||
INFO: 流程阶段切换、统计信息
|
||||
DEBUG: 节点级别的详细操作
|
||||
WARN: 检查发现的警告
|
||||
ERROR: 检查发现的错误、异常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 幂等性保证
|
||||
|
||||
**要求**: 同一个 Collection 的多次同步应该是幂等的。
|
||||
|
||||
**设计保证**:
|
||||
1. **Bind 阶段**: 基于绑定记录判定状态,已绑定的节点不会重复绑定
|
||||
2. **Create 阶段**: 已有绑定的节点(NORMAL)不会重复创建
|
||||
3. **Update 阶段**: 基于 `compute_diff()` 判定,无差异则不执行
|
||||
4. **Sync 阶段**: DataSource 根据 `action` 执行,`action=NONE` 的节点跳过
|
||||
|
||||
**前提条件**:
|
||||
- Binding 记录正确持久化
|
||||
- 节点的 `binding_status` 正确保存和加载
|
||||
- `compute_diff()` 逻辑准确
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
详细的检查规范和状态转换规则,请参考:
|
||||
- [CHECK_SPEC.md](./CHECK_SPEC.md) - 检查点详细规范
|
||||
- [STATE_TRANSITIONS.md](./STATE_TRANSITIONS.md) - 状态转换表
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# 节点生命周期状态转移 (Node Lifecycle State Transitions)
|
||||
|
||||
本文档从一个**单节点视角**出发,完整描述一个 `SyncNode` 从诞生到持久化的全部状态变化。
|
||||
|
||||
> **代码映射**:
|
||||
> - 持久化重置:`sync_system_new/common/state_reset.py` → `NodeStateReset`
|
||||
> - 绑定判定:`sync_system_new/sync_system/decision.py` → `SyncDecisionEngine`
|
||||
> - 策略执行:`sync_system_new/sync_system/strategy.py` → `DefaultSyncStrategy`
|
||||
> - ID 解析:`sync_system_new/sync_system/resolve_ids.py` → `IDResolver`
|
||||
> - 物理同步:`sync_system_new/datasource/datasource.py` → `BaseDataSource`
|
||||
> - Handler 降级:`sync_system_new/domain/*/api_handler.py`
|
||||
|
||||
---
|
||||
|
||||
## 状态字段一览
|
||||
|
||||
| 字段 | 类型 | 含义 | 谁负责设置 |
|
||||
|------|------|------|-----------|
|
||||
| `binding_status` | BindingStatus | 绑定健康状态 | Strategy.bind() |
|
||||
| `action` | SyncAction | 待执行动作 | Strategy.create() / update() |
|
||||
| `status` | SyncStatus | 执行结果状态 | DataSource.sync_all() |
|
||||
| `data_id` | str | 业务主键ID | 数据加载 / DataSource 回填 |
|
||||
| `error` | str \| None | 错误信息 | 各阶段 |
|
||||
|
||||
---
|
||||
|
||||
## 完整生命周期
|
||||
|
||||
### 第一次运行(无持久化数据)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 节点尚不存在 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
数据源加载 (Stage 1)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 节点诞生 │
|
||||
│ │
|
||||
│ binding_status = UNCHECKED ← Collection 创建节点时的初始值 │
|
||||
│ action = NONE │
|
||||
│ status = PENDING │
|
||||
│ data_id = "abc-123" ← 从数据源获取的业务主键 │
|
||||
│ data = {...} ← 从数据源获取的业务数据 │
|
||||
│ origin_data = {...} ← 同上,原始快照 │
|
||||
│ depend_ids = [] ← 空,待 bind 阶段填充 │
|
||||
│ error = None │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 第二次及后续运行(有持久化数据)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 从持久化恢复(原始值) │
|
||||
│ │
|
||||
│ binding_status = NORMAL (上次值) │
|
||||
│ action = UPDATE (上次值) │
|
||||
│ status = SUCCESS (上次值) │
|
||||
│ data_id = "abc-123" │
|
||||
│ error = None (上次值) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
阶段1:加载时重置 (NodeStateReset)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 阶段1重置后 │
|
||||
│ │
|
||||
│ binding_status = UNCHECKED ← 重置(每次重新判定) │
|
||||
│ action = UPDATE ← 保留!(用于阶段2识别 CREATE 失败) │
|
||||
│ status = SUCCESS ← 保留!(不自动重置) │
|
||||
│ data_id = "abc-123" ← 不变 │
|
||||
│ data = None ← 重置(等待数据源覆盖) │
|
||||
│ origin_data = None ← 重置(等待数据源覆盖) │
|
||||
│ error = None ← 清空(避免上次错误干扰) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
阶段2:CREATE 僵尸清理
|
||||
(只影响 action=CREATE 且 status=FAILED 或 data_id="" 的节点)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 阶段2清理后(正常节点) │
|
||||
│ │
|
||||
│ action = NONE ← 全部重置为 NONE,准备进入策略阶段 │
|
||||
│ 其他字段不变 │
|
||||
│ │
|
||||
│ (若为 CREATE 僵尸节点:直接从 Collection 删除 + 解除绑定) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
数据源加载覆盖 (Stage 1 后半)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据源加载后 │
|
||||
│ │
|
||||
│ data = {...} ← 数据源最新数据覆盖 │
|
||||
│ origin_data = {...} ← 数据源最新数据覆盖 │
|
||||
│ 其他字段不变 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bind 阶段状态转移 (Stage 2.1)
|
||||
|
||||
Bind 分三个子阶段顺序执行,每个子阶段只修改 `binding_status`。
|
||||
|
||||
### Phase 1: 核心状态判定
|
||||
|
||||
> **代码**:`_phase_core_state()` → `SyncDecisionEngine.determine_core_status()`
|
||||
|
||||
```
|
||||
输入:binding_status = UNCHECKED
|
||||
|
||||
┌─ 有绑定记录?
|
||||
│
|
||||
┌─────┤
|
||||
│ YES │
|
||||
│ └─── 本地数据 + 远程数据 都健康?
|
||||
│ │
|
||||
│ ┌────┴────┐
|
||||
│ │ YES │ NO
|
||||
│ ▼ ▼
|
||||
│ NORMAL WARNING / ABNORMAL
|
||||
│
|
||||
│ NO
|
||||
└──────────────► MISSING
|
||||
```
|
||||
|
||||
| 输入条件 | binding_status 输出 |
|
||||
|---------|-------------------|
|
||||
| 有绑定,双方数据健康 | **NORMAL** |
|
||||
| 有绑定,自身数据损坏 | **ABNORMAL** |
|
||||
| 有绑定,对方数据损坏 | **WARNING** |
|
||||
| 无绑定记录 | **MISSING** |
|
||||
|
||||
### Phase 2: 依赖检查
|
||||
|
||||
> **代码**:`_phase_dependency_check()` → `_check_dependencies_for_nodes()`
|
||||
|
||||
**仅处理 `binding_status = MISSING` 的节点**,其他状态跳过。
|
||||
|
||||
```
|
||||
输入:binding_status = MISSING
|
||||
|
||||
┌─ 有配置 depend_fields?
|
||||
│
|
||||
┌─────┤
|
||||
│ NO └──► 保持 MISSING(无依赖,直接通过)
|
||||
│
|
||||
│ YES ──► 逐一检查依赖节点:
|
||||
│ │
|
||||
│ ┌────┴────────────────────────┐
|
||||
│ │ 全部依赖节点: │
|
||||
│ │ - 存在? ✅ │
|
||||
│ │ - binding_status=NORMAL? ✅ │
|
||||
│ │ - data_id 非空? ✅ │
|
||||
│ └────┬────────────────────────┘
|
||||
│ │
|
||||
│ ┌────┴────┐
|
||||
│ │ 全通过 │ 任一失败
|
||||
│ ▼ ▼
|
||||
│ MISSING DEPENDENCY_ERROR
|
||||
│ + error = "依赖项不满足: ..."
|
||||
│ + depend_ids 已填充
|
||||
```
|
||||
|
||||
**副作用**:无论检查是否通过,都会填充 `node.depend_ids`。
|
||||
|
||||
### Phase 3: 自动绑定
|
||||
|
||||
> **代码**:`_phase_auto_binding()`
|
||||
|
||||
**仅处理 `binding_status = MISSING` 且依赖已通过的节点**。
|
||||
|
||||
```
|
||||
输入:binding_status = MISSING
|
||||
|
||||
auto_bind = False?
|
||||
└──► MISSING → WARNING("跳过自动绑定,无法确认是否需要创建")
|
||||
|
||||
auto_bind = True:
|
||||
│
|
||||
├─ 业务键字段缺失?
|
||||
│ └──► MISSING → WARNING("缺少业务键字段: ...")
|
||||
│
|
||||
├─ 业务键中 ID 字段解析失败?
|
||||
│ └──► MISSING → DEPENDENCY_ERROR("业务键中的ID字段解析失败")
|
||||
│
|
||||
├─ 去噪后 1:1 匹配?
|
||||
│ └──► MISSING → NORMAL(执行自动绑定,建立绑定记录)
|
||||
│
|
||||
├─ N:0(无远程匹配)?
|
||||
│ ├──► create_enabled = True → MISSING → NORMAL,并 spawn 目标 CREATE 节点(S06)
|
||||
│ └──► create_enabled = False → MISSING → WARNING(孤儿且不允许自动创建,需人工处理)
|
||||
│
|
||||
└─ N:M / 多对多 / 多对一?
|
||||
└──► MISSING → WARNING("存在多个候选节点,无法自动绑定")
|
||||
```
|
||||
|
||||
### Bind 阶段结束后可能的状态
|
||||
|
||||
| binding_status | 后续可执行的操作 |
|
||||
|---------------|----------------|
|
||||
| **NORMAL** | UPDATE(如有差异)|
|
||||
| **MISSING** | CREATE |
|
||||
| **ABNORMAL** | 无(需人工) |
|
||||
| **WARNING** | 无(阻止自动操作) |
|
||||
| **DEPENDENCY_ERROR** | 无(等待依赖修复) |
|
||||
|
||||
---
|
||||
|
||||
## Create 阶段状态转移 (Stage 2.2)
|
||||
|
||||
> **代码**:`create()` → `_add_create_action()`
|
||||
|
||||
> **对齐说明(与状态机配置一致)**:
|
||||
> - create_prepare 的入口仍是源节点 `binding_status = MISSING`。
|
||||
> - 但当前配置下,`N:0 + create_enabled=true` 可以在自动绑定阶段直接把源节点转为 `NORMAL`,并 `emit spawn_target_node_state=S06`。
|
||||
> - 因此“触发创建”的入口有两条:
|
||||
> 1) auto_bind 分支(E08f)直接 spawn;
|
||||
> 2) create_prepare 分支(E09b)在 create 检查成功后 spawn。
|
||||
|
||||
**仅处理 `binding_status = MISSING` 的节点**。
|
||||
|
||||
```
|
||||
输入:源节点 binding_status = MISSING, action = NONE
|
||||
|
||||
┌─ 源节点数据为空?
|
||||
│ └──► 跳过,记录日志
|
||||
│
|
||||
├─ IDResolver.resolve_and_validate() 失败?
|
||||
│ └──► binding_status → DEPENDENCY_ERROR
|
||||
│ error = "创建时依赖 ID 未解析: ..."
|
||||
│
|
||||
└─ 成功 ──► 在目标 Collection 创建新节点
|
||||
│
|
||||
├─ 源节点:
|
||||
│ binding_status → NORMAL("从本地创建到远程")
|
||||
│ action 不变(仍为 NONE)
|
||||
│
|
||||
└─ 新建目标节点:
|
||||
binding_status = NORMAL
|
||||
action = CREATE
|
||||
status = PENDING
|
||||
data_id = ""(待远程返回)
|
||||
data = 已替换 ID 的数据副本
|
||||
│
|
||||
└─ 同时建立绑定记录:local_id ↔ remote_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Creates — 物理同步 (Stage 2.3)
|
||||
|
||||
> **代码**:`DataSource.sync_all()` → Handler.create()
|
||||
|
||||
```
|
||||
输入:action = CREATE, status = PENDING
|
||||
|
||||
┌─ Handler 执行 API 调用
|
||||
│
|
||||
├─ 同步 API 成功(200/201)
|
||||
│ └──► status → SUCCESS
|
||||
│ data_id = "remote-xyz"(远程分配的 ID 回填)
|
||||
│ data["id"] = "remote-xyz"(主键字段回写)
|
||||
│
|
||||
├─ 异步 API 提交成功(202)
|
||||
│ └──► status → IN_PROGRESS
|
||||
│ context.task_id = "task-xxx"
|
||||
│ │
|
||||
│ └─ 轮询结果:
|
||||
│ ├─ 成功 → status → SUCCESS, data_id 回填
|
||||
│ ├─ 失败 → status → FAILED, error = "..."
|
||||
│ └─ 超时 → status → FAILED, error = "Timeout"
|
||||
│
|
||||
└─ API 失败(4xx/5xx)
|
||||
└──► status → FAILED
|
||||
error = "远程操作失败: ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update 阶段状态转移 (Stage 2.4)
|
||||
|
||||
> **代码**:`update()` → `_add_update_action()`
|
||||
|
||||
> **对齐说明**:图里的 `E12a` 从 `S01` 出发并不矛盾。`S01` 约束的是“源节点”有 `data_id`;
|
||||
> `E12a` 检查的是“目标节点”是否有 `data_id`(guard: `target_has_data_id=false`),两者不是同一个对象。
|
||||
|
||||
**过滤条件**:`binding_status = NORMAL` 且 `status != FAILED`
|
||||
|
||||
```
|
||||
输入:源节点 binding_status = NORMAL, action = NONE
|
||||
|
||||
┌─ status == FAILED?
|
||||
│ └──► 跳过(保护上次失败的状态和错误信息)
|
||||
│
|
||||
├─ 源节点数据为空?
|
||||
│ └──► 跳过
|
||||
│
|
||||
├─ 通过绑定找不到目标节点?
|
||||
│ └──► 跳过,记录日志
|
||||
│
|
||||
├─ 目标节点 data_id 为空?
|
||||
│ └──► 目标节点 status → PRECONDITION_FAILED
|
||||
│ ("目标节点缺少 data_id")
|
||||
│
|
||||
├─ IDResolver.resolve_and_validate() 失败?
|
||||
│ └──► 目标节点 action → UPDATE
|
||||
│ 目标节点 status → PRECONDITION_FAILED
|
||||
│ 目标节点 error = "依赖 ID 未解析: ..."
|
||||
│
|
||||
├─ _needs_update() 判定无差异?
|
||||
│ └──► 跳过(不设置 action)
|
||||
│
|
||||
└─ 有差异 ──► 更新目标节点:
|
||||
action → UPDATE
|
||||
status → PENDING
|
||||
data = 已替换 ID 的数据副本
|
||||
error = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Updates — 物理同步 (Stage 2.5)
|
||||
|
||||
> **代码**:`DataSource.sync_all()` → Handler.update()
|
||||
|
||||
```
|
||||
输入:action = UPDATE, status = PENDING
|
||||
|
||||
┌─ Handler 构建 API 请求
|
||||
│
|
||||
├─ Handler 发现无实际变更字段
|
||||
│ └──► action → NONE(Handler 降级,不提交 API)
|
||||
│
|
||||
├─ 同步 API 成功(200)
|
||||
│ └──► status → SUCCESS
|
||||
│
|
||||
├─ 异步 API 提交成功(202)
|
||||
│ └──► status → IN_PROGRESS → (轮询)→ SUCCESS / FAILED
|
||||
│
|
||||
└─ API 失败
|
||||
└──► status → FAILED
|
||||
error = "远程操作失败: ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 持久化阶段 (Stage 5)
|
||||
|
||||
```
|
||||
节点当前状态原样写入存储。
|
||||
|
||||
持久化字段:
|
||||
✅ node_id, data_id, data, origin_data
|
||||
✅ binding_status, action, status, error, context
|
||||
❌ depend_ids(不持久化,下次 bind 时从 data 实时计算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 全景时间线表
|
||||
|
||||
下表展示**一个节点在典型场景中的状态变化**:
|
||||
|
||||
### 场景 A:首次创建(本地 → 远程)
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 数据加载 | UNCHECKED | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase1 | **MISSING** | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase2 | MISSING | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase3 | MISSING | NONE | PENDING | "abc-123" | None |
|
||||
| create | **NORMAL** | NONE | PENDING | "abc-123" | None |
|
||||
| _(新建远程节点)_ | NORMAL | **CREATE** | PENDING | **""** | None |
|
||||
| commit create | NORMAL | CREATE | **SUCCESS** | **"remote-456"** | None |
|
||||
| 持久化 | NORMAL | CREATE | SUCCESS | "remote-456" | None |
|
||||
|
||||
### 场景 B:日常更新
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 持久化恢复 | NORMAL→**UNCHECKED** | CREATE→保留 | SUCCESS→保留 | "abc-123" | 清空 |
|
||||
| 阶段2 action重置 | UNCHECKED | **NONE** | SUCCESS | "abc-123" | None |
|
||||
| 数据源覆盖 | UNCHECKED | NONE | SUCCESS | "abc-123" | None |
|
||||
| bind phase1 | **NORMAL** | NONE | SUCCESS | "abc-123" | None |
|
||||
| update 检测差异 | NORMAL | NONE | SUCCESS | "abc-123" | None |
|
||||
| _(目标节点)_ | NORMAL | **UPDATE** | **PENDING** | "remote-456" | None |
|
||||
| commit update | NORMAL | UPDATE | **SUCCESS** | "remote-456" | None |
|
||||
|
||||
### 场景 C:依赖阻断
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 数据加载 | UNCHECKED | NONE | PENDING | "contract-789" | None |
|
||||
| bind phase1 | **MISSING** | NONE | PENDING | "contract-789" | None |
|
||||
| bind phase2 | **DEPENDENCY_ERROR** | NONE | PENDING | "contract-789" | "依赖项不满足: project_id: 状态=dep_err" |
|
||||
| _(后续阶段不处理)_ | | | | | |
|
||||
| 持久化 | DEPENDENCY_ERROR | NONE | PENDING | "contract-789" | "依赖项不满足..." |
|
||||
|
||||
### 场景 D:CREATE 失败 → 下次自动恢复
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| **第 1 次运行** | | | | | |
|
||||
| create | NORMAL | CREATE | PENDING | "" | None |
|
||||
| commit create 失败 | NORMAL | CREATE | **FAILED** | "" | "远程操作失败: 500" |
|
||||
| 持久化 | NORMAL | CREATE | FAILED | "" | "远程操作失败: 500" |
|
||||
| **第 2 次运行** | | | | | |
|
||||
| 阶段1恢复 | **UNCHECKED** | CREATE | FAILED | "" | **None** |
|
||||
| 阶段2僵尸清理 | _(节点被删除,绑定被解除)_ | | | | |
|
||||
| 数据源重新加载 | UNCHECKED | NONE | PENDING | "" → 新 node_id | None |
|
||||
| bind → create | 重新走首次创建流程 | | | | |
|
||||
|
||||
### 场景 E:UPDATE 失败 → 保留供人工处理
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| **第 1 次运行** | | | | | |
|
||||
| update | NORMAL | UPDATE | PENDING | "remote-456" | None |
|
||||
| commit update 失败 | NORMAL | UPDATE | **FAILED** | "remote-456" | "403 Forbidden" |
|
||||
| 持久化 | NORMAL | UPDATE | FAILED | "remote-456" | "403 Forbidden" |
|
||||
| **第 2 次运行** | | | | | |
|
||||
| 阶段1恢复 | UNCHECKED | UPDATE | FAILED | "remote-456" | None |
|
||||
| 阶段2 action重置 | UNCHECKED | **NONE** | **FAILED** | "remote-456" | None |
|
||||
| bind | NORMAL | NONE | FAILED | "remote-456" | None |
|
||||
| update 跳过 | _(status=FAILED,自动跳过)_ | | | | |
|
||||
| 持久化 | NORMAL | NONE | FAILED | "remote-456" | None |
|
||||
| _(需人工重置 status 后才能重新更新)_ | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 状态转换合法性约束
|
||||
|
||||
### binding_status 允许的转换
|
||||
|
||||
```
|
||||
UNCHECKED → NORMAL (核心判定: 有绑定且健康)
|
||||
UNCHECKED → MISSING (核心判定: 无绑定记录)
|
||||
UNCHECKED → ABNORMAL (核心判定: 自身数据损坏)
|
||||
UNCHECKED → WARNING (核心判定: 对方数据损坏)
|
||||
|
||||
MISSING → DEPENDENCY_ERROR (依赖检查: 父节点不满足)
|
||||
MISSING → WARNING (自动绑定: 业务键缺失 / 多候选 / auto_bind=False)
|
||||
MISSING → DEPENDENCY_ERROR (自动绑定: ID 字段解析失败)
|
||||
MISSING → NORMAL (自动绑定: 1:1 成功)
|
||||
MISSING → NORMAL (create: 源节点创建成功后)
|
||||
MISSING → DEPENDENCY_ERROR (create: ID 解析失败)
|
||||
```
|
||||
|
||||
> **注意**:当前 `SyncNode.set_binding_status()` 不做转换合法性校验【未实现】。
|
||||
|
||||
### action 允许的转换
|
||||
|
||||
```
|
||||
NONE → CREATE (create: 新建目标节点)
|
||||
NONE → UPDATE (update: 检测到差异)
|
||||
UPDATE → NONE (Handler 降级: 实际无变更字段)
|
||||
```
|
||||
|
||||
### status 允许的转换
|
||||
|
||||
```
|
||||
PENDING → IN_PROGRESS (DataSource: 异步提交成功)
|
||||
PENDING → SUCCESS (DataSource: 同步执行成功)
|
||||
PENDING → FAILED (DataSource: 执行失败)
|
||||
PENDING → SKIPPED (DataSource: 被跳过)
|
||||
(常见于 CREATE/UPDATE:Handler 返回 SKIPPED(无可执行操作/业务条件不满足),对应状态机里的 E19→S13 或 E20→S14)
|
||||
PENDING → PRECONDITION_FAILED (Strategy: 前置条件不满足)
|
||||
IN_PROGRESS → SUCCESS (DataSource: 异步轮询成功)
|
||||
IN_PROGRESS → FAILED (DataSource: 异步轮询失败/超时)
|
||||
```
|
||||
|
||||
> **注意**:当前 `SyncNode.set_status()` 不做转换合法性校验【未实现】。
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [状态定义规范](./状态定义规范.md) — 状态枚举和判定规则
|
||||
- [持久化与人工介入规范](./持久化与人工介入规范.md) — 持久化重置策略
|
||||
- [编排流程规范](./编排流程规范.md) — Pipeline 阶段定义
|
||||
- [架构分层设计](./架构分层设计.md) — 各层职责
|
||||
- [node_id_data_id_lifecycle](./node_id_data_id_lifecycle.md) — ID 生命周期
|
||||
@@ -0,0 +1,14 @@
|
||||
# Root docs migration note
|
||||
|
||||
本目录为从项目根目录 `docs/` 迁移过来的文档快照(全量复制,不跳过)。
|
||||
|
||||
目的:
|
||||
- 保留原始设计背景与历史规范,便于在 `sync_state_machine` 目录内就地查阅;
|
||||
- 与当前 `sync_state_machine/docs/` 的“现行实现文档”并存。
|
||||
|
||||
使用建议:
|
||||
- 以 `sync_state_machine/docs/` 下现行文档(如 `state_machine.md`、`sync_flow.md`、`architecture.md`)作为运行时真义;
|
||||
- 本目录文档作为历史参考来源,遇到冲突时以现行实现与状态机配置为准:
|
||||
- `sync_state_machine/config/node_state_machine.yaml`
|
||||
- `sync_state_machine/sync_system/strategy.py`
|
||||
- `sync_state_machine/engine/semantics.py`
|
||||
@@ -0,0 +1,706 @@
|
||||
# API DataSource 架构设计
|
||||
|
||||
## 架构总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Pipeline │
|
||||
│ (同步流程编排) │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ApiDataSource │
|
||||
│ (数据源协调层) │
|
||||
│ │
|
||||
│ - 持有 ApiClient 实例 │
|
||||
│ - 注册和管理 Handler │
|
||||
│ - 实现 load_all / execute_batch │
|
||||
│ - 注入 api_client 到 Handler │
|
||||
└───────────────────────────┬─────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ Handler │ │ Handler │ │ Handler │
|
||||
│ Project │ │ Contract │ │ Material │
|
||||
│ │ │ │ │ │
|
||||
│ 业务逻辑层 │ │ 业务逻辑层 │ │ 业务逻辑层 │
|
||||
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└───────────────────┼───────────────────┘
|
||||
│ 使用 self.api_client
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ ApiClient │
|
||||
│ (HTTP 通信层) │
|
||||
│ │
|
||||
│ - 签名生成 │
|
||||
│ - HTTP 请求 │
|
||||
│ - Push Log │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**调用流程**:
|
||||
1. Pipeline 创建 ApiDataSource,传入 base_url, uid, secret
|
||||
2. ApiDataSource 内部创建 ApiClient 实例
|
||||
3. ApiDataSource 注册各业务 Handler,并注入 api_client
|
||||
4. Handler 使用 `self.api_client.get/post/put()` 发送请求
|
||||
5. ApiClient 自动处理签名、push_log 查询、错误处理
|
||||
|
||||
**优势**:
|
||||
- ✅ **职责清晰**:ApiClient(通信)、DataSource(协调)、Handler(业务)
|
||||
- ✅ **避免循环依赖**:Handler → ApiClient(单向依赖)
|
||||
- ✅ **易于测试**:ApiClient 可 mock,Handler 独立测试
|
||||
- ✅ **易于扩展**:新业务只需实现 Handler
|
||||
|
||||
## 1. 问题分析:旧方案的复杂性
|
||||
|
||||
旧方案 `sync_system/datasource/api` 存在的问题:
|
||||
|
||||
1. **过度抽象**:引入了 `ApiDataSource` → `Repository` → `ResponseHandle` → `Cache` 多层封装,每个业务需要独立 Repository 类,Protocol 定义繁琐
|
||||
2. **状态管理混乱**:缓存策略不清晰,ResponseHandle 封装异步结果但使用困难,缺少统一的 push_log 查询机制
|
||||
3. **依赖关系困难**:Repository 之间互相传递引用,难以从 collection 获取已有数据
|
||||
4. **API 路由分散**:每个 Repository 手动实现多个 update 方法,缺少自动路由机制
|
||||
|
||||
## 2. 新方案设计原则
|
||||
|
||||
- **简单直接**:Handler 直接调用 HTTP API,不过度封装
|
||||
- **状态透明**:DataSource 统一管理 session、签名、push_log 查询
|
||||
- **依赖清晰**:通过 collection 获取依赖数据,使用 context 存储额外信息
|
||||
- **智能路由**:根据数据字段自动选择 API endpoint
|
||||
|
||||
## 3. 核心组件职责
|
||||
|
||||
### 3.1 ApiClient - HTTP 通信层
|
||||
|
||||
**职责**:
|
||||
- 封装所有 HTTP 通信逻辑
|
||||
- 管理 httpx.AsyncClient,维护连接池和 session
|
||||
- 生成请求签名(timestamp, nonce, sign)
|
||||
- 统一请求入口,自动注入 uid
|
||||
- 批量 Push Log 查询
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str, uid: str, secret: str):
|
||||
self._client = httpx.AsyncClient()
|
||||
self._base_url = base_url
|
||||
self._uid = uid
|
||||
self._secret = secret
|
||||
|
||||
async def request(self, method: str, url: str, **kwargs) -> httpx.Response
|
||||
"""统一请求入口,自动签名"""
|
||||
|
||||
async def get(self, url: str, params: dict = None) -> httpx.Response
|
||||
"""GET 请求"""
|
||||
|
||||
async def post(self, url: str, data: dict) -> httpx.Response
|
||||
"""POST 请求,data 中已包含 push_id/biz_id(由 Handler 生成)"""
|
||||
|
||||
async def put(self, url: str, data: dict) -> httpx.Response
|
||||
"""PUT 请求,data 中已包含 push_id/biz_id"""
|
||||
|
||||
async def delete(self, url: str, data: dict) -> httpx.Response
|
||||
"""DELETE 请求,data 中已包含 push_id/biz_id"""
|
||||
|
||||
async def get_push_logs(self, push_ids: List[str]) -> Dict[str, dict]
|
||||
"""批量查询 push_log 状态,返回 {push_id: push_log_data}
|
||||
注意:单个查询也传单元素列表
|
||||
当前实现:用 for 循环模拟,后续等后端实现批量接口
|
||||
"""
|
||||
|
||||
def _generate_signature(self, params: dict) -> dict
|
||||
"""生成签名参数(timestamp, nonce, sign)"""
|
||||
```
|
||||
|
||||
### 3.2 ApiDataSource - 数据源协调层
|
||||
|
||||
**职责**:
|
||||
- 持有 ApiClient 实例,提供给 Handler 使用
|
||||
- 实现 DataSource 接口(load_all, sync_all)
|
||||
- 协调多个 Handler 的执行顺序(通过 sync_all)
|
||||
- Handler 内部负责:生成 push_id/biz_id、调用 poll 轮询、提取 created_id
|
||||
- 统一异常处理和日志记录
|
||||
|
||||
**核心属性和方法**:
|
||||
```python
|
||||
class ApiDataSource(DataSource):
|
||||
def __init__(self, base_url: str, uid: str, secret: str):
|
||||
self.client = ApiClient(base_url, uid, secret)
|
||||
self._handlers: Dict[str, BaseApiHandler] = {}
|
||||
|
||||
def register_handler(self, node_type: str, handler: BaseApiHandler):
|
||||
"""注册业务 Handler"""
|
||||
handler.api_client = self.client # 注入 client
|
||||
self._handlers[node_type] = handler
|
||||
|
||||
async def load_all(self, collection: DataCollection, order: List[str]) -> None:
|
||||
"""调用基类的 load_all,按顺序加载数据"""
|
||||
|
||||
async def sync_all(self, collection: DataCollection, order: List[str]) -> None:
|
||||
"""调用基类的 sync_all,按顺序执行同步"""
|
||||
```
|
||||
|
||||
### 3.3 BaseApiHandler - 业务逻辑基类
|
||||
|
||||
**职责**:
|
||||
- 实现 Handler 接口(load, create, update, delete, poll)
|
||||
- 使用 self.api_client 发送 HTTP 请求
|
||||
- 生成 push_id 和 biz_id(业务标识)
|
||||
- 从 collection 获取依赖节点数据
|
||||
- 管理节点的 context(存储 project_id, contract_id 等)
|
||||
- 实现 poll 方法,调用 api_client.get_push_logs 批量查询
|
||||
- 提取 created_id(因为不同业务的 id 字段名和位置可能不同)
|
||||
- 返回 TaskResult(SUCCESS/FAILED/IN_PROGRESS)
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
class BaseApiHandler(NodeHandler):
|
||||
def __init__(self):
|
||||
self.api_client: ApiClient = None # 由 DataSource 注入
|
||||
self.collection: DataCollection = None # 由 DataSource 注入
|
||||
|
||||
async def load(self, collection: DataCollection) -> List[dict]
|
||||
async def create(self, node: SyncNode) -> TaskResult
|
||||
async def update(self, node: SyncNode) -> TaskResult
|
||||
async def delete(self, node: SyncNode) -> TaskResult
|
||||
async def poll(self, node: SyncNode, task_id: str) -> TaskResult
|
||||
|
||||
def extract_created_id(self, push_log: dict) -> str
|
||||
"""从 push_log 提取 created_id,子类可重写"""
|
||||
|
||||
# 重试逻辑(可选,子类可重写)
|
||||
async def _execute_with_retry(self, operation, max_retries: int = 3) -> Any
|
||||
"""通用重试逻辑,子类可自定义重试策略"""
|
||||
```
|
||||
|
||||
**重试策略说明**:
|
||||
- **重试在 Handler 内部**:create/update/delete 内部可调用 _execute_with_retry
|
||||
- **返回给 DataSource**:最终只返回 SUCCESS/FAILED/IN_PROGRESS
|
||||
- **灵活控制**:不同业务可重写 _execute_with_retry 实现自定义策略
|
||||
- **默认策略**:网络错误/5xx 重试 3 次,4xx 不重试
|
||||
|
||||
**未实现 API 的处理**:
|
||||
- 部分业务可能没有实现 create 或 delete API
|
||||
- 此时 Handler 应在对应方法中直接返回 FAILED 状态,并在 error 中说明原因
|
||||
- 示例:`return TaskResult(status=TaskStatus.FAILED, error="Create API not implemented for this business")`
|
||||
|
||||
### 3.4 具体业务 Handler
|
||||
|
||||
**职责**:
|
||||
- 实现具体业务的 load 逻辑(调用 self.api_client.get())
|
||||
- 创建节点时设置 context
|
||||
- 重写 API endpoint 选择逻辑(可选)
|
||||
- 定制 created_id 提取逻辑(可选)
|
||||
|
||||
每个业务 Handler 位于 `sync_system_new/domain/{业务}/api_handler.py`
|
||||
|
||||
## 4. HTTP 请求状态管理
|
||||
|
||||
### 4.0 状态系统概述
|
||||
|
||||
**通用状态枚举**(适用于所有 DataSource):
|
||||
|
||||
```python
|
||||
class TaskStatus(Enum):
|
||||
"""Handler 返回的任务执行状态"""
|
||||
SUCCESS = "success" # 同步完成,立即成功
|
||||
FAILED = "failed" # 同步失败
|
||||
IN_PROGRESS = "in_progress" # 异步任务进行中
|
||||
|
||||
class SyncStatus(Enum):
|
||||
"""SyncNode 的同步执行状态"""
|
||||
PENDING = "PENDING"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
SUCCESS = "SUCCESS"
|
||||
FAILED = "FAILED"
|
||||
SKIPPED = "SKIPPED"
|
||||
PRECONDITION_FAILED = "PRECONDITION_FAILED"
|
||||
```
|
||||
|
||||
**状态映射规则**:
|
||||
- TaskStatus 是 Handler 执行操作后的返回值
|
||||
- SyncStatus 是 SyncNode 的最终状态,由 DataSource 根据 TaskStatus 更新
|
||||
- 普通 DataSource(FileDataSource)和 API DataSource 使用相同的状态枚举
|
||||
- 区别在于:**API DataSource 的网络操作如何转化为 TaskStatus**
|
||||
|
||||
**重要说明:Load 阶段 vs 执行阶段的状态**:
|
||||
- **Load 阶段**(Handler.load()):从 API 拉取数据创建 SyncNode
|
||||
- 此时节点状态为 `PENDING`(等待后续 bind/create/update)
|
||||
- GET 请求失败不影响节点创建,只是该节点不存在于 collection
|
||||
- **执行阶段**(Handler.create/update/delete):执行同步操作
|
||||
- DataSource 在调用前设置 `node.status = IN_PROGRESS`
|
||||
- Handler 返回 TaskResult,DataSource 据此更新 node.status
|
||||
- POST/PUT/DELETE 成功后才设置为 SUCCESS
|
||||
|
||||
**核心映射逻辑**(在 DataSource 中):
|
||||
```python
|
||||
# 执行前:DataSource 设置状态
|
||||
node.status = SyncStatus.IN_PROGRESS
|
||||
|
||||
# 调用 Handler(Handler 内部可能重试多次)
|
||||
result = await handler.create(node)
|
||||
|
||||
# 执行后:DataSource 根据最终结果更新状态
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
node.data_id = result.data_id
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
elif result.status == TaskStatus.IN_PROGRESS:
|
||||
# 记录 task_id 等待轮询
|
||||
async_tasks[node.node_id] = result.task_id
|
||||
```
|
||||
|
||||
**重要**:Handler 负责重试,最终只返回确定的结果(SUCCESS/FAILED/IN_PROGRESS)
|
||||
|
||||
### 4.1 请求类型与状态流转
|
||||
|
||||
**API DataSource 的特殊性**:
|
||||
- **Load 阶段**:GET 请求拉取数据,创建 PENDING 节点(失败则不创建节点)
|
||||
- **执行阶段**:POST/PUT/DELETE 先设置 IN_PROGRESS,通过 poll() 轮询变为 SUCCESS/FAILED
|
||||
|
||||
#### Load 阶段 - 数据拉取
|
||||
|
||||
**目的**:从 API 获取数据,创建 SyncNode 放入 collection
|
||||
|
||||
| 阶段 | HTTP 操作 | 成功行为 | 失败行为 | 节点状态 |
|
||||
|------|----------|---------|---------|---------|
|
||||
| 1. 调用 load() | GET /api/list | 返回数据列表 | 返回空列表或抛异常 | - |
|
||||
| 2. 创建节点 | - | 为每条数据创建 SyncNode | 不创建节点 | PENDING |
|
||||
|
||||
**代码示例**:
|
||||
```python
|
||||
async def load(self, collection: DataCollection) -> List[dict]:
|
||||
"""加载数据 - GET 阶段不涉及状态转换"""
|
||||
try:
|
||||
response = await self.api_client.get("project/list")
|
||||
if response.status_code == 200:
|
||||
data_list = response.json().get("list", [])
|
||||
# 返回数据,由 DataSource 创建 PENDING 节点
|
||||
return data_list
|
||||
else:
|
||||
# 失败:返回空列表,不创建节点
|
||||
logger.error(f"Load failed: {response.status_code}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Load exception: {e}")
|
||||
return [] # 或者抛出异常,由调用方处理
|
||||
```
|
||||
|
||||
#### Mutation 请求(POST/PUT/DELETE) - 执行阶段
|
||||
|
||||
**状态变化时机**:
|
||||
1. **执行前**:DataSource 设置 `node.status = IN_PROGRESS`
|
||||
2. **Handler 执行**:内部可能重试,最终返回 SUCCESS/FAILED/IN_PROGRESS
|
||||
3. **轮询完成**:poll() 返回 SUCCESS/FAILED
|
||||
|
||||
| 阶段 | Push Log | TaskStatus | SyncStatus | 说明 |
|
||||
|------|---------|-----------|-----------|------|
|
||||
| 执行前 | - | - | PENDING → IN_PROGRESS | DataSource 设置 |
|
||||
| 提交 | pending | IN_PROGRESS | IN_PROGRESS | 任务已提交 |
|
||||
| 轮询 | processing | IN_PROGRESS | IN_PROGRESS | 后端处理中 |
|
||||
| 成功 | success | SUCCESS | SUCCESS | 同步完成 |
|
||||
| 失败 | failed | FAILED | FAILED | 同步失败 |
|
||||
|
||||
**示例**:
|
||||
```python
|
||||
async def create(self, node: SyncNode) -> TaskResult:
|
||||
"""Handler 内部可重试,最终返回确定结果"""
|
||||
response = await self.api_client.post(url, node.data)
|
||||
if response.status_code == 202:
|
||||
return TaskResult.in_progress(task_id=response.json()["push_id"])
|
||||
else:
|
||||
return TaskResult.failed(error=f"HTTP {response.status_code}")
|
||||
|
||||
async def poll(self, node: SyncNode, task_id: str) -> TaskResult:
|
||||
push_logs = await self.api_client.get_push_logs([task_id])
|
||||
status = push_logs.get(task_id, {}).get("status")
|
||||
if status == "success":
|
||||
return TaskResult.success(data_id=self.extract_created_id(push_logs[task_id]))
|
||||
elif status == "failed":
|
||||
return TaskResult.failed(error="Backend processing failed")
|
||||
else:
|
||||
return TaskResult.in_progress(task_id=task_id)
|
||||
```
|
||||
|
||||
### 4.2 状态转换完整流程
|
||||
|
||||
```
|
||||
Load 阶段:
|
||||
Handler.load() → 返回数据列表 → DataSource 创建 PENDING 节点
|
||||
|
||||
执行阶段(同步操作):
|
||||
DataSource: node.status = IN_PROGRESS
|
||||
→ Handler.create() → TaskResult.success/failed()
|
||||
→ DataSource: node.status = SUCCESS/FAILED
|
||||
|
||||
执行阶段(异步操作):
|
||||
DataSource: node.status = IN_PROGRESS
|
||||
→ Handler.create() → TaskResult.in_progress(push_id)
|
||||
→ DataSource 轮询: Handler.poll(push_id)
|
||||
→ TaskResult.success/failed()
|
||||
→ DataSource: node.status = SUCCESS/FAILED
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- Handler 负责重试,DataSource 只处理最终结果
|
||||
- 不同业务可在 BaseApiHandler._execute_with_retry 中自定义重试策略
|
||||
- FileDataSource 和 ApiDataSource 使用相同的状态枚举和转换逻辑
|
||||
|
||||
### 4.3 Push Log 状态机
|
||||
|
||||
```
|
||||
pending → processing → success/failed
|
||||
```
|
||||
|
||||
- 轮询间隔:0.5秒
|
||||
- 超时:30秒
|
||||
- 成功:提取 created_id
|
||||
- 失败:获取 error_message
|
||||
|
||||
### 4.4 状态总结表
|
||||
|
||||
**Load 阶段(数据拉取)**:
|
||||
|
||||
| HTTP 操作 | 响应码 | 行为 | 创建节点 | 节点状态 | 说明 |
|
||||
|---------|-------|-----|---------|---------|------|
|
||||
| GET /list | 200 | 返回数据列表 | ✅ 是 | PENDING | 等待后续 bind/create/update |
|
||||
| GET /list | 4xx/5xx | 返回空列表 | ❌ 否 | - | 不创建节点 |
|
||||
|
||||
**执行阶段(POST/PUT/DELETE)**:
|
||||
|
||||
| HTTP 操作 | 响应码 | Push Log | TaskStatus | SyncStatus | 说明 |
|
||||
|---------|-------|---------|-----------|-----------|------|
|
||||
| POST/PUT | 202 | pending | IN_PROGRESS | IN_PROGRESS | 异步开始 |
|
||||
| - | - | processing | IN_PROGRESS | IN_PROGRESS | 异步处理中 |
|
||||
| - | - | success | SUCCESS | SUCCESS | 异步成功 |
|
||||
| - | - | failed | FAILED | FAILED | 异步失败 |
|
||||
| - | - | timeout | FAILED | FAILED | 轮询超时 |
|
||||
|
||||
## 5. Context 机制
|
||||
|
||||
### 5.1 Context 的作用
|
||||
|
||||
Context 是 SyncNode 的字典字段,用于存储业务数据之外的上下文信息,例如:
|
||||
- `project_id`: 当前对象所属的项目ID
|
||||
- `contract_id`: 合同变更所属的合同ID
|
||||
- `parent_id`: 父对象ID
|
||||
- 其他 API 调用需要但 schema 中没有的信息
|
||||
|
||||
### 5.2 Context 使用流程
|
||||
|
||||
**场景:加载合同数据**
|
||||
|
||||
1. **Handler.load()** 从 self._collection 获取所有 project
|
||||
2. 遍历每个 project,调用 `GET contract/list?project_id=xxx`
|
||||
3. 为每个返回的 contract 数据添加临时标记 `_context = {"project_id": xxx}`
|
||||
4. **Handler.create_node()** 创建 SyncNode 时,提取 `_context` 并设置到 `node.context`
|
||||
5. **Handler.update()** 更新时,从 `node.context["project_id"]` 获取 project_id 并添加到请求数据
|
||||
|
||||
### 5.3 Context 传递规则
|
||||
|
||||
- 父 → 子:从依赖的父节点继承 context
|
||||
- 横向传递:同级节点不共享 context
|
||||
- 更新时使用:从 context 提取必要信息添加到请求 payload
|
||||
|
||||
## 6. 智能 API 路由
|
||||
|
||||
### 6.1 路由策略
|
||||
|
||||
根据节点数据的字段,自动选择正确的 API endpoint:
|
||||
|
||||
**方案1:字段匹配**
|
||||
- 检查 node.data 中的字段集合
|
||||
- 匹配预定义的 endpoint → required_fields 映射表
|
||||
- 选择第一个匹配的 endpoint
|
||||
|
||||
**方案2:Schema 类型**
|
||||
- 根据 node.schema 的类型
|
||||
- 查找 schema_type → endpoint 映射表
|
||||
|
||||
**默认规则**:
|
||||
- CREATE: `{node_type}`
|
||||
- UPDATE: 子类重写 `_select_update_endpoint()`
|
||||
- DELETE: `{node_type}`
|
||||
|
||||
### 6.2 Project 示例
|
||||
|
||||
Project 有多个更新接口:
|
||||
- `project/key_constraints` - 需要字段:key_constraints, key_constraints_remark
|
||||
- `project/complete_investment` - 需要字段:complete_investment
|
||||
- `project/dynamic_investment` - 需要字段:dynamic_investment
|
||||
- `project/plan_construction` - 需要字段:plan_construction_capacity, plan_construction_date
|
||||
- ... 等
|
||||
|
||||
Handler 定义映射表,根据实际数据字段自动选择。
|
||||
|
||||
## 7. 依赖数据获取
|
||||
|
||||
### 7.1 获取模式
|
||||
|
||||
Handler 通过 `collection.filter(node_type="xxx")` 获取依赖数据:
|
||||
|
||||
**加载顺序**(按依赖关系):
|
||||
1. Project(无依赖)
|
||||
2. Contract(依赖 Project)
|
||||
3. Contract Change(依赖 Project + Contract)
|
||||
|
||||
### 7.2 数据流
|
||||
|
||||
```
|
||||
Pipeline
|
||||
→ 按拓扑序加载
|
||||
→ ProjectHandler.load()
|
||||
→ GET project/list
|
||||
→ 创建 ProjectSyncNode
|
||||
→ ContractHandler.load()
|
||||
→ collection.filter(node_type="project")
|
||||
→ 遍历 project,GET contract/list?project_id=xxx
|
||||
→ 创建 ContractSyncNode,设置 context["project_id"]
|
||||
→ ContractChangeHandler.load()
|
||||
→ collection.filter(node_type="contract")
|
||||
→ 遍历 contract,从 context 获取 project_id
|
||||
→ GET contract_change/list?project_id=xxx&contract_id=yyy
|
||||
→ 创建节点,设置 context
|
||||
```
|
||||
|
||||
## 8. 实现计划
|
||||
|
||||
### Phase 1: HTTP 通信层
|
||||
- 实现 **ApiClient** 类
|
||||
- HTTP Client 封装(httpx.AsyncClient)
|
||||
- 签名生成(_generate_signature)
|
||||
- 统一请求方法(get/post/put/delete)
|
||||
- **批量 Push Log 查询**(get_push_logs,单个也传列表)
|
||||
- ~~wait_for_push_log~~(不实现,由 DataSource 调用 poll)
|
||||
|
||||
### Phase 2: DataSource 协调层
|
||||
- 实现 **ApiDataSource** 类
|
||||
- 持有 ApiClient 实例
|
||||
- Handler 注册和管理
|
||||
- 实现 DataSource 接口(load_all, execute_batch)
|
||||
- 注入 api_client 到 Handler
|
||||
- 实现轮询逻辑(_poll_async_tasks)
|
||||
|
||||
### Phase 3: Handler 基类
|
||||
- 实现 **BaseApiHandler** 类
|
||||
- load/create/update/delete/poll 方法
|
||||
- 使用 self.api_client 发送请求
|
||||
- **extract_created_id**(子类可重写)
|
||||
- **_execute_with_retry**(可选,子类可重写重试策略)
|
||||
- Context 管理
|
||||
|
||||
### Phase 4: 业务验证
|
||||
- 实现 **ProjectApiHandler**(无依赖,简单场景)
|
||||
- 实现 load:GET project/list
|
||||
- 实现 create:POST project
|
||||
- 实现 poll:查询 push_log
|
||||
- 实现 **ContractApiHandler**(依赖 Project,验证 context)
|
||||
- 实现 load:遍历 project,GET contract/list?project_id=xxx
|
||||
- 设置 context["project_id"]
|
||||
- 编写测试验证设计
|
||||
|
||||
### Phase 5: 完善功能
|
||||
- 批量 Push Log 查询优化(等待后端实现批量接口)
|
||||
- 超时控制(可配置)
|
||||
- 请求日志和调试
|
||||
|
||||
### Phase 6: 推广
|
||||
- 实现剩余业务 Handler
|
||||
- 性能优化(并发控制)
|
||||
- 文档和示例
|
||||
|
||||
## 9. 与现有系统集成
|
||||
|
||||
**调用关系**:
|
||||
```
|
||||
Pipeline
|
||||
↓
|
||||
ApiDataSource (持有 ApiClient)
|
||||
↓
|
||||
BaseApiHandler (使用 api_client)
|
||||
↓
|
||||
ApiClient (发送 HTTP 请求)
|
||||
```
|
||||
|
||||
**职责分离**:
|
||||
- **ApiClient**: 纯粹的 HTTP 通信,无业务逻辑
|
||||
- **ApiDataSource**: 协调 Handler,管理执行流程
|
||||
- **BaseApiHandler**: 业务逻辑,调用 ApiClient 完成网络操作
|
||||
- **具体 Handler**: 实现特定业务的 load/create/update/delete
|
||||
|
||||
**优点**:
|
||||
- ApiClient 和 Handler 解耦,避免循环依赖
|
||||
- Handler 通过 self.api_client 访问 HTTP 功能
|
||||
- DataSource 统一管理 ApiClient 生命周期
|
||||
- Handler 内部灵活控制重试策略
|
||||
|
||||
## 10. 关键优势
|
||||
|
||||
1. **简化架构**:去掉 Repository 层,代码量减少 50%
|
||||
2. **职责清晰**:
|
||||
- ApiClient:HTTP 通信
|
||||
- DataSource:协调和轮询
|
||||
- Handler:业务逻辑和重试
|
||||
3. **重试灵活**:Handler 内部实现,不同业务可自定义策略
|
||||
4. **状态清晰**:Load 阶段创建 PENDING 节点,执行阶段明确状态转换
|
||||
5. **批量优化**:Push Log 批量查询,减少 HTTP 请求
|
||||
6. **易于扩展**:新业务只需实现 Handler,复用 Client 和 DataSource
|
||||
7. **易于测试**:ApiClient 可 mock,Handler 独立测试
|
||||
|
||||
## 11. 设计改进总结
|
||||
|
||||
| 改进点 | 初始设计 | 最终设计 | 理由 |
|
||||
|-------|---------|---------|------|
|
||||
| Push Log 查询 | get_push_log(id) | get_push_logs([ids]) | 批量查询 |
|
||||
| 轮询逻辑 | wait_for_push_log 在 Client | 移除,DataSource 调用 poll | 职责清晰 |
|
||||
| created_id 提取 | 在 Client | 在 Handler 基类 | 业务差异 |
|
||||
| 重试逻辑 | 在 DataSource | **在 Handler** | 业务灵活性 |
|
||||
| Load 状态 | GET 成功 → SUCCESS | GET 成功 → PENDING | Load ≠ 同步完成 |
|
||||
| 执行状态 | 不明确 | 执行前设置 IN_PROGRESS | 状态清晰 |
|
||||
7. **易于测试**:ApiClient 可以 mock,Handler 单独测试业务逻辑
|
||||
|
||||
## 12. 使用示例
|
||||
|
||||
### 12.1 基本使用流程
|
||||
|
||||
```python
|
||||
# 1. 创建 DataSource
|
||||
datasource = ApiDataSource(
|
||||
base_url="https://api.example.com",
|
||||
uid="user123",
|
||||
secret="secret_key"
|
||||
)
|
||||
|
||||
# 2. 初始化(必须调用)
|
||||
await datasource.initialize()
|
||||
|
||||
# 3. 注册 Handler
|
||||
datasource.register_handler(ProjectApiHandler())
|
||||
datasource.register_handler(ContractApiHandler())
|
||||
|
||||
# 4. 创建 Collection
|
||||
collection = DataCollection()
|
||||
|
||||
# 5. 加载数据(自动处理异常,不会抛出)
|
||||
await datasource.load_all(collection, order=["project", "contract"])
|
||||
|
||||
# 6. 执行同步(自动处理异常,不会抛出)
|
||||
await datasource.sync_all(collection, order=["project", "contract"])
|
||||
|
||||
# 7. 关闭连接(必须调用)
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
### 12.2 异常处理说明
|
||||
|
||||
**设计原则**:
|
||||
- ✅ **load_all 和 sync_all 不会抛出异常**
|
||||
- ✅ **Handler 的 create/update/delete 捕获所有 HTTP 异常**
|
||||
- ✅ **所有错误转换为状态(SyncStatus.FAILED)和错误信息(node.error)**
|
||||
|
||||
**为什么不需要 try-finally?**
|
||||
|
||||
```python
|
||||
# ❌ 不需要这样做
|
||||
try:
|
||||
await datasource.load_all(collection, order=["project"])
|
||||
finally:
|
||||
await datasource.close()
|
||||
|
||||
# ✅ 直接调用即可
|
||||
await datasource.initialize()
|
||||
await datasource.load_all(collection, order=["project"]) # 内部已处理异常
|
||||
await datasource.sync_all(collection, order=["project"]) # 内部已处理异常
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
**异常处理机制**:
|
||||
|
||||
1. **load_all 异常处理**:
|
||||
```python
|
||||
# 加载某个类型失败,记录错误但继续处理其他类型
|
||||
for node_type in order:
|
||||
try:
|
||||
raw_items = await handler.load(collection)
|
||||
# ... 创建节点
|
||||
except Exception as e:
|
||||
print(f"Error loading {node_type}: {e}")
|
||||
# 继续处理下一个类型
|
||||
```
|
||||
|
||||
2. **sync_all 异常处理**:
|
||||
```python
|
||||
# Handler 返回的异常已被捕获转为 TaskResult
|
||||
for node in nodes:
|
||||
try:
|
||||
result = await handler.create(node) # Handler 内部捕获异常
|
||||
# 根据 result.status 更新节点状态
|
||||
except Exception as e:
|
||||
# DataSource 的最后保护
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = str(e)
|
||||
```
|
||||
|
||||
3. **Handler 异常处理**:
|
||||
```python
|
||||
async def create(self, node: SyncNode) -> TaskResult:
|
||||
try:
|
||||
# 生成参数
|
||||
push_id = self._generate_push_id()
|
||||
data = {..., "push_id": push_id, "biz_id": self._generate_biz_id(node)}
|
||||
|
||||
# 调用 API
|
||||
response = await self.api_client.post("/api/v2/project", data)
|
||||
|
||||
# 返回成功
|
||||
return TaskResult(status=TaskStatus.SUCCESS, data_id=response['data']['id'])
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# HTTP 错误(4xx, 5xx)
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"HTTP {e.response.status_code}: {e}")
|
||||
except httpx.NetworkError as e:
|
||||
# 网络错误
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"Network error: {e}")
|
||||
except Exception as e:
|
||||
# 其他异常
|
||||
return TaskResult(status=TaskStatus.FAILED, error=f"Unexpected error: {e}")
|
||||
```
|
||||
|
||||
### 12.3 Pipeline 集成示例
|
||||
|
||||
```python
|
||||
async def sync_pipeline(datasource: ApiDataSource):
|
||||
"""同步流程编排"""
|
||||
|
||||
# 初始化
|
||||
await datasource.initialize()
|
||||
|
||||
# 创建 Collection
|
||||
collection = DataCollection()
|
||||
|
||||
# 执行同步流程(所有错误都被内部处理)
|
||||
await datasource.load_all(collection, order=["project", "contract", "material"])
|
||||
await datasource.sync_all(collection, order=["project", "contract", "material"])
|
||||
|
||||
# 检查结果
|
||||
failed_nodes = collection.filter(status=SyncStatus.FAILED)
|
||||
if failed_nodes:
|
||||
print(f"Failed nodes: {len(failed_nodes)}")
|
||||
for node in failed_nodes:
|
||||
print(f" - {node.node_type}:{node.node_id}: {node.error}")
|
||||
|
||||
# 清理
|
||||
await datasource.close()
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 不需要 try-except 包裹 load_all/sync_all
|
||||
- ✅ 通过检查节点状态判断成功/失败
|
||||
- ✅ 错误信息保存在 node.error 中
|
||||
- ✅ Pipeline 专注于流程编排,不处理异常细节
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
# DataSource + Handler 架构设计 V2
|
||||
|
||||
## 核心原则
|
||||
|
||||
### **职责分离**
|
||||
|
||||
```text
|
||||
Strategy (业务逻辑层)
|
||||
↓ 设置 action, 完成 ID 映射
|
||||
DataSource (状态管理 + 编排层)
|
||||
↓ 调用 Handler, 管理状态流转
|
||||
Handler (纯执行层)
|
||||
↓ 调用后端 API, 返回结果
|
||||
Backend API
|
||||
```
|
||||
|
||||
### **关键设计决策**
|
||||
|
||||
1. **ID 映射在 Strategy 完成** ✅
|
||||
- `Strategy.create()` / `Strategy.update()` 时,已将依赖字段的本地 ID 替换为远程 ID
|
||||
- DataSource 拿到的 `node.data` 已经是映射好的数据
|
||||
- Handler 直接使用 `node.data`,不需要关心 ID 映射
|
||||
|
||||
2. **状态管理在 DataSource** ✅
|
||||
- 所有 `node.status` 的转换由 DataSource 负责
|
||||
- Handler 只返回 `TaskResult`(成功/失败/进行中)
|
||||
- DataSource 根据 `TaskResult` 更新 `node.status`
|
||||
|
||||
3. **Handler 只负责执行** ✅
|
||||
- 调用后端 API(load/create/update/delete)
|
||||
- 返回执行结果(TaskResult)
|
||||
- 提供异步任务轮询(poll_tasks)
|
||||
- 提供数据质量检查(validate)
|
||||
|
||||
---
|
||||
|
||||
## Handler 接口设计
|
||||
|
||||
### **核心方法**
|
||||
|
||||
```python
|
||||
class NodeHandler(Protocol[T]):
|
||||
# ===== 数据加载 =====
|
||||
async def load(self, collection: Optional[DataCollection] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
从后端加载全部数据
|
||||
|
||||
Args:
|
||||
collection: Collection 上下文(可选,用于依赖查询)
|
||||
|
||||
Returns:
|
||||
原始数据列表
|
||||
|
||||
示例:
|
||||
# Contract Handler(依赖 Project)
|
||||
async def load(self, collection=None):
|
||||
# 从 collection 获取 project 列表
|
||||
if collection:
|
||||
projects = collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
else:
|
||||
project_ids = []
|
||||
|
||||
# 只加载这些项目的合同
|
||||
response = await self.api.get_contracts(project_ids=project_ids)
|
||||
return response["data"]
|
||||
"""
|
||||
|
||||
# ===== 数据操作 =====
|
||||
async def create(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""
|
||||
创建节点
|
||||
|
||||
注意:node.data 已经过 ID 映射,直接使用即可
|
||||
|
||||
Returns:
|
||||
TaskResult:
|
||||
- SUCCESS: 同步完成,返回 data_id
|
||||
- IN_PROGRESS: 异步任务,返回 task_id
|
||||
- FAILED: 失败,返回 error
|
||||
|
||||
示例:
|
||||
# 同步接口
|
||||
async def create(self, node):
|
||||
try:
|
||||
response = await self.api.create_contract(node.data)
|
||||
return TaskResult.success(data_id=response["id"])
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
# 异步接口
|
||||
async def create(self, node):
|
||||
try:
|
||||
response = await self.api.submit_contract_async(node.data)
|
||||
return TaskResult.in_progress(task_id=response["task_id"])
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
"""
|
||||
|
||||
async def update(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""更新节点(同 create)"""
|
||||
|
||||
async def delete(self, node: SyncNode[T]) -> TaskResult:
|
||||
"""删除节点(同 create)"""
|
||||
|
||||
# ===== 异步任务轮询 =====
|
||||
async def poll_tasks(self, task_ids: List[str]) -> Dict[str, TaskResult]:
|
||||
"""
|
||||
批量轮询异步任务状态
|
||||
|
||||
Args:
|
||||
task_ids: 任务 ID 列表
|
||||
|
||||
Returns:
|
||||
{task_id: TaskResult} 字典
|
||||
|
||||
示例:
|
||||
async def poll_tasks(self, task_ids):
|
||||
response = await self.api.batch_query_tasks(task_ids)
|
||||
results = {}
|
||||
for task in response["tasks"]:
|
||||
if task["status"] == "completed":
|
||||
results[task["id"]] = TaskResult.success(
|
||||
data_id=task["result"]["contract_id"]
|
||||
)
|
||||
elif task["status"] == "failed":
|
||||
results[task["id"]] = TaskResult.failed(
|
||||
error=task["error"]
|
||||
)
|
||||
else: # running
|
||||
results[task["id"]] = TaskResult.in_progress(
|
||||
task_id=task["id"]
|
||||
)
|
||||
return results
|
||||
"""
|
||||
|
||||
# ===== 数据质量检查 =====
|
||||
async def validate(self, data: Dict[str, Any]) -> ValidationResult:
|
||||
"""
|
||||
数据质量检查
|
||||
|
||||
Args:
|
||||
data: 原始数据
|
||||
|
||||
Returns:
|
||||
ValidationResult(是否有效 + 错误列表)
|
||||
|
||||
示例:
|
||||
async def validate(self, data):
|
||||
errors = []
|
||||
if not data.get("code"):
|
||||
errors.append("合同编码为空")
|
||||
if not data.get("project_id"):
|
||||
errors.append("项目 ID 为空")
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors
|
||||
)
|
||||
"""
|
||||
|
||||
# ===== 辅助方法 =====
|
||||
def extract_id(self, data: Dict[str, Any]) -> str:
|
||||
"""从原始数据提取 ID"""
|
||||
|
||||
def create_node(self, data: Dict[str, Any]) -> SyncNode[T]:
|
||||
"""从原始数据创建 SyncNode"""
|
||||
```
|
||||
|
||||
### **TaskResult 设计**
|
||||
|
||||
```python
|
||||
class TaskStatus(Enum):
|
||||
SUCCESS = "success" # 同步完成
|
||||
FAILED = "failed" # 失败
|
||||
IN_PROGRESS = "in_progress" # 异步任务进行中
|
||||
|
||||
|
||||
class TaskResult:
|
||||
def __init__(
|
||||
self,
|
||||
status: TaskStatus,
|
||||
data_id: Optional[str] = None, # 成功时返回的 ID
|
||||
task_id: Optional[str] = None, # 异步任务 ID
|
||||
error: Optional[str] = None, # 失败原因
|
||||
metadata: Optional[Dict] = None # 其他元数据
|
||||
):
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def success(cls, data_id: Optional[str] = None) -> TaskResult:
|
||||
"""创建成功结果"""
|
||||
|
||||
@classmethod
|
||||
def failed(cls, error: str) -> TaskResult:
|
||||
"""创建失败结果"""
|
||||
|
||||
@classmethod
|
||||
def in_progress(cls, task_id: str) -> TaskResult:
|
||||
"""创建进行中结果"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DataSource 接口设计
|
||||
|
||||
### **接口方法**
|
||||
|
||||
```python
|
||||
class BaseDataSource(ABC):
|
||||
# ===== Handler 管理 =====
|
||||
def register_handler(self, handler: NodeHandler) -> None:
|
||||
"""注册 Handler"""
|
||||
|
||||
def get_handler(self, node_type: str) -> NodeHandler:
|
||||
"""获取 Handler"""
|
||||
|
||||
# ===== 数据加载 =====
|
||||
async def load_all(
|
||||
self,
|
||||
order: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
按依赖顺序加载所有数据
|
||||
|
||||
注意:调用前需先 set_collection()
|
||||
|
||||
Args:
|
||||
order: 节点类型顺序(如 ["project", "contract", "construction"])
|
||||
|
||||
工作流程:
|
||||
1. 按顺序遍历每个节点类型
|
||||
2. 调用 Handler.load()
|
||||
3. Handler 可以从 self._collection 查询依赖节点
|
||||
4. 使用 Handler.create_node() 创建 SyncNode
|
||||
5. 添加到 Collection
|
||||
"""
|
||||
|
||||
# ===== 同步执行 =====
|
||||
async def sync_all(
|
||||
self,
|
||||
order: List[str]
|
||||
) -> None:
|
||||
"""
|
||||
按依赖顺序执行同步
|
||||
|
||||
流程:
|
||||
1. 按顺序遍历每个节点类型
|
||||
2. 筛选需要同步的节点(status=PENDING, action!=NONE)
|
||||
3. 调用 Handler 的 create/update/delete
|
||||
4. 根据 TaskResult 更新节点状态
|
||||
5. 处理异步任务轮询
|
||||
"""
|
||||
|
||||
# ===== 统计信息 =====
|
||||
def get_sync_summary(self, collection: DataCollection) -> Dict[str, Any]:
|
||||
"""获取同步统计信息"""
|
||||
```
|
||||
|
||||
### **状态流转逻辑**
|
||||
|
||||
```python
|
||||
async def _sync_nodes(self, handler, nodes):
|
||||
async_tasks = {} # {node_id: task_id}
|
||||
|
||||
for node in nodes:
|
||||
# 1. 跳过不需要处理的节点
|
||||
if node.action == SyncAction.NONE:
|
||||
continue
|
||||
|
||||
# 2. 跳过异常状态节点
|
||||
if node.binding_status in [DEPENDENCY_ERROR, ABNORMAL, WARNING]:
|
||||
node.status = SyncStatus.SKIPPED
|
||||
continue
|
||||
|
||||
# 3. 只处理 PENDING 节点
|
||||
if node.status != SyncStatus.PENDING:
|
||||
continue
|
||||
|
||||
# 4. 执行操作
|
||||
try:
|
||||
node.status = SyncStatus.IN_PROGRESS
|
||||
|
||||
if node.action == SyncAction.CREATE:
|
||||
result = await handler.create(node)
|
||||
elif node.action == SyncAction.UPDATE:
|
||||
result = await handler.update(node)
|
||||
elif node.action == SyncAction.DELETE:
|
||||
result = await handler.delete(node)
|
||||
|
||||
# 5. 处理结果
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
if result.data_id:
|
||||
node.data_id = result.data_id # 回填 ID
|
||||
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
|
||||
elif result.status == TaskStatus.IN_PROGRESS:
|
||||
async_tasks[node.node_id] = result.task_id
|
||||
|
||||
except Exception as e:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = str(e)
|
||||
|
||||
# 6. 轮询异步任务
|
||||
if async_tasks:
|
||||
await self._poll_async_tasks(handler, nodes, async_tasks)
|
||||
```
|
||||
|
||||
### **异步任务轮询**
|
||||
|
||||
```python
|
||||
async def _poll_async_tasks(self, handler, nodes, async_tasks):
|
||||
node_map = {n.node_id: n for n in nodes}
|
||||
|
||||
max_retries = poll_max_retries # 默认轮询 1 次,可配置
|
||||
retry_count = 0
|
||||
|
||||
while async_tasks and retry_count < max_retries:
|
||||
# 批量查询
|
||||
task_ids = list(async_tasks.values())
|
||||
results = await handler.poll_tasks(task_ids)
|
||||
|
||||
# 处理结果
|
||||
for node_id, task_id in list(async_tasks.items()):
|
||||
if task_id not in results:
|
||||
continue
|
||||
|
||||
result = results[task_id]
|
||||
node = node_map[node_id]
|
||||
|
||||
if result.status == TaskStatus.SUCCESS:
|
||||
node.status = SyncStatus.SUCCESS
|
||||
if result.data_id:
|
||||
node.data_id = result.data_id
|
||||
async_tasks.pop(node_id)
|
||||
|
||||
elif result.status == TaskStatus.FAILED:
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = result.error
|
||||
async_tasks.pop(node_id)
|
||||
|
||||
# 还有未完成任务,等待后重试
|
||||
if async_tasks:
|
||||
await asyncio.sleep(2)
|
||||
retry_count += 1
|
||||
|
||||
# 超时未完成的任务标记为失败
|
||||
for node_id in async_tasks:
|
||||
node = node_map[node_id]
|
||||
node.status = SyncStatus.FAILED
|
||||
node.error = "Async task timeout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流程示例
|
||||
|
||||
### **Pipeline 编排**
|
||||
|
||||
```python
|
||||
# Stage 0: 初始化
|
||||
collection = DataCollection()
|
||||
datasource = ApiDataSource(api_client)
|
||||
|
||||
# 注册 Handler
|
||||
datasource.register_handler(ProjectNodeHandler(api_client))
|
||||
datasource.register_handler(ContractNodeHandler(api_client))
|
||||
|
||||
# Stage 1: 加载数据
|
||||
datasource.set_collection(collection)
|
||||
await datasource.load_all(
|
||||
order=["project", "contract"] # 依赖顺序
|
||||
)
|
||||
|
||||
# Stage 2: Strategy 执行
|
||||
for strategy in [project_strategy, contract_strategy]:
|
||||
await strategy.bind() # 设置 binding_status
|
||||
await strategy.create() # 设置 action=CREATE + ID 映射
|
||||
await strategy.update() # 设置 action=UPDATE + ID 映射
|
||||
await strategy.pre_sync_check()
|
||||
|
||||
# Stage 3: 物理同步
|
||||
await datasource.sync_all(
|
||||
order=["project", "contract"]
|
||||
)
|
||||
|
||||
# Stage 4: 检查结果
|
||||
summary = datasource.get_sync_summary(collection)
|
||||
print(f"Success: {summary['success']}, Failed: {summary['failed']}")
|
||||
```
|
||||
|
||||
### **Contract Handler 实现**
|
||||
|
||||
```python
|
||||
class ContractNodeHandler(BaseNodeHandler[ContractResponse]):
|
||||
def __init__(self, api_client):
|
||||
super().__init__(
|
||||
node_type="contract",
|
||||
node_class=ContractSyncNode,
|
||||
schema=ContractResponse
|
||||
)
|
||||
self.api = api_client
|
||||
|
||||
async def load(self):
|
||||
"""加载合同(依赖 Project)"""
|
||||
if self._collection:
|
||||
projects = self._collection.filter(node_type="project")
|
||||
project_ids = [p.data_id for p in projects if p.data_id]
|
||||
else:
|
||||
project_ids = []
|
||||
|
||||
response = await self.api.get_contracts(project_ids=project_ids)
|
||||
return response["data"]
|
||||
|
||||
async def create(self, node):
|
||||
"""创建合同(异步接口)"""
|
||||
try:
|
||||
# node.data 已经过 ID 映射,直接使用
|
||||
response = await self.api.create_contract_async(node.data)
|
||||
|
||||
if response.get("async"):
|
||||
return TaskResult.in_progress(task_id=response["task_id"])
|
||||
else:
|
||||
return TaskResult.success(data_id=response["id"])
|
||||
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
async def update(self, node):
|
||||
"""更新合同(同步接口)"""
|
||||
try:
|
||||
response = await self.api.update_contract(node.data_id, node.data)
|
||||
return TaskResult.success()
|
||||
except Exception as e:
|
||||
return TaskResult.failed(error=str(e))
|
||||
|
||||
async def poll_tasks(self, task_ids):
|
||||
"""轮询异步任务"""
|
||||
response = await self.api.batch_query_tasks(task_ids)
|
||||
results = {}
|
||||
for task in response["tasks"]:
|
||||
if task["status"] == "completed":
|
||||
results[task["id"]] = TaskResult.success(
|
||||
data_id=task["result"]["contract_id"]
|
||||
)
|
||||
elif task["status"] == "failed":
|
||||
results[task["id"]] = TaskResult.failed(error=task["error"])
|
||||
else:
|
||||
results[task["id"]] = TaskResult.in_progress(task_id=task["id"])
|
||||
return results
|
||||
|
||||
async def validate(self, data):
|
||||
"""数据质量检查"""
|
||||
errors = []
|
||||
if not data.get("code"):
|
||||
errors.append("合同编码为空")
|
||||
if not data.get("project_id"):
|
||||
errors.append("项目 ID 为空")
|
||||
|
||||
return ValidationResult(is_valid=len(errors) == 0, errors=errors)
|
||||
|
||||
def extract_id(self, data):
|
||||
return data["id"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心优势
|
||||
|
||||
### **1. 职责清晰**
|
||||
|
||||
- **Strategy**: 业务逻辑 + ID 映射
|
||||
- **DataSource**: 状态管理 + 编排
|
||||
- **Handler**: 纯执行(后端 API 调用)
|
||||
|
||||
### **2. ID 映射前置**
|
||||
|
||||
- ID 映射在 Strategy 完成,DataSource 和 Handler 无需关心
|
||||
- `node.data` 已经是映射好的数据,直接使用
|
||||
|
||||
### **3. 状态统一管理**
|
||||
|
||||
- 所有状态流转由 DataSource 负责
|
||||
- Handler 只返回结果,不改变节点状态
|
||||
|
||||
### **4. 异步任务支持**
|
||||
|
||||
- Handler 返回 `TaskResult.in_progress(task_id)`
|
||||
- DataSource 自动轮询直到完成
|
||||
|
||||
### **5. 数据质量保障**
|
||||
|
||||
|
||||
- Handler 提供 `validate()` 方法
|
||||
- DataSource 在加载时检查数据质量
|
||||
|
||||
### **6. 易于测试**
|
||||
|
||||
- Handler 可以 Mock(返回假 TaskResult)
|
||||
- DataSource 可以单独测试状态流转逻辑
|
||||
- Strategy 可以单独测试 ID 映射逻辑
|
||||
|
||||
---
|
||||
|
||||
## 与现有架构的兼容性
|
||||
|
||||
完全符合 [架构分层设计.md](./架构分层设计.md) 的四层模型:
|
||||
|
||||
1. **Common 层**: SyncNode, DataCollection, BindingManager
|
||||
2. **Sync System 层**: SyncStrategy(负责 bind/create/update + ID 映射)
|
||||
3. **DataSource 层**: BaseDataSource + NodeHandler(状态管理 + 执行)
|
||||
4. **Pipeline 层**: 流程编排 + 依赖顺序
|
||||
|
||||
符合 [状态定义规范.md](./状态定义规范.md) 的状态流转逻辑。
|
||||
@@ -0,0 +1,150 @@
|
||||
# node_id / data_id / 持久化与生命周期说明
|
||||
|
||||
本文档说明同步系统中 `node_id` 与 `data_id` 的职责、生成时机、持久化行为,以及同步流程中“创建/绑定/回填”的完整生命周期。目标是避免“重复生成 node_id 导致绑定失效”的问题,并明确各层职责。
|
||||
|
||||
---
|
||||
|
||||
## 1. 基础概念
|
||||
|
||||
- **data_id**
|
||||
- 业务主键(来自数据源/远端系统的资源 ID)。
|
||||
- 在加载阶段通常已存在;在 CREATE 场景下初始为空,成功后由 DataSource 回填。
|
||||
|
||||
- **node_id**
|
||||
- 同步系统内部的**稳定标识符**(绑定与持久化的主键)。
|
||||
- 用于绑定表、持久化节点、依赖追踪等。
|
||||
- 设计目标:**跨同步周期稳定**,不能随每次加载而变化。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心结论(必须遵守)
|
||||
|
||||
1. **node_id 一旦建立,不应再被数据源“重新生成”。**
|
||||
- 绑定关系与持久化状态依赖 `node_id`;若每次加载都重新生成,会导致历史绑定失效。
|
||||
2. **加载阶段应先恢复 Collection(持久化节点),再加载 DataSource 数据。**
|
||||
- 数据源加载出的 `data_id` 应优先匹配已持久化的节点;匹配到则复用旧 `node_id`。
|
||||
3. **CREATE 场景允许临时/新建 node_id**,但必须通过后续回填与绑定更新保持一致性。
|
||||
|
||||
---
|
||||
|
||||
## 3. 正确的初始化/加载顺序
|
||||
|
||||
### 推荐流程(Pipeline 前半段)
|
||||
|
||||
1. **持久化恢复阶段**
|
||||
- 必须同时恢复 `node_id` 与 `data_id`(以及状态字段),否则无法建立稳定的映射关系。
|
||||
- Collection 可能为空,这属于合法情况。
|
||||
|
||||
2. **数据源加载阶段(DataSource.load_all)**
|
||||
- 对每一条业务数据(含 `data_id`):
|
||||
- **若 Collection 内已存在该 `data_id` 对应节点**:将数据写回原节点(保留旧 `node_id`)。
|
||||
- **若不存在**:由 **Collection** 生成新的 `node_id`,并创建新节点。
|
||||
|
||||
> 这样可避免“第二次同步时重建节点,导致旧绑定丢失”。
|
||||
|
||||
---
|
||||
|
||||
## 4. node_id 的来源与稳定性
|
||||
|
||||
### A. 持久化恢复阶段
|
||||
|
||||
- **恢复时必须包含 `node_id` 与 `data_id`**:
|
||||
- 两者成对恢复,才能保证后续 load 阶段按 `data_id` 命中旧节点。
|
||||
|
||||
### B. 数据源加载阶段
|
||||
|
||||
- **优先复用持久化 node_id**:
|
||||
- 通过 `data_id` 在 Collection 中查找并更新已有节点。
|
||||
- **仅在未知 data_id 时新建 node_id**:
|
||||
- 新节点 `node_id` 必须由 **Collection** 生成,而非 DataSource/Handler 直接拼接。
|
||||
|
||||
### C. 创建阶段(Strategy.create)
|
||||
|
||||
- 新建“目标端”节点时,**必须有 node_id**:
|
||||
- 用于写入 Collection、记录状态、建立临时绑定。
|
||||
- 成功后回填 `data_id`,**node_id 不变**。
|
||||
|
||||
---
|
||||
|
||||
## 5. data_id 的来源与回填
|
||||
|
||||
本节按阶段拆分说明 `data_id` 的来源与写回。
|
||||
|
||||
### A. 持久化恢复阶段
|
||||
|
||||
- `data_id` 从持久化数据中恢复(与 `node_id` 一起恢复)。
|
||||
|
||||
### B. 数据源加载阶段
|
||||
|
||||
- `data_id` 来自数据源原始数据(`id` / `_id`)。
|
||||
- 若命中已有节点,则仅更新数据与状态,不改变 `node_id`。
|
||||
|
||||
### C. 同步创建与轮询阶段
|
||||
|
||||
- CREATE 提交后,DataSource 从响应/轮询结果提取 `biz_id`。
|
||||
- 成功时回填到 `node.data_id`,并回写 `node.data` 的主键字段(仅 `id` / `_id`)。
|
||||
|
||||
### D. 持久化阶段
|
||||
|
||||
- 将更新后的 `node_id` / `data_id` / 状态字段持久化,供下次同步恢复使用。
|
||||
|
||||
---
|
||||
|
||||
## 6. 绑定与 node_id 的关系
|
||||
|
||||
- 绑定表的 key 为 `node_id`,绑定记录是 `local_node_id <-> remote_node_id`。
|
||||
- 因此:**node_id 必须稳定**,才能保证绑定长期有效。
|
||||
- 绑定可在创建前建立“临时关系”,但成功后不应更换 node_id。
|
||||
|
||||
---
|
||||
|
||||
## 7. 同步周期中的典型场景
|
||||
|
||||
### 场景 1:第一次同步(无持久化)
|
||||
- Collection 为空 → DataSource load 新节点 → 生成新 `node_id`。
|
||||
- Strategy bind / create / update 执行。
|
||||
- 持久化后,下次同步可复用 `node_id`。
|
||||
|
||||
### 场景 2:第二次同步(已有持久化)
|
||||
- 先恢复 Collection(已有 `node_id`)。
|
||||
- DataSource load 时按 `data_id` 匹配旧节点并更新数据。
|
||||
- 绑定关系仍有效。
|
||||
|
||||
### 场景 3:创建新数据(CREATE)
|
||||
- Strategy 产生目标端节点(`node_id` 新生成)。
|
||||
- DataSource 执行创建,回填 `data_id`。
|
||||
- 绑定记录更新为真实 `data_id`(但 node_id 保持不变)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键规则总结
|
||||
|
||||
- **node_id 是“同步系统内的稳定 ID”,必须持久化与复用。**
|
||||
- **data_id 是“业务系统的资源 ID”,由数据源加载或创建后回填。**
|
||||
- **加载顺序:先恢复持久化节点 → 再 load 数据源。**
|
||||
- **遇到已存在 data_id:复用 node_id;否则创建新节点。**
|
||||
- **CREATE 成功后只更新 data_id,不更换 node_id。**
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文件参考
|
||||
|
||||
- 绑定与状态定义:见 [docs/状态定义规范.md](状态定义规范.md)
|
||||
- 编排流程:见 [docs/编排流程规范.md](编排流程规范.md)
|
||||
- BaseDataSource:见 [sync_system_new/datasource/datasource.py](../sync_system_new/datasource/datasource.py)
|
||||
- BaseApiHandler:见 [sync_system_new/datasource/api/handler.py](../sync_system_new/datasource/api/handler.py)
|
||||
|
||||
---
|
||||
|
||||
## 10. 明确规则与建议实现
|
||||
|
||||
- **DataSource.load_all 必须先按 `data_id` 命中已有节点并更新**,未命中时再由 Collection 创建新节点并分配新的 `node_id`。
|
||||
- **推荐实现方式**:先 `find_by_data_id`,命中走通用更新;未命中再创建新节点(无需强制 `upsert_by_data_id` 接口)。
|
||||
- **创建流程中的临时绑定/成功更新规则**:
|
||||
- CREATE 节点创建时:`binding_status = NORMAL`,`action = CREATE`,`status = PENDING`,`data_id = ""`。
|
||||
- 执行提交前置为 `status = IN_PROGRESS`(异步)或保持 `PENDING`(同步接口待执行)。
|
||||
- 成功回填后:`status = SUCCESS`,`data_id` 回填并写回主键字段,绑定记录保持 `node_id` 不变。
|
||||
- 过程中出现错误:`status = FAILED`,`error` 记录原因。
|
||||
- **异常节点持久化/恢复策略**:
|
||||
- 持久化时保留异常节点与错误信息。
|
||||
- 恢复时是否保留/清理异常节点应通过策略配置控制(默认建议保留以便人工修复)。
|
||||
@@ -0,0 +1,232 @@
|
||||
# Skip Sync 行为修复说明
|
||||
|
||||
## 问题分析
|
||||
|
||||
### 1. **绑定失败的根本原因**
|
||||
|
||||
**问题现象**:
|
||||
- Company: 1/39 绑定
|
||||
- Supplier: 0/65202 绑定(完全没绑定!)
|
||||
- 导致依赖supplier的业务(contract, material等)也绑不上
|
||||
- 远程异常创建了74条数据
|
||||
|
||||
**根本原因有两个**:
|
||||
|
||||
#### 原因1:skip_sync 跳过了整个策略(包括 bind)
|
||||
|
||||
```python
|
||||
# 旧逻辑:skip_sync=True 时跳过整个策略
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
continue # ← 跳过了 bind/create/update 全部!
|
||||
```
|
||||
|
||||
修复后:
|
||||
```python
|
||||
# 新逻辑:bind() 始终执行,skip_sync 只跳过 create/update/sync
|
||||
await strategy.bind() # ← 始终执行
|
||||
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
continue # ← 只跳过 create/update
|
||||
```
|
||||
|
||||
#### 原因2:自动绑定ID解析失败没有设置DEPENDENCY_ERROR
|
||||
|
||||
```python
|
||||
# 旧逻辑:ID解析失败只是跳过
|
||||
if not resolved_key_dict:
|
||||
continue # ← 节点保持 MISSING,会被误创建!
|
||||
```
|
||||
|
||||
修复后:
|
||||
```python
|
||||
# 新逻辑:ID解析失败设置DEPENDENCY_ERROR
|
||||
if not resolved_key_dict:
|
||||
node.set_binding_status(BindingStatus.DEPENDENCY_ERROR, "业务键中的ID字段解析失败")
|
||||
continue # ← 节点为 DEPENDENCY_ERROR,不会被创建
|
||||
```
|
||||
|
||||
### 2. **状态与创建的关系**
|
||||
|
||||
| 状态 | 来源 | 允许创建 | 说明 |
|
||||
|------|------|:---:|------|
|
||||
| **UNCHECKED** | 加载后初始状态 | ❌ | bind() 未执行 |
|
||||
| **NORMAL** | 有绑定记录 | ❌ | 已绑定 |
|
||||
| **MISSING** | 无绑定记录 | ✅ | 真正的孤儿 |
|
||||
| **WARNING** | 业务键字段缺失 | ❌ | 数据不完整 |
|
||||
| **DEPENDENCY_ERROR** | 依赖不满足 | ❌ | 父节点未就绪 |
|
||||
| **ABNORMAL** | 数据损坏 | ❌ | 需人工处理 |
|
||||
|
||||
**关键原则**:
|
||||
- `create()` 只查找 `binding_status == MISSING` 的节点
|
||||
- 如果 bind() 没执行,状态是 UNCHECKED → 不会创建
|
||||
- 如果 bind() 执行了但依赖不满足 → DEPENDENCY_ERROR → 不会创建
|
||||
|
||||
### 3. **Supplier 默认行为变更**
|
||||
|
||||
**旧行为**:
|
||||
- `default_skip_sync = True` → 默认跳过同步
|
||||
- 需要在调用层设置 `force_sync_types=["supplier"]`
|
||||
|
||||
**新行为**:
|
||||
- `default_skip_sync` 已移除(使用基类默认值 False)
|
||||
- Supplier 默认会执行完整同步(bind/create/update)
|
||||
- 如需跳过,在调用层配置 `skip_sync_types=["supplier"]`
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 修改 1:分离 bind() 和 skip_sync
|
||||
|
||||
**新逻辑**:
|
||||
```python
|
||||
# sync_system_new/pipeline/full_sync_pipeline.py
|
||||
|
||||
# Step 1a: 绑定(始终执行)
|
||||
await strategy.bind()
|
||||
|
||||
# Step 1b: 检查是否跳过 create/update/sync
|
||||
if getattr(strategy, 'skip_sync', False):
|
||||
print(f"⏭️ Skipping create/update/sync for {node_type}")
|
||||
# 继续执行 pre_sync_check,但跳过 create/update/sync
|
||||
if hasattr(strategy, "pre_sync_check"):
|
||||
ok = await strategy.pre_sync_check()
|
||||
...
|
||||
continue
|
||||
|
||||
# Step 1c: 正常的 create/update
|
||||
await strategy.create()
|
||||
await strategy.update()
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ **bind() 始终执行**(建立绑定关系)
|
||||
- ✅ **skip_sync=True 只跳过 create/update/sync**(避免昂贵操作)
|
||||
- ✅ **pre_sync_check 仍然执行**(依赖检查)
|
||||
|
||||
### 修改 2:添加 force_sync_types 配置
|
||||
|
||||
**配置示例**:
|
||||
```python
|
||||
# tests/run_full_sync_pipeline_simple.py
|
||||
CONFIG = {
|
||||
# 第一次运行:从 API 加载 + 绑定 + 创建
|
||||
"force_sync_types": ["supplier"],
|
||||
"wipe_persistence_on_start": True,
|
||||
|
||||
# 第二次运行:从持久化加载 + 跳过同步
|
||||
# "handler_configs": {"supplier": {"load_mode": "persisted_only"}},
|
||||
# "skip_sync_types": ["supplier"],
|
||||
# "wipe_persistence_on_start": False,
|
||||
}
|
||||
```
|
||||
|
||||
**优先级**:
|
||||
1. **force_sync_types**(最高)→ `skip_sync = False`
|
||||
2. **skip_sync_types** → `skip_sync = True`
|
||||
3. **策略默认值** (default_skip_sync)
|
||||
|
||||
### 修改 3:添加测试日志保存
|
||||
|
||||
**功能**:
|
||||
```python
|
||||
# tests/run_full_sync_pipeline_simple.py
|
||||
log_path = log_dir / f"simple_pipeline_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
sys.stdout = Tee(original_stdout, log_file)
|
||||
```
|
||||
|
||||
**输出位置**:
|
||||
```
|
||||
test_results/simple_pipeline_20260204_162345.log
|
||||
```
|
||||
|
||||
## 使用指南
|
||||
|
||||
### Phase 1: 首次运行(API + 绑定 + 创建)
|
||||
|
||||
```python
|
||||
CONFIG = {
|
||||
# 不需要特殊配置,所有类型默认都会同步
|
||||
"wipe_persistence_on_start": True, # 清空数据库
|
||||
}
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- Supplier: 从API加载60000条 → 自动绑定(credit_code) → 拉取到本地 → 持久化
|
||||
- Company: 自动绑定 → 拉取
|
||||
- Contract等: 通过supplier_id/project_id绑定 → 创建
|
||||
|
||||
### Phase 2: 后续运行(持久化 + 跳过同步)
|
||||
|
||||
```python
|
||||
CONFIG = {
|
||||
"handler_configs": {
|
||||
"supplier": {"load_mode": "persisted_only"} # 从DB加载
|
||||
},
|
||||
"skip_sync_types": ["supplier"], # 跳过create/update(但仍执行bind)
|
||||
"wipe_persistence_on_start": False, # 保留数据
|
||||
}
|
||||
```
|
||||
|
||||
**预期结果**:
|
||||
- Supplier: 从DB加载(带绑定关系) → 执行bind(验证状态) → 跳过create/update
|
||||
- 其他业务: 正常同步
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 1. 删除远程异常创建的数据
|
||||
|
||||
```bash
|
||||
# TODO: 通过API删除远程的74条测试数据
|
||||
```
|
||||
|
||||
### 2. 清空数据库重新测试
|
||||
|
||||
```bash
|
||||
rm _runtime/test_simple.db
|
||||
python scripts/legacy/run_full_sync_pipeline_simple.py
|
||||
```
|
||||
|
||||
### 3. 验证绑定统计
|
||||
|
||||
**期望输出**:
|
||||
```
|
||||
Local Summary:
|
||||
company: 39/39 bindings ← 全部绑定
|
||||
supplier: 60000+/65202 bindings ← 大量绑定
|
||||
contract: XX/34 bindings ← 通过supplier_id绑定
|
||||
|
||||
Remote Summary:
|
||||
company: 39/39 bindings
|
||||
supplier: 60000+/60000 bindings
|
||||
contract: XX/155 bindings (XX creates)
|
||||
```
|
||||
|
||||
### 4. 检查日志文件
|
||||
|
||||
```bash
|
||||
cat test_results/simple_pipeline_*.log | grep "Skipping"
|
||||
```
|
||||
|
||||
**期望**:
|
||||
- 第一次运行:**不应该有** "Skipping" 输出
|
||||
- 第二次运行:`⏭️ Skipping create/update/sync for supplier`
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 影响范围
|
||||
- ✅ 修复了 supplier/company 绑定失败问题
|
||||
- ✅ 防止未绑定节点异常创建
|
||||
- ✅ 保持了两阶段工作流(首次API+绑定,后续持久化)
|
||||
- ⚠️ 可能影响其他使用 skip_sync 的场景(需要测试)
|
||||
|
||||
### 向后兼容性
|
||||
- ✅ 原始脚本 (run_full_sync_pipeline_api.py) 不受影响
|
||||
- ✅ 没有 skip_sync 属性的策略行为不变
|
||||
- ✅ 新增的 force_sync_types 是可选参数
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [ ] 测试所有业务类型的绑定统计
|
||||
- [ ] 验证依赖关系链(project → contract → material → detail)
|
||||
- [ ] 检查 construction_task 为什么没绑定(0/32)
|
||||
- [ ] 测试第二次运行的持久化加载逻辑
|
||||
- [ ] 更新文档说明 skip_sync 的新语义
|
||||
@@ -0,0 +1,72 @@
|
||||
# 业务流程与依赖关系
|
||||
|
||||
本文档描述推送系统涉及的业务实体及其依赖关系,用于指导同步顺序编排。
|
||||
|
||||
---
|
||||
|
||||
## 业务实体分类
|
||||
|
||||
### 1. 基础数据(仅拉取)
|
||||
|
||||
- **用户(User)**:拉取集团管理员、区域公司管理员及特定用户信息,用于跨平台穿透。其他用户需手动创建。
|
||||
- **公司(Company)**:仅拉取,不推送。
|
||||
- **供应商(Supplier)**:仅拉取,不推送。
|
||||
|
||||
### 2. 项目及扩展数据
|
||||
|
||||
**项目(Project)**
|
||||
- **绑定方式**:必须手动绑定(根节点)
|
||||
- **同步操作**:允许更新和拉取
|
||||
- **依赖项**:公司、附件(Attachment)
|
||||
|
||||
**项目扩展业务**
|
||||
- 通过 `(project_id, key)` 或 `(project_id, code)` 自动绑定
|
||||
- 允许创建、更新、拉取
|
||||
- 依赖项目
|
||||
|
||||
### 3. 合同及子业务
|
||||
|
||||
**合同(Contract)**
|
||||
- **绑定方式**:通过业务键自动绑定(需与 ECP 平台数据交叉检验)
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **依赖项**:项目
|
||||
|
||||
**合同子业务**(施工任务、物资、结算、力能)
|
||||
- **绑定方式**:通过 `(contract_id, code)` 自动绑定
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **特殊规则**:首次绑定后,项目侧不会同步创建远程已有的数据
|
||||
- **依赖项**:合同
|
||||
|
||||
**合同子业务明细**(施工明细、物资明细、结算明细、力能明细)
|
||||
- **绑定方式**:通过 `(parent_id, date)` 自动绑定
|
||||
- **同步操作**:允许拉取、创建、更新、删除
|
||||
- **特殊规则**:首次绑定后,项目侧不会同步创建远程已有的数据
|
||||
- **依赖项**:对应的合同子业务
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系图
|
||||
|
||||
```
|
||||
Company (基础)
|
||||
↓
|
||||
Project (手动绑定)
|
||||
↓
|
||||
Contract (自动绑定)
|
||||
↓
|
||||
Contract Sub-Business (施工/物资/结算/力能)
|
||||
↓
|
||||
Details (明细)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 同步顺序原则
|
||||
|
||||
1. **依赖优先**:父节点必须先于子节点同步
|
||||
2. **绑定传递**:子节点的业务键构建依赖父节点的 ID 映射
|
||||
3. **故障隔离**:父节点同步失败时,子节点标记为 `DEPENDENCY_ERROR` 并跳过
|
||||
|
||||
详细状态定义参见:[sync_system_new/sync_system/STATE_DEFINITIONS.md](../sync_system_new/sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
详细架构设计参见:[sync_system_new/architecture_layers.md](../sync_system_new/architecture_layers.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,152 @@
|
||||
# 节点状态持久化与人工介入规范
|
||||
|
||||
> **代码实现**:`sync_system_new/common/state_reset.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 持久化规则
|
||||
|
||||
### 1.1 持久化目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| **可恢复性** | 系统重启后能从上次状态继续 |
|
||||
| **可观测性** | 失败和异常状态要保留,供人工查看 |
|
||||
| **可追溯性** | 失败信息持久化,不自动重试,需人工确认 |
|
||||
| **绑定稳定性** | 手工绑定一旦建立,永不自动改变 |
|
||||
|
||||
### 1.2 分阶段重置设计
|
||||
|
||||
状态重置分为两个阶段执行:
|
||||
|
||||
#### 阶段1:加载时重置(Collection.load_from_persistence)
|
||||
|
||||
| 字段 | 性质 | 持久化 | 阶段1重置 |
|
||||
|------|------|--------|-----------|
|
||||
| `node_id` | 恒定标识 | ✅ 必须 | ❌ 不重置 |
|
||||
| `data_id` | 业务主键 | ✅ 必须 | ❌ 不重置 |
|
||||
| `data` | 业务数据 | ✅ 必须 | ✅ **重置**(每次重新取数) |
|
||||
| `origin_data` | 原始快照 | ✅ 必须 | ✅ **重置**(每次重新取数) |
|
||||
| `depend_ids` | 依赖列表 | ❌ **不持久化** | ✅ **初始化为[]**(bind 阶段从 data 实时计算) |
|
||||
| `binding_status` | 绑定状态 | ✅ 必须 | ✅ **重置为 UNCHECKED** |
|
||||
| `action` | 同步意图 | ✅ 保存 | ❌ **保留**(用于阶段2判断) |
|
||||
| `status` | 执行状态 | ✅ 保存 | ❌ **保留**(全部保留,不重置) |
|
||||
| `error` | 错误信息 | ✅ 保存 | ✅ **重置为None**(清空上次错误) |
|
||||
| `context` | 扩展上下文 | ✅ 保存 | ❌ 不重置 |
|
||||
|
||||
#### 阶段2:数据加载后清理(Pipeline._cleanup_create_failed_zombies)
|
||||
|
||||
在数据加载完成后、策略执行前,执行CREATE失败节点清理:
|
||||
|
||||
1. **检查条件**:
|
||||
- 本地节点 `action == CREATE`(上次尝试创建)
|
||||
- 本地节点 `status == FAILED`(创建失败)
|
||||
|
||||
2. **清理动作**:
|
||||
- **删除本地失败节点**(从 collection 中移除)
|
||||
- 如果有绑定记录:
|
||||
- 解除绑定关系
|
||||
- 删除远程 collection 中的僵尸节点
|
||||
|
||||
3. **最后重置 action**:
|
||||
- 所有剩余节点的 `action` 重置为 NONE
|
||||
- 准备进入策略阶段
|
||||
|
||||
4. **下次同步的自动恢复**:
|
||||
- 被删除的节点会从数据源重新加载
|
||||
- 如果远程实际已创建成功(网络波动导致未获取到结果)→ 绑定阶段会自动绑定上
|
||||
- 如果远程实际也失败了 → 重新尝试创建
|
||||
|
||||
5. **UPDATE 阶段保护**:
|
||||
- UPDATE 操作会跳过所有 `status == FAILED` 的节点
|
||||
- 防止 UPDATE 阶段覆盖 CREATE/UPDATE 失败的状态和错误信息
|
||||
- 确保失败节点的 action 和 error 信息保持不变,直到下次同步清理
|
||||
|
||||
> **说明**:
|
||||
> - `data` / `origin_data`:每次运行从数据源重新获取,保证数据最新
|
||||
> - `depend_ids`:**不持久化**,每次 bind 阶段从 data 的业务字段(如 contract_id, project_id)实时提取
|
||||
> - `binding_status`:持久化用于审计,加载时重置为 `UNCHECKED`,由 bind() 重新判定
|
||||
> - `error`:**阶段1清空**,避免上次错误影响本次运行,新错误会在各阶段重新生成
|
||||
> - `action`:**阶段1保留**用于识别 CREATE 失败的节点,**阶段2清理后重置**
|
||||
> - `status`:**阶段1全部保留**(包括 FAILED、PRECONDITION_FAILED 等),不自动重置
|
||||
> - **CREATE 失败节点会被自动删除并重新加载**,UPDATE/DELETE 失败保留需人工处理
|
||||
> - **UPDATE 操作会跳过 FAILED 节点**,避免覆盖失败状态和错误信息
|
||||
|
||||
### 1.3 绑定记录 vs binding_status
|
||||
|
||||
| 概念 | 存储位置 | 持久性 | 如何改变 |
|
||||
|------|---------|--------|---------|
|
||||
| **绑定记录** | `bindings` 表 | **永久保留** | 只能手工 bind/unbind,或 CREATE 失败自动清理 |
|
||||
| **binding_status** | `nodes` 表 | 保存但可重判 | 每次 bind() 重新判定 |
|
||||
|
||||
### 1.4 CREATE 失败的自动恢复
|
||||
|
||||
当 CREATE 操作失败时,会产生"僵尸绑定":
|
||||
- 绑定记录已建立(local_id → remote_node_id)
|
||||
- 但远程节点的 `data_id` 为空(API 未返回真实 ID)
|
||||
|
||||
**下次同步时的自动恢复流程**:
|
||||
1. 阶段1加载:保留 `action=CREATE`
|
||||
2. 阶段2清理:检测到僵尸绑定,自动清理
|
||||
3. 本地节点恢复为 MISSING 状态
|
||||
4. 策略阶段重新判定,再次尝试 CREATE
|
||||
|
||||
---
|
||||
|
||||
## 2. 人工介入规范
|
||||
|
||||
### 2.1 介入时机
|
||||
|
||||
| 时机 | 场景 | 操作 |
|
||||
|------|------|------|
|
||||
| **运行前** | 查看上次失败记录 | 查看持久化的 error,决定是否修复后重试 |
|
||||
| **bind() 后** | 出现 ABNORMAL/WARNING | 检查数据一致性,手工修复或确认 |
|
||||
| **bind() 后** | 1:N / N:N 映射 | 手工绑定指定远程记录 |
|
||||
| **sync() 后** | 批量失败 | 分析失败原因,决定重试策略 |
|
||||
|
||||
### 2.2 binding_status 与介入需求
|
||||
|
||||
| binding_status | 含义 | 需要介入 | 说明 |
|
||||
|----------------|------|---------|------|
|
||||
| `UNCHECKED` | 未检查 | ❌ | 等待 bind() 判定 |
|
||||
| `NORMAL` | 绑定正常 | ❌ | 自动处理 |
|
||||
| `MISSING` | 无绑定记录 | ❌ | 自动创建 |
|
||||
| `WARNING` | 本地有绑定但远程无数据 | ⚠️ 可选 | 可能是远程删除,需确认 |
|
||||
| `ABNORMAL` | 状态不一致 | ✅ **必须** | 需人工排查 |
|
||||
| `DEPENDENCY_ERROR` | 依赖未就绪 | ❌ | 等依赖解决后自动重试 |
|
||||
|
||||
### 2.3 人工绑定操作
|
||||
|
||||
```python
|
||||
# 手工绑定(1:N 场景)
|
||||
binding_manager.bind(node_id="local_xxx", data_id="remote_xxx")
|
||||
|
||||
# 手工解绑
|
||||
binding_manager.unbind(node_id="local_xxx")
|
||||
```
|
||||
|
||||
绑定记录一旦建立,永不自动改变,只能手工操作。
|
||||
|
||||
### 2.4 职责分工
|
||||
|
||||
| 职责 | 系统自动 | 人工介入 |
|
||||
|------|---------|---------|
|
||||
| 1:1 自动绑定 | ✅ | - |
|
||||
| 1:N / N:N 绑定 | ❌ | ✅ |
|
||||
| NORMAL 节点同步 | ✅ | - |
|
||||
| MISSING 节点创建 | ✅ | - |
|
||||
| CREATE 失败重试 | ✅(自动清理僵尸) | ⚠️(需确认后手动重置) |
|
||||
| UPDATE 失败重试 | ❌(保留FAILED状态) | ✅(需确认后手动重置) |
|
||||
| ABNORMAL 处理 | ❌ | ✅ |
|
||||
| WARNING 确认 | ❌ | ✅(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 总结
|
||||
|
||||
1. **分阶段重置**:
|
||||
- 阶段1(加载时):只重置 binding_status,**保留 action/status/error**
|
||||
- 阶段2(数据加载后):清理 CREATE 失败僵尸,然后重置 action
|
||||
2. **失败状态持久化**:FAILED 状态和 error 信息保留,不自动重试
|
||||
3. **绑定记录**:永久保留,除非 CREATE 失败自动清理或手工操作
|
||||
4. **人工介入**:失败需确认后手动重置,ABNORMAL/WARNING 需人工处理,1:N 绑定需手工指定
|
||||
@@ -0,0 +1,112 @@
|
||||
# 架构分层设计:四层模型
|
||||
|
||||
本架构旨在将同步系统的**数据容器**、**同步逻辑**、**执行层**与**编排流程**完全解耦,提供极简且类型安全的开发体验。
|
||||
|
||||
---
|
||||
|
||||
## 1. 架构概览
|
||||
|
||||
系统分为四层,依赖关系严格从上到下:
|
||||
|
||||
1. **通用层 (Common)**: 定义纯数据结构(Data Container)和基础工具。
|
||||
2. **同步系统层 (Sync System)**: 定义同步行为接口(SyncStrategy)与业务规则。
|
||||
3. **数据源层 (DataSource)**: 实现数据的加载与持久化,属于盲目执行层。
|
||||
4. **编排层 (Pipeline)**: 应用层逻辑,硬编码同步顺序,协调 ID 映射与错误传播。
|
||||
|
||||
---
|
||||
|
||||
## 2. 通用层 (Layer 0: Common)
|
||||
|
||||
本层不包含业务逻辑,仅定义数据契约。
|
||||
|
||||
- **SyncNode[T]**: 核心状态机容器。
|
||||
- `origin_data`: 原始数据(对应 `data_id`)。
|
||||
- `data`: 计算后的最新目标数据。
|
||||
- `data_id`: 业务主键ID。加载时必定有值,CREATE 节点初始为空字符串 `""`(布尔值为 False,可通过校验)。
|
||||
- `action`: 同步意图 (NONE, CREATE, UPDATE, DELETE)。
|
||||
- `status`: 执行状态 (PENDING, IN_PROGRESS, SUCCESS, FAILED, SKIPPED, PRECONDITION_FAILED)。
|
||||
- 详见 [状态定义规范 § 5.A](./状态定义规范.md#5-执行同步状态转换-physical-sync-state-transitions)
|
||||
- `binding_status`: 绑定状态 (UNCHECKED, NORMAL, MISSING, DEPENDENCY_ERROR, ABNORMAL, WARNING)。
|
||||
- 详见 [状态定义规范 § 1](./状态定义规范.md#1-核心状态判定表-core-binding-matrix)
|
||||
- `error`: 异常详情。
|
||||
- **DataCollection**: 节点的集合,提供类型安全的查询和过滤方法。
|
||||
- **BindingManager**:
|
||||
- **ID 映射**: 维护 `local_node_id` <-> `remote_node_id` 的双向映射。
|
||||
- **绑定键**: 使用 `node_id` 作为绑定记录的键,**一致不变**。CREATE 成功后只更新节点的 `data_id`,绑定记录不变。
|
||||
- **持久化**: 将映射关系持久化到存储(如 JSON/数据库)。
|
||||
- **Enums**: `SyncAction`, `SyncStatus`, `BindingStatus` 等。
|
||||
|
||||
---
|
||||
|
||||
## 3. 同步系统层 (Layer 1: Sync System)
|
||||
|
||||
核心业务逻辑层。通过实现 `SyncStrategy` 接口来定义特定实体的同步逻辑。
|
||||
|
||||
- **SyncStrategy[T] 接口**:
|
||||
- `bind()`:
|
||||
- 职责:判定 `binding_status` (NORMAL/MISSING/ABNORMAL/WARNING/DEPENDENCY_ERROR)
|
||||
- 详见 [状态定义规范 § 1-3](./状态定义规范.md)
|
||||
- `create()`:
|
||||
- 职责:处理孤儿节点(MISSING),在对方侧创建新节点并设置 `action=CREATE`
|
||||
- **ID 替换**: 创建新节点时,复制数据并将依赖字段的本地 ID 替换为远程 ID
|
||||
- **内联检查**: 通过 `IDResolver.resolve_and_validate()` 验证依赖 ID 解析
|
||||
- 详见 [状态定义规范 § 4.C](./状态定义规范.md)
|
||||
- `update()`:
|
||||
- 职责:处理差异节点(NORMAL 且有 diff),设置 `action=UPDATE`
|
||||
- **ID 替换**: 准备更新数据时,替换数据中的依赖 ID
|
||||
- **内联检查**: 验证目标节点 data_id 完整性,失败则设置 `PRECONDITION_FAILED`
|
||||
- **FAILED 保护**: 自动跳过 `status=FAILED` 的节点
|
||||
- 详见 [状态定义规范 § 4.A](./状态定义规范.md)
|
||||
---
|
||||
|
||||
## 4. 数据源层 (Layer 2: DataSource)
|
||||
|
||||
作为各个后端的适配器,负责数据加载与持久化。
|
||||
|
||||
- **职责**:
|
||||
- `load_nodes(node_type)`: 从后端加载并返回 `SyncNode` 集合,自动添加到 `DataCollection`。
|
||||
- `save_nodes(nodes)`:
|
||||
- 遍历节点,仅处理 `status == PENDING` 且 `action != NONE` 的节点。
|
||||
- 根据 `action` 执行相应的 CREATE/UPDATE/DELETE 操作。
|
||||
- 执行成功后,更新节点的 `status` 和相关信息(如新生成的 remote_id)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 编排层 (Layer 3: Pipeline)
|
||||
|
||||
最高层级,负责流程编排。
|
||||
|
||||
- **职责**:
|
||||
- **显式顺序**: 直接在代码中按顺序调用同步逻辑(例如:先同步项目,再同步合同)。
|
||||
- **故障传播**: 若前置实体某节点同步失败(`FAILED`),编排器在处理后续依赖节点时可将其标记为 `DEPENDENCY_ERROR`。
|
||||
- **生命周期管理**: 初始化 `BindingManager` 和 `DataCollection`,同步结束后调用持久化方法。
|
||||
|
||||
---
|
||||
|
||||
## 6. ID 映射与对齐:核心流程
|
||||
|
||||
为了解决本地数据引用本地 ID 与远程数据引用远程 ID 的冲突,系统采用以下映射逻辑:
|
||||
|
||||
### 绑定阶段 (Binding)
|
||||
- **时机**: `Strategy.bind`
|
||||
- **目标**: 在 `BindingManager` 中确定 local_id ↔ remote_id 映射
|
||||
- **手段**: 业务键匹配(如 Code 匹配)、历史记录恢复等
|
||||
|
||||
### 执行阶段 (Execution)
|
||||
- **时机**: `Strategy.create()` / `Strategy.update()` 阶段
|
||||
- **目标**: 在创建新节点或准备更新时,将依赖字段的本地 ID 替换为远程 ID
|
||||
- **手段**:
|
||||
- `create()`: 创建对方节点时,复制数据并替换所有依赖 ID
|
||||
- `update()`: 准备更新数据时,替换数据中的依赖 ID
|
||||
- **验证**: `_pre_commit_check` 在 create/update 最后检查,确保所有依赖节点都已有远程 ID
|
||||
|
||||
**注意**: ID 替换在 Strategy 的 create/update 阶段完成,DataSource 只负责盲目执行已准备好的数据。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键设计原则
|
||||
|
||||
- **删除类型依赖配置**: 依赖关系通过 `Pipeline` 中的代码调用顺序自然表达。
|
||||
- **无状态 DataSource**: 数据源不了解绑定逻辑,只看到 `action`、`status` 和 `binding_status`。
|
||||
- **BindingManager 为核心**: 负责 ID 空间映射和持久化。
|
||||
- **配置驱动**: 通过 `StrategyConfig` 和预设配置(ConfigPresets)灵活控制同步行为。
|
||||
@@ -0,0 +1,342 @@
|
||||
# 同步系统状态定义 (Final Spec)
|
||||
|
||||
本文档定义了同步系统的状态机逻辑。逻辑流转遵循以下顺序:
|
||||
1. **核心状态判定** (Core State)
|
||||
2. **依赖检查与ID解析** (Dependency)
|
||||
3. **自动绑定/发现** (Discovery)
|
||||
4. **策略执行** (Action)
|
||||
|
||||
---
|
||||
|
||||
## 0. 绑定状态枚举 (BindingStatus)
|
||||
|
||||
| 状态值 | 含义 | 来源 | 允许创建 |
|
||||
| :--- | :--- | :--- | :---: |
|
||||
| **UNCHECKED** | 未执行绑定检查 | 加载后的初始状态 | ❌ |
|
||||
| **NORMAL** | 绑定正常 | 核心状态判定 | ❌ |
|
||||
| **MISSING** | 无绑定记录(孤儿) | 核心状态判定 | ✅ |
|
||||
| **WARNING** | 业务键不完整 | 自动绑定阶段 | ❌ |
|
||||
| **ABNORMAL** | 数据损坏 | 核心状态判定 | ❌ |
|
||||
| **DEPENDENCY_ERROR** | 依赖未就绪 | 依赖检查或自动绑定 | ❌ |
|
||||
|
||||
**关键原则**:
|
||||
- **UNCHECKED 阻止创建**:加载后节点状态为 UNCHECKED,只有执行了 `bind()` 后才会转为其他状态
|
||||
- **只有 MISSING 状态才能触发 CREATE**:`create()` 方法只查找 `binding_status == MISSING` 的节点
|
||||
- **如果 bind() 被跳过**(如 skip_sync=True),节点保持 UNCHECKED,不会被创建
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心状态判定表 (Core Binding Matrix)
|
||||
|
||||
**说明**:
|
||||
* **Binding Record Pair**: 数据库中的绑定记录 (必须成对出现,已排除单边绑定)。
|
||||
* **Data Valid**: 指数据是否可读、Schema校验是否通过。
|
||||
* **Warning vs Abnormal**:
|
||||
* `ABNORMAL`: 自身数据损坏。
|
||||
* `WARNING`: 自身健康,但因为对方损坏导致**连接不可用**。
|
||||
|
||||
| Bind Pair (Record) | Local Data (Valid?) | Remote Data (Valid?) | -> | **Local Status** | **Remote Status** | **说明** |
|
||||
| :---: | :---: | :---: | :--- | :--- | :--- | :--- |
|
||||
| **YES** | **YES** | **YES** | -> | **NORMAL** | **NORMAL** | **稳态**。链路完整,数据健康,可进行 Diff/Update。 |
|
||||
| **YES** | **YES** | **NO** | -> | **WARNING** | **ABNORMAL** | **远程损坏**。本地虽好,但链路已断 (Link Degraded)。禁止同步。 |
|
||||
| **YES** | **NO** | **YES** | -> | **ABNORMAL** | **WARNING** | **本地损坏**。远程虽好,但链路已断。禁止同步。 |
|
||||
| **YES** | **NO** | **NO** | -> | **ABNORMAL** | **ABNORMAL** | **双向损坏**。 |
|
||||
| **NO** | **YES** | N/A | -> | **MISSING** | N/A | **本地孤儿**。进入“依赖与发现”流程。 |
|
||||
| **NO** | N/A | **YES** | -> | N/A | **MISSING** | **远程孤儿**。进入“依赖与发现”流程。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 依赖与业务键解析 (Dependency & ID Resolution)
|
||||
|
||||
**适用范围**: 所有涉及外键依赖 (Foreign Key) 的节点。
|
||||
**前置条件**: 核心状态 = `MISSING` (准备进行自动绑定或新建)。
|
||||
|
||||
在进行 `Auto-Bind` 或 `Create` 之前,必须先尝试构建**远程业务键**。
|
||||
例如:本地 `Contract(proj_id="P1", code="C01")` 想找远程对象,必须先查询 `P1` 对应的远程 ID `RP_99`,转换成 `(RP_99, "C01")` 才能去远程搜索。
|
||||
|
||||
| 依赖项 (Dependency) | 依赖项绑定状态 | -> | **能否构建业务键** | **节点状态** | **说明** |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **无依赖** | N/A | -> | ✅ Yes | **MISSING** | 根节点 (如 Project),直接进入下一阶段。 |
|
||||
| **有依赖** | **NORMAL** 且 `data_id` 非空(`status` 任意) | -> | ✅ Yes | **MISSING** | 依赖项已获得远程ID,可进入下一阶段。 |
|
||||
| **有依赖** | **NORMAL** 且 `data_id` 为空("",含 `status = FAILED`) | -> | ❌ No | **DEPENDENCY_ERROR** | 依赖项未回填ID,阻断绑定与同步。 |
|
||||
| **有依赖** | **MISSING / ABNORMAL** | -> | ❌ No | **DEPENDENCY_ERROR** | **依赖阻断**。父节点未同步或损坏,无法计算远程键,无法绑定,无法创建。 |
|
||||
| **有依赖** | **WARNING** | -> | ❌ No | **DEPENDENCY_ERROR** | 父节点状态不确定(如连接降级),禁止基于此建立新关系。 |
|
||||
|
||||
> **处理策略**: 处于 `DEPENDENCY_ERROR` 状态的节点,其动作强制为 **SKIP** (或 PENDING)。必须先修复父节点。
|
||||
>
|
||||
> **错误信息格式**: `DEPENDENCY_ERROR` 节点的 `error` 字段会包含详细的依赖失败信息:
|
||||
> - `dep_err: not_found: field_name(node_type)=value` - 依赖节点未找到
|
||||
> - `dep_err: field_name: status=FAILED` - 依赖节点状态为 FAILED
|
||||
> - 多个依赖失败时用分号分隔:`dep_err: not_found: project_id(project)=P1; contract_id: status=FAILED`
|
||||
|
||||
---
|
||||
|
||||
## 3. 自动绑定状态处理 (Auto-Binding State Handling)
|
||||
|
||||
此逻辑遵循“去噪后 1:1”原则:只有在排除掉所有健康同步对后,若剩余节点形成绝对的 1:1 关系,才允许自动绑定。
|
||||
|
||||
**配置说明与触发机制**:
|
||||
- **初始化场景**: 通常在系统第一次初始化或首次接入新实体时,配置 `auto_bind = True` 来对齐历史数据。
|
||||
- **后续同步**: 系统在日常同步中不建议开启全局自动绑定。自动绑定仅作为“发现”手段,一旦建立过关系的节点,其后续绑定操作通常仅在“自动创建”逻辑中伴随产生(详见第 4 节孤儿处理)。
|
||||
- **执行前提**: 只有当节点状态为 `MISSING` 且依赖解析成功(`DEPENDENCY_ERROR = False`)时,才会尝试以下探测逻辑。
|
||||
- **业务键完整性**: 若业务键字段缺失(如 `serial_number = null`),直接标记 `binding_status = WARNING`,并在 `node.error` 记录原因(`Auto-bind skipped: missing fields ...`),不参与自动绑定与孤儿创建判定。- **ID字段解析失败**: 若业务键包含ID字段(如 `supplier_id`)且依赖节点未绑定,ID解析失败,标记 `binding_status = DEPENDENCY_ERROR`,阻止孤儿创建。
|
||||
**核心算法**:
|
||||
1. **全集搜索**: 寻找本地/远端所有共享同一个业务键(Business Key)的记录。
|
||||
2. **排除稳态对**: 识别并移除**成对且互绑**的 `NORMAL` 记录。
|
||||
- 只有当本地 A 与远端 B 互为绑定对象,且状态皆为 `NORMAL` 时,才将这一对记录从待判定集合中剔除。
|
||||
- 若出现单边 `NORMAL` 记录(即其配对项不在本次搜索范围内或状态不属于另一端的 `NORMAL`),则不予剔除,视为环境污染项。
|
||||
3. **判定剩余**: 检查排除上述稳态对后的剩余集合(包含 `MISSING`, `ABNORMAL`, `WARNING` 及单边已占用的 `NORMAL` 记录)。
|
||||
|
||||
> **术语**:
|
||||
> - `L_remain` = 本地记录中,排除“成对稳态”后剩余的数量。
|
||||
> - `R_remain` = 远端记录中,排除“成对稳态”后剩余的数量。
|
||||
|
||||
| `L_remain` | `R_remain` | -> | **动作** | **说明** |
|
||||
| :---: | :---: | :--- | :--- | :--- |
|
||||
| **1** | **1** | -> | **BIND (Link)** | **1对1 自动对齐**:仅当两端剩余记录皆为 `MISSING` 且绝对 1:1 时,执行自动绑定。 |
|
||||
| **1** | **1** | -> | **SKIP** | **带障不处理**:若剩余记录中包含 `ABNORMAL` 等异常状态,说明存在历史冲突,不执行自动动作。 |
|
||||
| **N** | **0** | -> | **CREATE** | **可判定缺失**:业务键完整且唯一,本地存在而远端不存在(N:0)时,才允许进入孤儿创建策略。 |
|
||||
| 其他 | 其他 | -> | **SKIP** | **非 1:1 不处理**:存在多对多、多对一或无候选情况,系统保持原状。 |
|
||||
|
||||
**设计优势**:
|
||||
1. **对故障极端敏感**:环境中存在任何 `ABNORMAL` 记录都会阻断自动绑定,强制人工介入处理异常。
|
||||
2. **状态纯净**:失败时不产生任何中间态(如已废弃的 `AMBIGUOUS`),保持语义清晰。
|
||||
3. **静默自愈**: 当外界清理了环境且满足 1:1 条件后,系统下次同步会自动完成绑定。
|
||||
|
||||
### 3.1 空数据处理策略
|
||||
|
||||
**原则**:**空数据不推送**。
|
||||
|
||||
**空数据定义**:
|
||||
- 初始化时创建但未编辑的业务记录(如空合同、空施工任务)
|
||||
- 业务键字段为空或关键字段缺失的记录
|
||||
|
||||
**处理方式**:
|
||||
- 在自动绑定阶段,若业务键字段缺失,标记 `binding_status = WARNING`,记录原因
|
||||
- 不参与自动绑定与孤儿创建判定
|
||||
- 不推送到远程系统
|
||||
|
||||
**设计理由**:
|
||||
1. **远程系统简化**:远程只需处理完整数据,无需兼容空数据校验逻辑
|
||||
2. **避免垃圾数据**:空数据通常会被本地删除,若已推送则清理复杂
|
||||
3. **数据质量保障**:远程系统只能看到本地已正确编辑保存的业务
|
||||
|
||||
**配置方式**(Strategy 层):
|
||||
```python
|
||||
# 在 Strategy 配置中指定必填字段
|
||||
default_config = StrategyConfig(
|
||||
auto_bind_fields=["contract_id", "task_type"], # 业务键字段
|
||||
# 如果这些字段为空,节点将被标记为 WARNING 而非 MISSING
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 动作策略表 (Action Policy Matrix)
|
||||
|
||||
此表定义了在特定状态下,`execute` 阶段允许产生的 **意图行动 (`SyncAction`)**。
|
||||
|
||||
#### A. 稳态更新 (NORMAL)
|
||||
适用于已有绑定关系且链路健康的节点。
|
||||
|
||||
| 状态 | 动作条件 | 执行动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **NORMAL** | `compute_diff` 判定存在差异 | **UPDATE** |
|
||||
| **NORMAL** | `compute_diff` 判定无差异 | **NONE** |
|
||||
|
||||
#### B. 异常处理 (阻断态)
|
||||
处于这些状态的节点,引擎会自动中止后续的读写操作。
|
||||
|
||||
| 状态 | 说明 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **DEPENDENCY_ERROR** | 父节点缺失/异常同步导致阻断 | **NONE** |
|
||||
| **ABNORMAL / WARNING** | 数据损坏或链路降级,需人工修复 | **NONE** |
|
||||
|
||||
#### C. 孤儿处理 (Create Only)
|
||||
适用于最终探测状态为 **MISSING** 且无法/不满足自动绑定的节点。
|
||||
|
||||
**本地孤儿 (Local Orphan)**: 本地有,远程无。
|
||||
| 策略配置 (`local_orphan_action`) | 对应的同步模式 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **"create_remote"** | Push (本地推远端) | **CREATE** |
|
||||
| **"delete_local"** | Pull (远端拉本地) | **报错 (未实现)** |
|
||||
| **"none"** | Any | **NONE** |
|
||||
|
||||
**远程孤儿 (Remote Orphan)**: 远程有,本地无。
|
||||
| 策略配置 (`remote_orphan_action`) | 对应的同步模式 | 动作 (`SyncAction`) |
|
||||
| :--- | :--- | :--- |
|
||||
| **"create_local"** | Pull (远端拉本地) | **CREATE** |
|
||||
| **"delete_remote"** | Push (本地推远端) | **报错 (未实现)** |
|
||||
| **"none"** | Any | **NONE** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 执行同步状态转换 (Physical Sync State Transitions)
|
||||
|
||||
在策略执行完成、设置好 `action` 后,DataSource 层负责执行实际的同步操作并更新 `status` 字段。
|
||||
|
||||
### A. 执行状态枚举 (SyncStatus)
|
||||
|
||||
| 状态值 | 含义 | 适用场景 |
|
||||
| :--- | :--- | :--- |
|
||||
| **PENDING** | 待执行 | 初始状态、无动作的节点 |
|
||||
| **IN_PROGRESS** | 执行中 | 异步接口已提交,等待结果回调 |
|
||||
| **SUCCESS** | 执行成功 | 同步操作完成且成功 |
|
||||
| **FAILED** | 执行失败 | 同步操作失败,`error` 字段记录原因 |
|
||||
| **SKIPPED** | 已跳过 | 节点被主动跳过(如依赖未满足) |
|
||||
| **PRECONDITION_FAILED** | 前置条件失败 | create/update 内部验证发现问题 |
|
||||
|
||||
### B. 执行转换矩阵
|
||||
|
||||
#### 同步接口(一步式)
|
||||
|
||||
适用于同步 API(调用后立即返回成功/失败)。
|
||||
|
||||
| 输入: action | API 调用结果 | → | 输出: status | 副作用 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **CREATE** | 成功返回 (200/201) | → | **SUCCESS** | `data_id` 设置为远程返回的ID |
|
||||
| **CREATE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **UPDATE** | 成功返回 (200) | → | **SUCCESS** | - |
|
||||
| **UPDATE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **DELETE** | 成功返回 (200/204) | → | **SUCCESS** | 节点可能被移除 |
|
||||
| **DELETE** | 失败返回 (4xx/5xx) | → | **FAILED** | `error` 记录错误信息 |
|
||||
| **NONE** | - | → | **PENDING** | 不执行,保持原状 |
|
||||
|
||||
#### 异步接口(两步式)
|
||||
|
||||
适用于异步 API(提交后需要轮询或回调获取结果)。
|
||||
|
||||
| 输入: action | API 提交结果 | → | 输出: status | 后续操作 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **CREATE** | 提交成功 (202 Accepted) | → | **IN_PROGRESS** | 记录 task_id,等待轮询 |
|
||||
| **CREATE** | 提交失败 (4xx/5xx) | → | **FAILED** | `error` 记录错误 |
|
||||
| **IN_PROGRESS** | 轮询返回成功 | → | **SUCCESS** | `data_id` 设置为远程ID |
|
||||
| **IN_PROGRESS** | 轮询返回失败 | → | **FAILED** | `error` 记录错误 |
|
||||
| **IN_PROGRESS** | 超时未完成 | → | **FAILED** | `error` 记录 "Timeout" |
|
||||
|
||||
### B+. Handler 降级(action 取消)
|
||||
|
||||
> **代码实现**:各 `domain/*/api_handler.py` 中的 `update()` 方法。
|
||||
|
||||
当 Handler 在构建 API 请求数据时,发现实际无需更新的字段(如 schema 校验后所有字段与远程一致),
|
||||
Handler 会主动将节点的 `action` 从 `UPDATE` 降级为 `NONE`:
|
||||
|
||||
```
|
||||
action=UPDATE → Handler 校验 → 无实际变更字段 → action=NONE(不提交 API)
|
||||
```
|
||||
|
||||
此机制确保 DataSource 不会向远程发送无意义的空更新请求。
|
||||
|
||||
### C. 关键副作用
|
||||
|
||||
| 动作 | 成功时的副作用 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| **CREATE** | 临时绑定已建立 | 绑定关系可在提交前创建,用于推送与追踪;是否成功不影响该绑定存在 |
|
||||
| **CREATE** | 设置 `data_id` | 成功后从API响应中提取远程分配的ID,更新到节点 |
|
||||
| **CREATE** | 回写 `data` 主键 | DataSource 将远程返回的ID写回 `SyncNode.data`(如 `id`/`*_id` 字段) |
|
||||
| **CREATE** | 更新绑定记录 | 若使用临时ID,成功后替换为真实的远程ID |
|
||||
| **UPDATE** | 更新 `origin_data` | 将当前 `data` 保存为新的 `origin_data` |
|
||||
| **DELETE** | 移除绑定记录 | 删除成功后,清除对应的绑定关系 |
|
||||
|
||||
### D. 错误处理
|
||||
|
||||
| 错误类型 | 记录方式 | 重试策略 |
|
||||
| :--- | :--- | :--- |
|
||||
| **网络错误** | `error = "Network timeout"` | 可重试 |
|
||||
| **权限错误** | `error = "403 Forbidden"` | 不可重试,需人工介入 |
|
||||
| **数据校验错误** | `error = "Validation failed: ..."` | 不可重试,需修复数据 |
|
||||
| **依赖缺失** | `status = PRECONDITION_FAILED` | 不执行,等待依赖修复 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 同步检查规范 (Sync Check Specification)
|
||||
|
||||
在执行同步前后,需要进行状态检查以确保操作的合法性和完整性。
|
||||
|
||||
### A. 内联前置检查(create/update 阶段)
|
||||
|
||||
> **实现说明**:原 `_pre_commit_check` 方法已移除(保留空壳兼容接口)。
|
||||
> 所有有效检查已内联到 `strategy_ops/create_ops.py::add_create_action()` 和 `strategy_ops/update_ops.py::add_update_action()` 中。
|
||||
|
||||
**create() 阶段内联检查**:
|
||||
|
||||
| 检查类别 | 检查条件 | 不通过时 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **数据完整性** | `src_node.get_data()` 不为空 | 跳过该节点,记录日志 | MISSING 但数据为空 |
|
||||
| **依赖 ID 解析** | `id_resolver.resolve_and_validate()` 成功 | `binding_status → DEPENDENCY_ERROR` | 依赖字段无法解析到目标系统 ID |
|
||||
|
||||
**update() 阶段内联检查**:
|
||||
|
||||
| 检查类别 | 检查条件 | 不通过时 | 说明 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **FAILED 保护** | `src_node.status != FAILED` | 跳过该节点 | 不覆盖上次失败的状态和错误 |
|
||||
| **数据完整性** | `src_node.get_data()` 不为空 | 跳过该节点 | NORMAL 但数据为空 |
|
||||
| **目标节点存在** | 通过绑定找到目标节点 | 跳过该节点 | 绑定记录指向的节点不在集合中 |
|
||||
| **data_id 完整性** | `target_node.data_id` 非空 | `status → PRECONDITION_FAILED` | UPDATE 必须有业务主键 |
|
||||
| **依赖 ID 解析** | `id_resolver.resolve_and_validate()` 成功 | `status → PRECONDITION_FAILED` | 依赖字段无法解析到目标系统 ID |
|
||||
| **差异检测** | `_needs_update()` 返回 True | 跳过该节点 | 无差异则不更新 |
|
||||
|
||||
> **注意**:以下检查项在当前实现中 **未覆盖**【未实现】:
|
||||
> - 状态合法性:`binding_status` 必须是已知状态
|
||||
> - 动作合法性:`CREATE/UPDATE` 只能用于 `NORMAL` 节点
|
||||
> - 绑定一致性:`NORMAL` 节点必须有绑定记录
|
||||
|
||||
### B. 同步后检查 (Post-Sync Check)
|
||||
|
||||
**目标**: 验证同步结果的完整性,发现残留问题。
|
||||
|
||||
> **实现说明**:当前 `_stage4_post_sync_check` 只打印摘要表格,不做异常检测。以下检查项均为【未实现】。
|
||||
|
||||
#### 检查项清单【未实现】
|
||||
|
||||
| 检查类别 | 检查条件 | 异常情况 | 严重程度 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **CREATE 完成性** | CREATE 节点应该有 `data_id` | `action = CREATE, status = SUCCESS` 但 `data_id = ""` | 🔴 ERROR |
|
||||
| **CREATE 绑定** | CREATE 节点应该有绑定记录 | CREATE 成功但无绑定 | 🔴 ERROR |
|
||||
| **UPDATE 完成性** | UPDATE 节点应该成功 | `action = UPDATE` 但 `status = FAILED` | ⚠️ WARNING |
|
||||
| **绑定保持** | UPDATE 节点的绑定应该保持 | UPDATE 后绑定记录丢失 | 🔴 ERROR |
|
||||
| **残留异常** | 不应有 ABNORMAL 节点 | 同步后仍有 `binding_status = ABNORMAL` | ⚠️ WARNING |
|
||||
| **残留依赖错误** | 不应有 DEPENDENCY_ERROR | 同步后仍有依赖阻断 | ⚠️ WARNING |
|
||||
|
||||
#### 统计指标
|
||||
|
||||
| 指标 | 计算方式 | 用途 |
|
||||
| :--- | :--- | :--- |
|
||||
| **成功率** | `SUCCESS / (CREATE + UPDATE)` | 评估同步质量 |
|
||||
| **异常率** | `(ABNORMAL + WARNING) / total` | 发现数据质量问题 |
|
||||
| **阻断率** | `DEPENDENCY_ERROR / total` | 发现依赖关系问题 |
|
||||
|
||||
#### 决策规则
|
||||
|
||||
| 检查结果 | ERROR 数量 | 决策 |
|
||||
| :--- | :--- | :--- |
|
||||
| ✅ 通过 | 0 | **继续持久化**,正常结束 |
|
||||
| ⚠️ 有警告 | 0, WARNING ≥1 | **继续持久化**,打印警告报告 |
|
||||
| ❌ 有错误 | ≥1 | **继续持久化**(已同步无法回滚),打印错误报告 |
|
||||
|
||||
> **注意**: Post-Sync Check 即使有 ERROR 也不会终止流程,因为同步已经执行,无法回滚。检查目的是发现问题并记录,供后续修复。
|
||||
|
||||
---
|
||||
|
||||
## 附录: 状态字段说明
|
||||
|
||||
### SyncNode 核心字段
|
||||
|
||||
| 字段 | 类型 | 设置阶段 | 含义 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `binding_status` | BindingStatus | Bind 阶段 | 绑定健康状态 |
|
||||
| `action` | SyncAction | Create/Update 阶段 | 待执行动作 |
|
||||
| `status` | SyncStatus | Physical Sync 阶段 | 执行结果状态 |
|
||||
| `data_id` | str | 数据加载 / CREATE 后 | 业务主键ID(CREATE 前为 "") |
|
||||
| `depend_ids` | List[str] | 数据加载 | 依赖节点的 node_id 列表 |
|
||||
| `error` | str \| None | Physical Sync 阶段 | 错误信息(失败时) |
|
||||
|
||||
### 状态流转时序
|
||||
|
||||
```
|
||||
[数据加载] [Bind] [Create/Update] [Physical Sync]
|
||||
binding_status: UNCHECKED → NORMAL/MISSING/... → (保持) → (保持)
|
||||
action: NONE → NONE → CREATE/UPDATE → (保持)
|
||||
status: PENDING → PENDING → PENDING → SUCCESS/FAILED
|
||||
data_id: 从源加载 → (保持) → 可能为 "" → CREATE 后设置
|
||||
```
|
||||
@@ -0,0 +1,498 @@
|
||||
# 状态机形式化规范
|
||||
|
||||
本文档用 **Guard-Event-Action (GEA)** 形式严格定义节点状态机。
|
||||
所有转换以代码实现为准,模糊之处在文档中显式标注。
|
||||
|
||||
> **代码基线**
|
||||
> - 枚举: `sync_system_new/common/types.py`
|
||||
> - 决策引擎: `sync_system_new/sync_system/decision.py`
|
||||
> - 重置策略: `sync_system_new/common/state_reset.py`
|
||||
> - 同步策略: `sync_system_new/sync_system/strategy.py`
|
||||
> - 物理同步: `sync_system_new/datasource/datasource.py`
|
||||
|
||||
> **配置基线(状态机真源)**
|
||||
> - `sync_system_new/state_machine/node_state_machine.yaml`
|
||||
|
||||
### 2026-02 对齐说明(重构前必读)
|
||||
|
||||
以下语义以 `node_state_machine.yaml` 为准,用于修正文档中易混淆点:
|
||||
|
||||
1. **E12a 为什么从 S01 出发?**
|
||||
- `S01` 只约束**源节点**是 NORMAL/NONE/PENDING 且有 `data_id`。
|
||||
- `E12a` 的 guard 是 `target_has_data_id=false`,检查对象是**目标节点**。
|
||||
- 所以“源节点在 S01”与“E12a 触发前置失败”并不冲突。
|
||||
|
||||
2. **S01 会不会触发创建?**
|
||||
- 当前配置中,create 的入口状态是 `S02(MISSING)`,不是 `S01`。
|
||||
- `S01` 进入的是 update_prepare(E12/E13/E14)。
|
||||
|
||||
3. **N:0 + create_enabled=true 的当前行为**
|
||||
- 自动绑定分支 `E08f`:`S02 -> S01`,并 `emit spawn_target_node_state: S06`。
|
||||
- create_prepare 分支 `E09b` 也可在检查成功后 `S02 -> S01 + spawn S06`。
|
||||
- 即:源节点不会停在“等待创建结果”的专用状态;创建执行结果体现在目标创建节点链路 `S06 -> S10/S11/S12/S13`。
|
||||
|
||||
4. **旧章节中的事件编号偏差**
|
||||
- 本文后续仍有部分“E11a/E11b/…”旧编号描述,已与当前 YAML 不一致。
|
||||
- 进行代码重构时,优先依据 YAML 的 `E08/E09/E10/E12/E13/E14/E15..E20`。
|
||||
|
||||
---
|
||||
|
||||
## §1 状态空间定义
|
||||
|
||||
### §1.1 状态维度
|
||||
|
||||
节点携带 **三个独立的状态字段** 和 **两个辅助字段**:
|
||||
|
||||
| 字段 | 枚举 | 值域 | 语义 |
|
||||
|------|------|------|------|
|
||||
| **B** – `binding_status` | `BindingStatus` | `UNCHECKED` · `NORMAL` · `MISSING` · `ABNORMAL` · `WARNING` · `DEPENDENCY_ERROR` | 绑定健康度 |
|
||||
| **A** – `action` | `SyncAction` | `NONE` · `CREATE` · `UPDATE` · `DELETE` | 待执行动作 |
|
||||
| **S** – `status` | `SyncStatus` | `PENDING` · `IN_PROGRESS` · `SUCCESS` · `FAILED` · `SKIPPED` · `PRECONDITION_FAILED` | 执行结果 |
|
||||
|
||||
辅助字段(影响 Guard 但不是状态维度):
|
||||
|
||||
| 字段 | 类型 | 语义 |
|
||||
|------|------|------|
|
||||
| `data_id` | `str` | 业务主键(空串 `""` = 尚未从远程获取) |
|
||||
| `error` | `str \| None` | 最近一次错误信息 |
|
||||
|
||||
理论组合 = 6 × 4 × 6 = **144**,实际可达 ≈ **23** 种。
|
||||
|
||||
### §1.2 可达状态枚举
|
||||
|
||||
以下列出所有在正常流程中可以到达的 `(binding_status, action, status)` 三元组。
|
||||
|
||||
| # | binding_status | action | status | 语义 | 产生阶段 |
|
||||
|---|---|---|---|------|---------|
|
||||
| R1 | UNCHECKED | NONE | PENDING | 首次加载 | 数据加载 |
|
||||
| R2 | UNCHECKED | NONE | SUCCESS | 从持久化恢复(上轮成功)后重置 | 阶段1/2重置 |
|
||||
| R3 | UNCHECKED | NONE | FAILED | 从持久化恢复(上轮失败)后重置 | 阶段1/2重置 |
|
||||
| R4 | UNCHECKED | NONE | SKIPPED | 从持久化恢复(上轮跳过)后重置 | 阶段1/2重置 |
|
||||
| R5 | UNCHECKED | NONE | PRECONDITION_FAILED | 从持久化恢复(上轮前置失败)后重置 | 阶段1/2重置 |
|
||||
| R6 | NORMAL | NONE | PENDING | 绑定正常,无需操作(首次) | bind.p1 |
|
||||
| R7 | NORMAL | NONE | SUCCESS | 绑定正常,无需操作(历史成功) | bind.p1 |
|
||||
| R8 | NORMAL | NONE | FAILED | 绑定正常但上次失败,被 FAILED 保护 | bind.p1 |
|
||||
| R9 | MISSING | NONE | PENDING | 孤儿,等待自动绑定或创建(首次) | bind.p1 |
|
||||
| R10 | MISSING | NONE | SUCCESS | 孤儿(历史成功但绑定丢失?) | bind.p1 |
|
||||
| R11 | ABNORMAL | NONE | * | 数据损坏,需人工 | bind.p1 |
|
||||
| R12 | WARNING | NONE | * | 多候选/对方损坏,需人工 | bind.p1/p3 |
|
||||
| R13 | DEPENDENCY_ERROR | NONE | PENDING | 依赖不满足 | bind.p2/p3/create |
|
||||
| R14 | NORMAL | CREATE | PENDING | 准备创建(目标节点) | create |
|
||||
| R15 | NORMAL | CREATE | IN_PROGRESS | 异步创建进行中 | sync |
|
||||
| R16 | NORMAL | CREATE | SUCCESS | 创建成功 | sync |
|
||||
| R17 | NORMAL | CREATE | FAILED | 创建失败 | sync |
|
||||
| R18 | NORMAL | UPDATE | PENDING | 准备更新(目标节点) | update |
|
||||
| R19 | NORMAL | UPDATE | IN_PROGRESS | 异步更新进行中 | sync |
|
||||
| R20 | NORMAL | UPDATE | SUCCESS | 更新成功 | sync |
|
||||
| R21 | NORMAL | UPDATE | FAILED | 更新失败 | sync |
|
||||
| R22 | NORMAL | UPDATE | PRECONDITION_FAILED | 更新前置条件失败 | update |
|
||||
| R23 | NORMAL | CREATE | SKIPPED | 创建被 Handler 跳过 | sync |
|
||||
|
||||
> **不可达组合(示例)**:
|
||||
> - `(MISSING, UPDATE, *)` — MISSING 节点不可能被安排 UPDATE
|
||||
> - `(ABNORMAL, CREATE, *)` — ABNORMAL 节点不可能被安排 CREATE
|
||||
> - `(*, DELETE, *)` — DELETE 尚未实现
|
||||
> - `(UNCHECKED, CREATE, *)` — UNCHECKED 是瞬态,在 bind 之前不会有 CREATE
|
||||
> - `(DEPENDENCY_ERROR, UPDATE, *)` — DEPENDENCY_ERROR 节点不可能进入 update 流程
|
||||
|
||||
### §1.3 辅助字段约束
|
||||
|
||||
| 对应状态 | binding_status | action | status | data_id 约束 | error 约束 |
|
||||
|---------|---|---|---|-------------|-----------|
|
||||
| R14 | NORMAL | CREATE | PENDING | `""` (待远程返回) | None |
|
||||
| R16 | NORMAL | CREATE | SUCCESS | 非空 (远程回填) | None |
|
||||
| R17 | NORMAL | CREATE | FAILED | `""` (未回填) | 非空 |
|
||||
| R22 | NORMAL | UPDATE | PRECONDITION_FAILED | * | 非空 |
|
||||
| R13 | DEPENDENCY_ERROR | NONE | PENDING | * | 非空 |
|
||||
|
||||
---
|
||||
|
||||
## §2 不变量 (Invariants)
|
||||
|
||||
以下约束在**任何事件执行后**都必须成立。违反即为 Bug。
|
||||
|
||||
```
|
||||
INV-1 B = UNCHECKED ⟹ A = NONE
|
||||
(UNCHECKED 是瞬态,在 bind 之前不允许有动作)
|
||||
|
||||
INV-2 B ∈ {ABNORMAL, WARNING, DEPENDENCY_ERROR} ⟹ A = NONE
|
||||
(阻断态节点不允许执行任何同步动作)
|
||||
|
||||
INV-3 B = MISSING ⟹ A ∈ {NONE, CREATE}
|
||||
(MISSING 节点只能等待创建或保持不动)
|
||||
|
||||
INV-4 A = CREATE ⟹ B = NORMAL
|
||||
(CREATE 动作只出现在目标节点上,其 binding_status 已被设为 NORMAL)
|
||||
|
||||
INV-5 A = UPDATE ⟹ B = NORMAL
|
||||
(UPDATE 动作只对 NORMAL 节点执行)
|
||||
|
||||
INV-6 S = IN_PROGRESS ⟹ A ∈ {CREATE, UPDATE, DELETE}
|
||||
(只有提交过 API 的节点才可能处于 IN_PROGRESS)
|
||||
|
||||
INV-7 S = PRECONDITION_FAILED ⟹ A = UPDATE
|
||||
(PRECONDITION_FAILED 只在 update 阶段产生)
|
||||
|
||||
INV-8 A = CREATE ∧ S = SUCCESS ⟹ data_id ≠ ""
|
||||
(创建成功必须回填 data_id)
|
||||
|
||||
INV-9 S = FAILED ⟹ error ≠ None
|
||||
(失败必须有错误信息)
|
||||
|
||||
INV-10 B = DEPENDENCY_ERROR ⟹ error ≠ None
|
||||
(依赖阻断必须有错误信息)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §3 事件与转换
|
||||
|
||||
### §3.1 事件总览图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 节点生命周期事件流 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
持久化恢复 策略层 (Strategy) 执行层 (DataSource)
|
||||
═══════════ ══════════════════════════════════ ══════════════════════════
|
||||
┌──────────────────────────────┐
|
||||
┌──────┐ │ bind() │ ┌──────────────────────┐
|
||||
│E01 │──────────▶│ E04 ─┐ │ │ sync_all() │
|
||||
│RESET1│ │ E05 ├─ phase1: core_state │ │ │
|
||||
└──┬───┘ │ E06 ─┘ │ │ E12 SYNC_OK │
|
||||
│ │ E07 ── phase2: dep_check │ ┌───▶│ E13 SYNC_FAIL │
|
||||
┌──▼───┐ │ E08 ─┐ │ │ │ E14 SYNC_ASYNC │
|
||||
│E02 │ │ E09 ├─ phase3: auto_bind │ │ │ E15 SYNC_TIMEOUT │
|
||||
│ZOMBIE│ │ E10 ─┘ │ │ │ E16 HANDLER_SKIP │
|
||||
└──┬───┘ └──────────────────────────────┘ │ │ E17 HANDLER_DOWN │
|
||||
│ ┌──────────────────────────────┐ │ └──────────────────────┘
|
||||
┌──▼───┐ │ create() / update() │ │
|
||||
│E03 │──────────▶│ E11a CREATE_OK │────┘
|
||||
│RESET2│ │ E11b CREATE_DEP_FAIL │
|
||||
└──────┘ │ E11c UPDATE_DIFF │
|
||||
│ E11d UPDATE_PRECOND_FAIL │
|
||||
│ E11e UPDATE_SKIP_FAILED │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
|
||||
### §3.2 状态转换详解
|
||||
|
||||
本节按 Pipeline 执行阶段组织。每个阶段包含:
|
||||
1. **状态转换图**:状态(R 编号)为节点,事件(E 编号)为有向边
|
||||
2. **转换表**:每个事件的精确规格——起始状态、判断条件、结束状态、副作用
|
||||
|
||||
---
|
||||
|
||||
#### 🔄 阶段 0:重置
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
╔═══════════════════════════════════════╗
|
||||
║ 任意持久化终态 (R6 ~ R23) ║
|
||||
╚═══════════════════╤═══════════════════╝
|
||||
│ E01
|
||||
▼
|
||||
┌────────────────────────────────────────┐ E02
|
||||
│ 瞬态: UNCHECKED │────────────▶ [节点删除 + 解除绑定]
|
||||
│ (action 保留, status 保留, error=None) │
|
||||
└────────────────────┬──────────────────┘
|
||||
│ E03 (存活节点)
|
||||
▼
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ [R1] UNCHECKED, NONE, PENDING ← 首次加载 ║
|
||||
║ [R2] UNCHECKED, NONE, SUCCESS ← 上轮成功 ║
|
||||
║ [R3] UNCHECKED, NONE, FAILED ← 上轮失败 ║
|
||||
║ [R4] UNCHECKED, NONE, SKIPPED ← 上轮跳过 ║
|
||||
║ [R5] UNCHECKED, NONE, PRECONDITION_FAILED ← 上轮前置 ║
|
||||
╚══════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E01**: PHASE1_RESET | R6~R23 (任意持久化终态) | 节点从持久化加载 | 瞬态 (UNCHECKED, action 保留, status 保留) | error → None; data/origin_data → None |
|
||||
| **E02**: ZOMBIE_KILL | 瞬态 | action = CREATE ∧ (status = FAILED ∨ data_id = "") | **节点删除** | 解除绑定关系; 对 local/remote 两侧都扫描 |
|
||||
| **E03**: ACTION_RESET | 瞬态 (存活节点) | E02 完成后所有存活节点 | R1 / R2 / R3 / R4 / R5 (按原 status 分) | action → NONE |
|
||||
|
||||
> **代码**: `NodeStateReset.get_phase1_reset_defaults()` · `cleanup_create_failed_zombies()` · `reset_action_for_nodes()`
|
||||
|
||||
---
|
||||
|
||||
#### 🔗 阶段 1:Bind
|
||||
|
||||
Bind 由三个子阶段串行执行。后一阶段只处理前一阶段的遗留 MISSING 节点。
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
[R1~R5] UNCHECKED, NONE, *
|
||||
│
|
||||
│ ══════════ Phase 1: 核心状态判定 ══════════
|
||||
│
|
||||
├── E04 (有绑定, 双方数据 VALID) ───────────▶ [R6/R7/R8] NORMAL ──────▶ 出口 ✅
|
||||
├── E04 (有绑定, 自身数据缺失/损坏) ────────▶ [R11] ABNORMAL ─────────▶ 出口 🔒
|
||||
├── E04 (有绑定, 对方数据缺失/损坏) ────────▶ [R12] WARNING ──────────▶ 出口 🔒
|
||||
│
|
||||
└── E05 (无绑定记录) ───────────────────────▶ [R9] MISSING
|
||||
│
|
||||
══════════ Phase 2: 依赖检查 ══════════ │
|
||||
│
|
||||
├── E06 (data = None) ──────────────────────▶ [R11] ABNORMAL ──────▶ 出口 🔒
|
||||
├── E07 (依赖节点不满足) ───────────────────▶ [R13] DEP_ERROR ─────▶ 出口 🔒
|
||||
│ (无依赖配置 或 依赖全部满足 → 继续 ▼)
|
||||
│
|
||||
══════════ Phase 3: 自动绑定 ══════════
|
||||
│
|
||||
├── E08 (auto_bind 关闭) ──────────────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E09 (去噪后 1:1 匹配成功) ─────────────▶ [R6] NORMAL ─────────▶ 出口 ✅
|
||||
├── E10a (去噪后非 1:1, 多候选) ───────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E10b (业务键字段缺失) ─────────────────▶ [R12] WARNING ────────▶ 出口 🔒
|
||||
├── E10c (业务键中 ID 字段解析失败) ───────▶ [R13] DEP_ERROR ─────▶ 出口 🔒
|
||||
│
|
||||
└── (1:0 无远程匹配)
|
||||
├── create_enabled = True → [R9] MISSING ──────────────▶ 进入 create
|
||||
└── create_enabled = False → [R12] WARNING ─────────────▶ 出口 🔒
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E04**: BIND_PAIRED | R1~R5 | 有绑定记录 ∧ 双方数据 VALID | R6 (PENDING) / R7 (SUCCESS) / R8 (FAILED) — status 保持 | 远程节点同步设置 binding_status |
|
||||
| **E04**: BIND_PAIRED (自身异常) | R1~R5 | 有绑定记录 ∧ 本方数据缺失或损坏 | R11 (ABNORMAL) | 对方节点设为 WARNING |
|
||||
| **E04**: BIND_PAIRED (对方异常) | R1~R5 | 有绑定记录 ∧ 对方数据缺失或损坏 | R12 (WARNING) | 对方节点设为 ABNORMAL |
|
||||
| **E05**: BIND_ORPHAN | R1~R5 | 无绑定记录 | R9 (MISSING, NONE, status 保持) | — |
|
||||
| **E06**: DATA_NULL | R9 | binding_status = MISSING ∧ data = None | R11 (ABNORMAL) | error = "数据损坏: 节点数据为空或不存在" |
|
||||
| **E07**: DEP_CHECK_FAIL | R9 | depend_fields 配置非空 ∧ ∃依赖节点: 不存在 ∨ status ≠ NORMAL ∨ data_id 为空 | R13 (DEPENDENCY_ERROR) | error = "依赖项不满足: ..."; depend_ids 填充 |
|
||||
| **E08**: AUTOBIND_DISABLED | R9 | auto_bind = False 或 auto_bind_fields 为空 | R12 (WARNING) | — |
|
||||
| **E09**: AUTOBIND_OK | R9 | 去噪后 local:remote = 1:1 ∧ 双方都是 MISSING | R6 (NORMAL) | 建立绑定记录; 双方都设为 NORMAL |
|
||||
| **E10a**: AUTOBIND_AMBIGUOUS | R9 | 去噪后非 1:1 (多候选) | R12 (WARNING) | error += "存在多个候选节点" |
|
||||
| **E10b**: AUTOBIND_KEY_MISSING | R9 | auto_bind_fields 中有字段不存在于 data | R12 (WARNING) | error += "缺少业务键字段: ..." |
|
||||
| **E10c**: AUTOBIND_ID_FAIL | R9 | 业务键包含 ID 字段 ∧ ID 解析失败 | R13 (DEPENDENCY_ERROR) | error = "业务键中的ID字段解析失败: ..." |
|
||||
| **E10d**: ORPHAN_CREATE_DISABLED | R9 | 去噪后 1:0 ∧ create_enabled = False | R12 (WARNING) | error += "孤儿且不允许自动创建" |
|
||||
|
||||
> **E04 核心状态矩阵** (完整查表):
|
||||
>
|
||||
> | | 远程: VALID | 远程: ABSENT | 远程: INVALID |
|
||||
> |---|---|---|---|
|
||||
> | **本地: VALID** | 本:NORMAL 远:NORMAL | 本:WARNING 远:ABNORMAL | 本:WARNING 远:ABNORMAL |
|
||||
> | **本地: ABSENT** | 本:ABNORMAL 远:WARNING | 本:ABNORMAL 远:ABNORMAL | — |
|
||||
> | **本地: INVALID** | 本:ABNORMAL 远:WARNING | — | 本:ABNORMAL 远:ABNORMAL |
|
||||
>
|
||||
> 不在矩阵中的组合 → 默认 (ABNORMAL, ABNORMAL)
|
||||
|
||||
> **代码**: `SyncDecisionEngine.determine_core_status()` · `strategy_ops/bind_ops.py::phase_core_state()` · `strategy_ops/bind_ops.py::check_dependencies_for_nodes()` · `strategy_ops/bind_ops.py::phase_auto_binding()`
|
||||
|
||||
---
|
||||
|
||||
#### ⚡ 阶段 2:Create / Update
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
═══════════════ Create ═══════════════
|
||||
|
||||
[R9] MISSING, NONE, PENDING (源节点, data ≠ None)
|
||||
│
|
||||
├── E11a (ID 解析成功) ────▶ 源节点: [R9] → [R6] NORMAL
|
||||
│ 新建目标节点: → [R14] NORMAL, CREATE, PENDING
|
||||
│ + 建立绑定记录, 目标 data_id = ""
|
||||
│
|
||||
└── E11b (ID 解析失败) ────▶ 源节点: [R9] → [R13] DEPENDENCY_ERROR
|
||||
|
||||
|
||||
═══════════════ Update ═══════════════
|
||||
|
||||
[R6/R7] NORMAL, NONE, PENDING/SUCCESS (源节点, status ≠ FAILED)
|
||||
│
|
||||
├── E11c (差异检测通过) ────▶ 目标节点 → [R18] NORMAL, UPDATE, PENDING
|
||||
├── E11d.1 (目标 data_id="") ▶ 目标节点 → [R22] status = PRECONDITION_FAILED
|
||||
├── E11d.2 (ID 解析失败) ───▶ 目标节点 → [R22] action = UPDATE, status = PRECOND_FAIL
|
||||
└── (无差异) ──────────────▶ 保持不变
|
||||
|
||||
|
||||
═══════════════ FAILED 保护 ═══════════════
|
||||
|
||||
[R8] NORMAL, NONE, FAILED (源节点)
|
||||
│
|
||||
└── E11e ──────────────────▶ 跳过,保持 [R8] 不变
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 (源节点) | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E11a**: CREATE_OK | R9 (MISSING) | data ≠ None ∧ IDResolver.resolve_and_validate() = OK | 源 → R6 (NORMAL); **新建**目标 → R14 | 建立绑定; 新节点 data = 已替换 ID 的副本, data_id = "" |
|
||||
| **E11b**: CREATE_DEP_FAIL | R9 (MISSING) | data ≠ None ∧ IDResolver.resolve_and_validate() = FAIL | R13 (DEPENDENCY_ERROR) | error = "创建时依赖 ID 未解析: ..." |
|
||||
| **E11c**: UPDATE_DIFF | R6/R7 (NORMAL, status≠FAILED) | 目标存在 ∧ 目标 data_id ≠ "" ∧ ID 解析 OK ∧ _needs_update() = True | 目标 → R18 (UPDATE, PENDING) | 目标: data 替换, error = None |
|
||||
| **E11d**: UPDATE_PRECOND_FAIL | R6/R7 (NORMAL, status≠FAILED) | 目标 data_id = "" (d1) 或 ID 解析失败 (d2) | 目标 → R22 (UPDATE, PRECONDITION_FAILED) | error = "目标缺少 data_id" 或 "依赖 ID 未解析" |
|
||||
| **E11e**: UPDATE_SKIP_FAILED | R8 (NORMAL, FAILED) | status = FAILED | R8 不变 | 保护上次的 error 和 FAILED 状态,不做任何修改 |
|
||||
|
||||
> **代码**: `strategy_ops/create_ops.py::add_create_action()` · `strategy_ops/update_ops.py::add_update_action()`
|
||||
|
||||
---
|
||||
|
||||
#### 📡 阶段 3:Sync(物理同步)
|
||||
|
||||
**状态转换图:**
|
||||
|
||||
```
|
||||
[R14] NORMAL, CREATE, PENDING [R18] NORMAL, UPDATE, PENDING
|
||||
│ │
|
||||
└───────────────┬───────────────────┘
|
||||
│
|
||||
├── E12 (API 成功) ──────▶ [R16] CREATE,SUCCESS / [R20] UPDATE,SUCCESS
|
||||
├── E13 (API 失败) ──────▶ [R17] CREATE,FAILED / [R21] UPDATE,FAILED
|
||||
├── E16 (Handler 跳过) ──▶ [R23] CREATE, SKIPPED
|
||||
├── E17 (Handler 降级) ──▶ [R6] NORMAL, NONE, PENDING (仅 UPDATE)
|
||||
│
|
||||
└── E14 (异步提交) ──────▶ [R15] CREATE,IN_PROGRESS / [R19] UPDATE,INP
|
||||
│
|
||||
├── (轮询成功) ──▶ [R16] / [R20] SUCCESS
|
||||
└── E15 (超时) ──▶ [R17] / [R21] FAILED
|
||||
```
|
||||
|
||||
**转换表:**
|
||||
|
||||
| 事件 | 起始状态 | 判断条件 (Guard) | 结束状态 | 副作用 |
|
||||
|------|---------|-----------------|---------|--------|
|
||||
| **E12**: SYNC_OK | R14 或 R18 | Handler API 返回 SUCCESS | R16 (CREATE) 或 R20 (UPDATE) | error → None; data_id 回填 (CREATE); origin_data 更新 (UPDATE) |
|
||||
| **E13**: SYNC_FAIL | R14 或 R18 | Handler API 返回 FAILED | R17 (CREATE) 或 R21 (UPDATE) | error = Handler 返回的错误信息 |
|
||||
| **E14**: SYNC_ASYNC | R14 或 R18 | Handler API 返回 IN_PROGRESS | R15 (CREATE) 或 R19 (UPDATE) | 记录 task_id, 进入轮询队列 |
|
||||
| **E15**: SYNC_TIMEOUT | R15 或 R19 | 轮询次数 > max_retries | R17 (CREATE) 或 R21 (UPDATE) | error = "Async task timeout after N seconds" |
|
||||
| **E16**: HANDLER_SKIP | R14 或 R18 | Handler API 返回 SKIPPED | R23 (CREATE,SKIPPED) | error → None |
|
||||
| **E17**: HANDLER_DOWNGRADE | R18 (仅 UPDATE) | Handler 运行时发现无实际变更字段 | R6 (NORMAL, NONE, PENDING) | action: UPDATE → NONE; 不提交 API; 统计列从 update 移入 none |
|
||||
|
||||
> **代码**: `BaseDataSource._handle_task_result()` · `_poll_async_tasks()` · `domain/*/api_handler.py`
|
||||
|
||||
---
|
||||
|
||||
## §4 终态分类与死胡同
|
||||
|
||||
### §4.1 终态分类
|
||||
|
||||
| 分类 | 状态 | (binding_status, action, status) | 语义 | 下一轮行为 |
|
||||
|------|------|------|------|---------|
|
||||
| ✅ 成功 | R16 | (NORMAL, CREATE, SUCCESS) | 创建成功 | →R2→bind→R7→update 检查 |
|
||||
| ✅ 成功 | R20 | (NORMAL, UPDATE, SUCCESS) | 更新成功 | →R2→bind→R7→update 检查 |
|
||||
| ❌ 失败 | R17 | (NORMAL, CREATE, FAILED) | 创建失败 | E02 zombie 判定→删除节点→对方回到 R9 重建 |
|
||||
| ❌ 失败 | R21 | (NORMAL, UPDATE, FAILED) | 更新失败 | →R3→bind→R8→FAILED 保护(E11e)→保持 |
|
||||
| 🔒 阻断 | R11 | (ABNORMAL, NONE, \*) | 数据损坏 | 每轮重新 bind 检查; 需人工修复数据 |
|
||||
| 🔒 阻断 | R12 | (WARNING, NONE, \*) | 多候选/对方异常 | 每轮重新 bind 检查; 需人工绑定或修复 |
|
||||
| 🔒 阻断 | R13 | (DEPENDENCY_ERROR, NONE, PENDING) | 依赖不满足 | 每轮重新检查依赖; 依赖修复后自动恢复 |
|
||||
| ⏭ 跳过 | R23 | (NORMAL, CREATE, SKIPPED) | Handler 跳过创建 | →R4→bind→R6→update 检查(无绑定则再 create) |
|
||||
| 🔄 异步 | R15 | (NORMAL, CREATE, IN\_PROGRESS) | 创建异步中 | 轮询直到 SUCCESS 或超时→FAILED |
|
||||
| 🔄 异步 | R19 | (NORMAL, UPDATE, IN\_PROGRESS) | 更新异步中 | 轮询直到 SUCCESS 或超时→FAILED |
|
||||
| 🔄 前置失败 | R22 | (NORMAL, UPDATE, PRECONDITION\_FAILED) | 前置条件不满足 | →R5→bind→R6/R7→update 重试 |
|
||||
|
||||
### §4.2 恢复路径
|
||||
|
||||
```
|
||||
R17 (CREATE, FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, CREATE, FAILED)
|
||||
→ E02: action=CREATE ∧ (status=FAILED ∨ data_id="") → 满足 zombie 条件
|
||||
→ 删除节点 + 解除绑定
|
||||
→ 对方节点回到 R9 (MISSING) → 重新进入 create 流程
|
||||
|
||||
R21 (UPDATE, FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, UPDATE, FAILED)
|
||||
→ E02: action=UPDATE ≠ CREATE → 非 zombie,存活
|
||||
→ E03: action → NONE → R3 (UNCHECKED, NONE, FAILED)
|
||||
→ bind → R8 (NORMAL, NONE, FAILED)
|
||||
→ E11e: FAILED 保护 → 跳过 update → 保持 R8
|
||||
⚠ 需人工清除 FAILED 才能恢复
|
||||
|
||||
R22 (UPDATE, PRECONDITION_FAILED):
|
||||
持久化 → 下轮 E01 → 瞬态(UNCHECKED, UPDATE, PRECONDITION_FAILED)
|
||||
→ E02: action=UPDATE ≠ CREATE → 非 zombie,存活
|
||||
→ E03: action → NONE → R5 (UNCHECKED, NONE, PRECONDITION_FAILED)
|
||||
→ bind → R6/R7 (NORMAL) → update 流程重试
|
||||
|
||||
R11 (ABNORMAL):
|
||||
每轮 E01 重置 → bind 重新检查
|
||||
→ 若数据被人工修复 → E04 → R6/R7/R8 (NORMAL)
|
||||
→ 若数据仍损坏 → 再次 R11
|
||||
|
||||
R12 (WARNING):
|
||||
每轮 E01 重置 → bind 重新检查
|
||||
→ 若人工建立了绑定 → E04 → R6/R7 (NORMAL)
|
||||
→ 若对方数据被修复 → E04 → R6/R7 (NORMAL)
|
||||
|
||||
R13 (DEPENDENCY_ERROR):
|
||||
每轮 E01 重置 → bind 重新检查依赖
|
||||
→ 依赖节点达到 NORMAL 且 data_id 非空后
|
||||
→ E07 通过 → 继续 auto_bind 或 create 流程
|
||||
```
|
||||
|
||||
### §4.3 FAILED 保护机制详解
|
||||
|
||||
FAILED 保护确保失败节点不会被后续流程静默覆盖,保留 error 信息供人工调查。
|
||||
|
||||
```
|
||||
进入条件:
|
||||
节点经过 bind 后为 R8 (NORMAL, NONE, FAILED)
|
||||
即: 绑定关系正常,但上一轮执行失败
|
||||
|
||||
保护行为:
|
||||
`strategy_ops/update_ops.py::add_update_action()` 中:
|
||||
if source.status == SyncStatus.FAILED:
|
||||
skip (→ 事件 E11e)
|
||||
结果: R8 原样保持 → 持久化 → 下一轮依然 R8
|
||||
|
||||
解除条件 (需人工介入):
|
||||
1. 清除 error 字段 (→ None)
|
||||
2. 将 status 改为 PENDING 或 SUCCESS
|
||||
3. 下轮 bind 后 → R6 或 R7 → 正常进入 update 流程
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §5 完备性分析
|
||||
|
||||
### §5.1 状态 × 事件矩阵
|
||||
|
||||
行 = 当前状态,列 = 可能触发的事件。`→Rx` = 转换到目标状态,`—` = 不可能触发。
|
||||
|
||||
| 状态 | E01 | E02 | E03 | E04/E05 | E06~E10 | E11a/b | E11c/d/e | E12~E17 |
|
||||
|------|-----|-----|-----|---------|---------|--------|----------|---------|
|
||||
| R1~R5 | — | — | — | →R6~R13 | — | — | — | — |
|
||||
| R6/R7 | →瞬态 | — | — | — | — | — | →R18/R22 | — |
|
||||
| R8 | →瞬态 | — | — | — | — | — | →R8(skip) | — |
|
||||
| R9 | →瞬态 | — | — | — | →R6/R11~R13 | →R6+R14/R13 | — | — |
|
||||
| R11~R13 | →瞬态 | — | — | — | — | — | — | — |
|
||||
| R14 | →瞬态 | — | — | — | — | — | — | →R15~R17/R23 |
|
||||
| R15 | →瞬态 | — | — | — | — | — | — | →R16/R17 |
|
||||
| R18 | →瞬态 | — | — | — | — | — | — | →R6/R19~R21/R23 |
|
||||
| R19 | →瞬态 | — | — | — | — | — | — | →R20/R21 |
|
||||
| R16/R17 | →瞬态 | — | — | — | — | — | — | — |
|
||||
| R20~R23 | →瞬态 | — | — | — | — | — | — | — |
|
||||
|
||||
> **读表方式**: 找到当前状态行和事件列组的交叉点,即可得到目标状态。`—` 表示该状态不可能触发该事件。
|
||||
|
||||
### §5.2 已知 GAP
|
||||
|
||||
| GAP | 描述 | 风险 | 建议 |
|
||||
|-----|------|------|------|
|
||||
| GAP-1 | `set_binding_status()` / `set_action()` / `set_status()` 是纯 setter,不验证转换合法性 | 中 | 加入前置断言:检查 (当前状态, 新状态) 是否匹配某条已知转换 |
|
||||
| GAP-2 | INV-1 ~ INV-10 未在代码中 assert | 中 | 每次 setter 调用后或阶段结束时断言所有不变量 |
|
||||
| GAP-3 | R22 (PRECONDITION\_FAILED) 节点下一轮行为路径需验证 | 低 | 已补充恢复路径: R22→E01→E03→R5→bind→R6/R7→update 重试 |
|
||||
| GAP-4 | Handler 降级 (E17) 后 status 残留为 PENDING 但 action 已清零 | 低 | 可考虑降级时同时重置 status 为 SUCCESS(当前保持 PENDING) |
|
||||
| GAP-5 | 物理同步后缺少整体一致性检查 (post-sync invariant check) | 低 | 在 `sync_all()` 结束后遍历所有节点验证 INV-1 ~ INV-10 |
|
||||
|
||||
---
|
||||
|
||||
## §6 引用
|
||||
|
||||
| 文档 | 路径 |
|
||||
|------|------|
|
||||
| 枚举定义 | `sync_system_new/common/types.py` |
|
||||
| 决策引擎 | `sync_system_new/sync_system/decision.py` |
|
||||
| 重置策略 | `sync_system_new/common/state_reset.py` |
|
||||
| 同步策略 | `sync_system_new/sync_system/strategy.py` |
|
||||
| 物理同步 | `sync_system_new/datasource/datasource.py` |
|
||||
| ID 解析 | `sync_system_new/sync_system/resolve_ids.py` |
|
||||
| 节点生命周期(叙事) | [节点生命周期状态转移](./节点生命周期状态转移.md) |
|
||||
@@ -0,0 +1,534 @@
|
||||
# 同步编排流程规范 (Pipeline Specification)
|
||||
|
||||
本文档定义了完整的同步编排流程,包括各阶段的职责、节点状态转换和检查点。
|
||||
|
||||
> **详细的状态定义和转换规则,请参考**: [STATE_DEFINITIONS.md](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
---
|
||||
|
||||
## 流程概览
|
||||
|
||||
同步系统的完整执行流程分为 5 个阶段:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 0: 初始化 (Initialization) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - 创建 Collection (Local/Remote) │
|
||||
│ - 创建 BindingManager │
|
||||
│ - 注册 SyncStrategy (各业务类型) │
|
||||
│ - 创建 DataSource (Local/Remote) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 1: 数据加载 (Data Loading) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 节点状态: binding_status=UNCHECKED, action=NONE, status=PENDING │
|
||||
│ - Persistence.load_nodes() → 从持久化恢复 │
|
||||
│ - DataSource.load_all() → 从数据源加载 │
|
||||
│ - BindingManager.load_bindings() → 加载绑定关系 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 2 & 3: 策略执行与同步 (按 node_type 顺序) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ For each node_type in sync_order: │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ 2.1 bind() → 绑定判定 │ │
|
||||
│ │ 2.2 create() → 设置 CREATE action │ │
|
||||
│ │ └─ 内联 ID 解析 + 前置检查 │ │
|
||||
│ │ 2.3 Commit Creates → 立即同步 CREATE 节点 │ │
|
||||
│ │ └─ DataSource.sync_all(poll_async=True) │ │
|
||||
│ │ 2.4 update() → 设置 UPDATE action │ │
|
||||
│ │ └─ 内联 ID 解析 + 前置检查 │ │
|
||||
│ │ 2.5 Commit Updates → 立即同步 UPDATE 节点 │ │
|
||||
│ │ └─ DataSource.sync_all(poll_async=True) │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 说明: │
|
||||
│ - 每个 node_type 完整同步后才处理下一个(保证依赖顺序) │
|
||||
│ - commit 操作内部包含异步轮询,无需独立轮询阶段 │
|
||||
│ - sync_all() 自动过滤 action=CREATE/UPDATE 的节点 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 4: 同步结果报告 (Summary Report) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - 打印同步结果摘要 │
|
||||
│ * 节点统计(按 action/status 分类) │
|
||||
│ * 绑定关系统计 │
|
||||
│ * 异常节点检查(data_id 缺失、绑定缺失等) │
|
||||
│ - 收集统计信息(成功/失败/残留问题) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Stage 5: 状态持久化 (State Persistence) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ - Collection.persist() → 保存节点状态 │
|
||||
│ - BindingManager.persist() → 保存绑定关系 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
【流程完成】
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 各阶段节点状态
|
||||
|
||||
### Stage 0: 初始化
|
||||
|
||||
**输出**: 所有组件实例化完成。
|
||||
|
||||
---
|
||||
|
||||
### Stage 1: 数据加载
|
||||
|
||||
**节点状态**:
|
||||
```python
|
||||
binding_status = BindingStatus.UNCHECKED # 未判定
|
||||
action = SyncAction.NONE # 无动作
|
||||
status = SyncStatus.PENDING # 待处理
|
||||
data_id = <从数据源加载> # 已有业务ID
|
||||
origin_data = <从数据源加载> # 原始数据快照
|
||||
data = <从数据源加载> # 当前数据
|
||||
depend_ids = <从数据源加载> # 依赖节点列表
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- `data_id` 在加载时**必定有值**(来自数据源)
|
||||
- 只有待创建的新节点 `data_id` 才可能为 ""(空字符串)
|
||||
- 从持久化恢复时应重置所有 `action` 为 `NONE`
|
||||
- `binding_status` 初始值是 `UNCHECKED`,不是 `None`
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.1: Bind (状态判定)
|
||||
|
||||
**状态转换**: `binding_status: UNCHECKED → NORMAL/MISSING/ABNORMAL/WARNING/DEPENDENCY_ERROR`
|
||||
|
||||
**可能的结果状态**:
|
||||
|
||||
| 结果状态 | 含义 | 占比 |
|
||||
|---------|------|-----|
|
||||
| **NORMAL** | 有绑定,数据健康 | 大部分 |
|
||||
| **MISSING** | 无绑定,孤儿节点 | 少量 |
|
||||
| **ABNORMAL** | 有绑定,自身数据损坏 | 极少 |
|
||||
| **WARNING** | 有绑定,对方数据损坏 | 极少 |
|
||||
| **DEPENDENCY_ERROR** | 依赖未满足 | 少量 |
|
||||
|
||||
**详细规则**: 参考 [STATE_DEFINITIONS.md § 1-3](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
**补充约束**:
|
||||
- 若依赖节点 `binding_status = NORMAL` 但 `data_id` 为空(含 `status = FAILED`),视为依赖阻断。
|
||||
- 只要依赖节点已回填 `data_id`,即使其 `status = FAILED`,后续类型仍允许进入 Create/Update。
|
||||
- 自动绑定只在**业务键完整且唯一**时才尝试:
|
||||
- 若业务键字段缺失(如 `serial_number = null`),设置 `binding_status = WARNING`,并在 `node.error` 记录原因:`Auto-bind skipped: missing fields ...`。
|
||||
- 若同一业务键出现多对多/多对一,设置 `binding_status = WARNING`,并在 `node.error` 记录原因:`Auto-bind skipped: ambiguous candidates`。
|
||||
- 以上情况仅记录 **Warning**,不进行自动绑定。
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.2: Create (孤儿处理)
|
||||
|
||||
**状态转换**: `action: NONE → CREATE` (对 MISSING 节点)
|
||||
|
||||
**补充约束**:
|
||||
- `binding_status = WARNING` 的节点不会进入 CREATE(`create()` 只查找 MISSING 节点)
|
||||
- 创建时会通过 `IDResolver.resolve_and_validate()` 二次验证依赖 ID,失败则设置 `DEPENDENCY_ERROR`
|
||||
|
||||
**副作用**:
|
||||
- 在对方 Collection 创建新节点
|
||||
- 新节点的 `binding_status = NORMAL, action = CREATE, data_id = ""`
|
||||
- 建立绑定记录
|
||||
- 原 MISSING 节点也变为 `NORMAL`
|
||||
|
||||
**示例**:
|
||||
```
|
||||
Before:
|
||||
local_node: binding_status=MISSING, action=NONE
|
||||
|
||||
After Create:
|
||||
local_node: binding_status=NORMAL, action=NONE
|
||||
remote_node (新建): binding_status=NORMAL, action=CREATE, data_id=""
|
||||
绑定记录: local_id ↔ remote_temp_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Stage 2.3: Update (差异同步)
|
||||
|
||||
**状态转换**: `action: NONE → UPDATE` (对 NORMAL 且有差异的节点)
|
||||
|
||||
**前提条件**:
|
||||
- `binding_status = NORMAL`
|
||||
- `status != FAILED`(保护上次失败的状态和错误信息)
|
||||
- `compute_diff()` 判定有差异
|
||||
- 配置的 `update_direction` 决定哪一方节点设置 UPDATE
|
||||
|
||||
**内联检查**:
|
||||
- 目标节点 `data_id` 为空 → `status = PRECONDITION_FAILED`
|
||||
- `IDResolver.resolve_and_validate()` 失败 → `status = PRECONDITION_FAILED`
|
||||
- Handler 校验后无实际变更字段 → `action` 降级为 `NONE`(不提交)
|
||||
|
||||
---
|
||||
|
||||
### Stage 2 & 3: 策略执行与同步
|
||||
|
||||
**执行顺序**: 按 `sync_order` 顺序逐个处理 node_type
|
||||
|
||||
**单个 node_type 的完整流程**:
|
||||
|
||||
```
|
||||
For node_type in sync_order:
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.1 Bind - 绑定判定 │
|
||||
│ - 设置 binding_status: NORMAL/MISSING/... │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.2 Create - 创建策略 │
|
||||
│ - 处理 MISSING 节点,设置 action=CREATE │
|
||||
│ - 内联 ID 解析验证 (resolve_and_validate) │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.3 Commit Creates - 立即同步 CREATE │
|
||||
│ - DataSource.sync_all(poll_async=True) │
|
||||
│ - 自动过滤 action=CREATE 的节点 │
|
||||
│ - 内部包含异步轮询(IN_PROGRESS→SUCCESS)│
|
||||
│ - ⭐ 执行 data_id 回填和绑定更新 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.4 Update - 更新策略 │
|
||||
│ - 处理差异节点,设置 action=UPDATE │
|
||||
│ - 内联 ID 解析验证 + data_id 检查 │
|
||||
│ - FAILED 节点自动跳过 │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 2.5 Commit Updates - 立即同步 UPDATE │
|
||||
│ - DataSource.sync_all(poll_async=True) │
|
||||
│ - 自动过滤 action=UPDATE 的节点 │
|
||||
│ - 内部包含异步轮询(IN_PROGRESS→SUCCESS)│
|
||||
└─────────────────────────────────────────────┘
|
||||
↓
|
||||
下一个 node_type
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
1. **按类型顺序**: 每个 node_type 完全同步后才处理下一个(保证依赖顺序)
|
||||
2. **立即提交**: create/update 后立即同步,不等待所有策略执行完
|
||||
3. **内置轮询**: `sync_all(poll_async=True)` 内部自动轮询异步任务
|
||||
4. **data_id 回填**: CREATE 成功后立即回填 data_id,下游依赖可用
|
||||
|
||||
**同步状态转换**:
|
||||
|
||||
**同步接口(一步式)**:
|
||||
```
|
||||
action=CREATE, API 成功 → status=SUCCESS, data_id=<远程ID>
|
||||
action=CREATE, API 失败 → status=FAILED, error=<错误信息>
|
||||
action=UPDATE, API 成功 → status=SUCCESS
|
||||
action=UPDATE, API 失败 → status=FAILED, error=<错误信息>
|
||||
action=NONE → status=PENDING (不执行)
|
||||
```
|
||||
|
||||
**异步接口(两步式,内置轮询)**:
|
||||
```
|
||||
action=CREATE, 提交成功 → status=IN_PROGRESS, task_id=<任务ID>
|
||||
status=IN_PROGRESS, 轮询中... → status=IN_PROGRESS (保持)
|
||||
status=IN_PROGRESS, 轮询成功 → status=SUCCESS, data_id=<远程ID>
|
||||
status=IN_PROGRESS, 轮询失败 → status=FAILED, error=<错误信息>
|
||||
status=IN_PROGRESS, 超时 → status=FAILED, error="Timeout"
|
||||
```
|
||||
|
||||
**关键副作用**(由 DataSource 在 sync_all 中执行):
|
||||
- CREATE 成功: `data_id` 从 "" 更新为远程分配的 ID,并回写 `data` 主键字段
|
||||
- CREATE 成功: 绑定记录从临时ID更新为真实ID
|
||||
- UPDATE 成功: `origin_data` 更新为当前 `data`
|
||||
- DELETE 成功: 移除绑定记录
|
||||
|
||||
**skip_sync 处理**:
|
||||
- 如果 `strategy.skip_sync = True`,跳过 create/update/commit 步骤
|
||||
- bind 步骤始终执行(用于状态判定)
|
||||
|
||||
**详细规则**: 参考 [STATE_DEFINITIONS.md § 5](../sync_system/STATE_DEFINITIONS.md)
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: 同步结果报告
|
||||
|
||||
**目标**: 打印同步结果摘要,帮助用户了解同步情况
|
||||
|
||||
**报告内容**:
|
||||
1. **节点统计**:按 node_type 统计各种 action/status 的节点数量
|
||||
2. **绑定关系**:每个 node_type 的绑定记录数
|
||||
3. **异常检查**:扫描并报告异常情况:
|
||||
- CREATE 成功但 data_id 为空(DataSource 回填失败)
|
||||
- CREATE 成功但绑定记录缺失
|
||||
- UPDATE/CREATE 失败的节点及错误原因
|
||||
|
||||
**输出示例**:
|
||||
```
|
||||
================================================================================
|
||||
📊 Local Collection Summary
|
||||
================================================================================
|
||||
Node Type Bindings Create Update Delete None Total
|
||||
--------------------------------------------------------------------------------
|
||||
project 5/5 0(0/0/0) 2(2/0/0) 0(0/0/0) 3 5(5/0/0)
|
||||
contract 10/12 2(2/0/0) 5(5/0/0) 0(0/0/0) 5 12(12/0/0)
|
||||
```
|
||||
|
||||
**注意**: 此阶段不修改任何状态,仅作为信息展示。
|
||||
|
||||
---
|
||||
|
||||
### Stage 5: 状态持久化
|
||||
|
||||
**持久化内容**:
|
||||
- `binding_status`, `action`, `status` 的最终值
|
||||
- `data_id` (可能在 CREATE 后更新)
|
||||
- `error` (如果失败)
|
||||
- 绑定关系的完整映射
|
||||
|
||||
---
|
||||
|
||||
## 错误处理策略
|
||||
|
||||
| 阶段 | 失败类型 | 处理策略 |
|
||||
|------|---------|---------|
|
||||
| Stage 0 | 初始化失败 | **终止流程**,抛出异常 |
|
||||
| Stage 1 | 数据加载失败 | **终止流程**,抛出异常 |
|
||||
| Stage 2 & 3 | 代码异常 | **终止流程**,抛出异常 |
|
||||
| Stage 2: create/update 内联检查 | 依赖ID未解析、data_id缺失 | **继续流程**,节点标记为 DEPENDENCY_ERROR 或 PRECONDITION_FAILED |
|
||||
| Stage 2 & 3 | API 调用失败 | **继续流程**,标记节点 FAILED |
|
||||
| Stage 4 | - | 总是继续,仅作为报告 |
|
||||
| Stage 5 | 持久化失败 | **记录错误**,不影响内存状态 |
|
||||
|
||||
---
|
||||
|
||||
## 节点状态完整时间线
|
||||
|
||||
```
|
||||
┌──────────────┬─────────────┬────────┬────────┬─────────┐
|
||||
│ 阶段 │ binding_ │ action │ status │ data_id │
|
||||
│ │ status │ │ │ │
|
||||
├──────────────┼─────────────┼────────┼────────┼─────────┤
|
||||
│ 数据加载 │ UNCHECKED │ NONE │PENDING │ 已加载 │
|
||||
│ Bind │ NORMAL/... │ NONE │PENDING │ 已加载 │
|
||||
│ Create │ NORMAL │ CREATE │PENDING │ ""* │
|
||||
│ Commit Create│ NORMAL │ CREATE │SUCCESS │ 远程ID** │
|
||||
│ Update │ NORMAL │ UPDATE │PENDING │ 已加载 │
|
||||
│ Commit Update│ NORMAL │ UPDATE │SUCCESS │ 已加载 │
|
||||
└──────────────┴─────────────┴────────┴────────┴─────────┘
|
||||
|
||||
* 新创建的节点 data_id 初始为 ""
|
||||
** CREATE 成功后 data_id 立即更新为远程分配的ID,下游依赖可用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置驱动的流程控制
|
||||
|
||||
### 跳过某些阶段
|
||||
|
||||
```python
|
||||
pipeline_config = {
|
||||
"skip_bind": False, # 是否跳过 bind
|
||||
"skip_create": False, # 是否跳过 create
|
||||
"skip_update": False, # 是否跳过 update
|
||||
"skip_post_check": False, # 是否跳过同步后检查
|
||||
"fail_on_warning": False, # Pre-Check 时是否将 WARNING 视为失败
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:`skip_pre_check` 已移除,原 `_pre_commit_check` 逻辑已内联到 create/update 方法中。
|
||||
```
|
||||
|
||||
### 策略预设应用
|
||||
|
||||
```python
|
||||
# 首次同步:启用自动绑定,双向创建
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("first_sync")
|
||||
|
||||
# 日常同步:仅推送更新
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("push_only")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 幂等性保证
|
||||
|
||||
**要求**: 同一个 Collection 的多次同步应该是幂等的。
|
||||
|
||||
**设计保证**:
|
||||
1. **Bind 阶段**: 基于绑定记录判定,已绑定节点不会重复绑定
|
||||
2. **Create 阶段**: 已有绑定的节点 (NORMAL) 不会重复创建
|
||||
3. **Update 阶段**: 基于 `compute_diff()` 判定,无差异则不执行
|
||||
4. **Sync 阶段**: `action=NONE` 的节点跳过执行
|
||||
|
||||
**前提条件**:
|
||||
- 绑定记录正确持久化
|
||||
- 节点的 `binding_status` 正确保存和加载
|
||||
- `compute_diff()` 逻辑准确
|
||||
|
||||
---
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 关键指标
|
||||
|
||||
| 指标 | 含义 | 阶段 |
|
||||
|------|------|-----|
|
||||
| `nodes_loaded` | 加载的节点总数 | Stage 1 |
|
||||
| `nodes_normal` | NORMAL 状态节点数 | Stage 2.1 |
|
||||
| `nodes_missing` | MISSING 状态节点数 | Stage 2.1 |
|
||||
| `nodes_abnormal` | ABNORMAL+WARNING 节点数 | Stage 2.1 |
|
||||
| `nodes_dep_error` | DEPENDENCY_ERROR 节点数 | Stage 2.1 |
|
||||
| `actions_create` | CREATE 动作节点数 | Stage 2.2 |
|
||||
| `actions_update` | UPDATE 动作节点数 | Stage 2.3 |
|
||||
| `sync_success` | 同步成功节点数 | Stage 3 |
|
||||
| `sync_failed` | 同步失败节点数 | Stage 3 |
|
||||
| `success_rate` | 成功率 (success/total) | Stage 4 |
|
||||
|
||||
### 日志级别
|
||||
|
||||
```
|
||||
INFO: 流程阶段切换、统计信息
|
||||
DEBUG: 节点级别的详细操作
|
||||
WARN: 检查发现的警告、同步失败
|
||||
ERROR: 检查发现的错误、致命异常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [STATE_DEFINITIONS.md](../sync_system/STATE_DEFINITIONS.md) - 详细的状态定义和转换规则
|
||||
- [CHECK_SPEC.md](./CHECK_SPEC.md) - 检查点的实现细节和代码示例(可选)
|
||||
# 1. 持久化 Collection
|
||||
await local_collection.persist()
|
||||
await remote_collection.persist()
|
||||
|
||||
# 2. 持久化 Binding
|
||||
await binding_manager.persist()
|
||||
```
|
||||
|
||||
**持久化内容**:
|
||||
- 节点的 `binding_status`, `action`, `status`, `error`
|
||||
- 节点的 `data_id`(可能在 CREATE 后更新)
|
||||
- 绑定关系的完整映射
|
||||
|
||||
---
|
||||
|
||||
## 错误处理策略
|
||||
|
||||
### 各阶段的失败处理
|
||||
|
||||
| 阶段 | 失败原因示例 | 处理策略 |
|
||||
|------|------------|---------|
|
||||
| Stage 1: 数据加载 | 网络错误、文件缺失 | **终止流程**,抛出异常 |
|
||||
| Stage 2.1: Bind | 代码bug | **终止流程**,抛出异常 |
|
||||
| Stage 2.2-2.3: Create/Update | 代码bug | **终止流程**,抛出异常 |
|
||||
| Stage 2: create/update 内联检查 | 依赖ID未解析、data_id缺失 | **继续流程**,节点标记为 DEPENDENCY_ERROR 或 PRECONDITION_FAILED |
|
||||
| Stage 3: Physical Sync | API错误、权限不足 | **继续流程**,标记节点为 FAILED |
|
||||
| Stage 4: Post-Sync Check | 部分节点同步失败 | **继续流程**,打印警告 |
|
||||
| Stage 5: Persistence | 磁盘满、权限错误 | **记录错误**,但不影响内存状态 |
|
||||
|
||||
### 部分成功的处理
|
||||
|
||||
在 Stage 3 中,允许部分节点成功、部分失败:
|
||||
- 成功的节点: `status = SUCCESS`
|
||||
- 失败的节点: `status = FAILED`, `error = "错误信息"`
|
||||
|
||||
Post-Sync Check 会统计成功率并给出警告。
|
||||
|
||||
---
|
||||
|
||||
## 配置驱动的流程控制
|
||||
|
||||
### 跳过某些阶段
|
||||
|
||||
通过配置控制哪些阶段执行:
|
||||
|
||||
```python
|
||||
pipeline_config = {
|
||||
"skip_bind": False, # 是否跳过 bind
|
||||
"skip_create": False, # 是否跳过 create
|
||||
"skip_update": False, # 是否跳过 update
|
||||
"skip_post_check": False, # 是否跳过同步后检查
|
||||
"fail_on_warning": False, # 是否将 WARNING 也视为失败
|
||||
}
|
||||
```
|
||||
|
||||
### 策略预设应用
|
||||
|
||||
在 Stage 2 之前,可以应用策略预设:
|
||||
|
||||
```python
|
||||
# 首次同步:启用自动绑定,双向创建
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("first_sync")
|
||||
|
||||
# 日常同步:仅推送更新
|
||||
for strategy in strategies:
|
||||
strategy.apply_preset("push_only")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 监控和日志
|
||||
|
||||
### 关键指标
|
||||
|
||||
在各阶段收集以下指标:
|
||||
|
||||
| 指标 | 含义 |
|
||||
|------|------|
|
||||
| `nodes_loaded` | 加载的节点总数 |
|
||||
| `nodes_bound` | 成功绑定的节点数 |
|
||||
| `nodes_missing` | MISSING 状态的节点数 |
|
||||
| `nodes_abnormal` | ABNORMAL/WARNING 状态的节点数 |
|
||||
| `nodes_created` | CREATE 动作的节点数 |
|
||||
| `nodes_updated` | UPDATE 动作的节点数 |
|
||||
| `sync_success` | 同步成功的节点数 |
|
||||
| `sync_failed` | 同步失败的节点数 |
|
||||
| `errors_count` | 检查发现的 ERROR 数量 |
|
||||
| `warnings_count` | 检查发现的 WARNING 数量 |
|
||||
|
||||
### 日志级别
|
||||
|
||||
```
|
||||
INFO: 流程阶段切换、统计信息
|
||||
DEBUG: 节点级别的详细操作
|
||||
WARN: 检查发现的警告
|
||||
ERROR: 检查发现的错误、异常
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 幂等性保证
|
||||
|
||||
**要求**: 同一个 Collection 的多次同步应该是幂等的。
|
||||
|
||||
**设计保证**:
|
||||
1. **Bind 阶段**: 基于绑定记录判定状态,已绑定的节点不会重复绑定
|
||||
2. **Create 阶段**: 已有绑定的节点(NORMAL)不会重复创建
|
||||
3. **Update 阶段**: 基于 `compute_diff()` 判定,无差异则不执行
|
||||
4. **Sync 阶段**: DataSource 根据 `action` 执行,`action=NONE` 的节点跳过
|
||||
|
||||
**前提条件**:
|
||||
- Binding 记录正确持久化
|
||||
- 节点的 `binding_status` 正确保存和加载
|
||||
- `compute_diff()` 逻辑准确
|
||||
|
||||
---
|
||||
|
||||
## 下一步
|
||||
|
||||
详细的检查规范和状态转换规则,请参考:
|
||||
- [CHECK_SPEC.md](./CHECK_SPEC.md) - 检查点详细规范
|
||||
- [STATE_TRANSITIONS.md](./STATE_TRANSITIONS.md) - 状态转换表
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# 节点生命周期状态转移 (Node Lifecycle State Transitions)
|
||||
|
||||
本文档从一个**单节点视角**出发,完整描述一个 `SyncNode` 从诞生到持久化的全部状态变化。
|
||||
|
||||
> **代码映射**:
|
||||
> - 持久化重置:`sync_system_new/common/state_reset.py` → `NodeStateReset`
|
||||
> - 绑定判定:`sync_system_new/sync_system/decision.py` → `SyncDecisionEngine`
|
||||
> - 策略执行:`sync_system_new/sync_system/strategy.py` → `DefaultSyncStrategy`
|
||||
> - ID 解析:`sync_system_new/sync_system/resolve_ids.py` → `IDResolver`
|
||||
> - 物理同步:`sync_system_new/datasource/datasource.py` → `BaseDataSource`
|
||||
> - Handler 降级:`sync_system_new/domain/*/api_handler.py`
|
||||
|
||||
---
|
||||
|
||||
## 状态字段一览
|
||||
|
||||
| 字段 | 类型 | 含义 | 谁负责设置 |
|
||||
|------|------|------|-----------|
|
||||
| `binding_status` | BindingStatus | 绑定健康状态 | Strategy.bind() |
|
||||
| `action` | SyncAction | 待执行动作 | Strategy.create() / update() |
|
||||
| `status` | SyncStatus | 执行结果状态 | DataSource.sync_all() |
|
||||
| `data_id` | str | 业务主键ID | 数据加载 / DataSource 回填 |
|
||||
| `error` | str \| None | 错误信息 | 各阶段 |
|
||||
|
||||
---
|
||||
|
||||
## 完整生命周期
|
||||
|
||||
### 第一次运行(无持久化数据)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 节点尚不存在 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
数据源加载 (Stage 1)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 节点诞生 │
|
||||
│ │
|
||||
│ binding_status = UNCHECKED ← Collection 创建节点时的初始值 │
|
||||
│ action = NONE │
|
||||
│ status = PENDING │
|
||||
│ data_id = "abc-123" ← 从数据源获取的业务主键 │
|
||||
│ data = {...} ← 从数据源获取的业务数据 │
|
||||
│ origin_data = {...} ← 同上,原始快照 │
|
||||
│ depend_ids = [] ← 空,待 bind 阶段填充 │
|
||||
│ error = None │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 第二次及后续运行(有持久化数据)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 从持久化恢复(原始值) │
|
||||
│ │
|
||||
│ binding_status = NORMAL (上次值) │
|
||||
│ action = UPDATE (上次值) │
|
||||
│ status = SUCCESS (上次值) │
|
||||
│ data_id = "abc-123" │
|
||||
│ error = None (上次值) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
阶段1:加载时重置 (NodeStateReset)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 阶段1重置后 │
|
||||
│ │
|
||||
│ binding_status = UNCHECKED ← 重置(每次重新判定) │
|
||||
│ action = UPDATE ← 保留!(用于阶段2识别 CREATE 失败) │
|
||||
│ status = SUCCESS ← 保留!(不自动重置) │
|
||||
│ data_id = "abc-123" ← 不变 │
|
||||
│ data = None ← 重置(等待数据源覆盖) │
|
||||
│ origin_data = None ← 重置(等待数据源覆盖) │
|
||||
│ error = None ← 清空(避免上次错误干扰) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
阶段2:CREATE 僵尸清理
|
||||
(只影响 action=CREATE 且 status=FAILED 或 data_id="" 的节点)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 阶段2清理后(正常节点) │
|
||||
│ │
|
||||
│ action = NONE ← 全部重置为 NONE,准备进入策略阶段 │
|
||||
│ 其他字段不变 │
|
||||
│ │
|
||||
│ (若为 CREATE 僵尸节点:直接从 Collection 删除 + 解除绑定) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
数据源加载覆盖 (Stage 1 后半)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据源加载后 │
|
||||
│ │
|
||||
│ data = {...} ← 数据源最新数据覆盖 │
|
||||
│ origin_data = {...} ← 数据源最新数据覆盖 │
|
||||
│ 其他字段不变 │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bind 阶段状态转移 (Stage 2.1)
|
||||
|
||||
Bind 分三个子阶段顺序执行,每个子阶段只修改 `binding_status`。
|
||||
|
||||
### Phase 1: 核心状态判定
|
||||
|
||||
> **代码**:`strategy_ops/bind_ops.py::phase_core_state()` → `SyncDecisionEngine.determine_core_status()`
|
||||
|
||||
```
|
||||
输入:binding_status = UNCHECKED
|
||||
|
||||
┌─ 有绑定记录?
|
||||
│
|
||||
┌─────┤
|
||||
│ YES │
|
||||
│ └─── 本地数据 + 远程数据 都健康?
|
||||
│ │
|
||||
│ ┌────┴────┐
|
||||
│ │ YES │ NO
|
||||
│ ▼ ▼
|
||||
│ NORMAL WARNING / ABNORMAL
|
||||
│
|
||||
│ NO
|
||||
└──────────────► MISSING
|
||||
```
|
||||
|
||||
| 输入条件 | binding_status 输出 |
|
||||
|---------|-------------------|
|
||||
| 有绑定,双方数据健康 | **NORMAL** |
|
||||
| 有绑定,自身数据损坏 | **ABNORMAL** |
|
||||
| 有绑定,对方数据损坏 | **WARNING** |
|
||||
| 无绑定记录 | **MISSING** |
|
||||
|
||||
### Phase 2: 依赖检查
|
||||
|
||||
> **代码**:`strategy_ops/bind_ops.py::phase_dependency_check()` → `strategy_ops/bind_ops.py::check_dependencies_for_nodes()`
|
||||
|
||||
**仅处理 `binding_status = MISSING` 的节点**,其他状态跳过。
|
||||
|
||||
```
|
||||
输入:binding_status = MISSING
|
||||
|
||||
┌─ 有配置 depend_fields?
|
||||
│
|
||||
┌─────┤
|
||||
│ NO └──► 保持 MISSING(无依赖,直接通过)
|
||||
│
|
||||
│ YES ──► 逐一检查依赖节点:
|
||||
│ │
|
||||
│ ┌────┴────────────────────────┐
|
||||
│ │ 全部依赖节点: │
|
||||
│ │ - 存在? ✅ │
|
||||
│ │ - binding_status=NORMAL? ✅ │
|
||||
│ │ - data_id 非空? ✅ │
|
||||
│ └────┬────────────────────────┘
|
||||
│ │
|
||||
│ ┌────┴────┐
|
||||
│ │ 全通过 │ 任一失败
|
||||
│ ▼ ▼
|
||||
│ MISSING DEPENDENCY_ERROR
|
||||
│ + error = "依赖项不满足: ..."
|
||||
│ + depend_ids 已填充
|
||||
```
|
||||
|
||||
**副作用**:无论检查是否通过,都会填充 `node.depend_ids`。
|
||||
|
||||
### Phase 3: 自动绑定
|
||||
|
||||
> **代码**:`strategy_ops/bind_ops.py::phase_auto_binding()`
|
||||
|
||||
**仅处理 `binding_status = MISSING` 且依赖已通过的节点**。
|
||||
|
||||
```
|
||||
输入:binding_status = MISSING
|
||||
|
||||
auto_bind = False?
|
||||
└──► MISSING → WARNING("跳过自动绑定,无法确认是否需要创建")
|
||||
|
||||
auto_bind = True:
|
||||
│
|
||||
├─ 业务键字段缺失?
|
||||
│ └──► MISSING → WARNING("缺少业务键字段: ...")
|
||||
│
|
||||
├─ 业务键中 ID 字段解析失败?
|
||||
│ └──► MISSING → DEPENDENCY_ERROR("业务键中的ID字段解析失败")
|
||||
│
|
||||
├─ 去噪后 1:1 匹配?
|
||||
│ └──► MISSING → NORMAL(执行自动绑定,建立绑定记录)
|
||||
│
|
||||
├─ N:0(无远程匹配)?
|
||||
│ ├──► create_enabled = True → MISSING → NORMAL,并 spawn 目标 CREATE 节点(S06)
|
||||
│ └──► create_enabled = False → MISSING → WARNING(孤儿且不允许自动创建,需人工处理)
|
||||
│
|
||||
└─ N:M / 多对多 / 多对一?
|
||||
└──► MISSING → WARNING("存在多个候选节点,无法自动绑定")
|
||||
```
|
||||
|
||||
### Bind 阶段结束后可能的状态
|
||||
|
||||
| binding_status | 后续可执行的操作 |
|
||||
|---------------|----------------|
|
||||
| **NORMAL** | UPDATE(如有差异)|
|
||||
| **MISSING** | CREATE |
|
||||
| **ABNORMAL** | 无(需人工) |
|
||||
| **WARNING** | 无(阻止自动操作) |
|
||||
| **DEPENDENCY_ERROR** | 无(等待依赖修复) |
|
||||
|
||||
---
|
||||
|
||||
## Create 阶段状态转移 (Stage 2.2)
|
||||
|
||||
> **代码**:`create()` → `strategy_ops/create_ops.py::add_create_action()`
|
||||
|
||||
> **对齐说明(与状态机配置一致)**:
|
||||
> - create_prepare 的入口仍是源节点 `binding_status = MISSING`。
|
||||
> - 但当前配置下,`N:0 + create_enabled=true` 可以在自动绑定阶段直接把源节点转为 `NORMAL`,并 `emit spawn_target_node_state=S06`。
|
||||
> - 因此“触发创建”的入口有两条:
|
||||
> 1) auto_bind 分支(E08f)直接 spawn;
|
||||
> 2) create_prepare 分支(E09b)在 create 检查成功后 spawn。
|
||||
|
||||
**仅处理 `binding_status = MISSING` 的节点**。
|
||||
|
||||
```
|
||||
输入:源节点 binding_status = MISSING, action = NONE
|
||||
|
||||
┌─ 源节点数据为空?
|
||||
│ └──► 跳过,记录日志
|
||||
│
|
||||
├─ IDResolver.resolve_and_validate() 失败?
|
||||
│ └──► binding_status → DEPENDENCY_ERROR
|
||||
│ error = "创建时依赖 ID 未解析: ..."
|
||||
│
|
||||
└─ 成功 ──► 在目标 Collection 创建新节点
|
||||
│
|
||||
├─ 源节点:
|
||||
│ binding_status → NORMAL("从本地创建到远程")
|
||||
│ action 不变(仍为 NONE)
|
||||
│
|
||||
└─ 新建目标节点:
|
||||
binding_status = NORMAL
|
||||
action = CREATE
|
||||
status = PENDING
|
||||
data_id = ""(待远程返回)
|
||||
data = 已替换 ID 的数据副本
|
||||
│
|
||||
└─ 同时建立绑定记录:local_id ↔ remote_id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Creates — 物理同步 (Stage 2.3)
|
||||
|
||||
> **代码**:`DataSource.sync_all()` → Handler.create()
|
||||
|
||||
```
|
||||
输入:action = CREATE, status = PENDING
|
||||
|
||||
┌─ Handler 执行 API 调用
|
||||
│
|
||||
├─ 同步 API 成功(200/201)
|
||||
│ └──► status → SUCCESS
|
||||
│ data_id = "remote-xyz"(远程分配的 ID 回填)
|
||||
│ data["id"] = "remote-xyz"(主键字段回写)
|
||||
│
|
||||
├─ 异步 API 提交成功(202)
|
||||
│ └──► status → IN_PROGRESS
|
||||
│ context.task_id = "task-xxx"
|
||||
│ │
|
||||
│ └─ 轮询结果:
|
||||
│ ├─ 成功 → status → SUCCESS, data_id 回填
|
||||
│ ├─ 失败 → status → FAILED, error = "..."
|
||||
│ └─ 超时 → status → FAILED, error = "Timeout"
|
||||
│
|
||||
└─ API 失败(4xx/5xx)
|
||||
└──► status → FAILED
|
||||
error = "远程操作失败: ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Update 阶段状态转移 (Stage 2.4)
|
||||
|
||||
> **代码**:`update()` → `strategy_ops/update_ops.py::add_update_action()`
|
||||
|
||||
> **对齐说明**:图里的 `E12a` 从 `S01` 出发并不矛盾。`S01` 约束的是“源节点”有 `data_id`;
|
||||
> `E12a` 检查的是“目标节点”是否有 `data_id`(guard: `target_has_data_id=false`),两者不是同一个对象。
|
||||
|
||||
**过滤条件**:`binding_status = NORMAL` 且 `status != FAILED`
|
||||
|
||||
```
|
||||
输入:源节点 binding_status = NORMAL, action = NONE
|
||||
|
||||
┌─ status == FAILED?
|
||||
│ └──► 跳过(保护上次失败的状态和错误信息)
|
||||
│
|
||||
├─ 源节点数据为空?
|
||||
│ └──► 跳过
|
||||
│
|
||||
├─ 通过绑定找不到目标节点?
|
||||
│ └──► 跳过,记录日志
|
||||
│
|
||||
├─ 目标节点 data_id 为空?
|
||||
│ └──► 目标节点 status → PRECONDITION_FAILED
|
||||
│ ("目标节点缺少 data_id")
|
||||
│
|
||||
├─ IDResolver.resolve_and_validate() 失败?
|
||||
│ └──► 目标节点 action → UPDATE
|
||||
│ 目标节点 status → PRECONDITION_FAILED
|
||||
│ 目标节点 error = "依赖 ID 未解析: ..."
|
||||
│
|
||||
├─ _needs_update() 判定无差异?
|
||||
│ └──► 跳过(不设置 action)
|
||||
│
|
||||
└─ 有差异 ──► 更新目标节点:
|
||||
action → UPDATE
|
||||
status → PENDING
|
||||
data = 已替换 ID 的数据副本
|
||||
error = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Updates — 物理同步 (Stage 2.5)
|
||||
|
||||
> **代码**:`DataSource.sync_all()` → Handler.update()
|
||||
|
||||
```
|
||||
输入:action = UPDATE, status = PENDING
|
||||
|
||||
┌─ Handler 构建 API 请求
|
||||
│
|
||||
├─ Handler 发现无实际变更字段
|
||||
│ └──► action → NONE(Handler 降级,不提交 API)
|
||||
│
|
||||
├─ 同步 API 成功(200)
|
||||
│ └──► status → SUCCESS
|
||||
│
|
||||
├─ 异步 API 提交成功(202)
|
||||
│ └──► status → IN_PROGRESS → (轮询)→ SUCCESS / FAILED
|
||||
│
|
||||
└─ API 失败
|
||||
└──► status → FAILED
|
||||
error = "远程操作失败: ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 持久化阶段 (Stage 5)
|
||||
|
||||
```
|
||||
节点当前状态原样写入存储。
|
||||
|
||||
持久化字段:
|
||||
✅ node_id, data_id, data, origin_data
|
||||
✅ binding_status, action, status, error, context
|
||||
❌ depend_ids(不持久化,下次 bind 时从 data 实时计算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 全景时间线表
|
||||
|
||||
下表展示**一个节点在典型场景中的状态变化**:
|
||||
|
||||
### 场景 A:首次创建(本地 → 远程)
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 数据加载 | UNCHECKED | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase1 | **MISSING** | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase2 | MISSING | NONE | PENDING | "abc-123" | None |
|
||||
| bind phase3 | MISSING | NONE | PENDING | "abc-123" | None |
|
||||
| create | **NORMAL** | NONE | PENDING | "abc-123" | None |
|
||||
| _(新建远程节点)_ | NORMAL | **CREATE** | PENDING | **""** | None |
|
||||
| commit create | NORMAL | CREATE | **SUCCESS** | **"remote-456"** | None |
|
||||
| 持久化 | NORMAL | CREATE | SUCCESS | "remote-456" | None |
|
||||
|
||||
### 场景 B:日常更新
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 持久化恢复 | NORMAL→**UNCHECKED** | CREATE→保留 | SUCCESS→保留 | "abc-123" | 清空 |
|
||||
| 阶段2 action重置 | UNCHECKED | **NONE** | SUCCESS | "abc-123" | None |
|
||||
| 数据源覆盖 | UNCHECKED | NONE | SUCCESS | "abc-123" | None |
|
||||
| bind phase1 | **NORMAL** | NONE | SUCCESS | "abc-123" | None |
|
||||
| update 检测差异 | NORMAL | NONE | SUCCESS | "abc-123" | None |
|
||||
| _(目标节点)_ | NORMAL | **UPDATE** | **PENDING** | "remote-456" | None |
|
||||
| commit update | NORMAL | UPDATE | **SUCCESS** | "remote-456" | None |
|
||||
|
||||
### 场景 C:依赖阻断
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| 数据加载 | UNCHECKED | NONE | PENDING | "contract-789" | None |
|
||||
| bind phase1 | **MISSING** | NONE | PENDING | "contract-789" | None |
|
||||
| bind phase2 | **DEPENDENCY_ERROR** | NONE | PENDING | "contract-789" | "依赖项不满足: project_id: 状态=dep_err" |
|
||||
| _(后续阶段不处理)_ | | | | | |
|
||||
| 持久化 | DEPENDENCY_ERROR | NONE | PENDING | "contract-789" | "依赖项不满足..." |
|
||||
|
||||
### 场景 D:CREATE 失败 → 下次自动恢复
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| **第 1 次运行** | | | | | |
|
||||
| create | NORMAL | CREATE | PENDING | "" | None |
|
||||
| commit create 失败 | NORMAL | CREATE | **FAILED** | "" | "远程操作失败: 500" |
|
||||
| 持久化 | NORMAL | CREATE | FAILED | "" | "远程操作失败: 500" |
|
||||
| **第 2 次运行** | | | | | |
|
||||
| 阶段1恢复 | **UNCHECKED** | CREATE | FAILED | "" | **None** |
|
||||
| 阶段2僵尸清理 | _(节点被删除,绑定被解除)_ | | | | |
|
||||
| 数据源重新加载 | UNCHECKED | NONE | PENDING | "" → 新 node_id | None |
|
||||
| bind → create | 重新走首次创建流程 | | | | |
|
||||
|
||||
### 场景 E:UPDATE 失败 → 保留供人工处理
|
||||
|
||||
| 阶段 | binding_status | action | status | data_id | error |
|
||||
|------|---------------|--------|--------|---------|-------|
|
||||
| **第 1 次运行** | | | | | |
|
||||
| update | NORMAL | UPDATE | PENDING | "remote-456" | None |
|
||||
| commit update 失败 | NORMAL | UPDATE | **FAILED** | "remote-456" | "403 Forbidden" |
|
||||
| 持久化 | NORMAL | UPDATE | FAILED | "remote-456" | "403 Forbidden" |
|
||||
| **第 2 次运行** | | | | | |
|
||||
| 阶段1恢复 | UNCHECKED | UPDATE | FAILED | "remote-456" | None |
|
||||
| 阶段2 action重置 | UNCHECKED | **NONE** | **FAILED** | "remote-456" | None |
|
||||
| bind | NORMAL | NONE | FAILED | "remote-456" | None |
|
||||
| update 跳过 | _(status=FAILED,自动跳过)_ | | | | |
|
||||
| 持久化 | NORMAL | NONE | FAILED | "remote-456" | None |
|
||||
| _(需人工重置 status 后才能重新更新)_ | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 状态转换合法性约束
|
||||
|
||||
### binding_status 允许的转换
|
||||
|
||||
```
|
||||
UNCHECKED → NORMAL (核心判定: 有绑定且健康)
|
||||
UNCHECKED → MISSING (核心判定: 无绑定记录)
|
||||
UNCHECKED → ABNORMAL (核心判定: 自身数据损坏)
|
||||
UNCHECKED → WARNING (核心判定: 对方数据损坏)
|
||||
|
||||
MISSING → DEPENDENCY_ERROR (依赖检查: 父节点不满足)
|
||||
MISSING → WARNING (自动绑定: 业务键缺失 / 多候选 / auto_bind=False)
|
||||
MISSING → DEPENDENCY_ERROR (自动绑定: ID 字段解析失败)
|
||||
MISSING → NORMAL (自动绑定: 1:1 成功)
|
||||
MISSING → NORMAL (create: 源节点创建成功后)
|
||||
MISSING → DEPENDENCY_ERROR (create: ID 解析失败)
|
||||
```
|
||||
|
||||
> **注意**:当前 `SyncNode.set_binding_status()` 不做转换合法性校验【未实现】。
|
||||
|
||||
### action 允许的转换
|
||||
|
||||
```
|
||||
NONE → CREATE (create: 新建目标节点)
|
||||
NONE → UPDATE (update: 检测到差异)
|
||||
UPDATE → NONE (Handler 降级: 实际无变更字段)
|
||||
```
|
||||
|
||||
### status 允许的转换
|
||||
|
||||
```
|
||||
PENDING → IN_PROGRESS (DataSource: 异步提交成功)
|
||||
PENDING → SUCCESS (DataSource: 同步执行成功)
|
||||
PENDING → FAILED (DataSource: 执行失败)
|
||||
PENDING → SKIPPED (DataSource: 被跳过)
|
||||
(常见于 CREATE/UPDATE:Handler 返回 SKIPPED(无可执行操作/业务条件不满足),对应状态机里的 E19→S13 或 E20→S14)
|
||||
PENDING → PRECONDITION_FAILED (Strategy: 前置条件不满足)
|
||||
IN_PROGRESS → SUCCESS (DataSource: 异步轮询成功)
|
||||
IN_PROGRESS → FAILED (DataSource: 异步轮询失败/超时)
|
||||
```
|
||||
|
||||
> **注意**:当前 `SyncNode.set_status()` 不做转换合法性校验【未实现】。
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [状态定义规范](./状态定义规范.md) — 状态枚举和判定规则
|
||||
- [持久化与人工介入规范](./持久化与人工介入规范.md) — 持久化重置策略
|
||||
- [编排流程规范](./编排流程规范.md) — Pipeline 阶段定义
|
||||
- [架构分层设计](./架构分层设计.md) — 各层职责
|
||||
- [node_id_data_id_lifecycle](./node_id_data_id_lifecycle.md) — ID 生命周期
|
||||
@@ -0,0 +1,11 @@
|
||||
# 同步系统重构目标
|
||||
|
||||
## 范围
|
||||
- 在 `sync_state_machine/` 下实现新状态机运行时,不改 `sync_system_new/` 现有实现。
|
||||
- 第一阶段聚焦 JSONL 路径,API 路径后续接入。
|
||||
|
||||
## 成功标准
|
||||
- 状态机配置可独立校验与出图。
|
||||
- 运行时可根据事件与上下文输出迁移决策。
|
||||
- 自动化测试可覆盖所有事件(E00~E20)并统计状态覆盖。
|
||||
- domain 业务层不直接改状态,状态变更由状态机输出驱动。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 迁移映射
|
||||
|
||||
## 旧 -> 新
|
||||
- `sync_system_new/state_machine/*` -> `sync_state_machine/config|tools/*`
|
||||
- `strategy.py` 决策逻辑 -> `sync_state_machine/engine/* + sync_state_machine/sync_system/strategy.py`
|
||||
- `datasource.py` 状态写回 -> `sync_state_machine/datasource/datasource.py`
|
||||
- bind/create/update 事件驱动 -> `sync_state_machine/sync_system/strategy.py` 直接 dispatch(无桥接层)
|
||||
|
||||
## 不变项
|
||||
- `sync_system_new/domain/*` 业务处理逻辑保持不动。
|
||||
- `schemas/`、`filtered_datasource/` 不修改。
|
||||
|
||||
## 目录差异说明
|
||||
- `sync_state_machine/adapters/` 已移除,避免“新目录实现 + 适配桥接”双轨并存。
|
||||
- 状态机配置、执行、测试均在 `sync_state_machine` 内闭环。
|
||||
@@ -0,0 +1,454 @@
|
||||
# 自动测试说明
|
||||
|
||||
## 1. 目标
|
||||
|
||||
这套测试只做一件事:
|
||||
|
||||
- 用固定配置初始化测试环境;
|
||||
- 一键运行测试;
|
||||
- 验证同步系统能不能完成一轮正常同步;
|
||||
- 验证常见的更新接口能不能工作;
|
||||
- 能把问题大致分成三类:数据问题、推送系统问题、后端接口问题。
|
||||
|
||||
不追求把所有情况都测完,也不追求覆盖所有脏数据。测试深度控制在:
|
||||
|
||||
- 能跑通一次正常流程;
|
||||
- 能验证一批关键 update;
|
||||
- 能处理一些基本异常;
|
||||
- 每轮都能留下清楚的报告,供下一轮继续改。
|
||||
|
||||
## 2. 测试系统的组成
|
||||
|
||||
测试系统由 4 部分组成:
|
||||
|
||||
1. **测试配置**
|
||||
2. **初始化脚本**
|
||||
3. **按 domain 拆分的用例**
|
||||
4. **统一运行脚本**
|
||||
|
||||
只要把这 4 部分准备好,后面就应该做到:**运行一个脚本,就自动完成初始化、执行、分析、出报告。**
|
||||
|
||||
### 当前已落地的文件
|
||||
|
||||
目前已经先实现了一版最小骨架:
|
||||
|
||||
- 测试总配置:[tests/fixtures/auto_sync/auto_test_config.yaml](tests/fixtures/auto_sync/auto_test_config.yaml)
|
||||
- 流水线测试配置:[run_profiles/jsonl_to_api.test.yaml](run_profiles/jsonl_to_api.test.yaml)
|
||||
- 初始化脚本:[scripts/auto_test_init.py](scripts/auto_test_init.py)
|
||||
- 统一运行脚本:[scripts/run_auto_test.py](scripts/run_auto_test.py)
|
||||
- 公共工具:[scripts/auto_test_lib.py](scripts/auto_test_lib.py)
|
||||
- 第一个 domain 用例:[tests/fixtures/auto_sync/domains/contract.yaml](tests/fixtures/auto_sync/domains/contract.yaml)
|
||||
|
||||
## 3. 配置管理
|
||||
|
||||
测试环境必须基于配置初始化,不能靠手工改路径。
|
||||
|
||||
配置里至少要能定义这些内容:
|
||||
|
||||
- 后端代码目录;
|
||||
- 后端启动命令;
|
||||
- 后端恢复数据库命令;
|
||||
- 测试使用的数据库名;
|
||||
- CouchDB 连接信息;
|
||||
- 本地同步状态数据库路径;
|
||||
- 基础种子数据目录;
|
||||
- 同步测试数据目录;
|
||||
- 临时测试数据目录;
|
||||
- 同步系统运行配置文件路径。
|
||||
|
||||
建议单独准备一个测试配置,例如:
|
||||
|
||||
- `run_profiles/jsonl_to_api.test.yaml`
|
||||
- `docs/auto_test_spec` 中再约定测试总配置文件
|
||||
|
||||
### 数据目录分工
|
||||
|
||||
测试里至少有 3 类数据目录:
|
||||
|
||||
1. **基础种子数据**
|
||||
- 用来恢复后端里那些不能通过同步创建的数据。
|
||||
- 例如:`company`、`project`、`design_doc`。
|
||||
- 这类数据先进入后端数据库,给后续同步提供依赖。
|
||||
|
||||
2. **同步测试数据**
|
||||
- 用来测试可创建、可更新的业务数据。
|
||||
- 例如合同、结算、施工任务等。
|
||||
|
||||
3. **临时运行目录**
|
||||
- 每次测试前,把测试数据复制到这里。
|
||||
- 后续每一轮如果要改数据,也只改这里,不改原始测试集。
|
||||
|
||||
## 4. 初始化方式
|
||||
|
||||
初始化必须自动完成,不能再靠手工执行几条命令。
|
||||
|
||||
目标流程如下:
|
||||
|
||||
1. 读取测试配置;
|
||||
2. 如有旧测试数据库,先自动删除;
|
||||
3. 自动删除本地同步状态数据库;
|
||||
4. 自动执行后端的数据库恢复命令,导入基础种子数据;
|
||||
5. 自动启动后端;
|
||||
6. 自动等待后端 ready;
|
||||
7. 自动准备临时测试目录;
|
||||
8. 开始跑测试。
|
||||
|
||||
### 运行后的要求
|
||||
|
||||
- **测试跑完以后,数据库保留**,方便查看结果;
|
||||
- **下次再运行时,再自动清空旧库并重建**;
|
||||
- 不要求每次结束自动销毁环境。
|
||||
|
||||
## 5. 运行方式
|
||||
|
||||
不强制使用 `pytest`。
|
||||
|
||||
当前更适合的方式是:
|
||||
|
||||
- 用一个主脚本统一运行全部流程;
|
||||
- domain 用例由脚本按顺序调度;
|
||||
- 每个 domain 可以包含多轮 pipeline;
|
||||
- 每轮的输入数据和检查点都由脚本控制。
|
||||
|
||||
也就是:
|
||||
|
||||
- `测试配置 + 初始化脚本 + 分 domain 的用例 + 一个完整运行脚本`
|
||||
|
||||
## 6. 测试轮次
|
||||
|
||||
一次完整测试,允许分成多轮 pipeline。
|
||||
|
||||
典型做法:
|
||||
|
||||
### 第 1 轮:初始化后首次同步
|
||||
|
||||
目的:
|
||||
|
||||
- 验证基础依赖准备正确;
|
||||
- 验证 create 流程能跑通;
|
||||
- 验证绑定关系是否正常。
|
||||
|
||||
### 第 2 轮:在临时目录修改部分数据后再次同步
|
||||
|
||||
目的:
|
||||
|
||||
- 验证 update 接口;
|
||||
- 验证哪些数据应该更新;
|
||||
- 验证哪些数据应该保持不动。
|
||||
|
||||
### 第 3 轮:补充少量基本异常场景
|
||||
|
||||
目的:
|
||||
|
||||
- 验证系统遇到常见错误时能不能给出清楚结果;
|
||||
- 验证错误是否能定位到数据、推送系统或后端。
|
||||
|
||||
不要求无限轮次,也不要求复杂压力测试。
|
||||
|
||||
## 7. Domain 用例怎么设计
|
||||
|
||||
每个 domain 单独设计用例,例如:
|
||||
|
||||
- `company`
|
||||
- `project`
|
||||
- `contract`
|
||||
- `supplier`
|
||||
- `construction_task`
|
||||
- `contract_settlement`
|
||||
|
||||
但不是所有 domain 都从创建开始测。
|
||||
|
||||
要根据业务真实情况决定:
|
||||
|
||||
- 哪些 domain 只能作为基础种子存在;
|
||||
- 哪些 domain 可以通过同步创建;
|
||||
- 哪些 domain 要重点测 update。
|
||||
|
||||
### Domain 用例必须写清楚的内容
|
||||
|
||||
每个 domain 的用例说明都要先写文字,再写代码。文字说明必须经过你审定。
|
||||
|
||||
每组用例至少写清楚:
|
||||
|
||||
1. 这个 domain 的前置依赖是什么;
|
||||
2. 这个 domain 本轮准备哪些测试数据;
|
||||
3. 第几轮会创建哪些记录;
|
||||
4. 第几轮会修改哪些字段;
|
||||
5. 预期通过什么方式判断结果;
|
||||
6. 这个用例主要想识别哪类问题。
|
||||
|
||||
## 8. Domain 用例的测试原则
|
||||
|
||||
### 原则 1:优先覆盖大部分接口
|
||||
|
||||
目标不是追求完美覆盖,而是尽量覆盖大部分 create / update 接口,尤其是 update。
|
||||
|
||||
### 原则 2:只测明确的业务关系
|
||||
|
||||
用例里的依赖关系必须有文字说明,不能边跑边猜。
|
||||
|
||||
### 原则 3:可以通过改数据来构造用例
|
||||
|
||||
允许通过修改临时目录里的数据来构造 update 场景。
|
||||
|
||||
### 原则 4:判断结果不能只看日志
|
||||
|
||||
要结合下面几类证据一起判断:
|
||||
|
||||
- 日志;
|
||||
- collection 中的数据;
|
||||
- 持久化数据库中的结果;
|
||||
- 绑定记录;
|
||||
- 同步状态数据库。
|
||||
|
||||
### 原则 5:用例要能分出问题归属
|
||||
|
||||
每个用例都应该尽量帮助判断问题属于:
|
||||
|
||||
- 测试数据问题;
|
||||
- 推送系统问题;
|
||||
- 后端接口逻辑问题。
|
||||
|
||||
## 9. 建议的用例层次
|
||||
|
||||
每个 domain 的用例,优先按下面 4 类来补:
|
||||
|
||||
1. **正常创建**
|
||||
2. **正常更新**
|
||||
3. **关键绑定关系检查**
|
||||
4. **基本异常检查**
|
||||
|
||||
这里的“基本异常”只做常见的、对当前系统最有价值的,例如:
|
||||
|
||||
- 缺少依赖;
|
||||
- 关键字段不合法;
|
||||
- 远端返回明确错误;
|
||||
- 本地状态和远端状态不一致。
|
||||
|
||||
## 10. 运行脚本应该负责什么
|
||||
|
||||
统一运行脚本应负责:
|
||||
|
||||
1. 读取测试配置;
|
||||
2. 清空旧测试环境;
|
||||
3. 初始化后端数据库;
|
||||
4. 启动后端并等待可用;
|
||||
5. 准备临时测试数据;
|
||||
6. 按 domain 顺序执行各轮用例;
|
||||
7. 每轮运行 pipeline;
|
||||
8. 收集结果;
|
||||
9. 输出报告。
|
||||
|
||||
## 11. 测试报告要求
|
||||
|
||||
每轮都要有清晰报告,给下一轮继续优化使用。
|
||||
|
||||
报告至少包含:
|
||||
|
||||
- 测试时间;
|
||||
- 使用的配置;
|
||||
- 运行了哪些 domain;
|
||||
- 每个 domain 跑了几轮;
|
||||
- 每轮改了哪些数据;
|
||||
- 每轮成功 / 失败统计;
|
||||
- 失败样本;
|
||||
- 问题分类:数据 / 推送系统 / 后端;
|
||||
- 下一轮建议。
|
||||
|
||||
## 12. 当前结论
|
||||
|
||||
当前最合适的方案不是全量自动化,也不是一次性测完所有东西。
|
||||
|
||||
而是:
|
||||
|
||||
- 先做一套固定配置的测试环境;
|
||||
- 用一个脚本跑完整个流程;
|
||||
- 从少量 but 关键的 domain 开始;
|
||||
- 每个 domain 先把 create 和主要 update 跑通;
|
||||
- 每轮都留下明确报告;
|
||||
- 再逐步扩展。
|
||||
|
||||
这样更容易维护,也更适合后续让 Agent 按计划继续写代码。
|
||||
|
||||
## 13. 当前执行计划(2026-03-07)
|
||||
|
||||
当前轮次先处理两类目标:
|
||||
|
||||
1. `project_detail_data`
|
||||
- 继续确认可按统一 `project_detail_data` 语义验证的 key 与相关 update 接口在测试报告中可见、可追踪;
|
||||
- 重点检查它与后续 project 级 domain 连跑时是否污染状态。
|
||||
- 当前水电项目下,`production` 先不混入这组统一回归:后端已确认该接口会直接更新 `project` 字段,但不保证创建/更新可轮询的 `project_detail_data.production` 记录;后续待业务确认后再单列测试方案。
|
||||
|
||||
2. 剩余 domain
|
||||
- 当前优先处理:`contract_settlement_detail`、`personnel`、`personnel_detail`
|
||||
- 已完成并保持回归:`preparation`、`production`、`contract`、`contract_settlement`、`construction_task`、`construction_task_detail`、`material`、`material_detail`
|
||||
- 之后再继续:`units`、`contract_change`、`lar`、`lar_change`
|
||||
|
||||
本轮执行顺序:
|
||||
|
||||
1. 先补齐文字测试计划;
|
||||
2. 再修改测试脚本与 `tests/` 下测试数据;
|
||||
3. 逐个 domain 跑通;
|
||||
4. 最后回到整套自动测试顺序验证全量可跑通。
|
||||
|
||||
### 本轮强约束
|
||||
|
||||
- **禁止修改 `schemas/` 下任何文件。**
|
||||
- 不允许为了让测试通过而绕过 schema 校验。
|
||||
- 如果问题根因是后端接口/后端返回/后端数据模型不一致,应直接在报告里暴露并记录,不擅自改 schema。
|
||||
- 只有在确认是后端错误并得到明确指示后,才讨论是否调整类型定义。
|
||||
|
||||
### 本轮排查重点
|
||||
|
||||
1. `project_detail_data` 单跑已基本可用,但要继续验证与 `preparation`、`production` 连跑时是否造成绑定或状态污染;
|
||||
2. `preparation`、`production` 当前更像是“整套顺序执行时失败、单独执行可通过”的问题,优先检查运行顺序、临时数据、持久化状态和绑定残留;
|
||||
3. 其余 domain 按“先单 domain 跑通,再放回全量顺序”推进;
|
||||
- 当前先按 `contract_settlement_detail -> personnel -> personnel_detail` 顺序落地;
|
||||
4. 每修完一个 domain,都要保证报告中能看出本轮覆盖内容、绑定结果、字段变化和失败归因。
|
||||
5. 对 `project_detail_data.production`,禁止再用“接口没报错就直接记成功”的短路逻辑掩盖真实语义;当前阶段直接绕过该 key 的统一回归,保留差异说明。
|
||||
|
||||
## 14. 大规模测试方法:按轮迭代,而不是一次堆上下文
|
||||
|
||||
大规模测试的目标不是“把所有失败细节一次讲完”,而是:
|
||||
|
||||
- 每一轮只有一个清晰基线;
|
||||
- 每一轮只记录少量必须保留的信息;
|
||||
- 每一轮都能看到总计变化;
|
||||
- 每一轮都能区分“新问题、已修复问题、遗留问题”。
|
||||
|
||||
当前推荐固定采用 **单 domain 验证 + 全量聚合回归** 的两层节奏。
|
||||
|
||||
### 14.1 固定节奏
|
||||
|
||||
每一轮都按下面顺序执行:
|
||||
|
||||
1. **选定本轮目标**
|
||||
- 只选 1 个主问题,或者 1 组同类问题。
|
||||
- 例如:
|
||||
- 一个 domain 的 update 接口失败;
|
||||
- 一组共享 helper 导致的脚本设计问题;
|
||||
- 一类后端读写不一致问题。
|
||||
|
||||
2. **先单 domain 复现**
|
||||
- 用 [scripts/run_auto_test.py](scripts/run_auto_test.py) 只跑目标 domain。
|
||||
- 目的不是求“全绿”,而是先确认问题是否稳定复现。
|
||||
|
||||
3. **定位并修复一类根因**
|
||||
- 如果是测试脚本问题,就只修脚本;
|
||||
- 如果是后端问题,就在报告里明确标注为后端;
|
||||
- 如果是数据问题,就保留失败,不用 fallback 掩盖。
|
||||
|
||||
4. **回跑目标 domain**
|
||||
- 只看本 domain 是否改善;
|
||||
- 记录这轮减少了多少失败 case。
|
||||
|
||||
5. **再跑聚合回归**
|
||||
- 用 [scripts/run_auto_test_all_domains.py](scripts/run_auto_test_all_domains.py) 执行全量聚合;
|
||||
- 当前聚合模式是“**每个 domain 独立初始化并单独执行**”,用于避免跨 domain 状态污染。
|
||||
|
||||
6. **输出轮次结论**
|
||||
- 只保留:
|
||||
- 总 case 数;
|
||||
- 通过 / 失败数;
|
||||
- 新增失败;
|
||||
- 已修复失败;
|
||||
- 遗留失败;
|
||||
- 每个遗留失败的归因分类。
|
||||
|
||||
### 14.2 每轮只保留 4 份信息
|
||||
|
||||
为了避免上下文失控,每轮总结时只保留下面 4 份信息:
|
||||
|
||||
1. **总计快照**
|
||||
- `total_domains`
|
||||
- `passed_domains`
|
||||
- `failed_domains`
|
||||
- `total_cases`
|
||||
- `passed_cases`
|
||||
- `failed_cases`
|
||||
|
||||
2. **变化清单**
|
||||
- 本轮修复了哪些 case;
|
||||
- 本轮新增了哪些 case;
|
||||
- 哪些 case 没变化。
|
||||
|
||||
3. **失败分类表**
|
||||
- `script_design`:脚本设计 / 运行配置 / 断言策略问题;
|
||||
- `push_system`:状态机、绑定、依赖、持久化、推送链路问题;
|
||||
- `backend`:后端接口、后端读写模型、后端返回值问题;
|
||||
- `data`:测试数据不满足前置条件或数据本身不合法;
|
||||
- `flaky`:暂时只在聚合中出现、单跑不能稳定复现的问题。
|
||||
|
||||
4. **下一轮唯一目标**
|
||||
- 下一轮最多列 1~3 个目标;
|
||||
- 不把所有猜测都带到下一轮。
|
||||
|
||||
### 14.3 轮次汇总格式
|
||||
|
||||
每轮报告建议固定成下面的结构:
|
||||
|
||||
1. **Round N 总计**
|
||||
2. **与 Round N-1 对比**
|
||||
3. **本轮修复**
|
||||
4. **本轮新增**
|
||||
5. **遗留问题分类**
|
||||
6. **下一轮目标**
|
||||
|
||||
建议直接从聚合报告的 `summary.json` 中抽取总计,避免手工整理时带入过多细节。
|
||||
|
||||
### 14.4 建议的对比口径
|
||||
|
||||
比较两轮结果时,统一只按 `domain::round_name` 比较,不按日志文本比较。
|
||||
|
||||
也就是把每个 case 视为一个稳定键,例如:
|
||||
|
||||
- `project::round_2_base_info_update`
|
||||
- `project::round_3_investment_update`
|
||||
- `material_detail::round_2_update`
|
||||
|
||||
这样做的好处是:
|
||||
|
||||
- 总数稳定;
|
||||
- 对比简单;
|
||||
- 很容易看出某个 case 是“由失败转通过”,还是“仍然失败”。
|
||||
|
||||
### 14.5 建议的推进策略
|
||||
|
||||
对于 10+ domain、数十个 case 的回归,建议始终按下面方式推进:
|
||||
|
||||
1. **先消灭稳定复现的单点失败**
|
||||
- 先修能稳定复现的问题,不先追偶发问题。
|
||||
|
||||
2. **再处理共享根因**
|
||||
- 如果多个 domain 共用同一个 helper、同一个运行配置、同一种绑定策略,优先处理这一层。
|
||||
|
||||
3. **最后单列 flaky**
|
||||
- 聚合里偶发失败、单跑通过的问题,不直接混入主结论;
|
||||
- 先标成 `flaky`,待重复出现后再升级处理。
|
||||
|
||||
### 14.6 当前这套系统中的实际判定方法
|
||||
|
||||
当前推荐把问题按下面顺序判定:
|
||||
|
||||
1. **先看单 domain 是否稳定失败**
|
||||
- 如果单跑失败,优先查该 domain 自身脚本、数据、后端接口。
|
||||
|
||||
2. **再看聚合回归是否额外引入失败**
|
||||
- 如果单跑通过、聚合失败,优先怀疑:
|
||||
- 运行顺序;
|
||||
- 环境残留;
|
||||
- 报告聚合方式;
|
||||
- 偶发后端错误。
|
||||
|
||||
3. **最后看后端是否写了但读不出来**
|
||||
- 如果 update 接口已命中,但回读字段缺失或不一致,要明确归类为 `backend`,而不是继续修改测试脚本绕过。
|
||||
|
||||
### 14.7 本文档要求的轮次目标
|
||||
|
||||
每一轮结束后,必须能一句话回答下面 3 个问题:
|
||||
|
||||
1. **总计有没有变好?**
|
||||
2. **变好是因为修了脚本、推送系统,还是确认了后端问题?**
|
||||
3. **下一轮只追哪一个主问题?**
|
||||
|
||||
如果一轮总结不能回答这 3 个问题,就说明这轮上下文保留得太多、结论还不够收敛。
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
large_scale_test:
|
||||
config_path: tests/fixtures/auto_sync/auto_test_config.yaml
|
||||
output_dir: test_results/auto_test/large_scale
|
||||
fail_on_failed_cases: false
|
||||
default_runner: aggregate
|
||||
default_init_mode: fresh
|
||||
classification:
|
||||
project::round_3_investment_update: backend
|
||||
material_detail::round_2_update: flaky
|
||||
rounds:
|
||||
- id: round_01_full_baseline
|
||||
title: 全量隔离基线
|
||||
enabled: true
|
||||
runner: aggregate
|
||||
init_mode: fresh
|
||||
objective: 建立当前 19 个 domain 的隔离回归基线,并确认遗留失败的分类。
|
||||
focus:
|
||||
- 全量聚合仍使用每个 domain 独立初始化,避免跨 domain 状态污染。
|
||||
- 先确认 project 与 material_detail 的当前状态,不在本轮混入额外修复动作。
|
||||
next_goal: 在保持全量基线的前提下,单独复核 project 与 material_detail,区分后端遗留问题和聚合偶发问题。
|
||||
- id: round_02_project_recheck
|
||||
title: project 后端复核
|
||||
enabled: true
|
||||
runner: domains
|
||||
init_mode: fresh
|
||||
domains:
|
||||
- project
|
||||
objective: 单独复核 project,确认 round_2 已修复,round_3 仅剩后端读回字段缺失。
|
||||
focus:
|
||||
- 只跑 project,避免无关上下文。
|
||||
- 明确区分脚本问题与后端问题。
|
||||
classification:
|
||||
project::round_3_investment_update: backend
|
||||
next_goal: 保留脚本侧修复结论,把 project round_3 作为后端待修项持续跟踪。
|
||||
- id: round_03_material_detail_recheck
|
||||
title: material_detail 波动复核
|
||||
enabled: true
|
||||
runner: domains
|
||||
init_mode: fresh
|
||||
domains:
|
||||
- material_detail
|
||||
objective: 单独复核 material_detail,确认聚合中的 round_2 失败是否只是偶发。
|
||||
focus:
|
||||
- 只跑 material_detail。
|
||||
- 如果单跑通过,就把聚合中的失败归为 flaky,不扩大处理范围。
|
||||
classification:
|
||||
material_detail::round_2_update: flaky
|
||||
next_goal: 若后续聚合再次出现同类失败,再升级为可复现问题处理。
|
||||
---
|
||||
# 大规模测试控制文档
|
||||
|
||||
这个文档同时承担两件事:
|
||||
|
||||
1. **控制大规模测试怎么跑**
|
||||
2. **记录每轮测试的目标、结果和改进过程**
|
||||
|
||||
## 使用方式
|
||||
|
||||
- 控制入口:调整本文档 frontmatter 中的 `rounds`
|
||||
- 执行脚本:[scripts/run_large_scale_auto_test.py](scripts/run_large_scale_auto_test.py)
|
||||
- 默认命令:`python scripts/run_large_scale_auto_test.py`
|
||||
|
||||
## 设计约束
|
||||
|
||||
- 默认使用 `fresh` 初始化,优先保证每轮从干净环境开始。
|
||||
- `aggregate` runner 适合大规模基线回归;它内部会对每个 domain 独立初始化。
|
||||
- `domains` runner 适合小范围复核;它会按 [scripts/run_auto_test.py](scripts/run_auto_test.py) 的语义执行。
|
||||
- 失败不应靠 fallback 掩盖;应在轮次记录里明确分类。
|
||||
|
||||
## Runner 约定
|
||||
|
||||
### `aggregate`
|
||||
|
||||
- 调用 [scripts/run_auto_test_all_domains.py](scripts/run_auto_test_all_domains.py)
|
||||
- 适合建立全量基线
|
||||
- 最能避免跨 domain 污染
|
||||
|
||||
### `domains`
|
||||
|
||||
- 调用 [scripts/run_auto_test.py](scripts/run_auto_test.py)
|
||||
- 适合复核单个或少量 domain
|
||||
- 用于缩小上下文,验证某个问题是否稳定复现
|
||||
|
||||
## Init 策略约定
|
||||
|
||||
### `fresh`
|
||||
|
||||
- 默认策略
|
||||
- 每轮都从新的初始化开始
|
||||
- 优先用于正式大规模测试
|
||||
|
||||
### `reuse`
|
||||
|
||||
- 透传 `--skip-init`
|
||||
- 只适合排查“连续执行是否污染状态”这一类问题
|
||||
- 不建议作为大规模基线默认值
|
||||
|
||||
### `clear-only`
|
||||
|
||||
- 只清空环境,不执行业务轮次
|
||||
- 用于单独校验清理逻辑
|
||||
|
||||
## 执行记录
|
||||
|
||||
下面这部分由脚本自动更新,不手工编辑。
|
||||
|
||||
<!-- LARGE_SCALE_RESULTS:START -->
|
||||
|
||||
## 最近一次执行 20260308-012707
|
||||
|
||||
- 控制文档: docs/auto_test_spec/large_scale_test_control.md
|
||||
- 执行轮次数: 1
|
||||
- 说明: 默认优先使用 fresh 初始化;若使用 aggregate runner,则每个 domain 仍会独立初始化。
|
||||
|
||||
### 轮次总览
|
||||
|
||||
| Round | 标题 | Runner | Init | Cases | Failed | 对比轮次 | 报告 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| round_01_full_baseline | 全量隔离基线 | aggregate | fresh | 42 | 1 | - | test_results/auto_test/reports/20260308-012707/report.md |
|
||||
|
||||
### round_01_full_baseline - 全量隔离基线
|
||||
|
||||
- 目标: 建立当前 19 个 domain 的隔离回归基线,并确认遗留失败的分类。
|
||||
- 运行方式: aggregate
|
||||
- 初始化策略: fresh
|
||||
- 执行命令: /Users/zyl/my_projects/ecm_sync_system/.venv/bin/python /Users/zyl/my_projects/ecm_sync_system/scripts/run_auto_test_all_domains.py --config tests/fixtures/auto_sync/auto_test_config.yaml
|
||||
- 单轮结果: 41 passed / 1 failed / 42 total cases
|
||||
- Domain 结果: 18 passed / 1 failed / 19 total domains
|
||||
- 原始报告: test_results/auto_test/reports/20260308-012707/report.md
|
||||
- 原始汇总: test_results/auto_test/reports/20260308-012707/summary.json
|
||||
- 本轮关注点:
|
||||
- 全量聚合仍使用每个 domain 独立初始化,避免跨 domain 状态污染。
|
||||
- 先确认 project 与 material_detail 的当前状态,不在本轮混入额外修复动作。
|
||||
- 对比轮次: 无可比较轮次
|
||||
- 可比较 case 数: 0
|
||||
- 对比结果: 本轮没有可比改进项,或与此前轮次无重叠 case。
|
||||
- 失败归因:
|
||||
- project::round_3_investment_update | backend | issues=1 | remote.project 字段校验失败: local=de4dc6d2-ea32-5c04-8430-83145d5d0765 target=f3e02e84-e81c-4aa6-88d4-f5aa108c8227; implementation_estimate: actual=None expected=1225000.0; static_total_investment: actual=None expected=1224300.0; completed_investment: actual=None expected=194500.0
|
||||
- 下一轮目标: 在保持全量基线的前提下,单独复核 project 与 material_detail,区分后端遗留问题和聚合偶发问题。
|
||||
- 脚本返回码: 1
|
||||
|
||||
<!-- LARGE_SCALE_RESULTS:END -->
|
||||
@@ -0,0 +1,42 @@
|
||||
# 差异字段汇总清单
|
||||
|
||||
本文档记录了在同步推流测试中,本地(Local payload)与远端(Remote API 响应/DB落库)产生明确差异的字段。这些差异通常是因为远端后端代码主动忽略了前端传值,并采用关联覆盖、自动计算或默认补全所致。
|
||||
|
||||
> **备注**:
|
||||
> 1. 根据梳理,完全被远端忽略的、只作为返回展示的字段已经从本地/远端的 `Create`、`Update` Schema 中彻底**剔除 (已删)**,不再作为入参推送。
|
||||
> 2. 标有 **[🔥远端接收并参与逻辑计算,但最终与本地不一致]** 的字段没有被删,作为强逻辑关联字段保留。
|
||||
|
||||
## 1. contract_settlement (合同结算)
|
||||
|
||||
### Create 请求 (`ContractSettlementCreate`)
|
||||
- **`contract_type` (合同类型)**: **(从 Schema 彻底剔除)** 远端完全不接收本地传值,根据关联逻辑自动赋予了实际类别(如 `'PC'`, `'GJ'`, `'E'`)。
|
||||
- **`contract_price` (合同总额)**: Create不需要传(本地本身无该入参)。远端在创建时直接从近端 Contract 数据库查取并覆盖。
|
||||
|
||||
### Update / Plan Update 请求 (`ContractSettlementUpdate` 等)
|
||||
- **`plan_complete_date`**: **[🔥远端接收并参与逻辑计算,但最终与本地不一致]** 远端接收该字段,主要作用于 `clean_plan_nodes` 数据清洗以保证非法边界节点被剔除,同时可能经过远端状态与明细上托更新,使得终值可能扭转。
|
||||
- **`complete_price` / `status` / `delay_days` / `statistic_date` / `actual_start_date`**: 远端自动计算累加的衍生只读字段,均不在 Create / Update Schema 范畴中,故不维护入参。
|
||||
|
||||
## 2. construction_task & construction_task_detail (施工任务及明细)
|
||||
|
||||
### Create 请求 (`ConstructionTaskCreate`)
|
||||
- **`contract_quantity` (合同工程量)**: **(已从 Create Schema 彻底剔除)** 远端由于 `create_construction` 函数底层逻辑完全不读此字段,现已被安全双端剪除。不在初始创建时推送。
|
||||
- **`plan_start_date` / `plan_complete_date`**: **[🔥远端接收并参与逻辑计算,但最终与本地不一致]** 远端校验WBS结构树约束时会接收这俩参数进行合规计算,如果超出父子节点范围,存在自动修正或打回改写的逻辑。
|
||||
|
||||
### Update / Plan Update 请求 (`ConstructionTaskUpdate` 及明细)
|
||||
- **`contract_quantity` (合同工程量)**: **[🔥远端接收并参与逻辑计算,但最终与本地不一致]** 在针对 `ConstructionPlanUpdate` 更新计划节点时,远程接受本字段控制计算与校验完成率基准,但仍可能会受 WBS 重制。
|
||||
- **`complete_quantity` / `complete_rate` / `rate` / `status` / `actual_complete_date`**: 纯内部状态更新触发、只读汇总,本地本来就未放至 Update Schema 内进行提交。
|
||||
|
||||
## 3. supplier (供应商)
|
||||
|
||||
### 字段表现
|
||||
- **`city_name`, `province_name`, `country_name`**: Response专用字段。本地不推,仅仅是远端依托外部库映射在接口返回中补全的只读显示。
|
||||
|
||||
## 4. company (组织/公司)
|
||||
|
||||
### 字段表现
|
||||
- **`parent_name` (父级机构名称)**: Response专用显示字段。远端依赖 `parent_id` 自动向上追溯返回字段。
|
||||
|
||||
## 5. production (产值等)
|
||||
|
||||
### Create / Update 请求
|
||||
- **`is_exist`**: **[🔥远端接收并参与逻辑计算,但最终与本地不一致]** 本地推流中有 `is_exist = True`,且远端后端提取了 `production_data.is_exist` 进行存入判断(确实参与了代码落库前置过滤逻辑),但最后远程抓回验证时为 `None`,这归因于远端的废弃映射和覆盖逻辑。该字段须保留不能删。
|
||||
@@ -0,0 +1,165 @@
|
||||
# Collection 查询现状与升级方案
|
||||
|
||||
## 1. 当前实现梳理
|
||||
|
||||
核心代码:
|
||||
- `sync_state_machine/common/collection.py`
|
||||
- `sync_state_machine/common/binding.py`
|
||||
- `sync_state_machine/datasource/datasource.py`
|
||||
- `sync_state_machine/sync_system/strategy_ops/*.py`
|
||||
|
||||
### 1.1 DataCollection 当前内存结构
|
||||
|
||||
`DataCollection` 目前核心是两张索引:
|
||||
- `_nodes: Dict[node_id, SyncNode]`
|
||||
- `_data_id_to_node_id: Dict[data_id, node_id]`
|
||||
|
||||
特点:
|
||||
- `node_id` 查找是 O(1)
|
||||
- `data_id` 反查 node 是 O(1)
|
||||
- 其他查询(按状态/动作/绑定状态/业务字段)目前主要依赖遍历
|
||||
|
||||
### 1.2 查询路径
|
||||
|
||||
#### A. 通用筛选 `filter(...)`
|
||||
- 先取 `_nodes.values()`
|
||||
- 可按 `node_type` 过滤
|
||||
- 可按 `node_filter`(节点顶层属性)过滤
|
||||
- 可按 `filter`(`node.data` 内字段)过滤
|
||||
|
||||
复杂度:最差接近 O(N)
|
||||
|
||||
#### B. 状态筛选 `filter_by_state_ids(...)`
|
||||
- 先调用 `filter(node_type=...)`
|
||||
- 对每个候选节点,根据状态机配置逐条匹配 `binding_status/action/status/data_id_exist`
|
||||
- `state_ids` 多个时,执行多次匹配
|
||||
|
||||
复杂度:约 O(N * |state_ids|)
|
||||
|
||||
### 1.3 调用热点
|
||||
|
||||
在 pipeline 中,以下路径高频调用状态筛选:
|
||||
- bind 阶段(S00)
|
||||
- create 阶段(S06)
|
||||
- update 阶段(S07)
|
||||
- delete 检查(S09)
|
||||
|
||||
对应代码:
|
||||
- `sync_state_machine/datasource/datasource.py` 的 `_sync_nodes`
|
||||
- `sync_state_machine/sync_system/strategy_ops/bind_ops.py`
|
||||
|
||||
### 1.4 BindingManager 当前查询
|
||||
|
||||
`BindingManager` 当前结构:
|
||||
- `_bindings: Dict[node_type, Dict[local_id, remote_id]]`
|
||||
|
||||
其中:
|
||||
- `get_remote_id`:O(1)
|
||||
- `get_local_id`:当前是遍历同类型全部绑定,O(K)
|
||||
|
||||
当绑定数量很大时,`get_local_id` 会成为热点。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次已修正项
|
||||
|
||||
### 2.1 `filter(data_filter)` 查询中的不必要深拷贝
|
||||
|
||||
原实现在 `filter` 的 data 过滤里每个节点调用 `node.get_data()`,会触发深拷贝,数据量大时会产生明显 CPU/内存开销。
|
||||
|
||||
已改为只读访问 `node.data`:
|
||||
- 不改变返回语义
|
||||
- 降低过滤时的对象复制成本
|
||||
|
||||
变更文件:
|
||||
- `sync_state_machine/common/collection.py`
|
||||
|
||||
### 2.2 类型标注修正
|
||||
|
||||
`node_filter` 的注解从 `callable` 修正为 `Callable[[SyncNode], bool]`,增强类型可读性和 IDE 体验。
|
||||
|
||||
---
|
||||
|
||||
## 3. 升级方案(针对三类目标)
|
||||
|
||||
目标:
|
||||
1) 查询性能
|
||||
2) 架构明确
|
||||
3) 内存数据不过多
|
||||
|
||||
### 阶段 0(当前)
|
||||
- 保持现有语义,先完成可观测性:记录各查询调用次数和耗时(尤其 `filter_by_state_ids` 与 `get_local_id`)
|
||||
|
||||
### 阶段 1(低风险,优先)
|
||||
|
||||
#### 1. 查询性能
|
||||
- 新增二级内存索引(不替换现有结构,仅并行维护):
|
||||
- `by_node_type: Dict[str, Set[node_id]]`
|
||||
- `by_status: Dict[SyncStatus, Set[node_id]]`
|
||||
- `by_action: Dict[SyncAction, Set[node_id]]`
|
||||
- `by_binding_status: Dict[BindingStatus, Set[node_id]]`
|
||||
- `by_type_status_action: Dict[(node_type,status,action), Set[node_id]]`
|
||||
- `filter_by_state_ids` 先用组合索引缩小候选,再做状态机细匹配
|
||||
|
||||
#### 2. 架构明确
|
||||
- 增加查询层接口(例如 `CollectionQueryEngine`),将“筛选逻辑”与“存储结构”分离
|
||||
- 业务侧只依赖查询接口,不直接依赖 `_nodes`
|
||||
|
||||
#### 3. 内存控制
|
||||
- 新增按需字段索引,仅为高频字段建立(如 `project_id/company_id/contract_id`)
|
||||
- 索引集采用 `set[node_id]`,避免复制节点对象
|
||||
|
||||
### 阶段 2(中风险)
|
||||
|
||||
#### 1. 查询性能
|
||||
- `BindingManager` 增加反向索引:
|
||||
- `remote_to_local: Dict[node_type, Dict[remote_id, local_id]]`
|
||||
- 将 `get_local_id` 从 O(K) 降至 O(1)
|
||||
|
||||
#### 2. 架构明确
|
||||
- 统一节点写路径(add/update/delete)触发索引增量更新
|
||||
- 明确“不允许绕过 Collection 写入节点”
|
||||
|
||||
#### 3. 内存控制
|
||||
- 增加可配置的“弱索引模式”:在低内存场景关闭部分次级索引
|
||||
|
||||
### 阶段 3(可选,较高风险)
|
||||
|
||||
#### 1. 查询性能
|
||||
- 引入 SQLite 热查询表(内存 SQLite 或磁盘 SQLite)承接复杂组合查询
|
||||
- 保留内存索引作为热点路径,DB 作为补充查询后端
|
||||
|
||||
#### 2. 架构明确
|
||||
- 形成分层:
|
||||
- 热路径:内存索引
|
||||
- 冷路径:SQLite 查询
|
||||
- 归档:持久化 DB
|
||||
|
||||
#### 3. 内存控制
|
||||
- 支持“分批加载 + 分批同步 + checkpoint”
|
||||
- 将非活跃节点转冷存储,只在命中时回载
|
||||
|
||||
---
|
||||
|
||||
## 4. 启动/重置语义说明(避免误判)
|
||||
|
||||
当前默认不是每次全清空:
|
||||
- 启动会先加载持久化,再执行 E01 归并清理
|
||||
- 仅在 `persist.wipe_on_start=true` 时会在启动前删除持久化 DB
|
||||
|
||||
因此若做“分批/多轮 pipeline”,建议:
|
||||
- 保持 `wipe_on_start=false`
|
||||
- 通过显式 checkpoint 与增量范围控制保证状态一致性
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论
|
||||
|
||||
短期最优路径:
|
||||
- 先完成阶段 1 + 阶段 2(低/中风险),通常能解决大部分查询性能问题
|
||||
- 再根据压测结果决定是否进入阶段 3(SQL 化冷查询)
|
||||
|
||||
这条路线兼顾:
|
||||
- 速度(热路径仍在内存)
|
||||
- 清晰性(查询层解耦)
|
||||
- 内存边界(按需索引 + 分批加载)
|
||||
@@ -0,0 +1,243 @@
|
||||
# 数据源指南(DataSource Guide)
|
||||
|
||||
本文档记录当前项目中不同数据源的职责、能力与配置方式,并按数据源分类说明。
|
||||
|
||||
## 1. 总览
|
||||
|
||||
当前实现包含两类数据源:
|
||||
|
||||
- JSONL 数据源:面向本地文件读写
|
||||
- API 数据源:面向远端 HTTP 接口推送与轮询
|
||||
|
||||
两者都基于统一编排层 `BaseDataSource`,具备一致的流程骨架:
|
||||
|
||||
1. 注册 Handler(按节点类型)
|
||||
2. load 阶段加载节点
|
||||
3. sync 阶段按动作执行 create/update/delete
|
||||
4. 统一处理 TaskResult 与状态机写回
|
||||
5. 统一异步任务轮询(IN_PROGRESS -> SUCCESS/FAILED)
|
||||
|
||||
这意味着:
|
||||
|
||||
- 业务策略可复用,不需关心底层介质差异
|
||||
- 数据源差异主要落在 Handler 与传输/存储细节
|
||||
|
||||
---
|
||||
|
||||
## 2. JSONL 数据源
|
||||
|
||||
### 2.1 主要功能
|
||||
|
||||
JSONL 数据源对应实现:
|
||||
|
||||
- `sync_state_machine/datasource/jsonl/datasource.py`
|
||||
- `sync_state_machine/datasource/jsonl/handler.py`
|
||||
|
||||
核心能力:
|
||||
|
||||
- 从目录中按 `{node_type}_N.jsonl` 规则发现并加载数据
|
||||
- 批量 create/update/delete 时,先改内存缓存,再统一刷盘
|
||||
- 支持 `read_only`,在只读模式下禁止写回
|
||||
- 支持按项目过滤(`target_project_ids`)在 load 阶段预过滤
|
||||
|
||||
### 2.2 行为特点
|
||||
|
||||
- load:逐行解析 JSONL,单行坏数据仅警告并继续
|
||||
- create/update/delete:返回统一 `TaskResult`,由 DataSource 层统一更新状态
|
||||
- 写回:默认写到 `{node_type}_1.jsonl`
|
||||
|
||||
### 2.3 关键配置
|
||||
|
||||
`JsonlDataSourceConfig` 字段:
|
||||
|
||||
- `type: jsonl`
|
||||
- `jsonl_dir`: 数据目录
|
||||
- `read_only`: 是否只读
|
||||
- `target_project_ids`: 项目过滤列表
|
||||
- `handler_configs`: 按节点类型的 handler 参数
|
||||
|
||||
---
|
||||
|
||||
## 3. API 数据源
|
||||
|
||||
### 3.1 主要功能
|
||||
|
||||
API 数据源对应实现:
|
||||
|
||||
- `sync_state_machine/datasource/api/datasource.py`
|
||||
- `sync_state_machine/datasource/api/client.py`
|
||||
- `sync_state_machine/datasource/api/handler.py`
|
||||
|
||||
核心能力:
|
||||
|
||||
- 统一 HTTP 请求入口(签名、uid 注入、错误日志、trace)
|
||||
- 统一 push 任务轮询(`/push/result`)
|
||||
- 统一请求链路追踪(submit/poll/load)
|
||||
- 统一限流(全局窗口限流)
|
||||
|
||||
### 3.2 API 全局限流(重点)
|
||||
|
||||
当前已支持全局异步限流,入口在 `ApiClient.request()` 前。
|
||||
|
||||
配置字段:
|
||||
|
||||
- `api_rate_limit_max_requests`:窗口内最大请求数
|
||||
- `api_rate_limit_window_seconds`:窗口秒数
|
||||
|
||||
默认值:
|
||||
|
||||
- `api_rate_limit_max_requests: 10`
|
||||
- `api_rate_limit_window_seconds: 10.0`
|
||||
|
||||
含义:
|
||||
|
||||
- 任意 10 秒窗口内最多发 10 个请求
|
||||
- 超出后,后续请求在客户端侧等待,不直接打到服务端
|
||||
|
||||
实现机制(简述):
|
||||
|
||||
- 全局 `asyncio.Lock` + 时间戳队列
|
||||
- 发送前清理窗口外时间戳
|
||||
- 若窗口未满,立即占位并发送
|
||||
- 若窗口已满,按最早时间戳计算等待时间后重试
|
||||
|
||||
禁用方式:
|
||||
|
||||
- `api_rate_limit_max_requests <= 0` 或 `api_rate_limit_window_seconds <= 0`
|
||||
|
||||
### 3.3 从 collection 按需提取数据(重点)
|
||||
|
||||
在 API 模式下,Handler 不是盲目请求,而是从 `collection` 按依赖提取后再调用 API。
|
||||
|
||||
常见模式:
|
||||
|
||||
1. load 前置依赖提取
|
||||
- 例如合同 Handler 先从 collection 取 project 节点,再按 project_id 拉合同列表
|
||||
2. create/update 提取上下文
|
||||
- 从 `node.context` 或 `node.get_data()` 中提取依赖字段(如 `project_id`)
|
||||
3. 节点复用/更新
|
||||
- `_create_basic_node` 会优先按 `data_id` 命中已有节点,避免重复节点
|
||||
|
||||
好处:
|
||||
|
||||
- 减少无效 API 调用(按依赖、按项目有选择地加载)
|
||||
- 保持节点关系与上下文完整(project/contract 等依赖链)
|
||||
|
||||
### 3.4 API 校验链路(重点)
|
||||
|
||||
API 数据源中,校验分为三层:
|
||||
|
||||
1. 领域 schema 校验(请求前/响应后)
|
||||
- 例如 `ContractCreate.model_validate(...)`、`ContractUpdate.model_validate(...)`
|
||||
- load 响应也会在解析后转为领域 schema
|
||||
2. 通用更新过滤与差异识别
|
||||
- `_filter_update_data`:仅在 schema 相关字段有变化时提交更新
|
||||
- `_get_schema_diff`:用于差异字段识别与调试输出
|
||||
3. 轮询接口输入校验
|
||||
- `PushIdsSchema` 校验 push_ids 格式后再请求 `/push/result`
|
||||
|
||||
设计原则:
|
||||
|
||||
- 校验失败直接显式失败,不静默降级
|
||||
- 保持状态机与错误日志可追踪
|
||||
|
||||
### 3.5 轮询与结果回写
|
||||
|
||||
- create 可返回 IN_PROGRESS(异步)或 SUCCESS(同步)
|
||||
- `BaseDataSource` 统一轮询并写回节点状态
|
||||
- 结果通过状态机事件落到 SUCCESS/FAILED
|
||||
- create 成功后会进行 `data_id` 回写与 collection 更新(必要时)
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置说明(按数据源区分)
|
||||
|
||||
### 4.1 JSONL 数据源配置示例
|
||||
|
||||
```yaml
|
||||
local_datasource:
|
||||
type: jsonl
|
||||
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
|
||||
read_only: false
|
||||
target_project_ids: []
|
||||
handler_configs: {}
|
||||
```
|
||||
|
||||
### 4.2 API 数据源配置示例(含限流)
|
||||
|
||||
```yaml
|
||||
remote_datasource:
|
||||
type: api
|
||||
api_base_url: http://localhost:8000/api/third/v2
|
||||
api_uid: your_uid
|
||||
api_secret: your_secret
|
||||
api_debug: true
|
||||
|
||||
# 轮询配置
|
||||
poll_max_retries: 1
|
||||
poll_interval: 2.0
|
||||
|
||||
# 全局限流:10 秒最多 10 次请求
|
||||
api_rate_limit_max_requests: 10
|
||||
api_rate_limit_window_seconds: 10.0
|
||||
|
||||
target_project_ids:
|
||||
- "project-id-1"
|
||||
handler_configs: {}
|
||||
```
|
||||
|
||||
### 4.3 配置模型位置
|
||||
|
||||
- `sync_state_machine/config/datasource_config.py`
|
||||
- `JsonlDataSourceConfig`
|
||||
- `ApiDataSourceConfig`
|
||||
|
||||
配置是严格模式(`extra="forbid"`):
|
||||
|
||||
- 未声明字段会报错
|
||||
- 避免运行时拼写错误导致“配置看起来生效、实际未生效”
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行建议与排障
|
||||
|
||||
### 5.1 API 触发限流(429)时
|
||||
|
||||
优先调整:
|
||||
|
||||
- 降低 `api_rate_limit_max_requests`
|
||||
- 提高 `api_rate_limit_window_seconds`
|
||||
|
||||
例如:
|
||||
|
||||
- `api_rate_limit_max_requests: 8`
|
||||
- `api_rate_limit_window_seconds: 10.0`
|
||||
|
||||
### 5.2 轮询超时
|
||||
|
||||
可调整:
|
||||
|
||||
- `poll_max_retries`
|
||||
- `poll_interval`
|
||||
|
||||
总等待时长约为:
|
||||
|
||||
- `poll_max_retries * poll_interval`
|
||||
|
||||
### 5.3 校验失败
|
||||
|
||||
建议直接修数据或 schema,不建议绕过校验;当前链路会保留失败信息用于定位。
|
||||
|
||||
---
|
||||
|
||||
## 6. 相关文件索引
|
||||
|
||||
- `sync_state_machine/datasource/datasource.py`
|
||||
- `sync_state_machine/datasource/jsonl/datasource.py`
|
||||
- `sync_state_machine/datasource/jsonl/handler.py`
|
||||
- `sync_state_machine/datasource/api/datasource.py`
|
||||
- `sync_state_machine/datasource/api/client.py`
|
||||
- `sync_state_machine/datasource/api/handler.py`
|
||||
- `sync_state_machine/config/datasource_config.py`
|
||||
- `run_profiles/jsonl_to_api.yaml`
|
||||
- `run_profiles/preset/jsonl_to_api.default.yaml`
|
||||
@@ -0,0 +1,360 @@
|
||||
# sync_state_machine 接入手册
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本项目现在可以作为可编辑安装库使用:
|
||||
|
||||
- 源码开发:`pip install -e .`
|
||||
- 代码引用:`import sync_state_machine`
|
||||
- 命令行运行:`ecm-sync-run --config_path=...`
|
||||
|
||||
推荐接入方式:
|
||||
|
||||
- `sync_state_machine` 负责同步引擎、状态机、pipeline、datasource 抽象。
|
||||
- 业务系统负责提供自己的数据源适配层,不要反向让 `sync_state_machine` 依赖业务系统。
|
||||
|
||||
---
|
||||
|
||||
## 2. 安装方式
|
||||
|
||||
### 2.1 本仓库本地安装
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
安装后可直接使用:
|
||||
|
||||
```python
|
||||
import sync_state_machine
|
||||
print(sync_state_machine.__version__)
|
||||
```
|
||||
|
||||
也可以直接走命令:
|
||||
|
||||
```bash
|
||||
ecm-sync-run --config_path=run_profiles/preset/jsonl_to_api.default.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 对外可直接引用的入口
|
||||
|
||||
常用入口:
|
||||
|
||||
- `sync_state_machine.build_config`
|
||||
- `sync_state_machine.build_default_config`
|
||||
- `sync_state_machine.run_pipeline_from_config`
|
||||
- `sync_state_machine.create_pipeline_from_config`
|
||||
- `sync_state_machine.PipelineRunConfig`
|
||||
- `sync_state_machine.JsonlDataSourceConfig`
|
||||
- `sync_state_machine.ApiDataSourceConfig`
|
||||
- `sync_state_machine.DomainRegistry`
|
||||
|
||||
内置 domain 注册会在导入包时自动完成。
|
||||
|
||||
---
|
||||
|
||||
## 4. 最小接入示例
|
||||
|
||||
### 4.1 直接运行现有 pipeline
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from sync_state_machine import build_config, load_overrides_from_file, run_pipeline_from_config
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
project_root = Path("/path/to/ecm_sync_system")
|
||||
profile_path = project_root / "run_profiles" / "preset" / "jsonl_to_api.default.yaml"
|
||||
|
||||
overrides = load_overrides_from_file(profile_path, project_root)
|
||||
config = build_config(project_root=project_root, overrides=overrides)
|
||||
await run_pipeline_from_config(config, title="demo", print_summary=True)
|
||||
```
|
||||
|
||||
适合场景:
|
||||
|
||||
- 仍沿用当前仓库的 domain 注册
|
||||
- 仍沿用当前仓库的 JSONL / API handler
|
||||
- 外部系统只想“调用引擎”
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据源怎么引用
|
||||
|
||||
当前引擎的 datasource 分两层:
|
||||
|
||||
1. `DataSource`
|
||||
- 管理加载、批量执行、轮询、collection 注入、handler 注册。
|
||||
2. `Handler`
|
||||
- 只处理某一个 `node_type` 的 I/O 逻辑。
|
||||
|
||||
因此接入时,不是“直接把业务 ORM 塞进 pipeline”,而是:
|
||||
|
||||
- 先确定你的数据源类型
|
||||
- 再决定复用已有 datasource,还是只新增 handler
|
||||
|
||||
### 5.1 已有两类 datasource
|
||||
|
||||
#### JSONL
|
||||
|
||||
可直接复用:
|
||||
|
||||
- `sync_state_machine.datasource.JsonlDataSource`
|
||||
- `sync_state_machine.datasource.BaseJsonlHandler`
|
||||
|
||||
适合:
|
||||
|
||||
- 本地 fixture
|
||||
- 离线回放
|
||||
- 调试数据集
|
||||
|
||||
#### API
|
||||
|
||||
可直接复用:
|
||||
|
||||
- `sync_state_machine.datasource.ApiDataSource`
|
||||
- `sync_state_machine.datasource.ApiClient`
|
||||
- `sync_state_machine.datasource.BaseApiHandler`
|
||||
|
||||
适合:
|
||||
|
||||
- HTTP 接口推送
|
||||
- 异步 push + poll
|
||||
- 远端系统同步
|
||||
|
||||
---
|
||||
|
||||
## 6. 怎么适配数据源
|
||||
|
||||
### 6.1 最推荐:复用已有 datasource,只写 handler
|
||||
|
||||
这是最小成本方案。
|
||||
|
||||
比如你的本地数据来自 backend 的数据库,但你不想改引擎核心,可以:
|
||||
|
||||
- 保持 `ApiDataSource` / `JsonlDataSource` 不变
|
||||
- 新增你自己的 handler
|
||||
- 在 handler 里把 backend 数据模型转换成节点 schema
|
||||
|
||||
如果是 HTTP 源:继承 `BaseApiHandler`。
|
||||
|
||||
如果是 JSONL 源:继承 `BaseJsonlHandler`。
|
||||
|
||||
#### API handler 示例
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
from sync_state_machine.datasource.api.handler import BaseApiHandler
|
||||
|
||||
|
||||
class DemoSchema(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class DemoApiHandler(BaseApiHandler[DemoSchema]):
|
||||
_node_type = "demo"
|
||||
_node_class = DemoSyncNode
|
||||
_schema = DemoSchema
|
||||
|
||||
async def load(self):
|
||||
payload = await self.api_client.get("/demo/list")
|
||||
return [self._create_node(item) for item in payload["data"]]
|
||||
|
||||
async def create_all(self, nodes):
|
||||
...
|
||||
|
||||
async def update_all(self, nodes):
|
||||
...
|
||||
|
||||
async def delete_all(self, nodes):
|
||||
...
|
||||
|
||||
async def poll_tasks(self, task_ids):
|
||||
...
|
||||
```
|
||||
|
||||
#### JSONL handler 示例
|
||||
|
||||
```python
|
||||
from sync_state_machine.datasource.jsonl.handler import BaseJsonlHandler
|
||||
|
||||
|
||||
class DemoJsonlHandler(BaseJsonlHandler):
|
||||
def __init__(self, datasource):
|
||||
super().__init__(
|
||||
datasource=datasource,
|
||||
node_type="demo",
|
||||
node_class=DemoSyncNode,
|
||||
schema=DemoSchema,
|
||||
)
|
||||
```
|
||||
|
||||
然后把它注册到 `DomainRegistry`。
|
||||
|
||||
---
|
||||
|
||||
### 6.2 如果 backend 是数据库:建议写“数据库 handler / adapter”,不要让引擎直接依赖 ORM
|
||||
|
||||
推荐结构:
|
||||
|
||||
```text
|
||||
backend/
|
||||
app/
|
||||
integrations/
|
||||
ecm_sync/
|
||||
datasource/
|
||||
user_db_handler.py
|
||||
contract_db_handler.py
|
||||
mapper/
|
||||
user_mapper.py
|
||||
contract_mapper.py
|
||||
services/
|
||||
sync_runner.py
|
||||
```
|
||||
|
||||
职责建议:
|
||||
|
||||
- `mapper/`
|
||||
- backend model -> sync schema
|
||||
- sync schema -> backend push payload
|
||||
- `datasource/handler`
|
||||
- 调 repository / service 获取数据
|
||||
- 转成 `SyncNode`
|
||||
- `services/sync_runner.py`
|
||||
- 组装配置并调用 pipeline
|
||||
|
||||
不要这样做:
|
||||
|
||||
- 不要在 `sync_state_machine` 包里 import backend 的 model/repository
|
||||
- 不要让 `DomainRegistry` 注册逻辑散落在 backend 业务层之外
|
||||
|
||||
---
|
||||
|
||||
### 6.3 什么时候需要新增 DataSource 类
|
||||
|
||||
只有当你的 I/O 模型与当前两种 datasource 都不匹配时,才新增 `BaseDataSource` 子类。
|
||||
|
||||
例如:
|
||||
|
||||
- MQ / Kafka 批量消费型源
|
||||
- 直接数据库快照源
|
||||
- 特殊 RPC 源
|
||||
|
||||
此时建议:
|
||||
|
||||
1. 继承 `BaseDataSource`
|
||||
2. 保持接口与现有 datasource 一致:
|
||||
- `register_handler()`
|
||||
- `set_collection()`
|
||||
- `load_all()`
|
||||
- `sync_all()`
|
||||
3. 把“节点类型差异”仍下沉到 handler,不要写死在 datasource 里
|
||||
|
||||
---
|
||||
|
||||
## 7. Domain 怎么注册
|
||||
|
||||
引擎通过 `DomainRegistry` 查找:
|
||||
|
||||
- `schema`
|
||||
- `node_class`
|
||||
- `strategy_class`
|
||||
- `jsonl_handler_class`
|
||||
- `api_handler_class`
|
||||
|
||||
示例:
|
||||
|
||||
```python
|
||||
from sync_state_machine.common.registry import DomainRegistry
|
||||
|
||||
DomainRegistry.register(
|
||||
node_type="demo",
|
||||
schema=DemoSchema,
|
||||
node_class=DemoSyncNode,
|
||||
strategy_class=DemoStrategy,
|
||||
jsonl_handler_class=DemoJsonlHandler,
|
||||
api_handler_class=DemoApiHandler,
|
||||
)
|
||||
```
|
||||
|
||||
如果是 backend 接入,建议在 backend 的 integration 启动阶段集中注册,而不是分散在 controller / service 里。
|
||||
|
||||
---
|
||||
|
||||
## 8. backend 里的推荐接法
|
||||
|
||||
建议 backend 只新增一层 integration:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from sync_state_machine import build_config, create_pipeline_from_config
|
||||
|
||||
|
||||
async def run_demo_sync() -> None:
|
||||
project_root = Path("/path/to/ecm_sync_system")
|
||||
overrides = {
|
||||
"node_types": ["project", "contract"],
|
||||
"local_datasource": {
|
||||
"type": "jsonl",
|
||||
"jsonl_dir": str(project_root / "tests" / "fixtures" / "demo"),
|
||||
},
|
||||
"remote_datasource": {
|
||||
"type": "api",
|
||||
"api_base_url": "https://example.com",
|
||||
"api_uid": "xxx",
|
||||
"api_secret": "xxx",
|
||||
"target_project_ids": ["demo-project"],
|
||||
},
|
||||
}
|
||||
|
||||
config = build_config(project_root=project_root, overrides=overrides)
|
||||
pipeline = await create_pipeline_from_config(config)
|
||||
await pipeline.run()
|
||||
```
|
||||
|
||||
如果 backend 自己提供本地数据库数据,不要把 backend model 直接灌进 pipeline;先经过 handler / mapper 转成 schema 再进入引擎。
|
||||
|
||||
---
|
||||
|
||||
## 9. 接入时的边界约束
|
||||
|
||||
建议保持下面的依赖方向:
|
||||
|
||||
- `backend integration -> sync_state_machine`
|
||||
- `sync_state_machine -X-> backend`
|
||||
|
||||
这样做的好处:
|
||||
|
||||
- 引擎能独立测试
|
||||
- backend 改 repository 不会直接污染引擎
|
||||
- 后续可以单独发布库
|
||||
|
||||
---
|
||||
|
||||
## 10. 当前阶段建议
|
||||
|
||||
如果项目还在高频迭代,推荐顺序:
|
||||
|
||||
1. 先 `pip install -e .`
|
||||
2. 先在 backend 做 integration 目录,不急着发私有包
|
||||
3. 先接 1~2 个 node_type,跑通完整链路
|
||||
4. 稳定后再考虑拆成正式发布包
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文件
|
||||
|
||||
- 包入口:[sync_state_machine/__init__.py](../sync_state_machine/__init__.py)
|
||||
- CLI:[sync_state_machine/cli.py](../sync_state_machine/cli.py)
|
||||
- 配置工厂:[sync_state_machine/config](../sync_state_machine/config)
|
||||
- datasource 基础层:[sync_state_machine/datasource](../sync_state_machine/datasource)
|
||||
- pipeline 工厂:[sync_state_machine/pipeline/factory.py](../sync_state_machine/pipeline/factory.py)
|
||||
- domain 注册:[sync_state_machine/common/registry.py](../sync_state_machine/common/registry.py)
|
||||
@@ -0,0 +1,79 @@
|
||||
# 节点状态定义(业务核心)
|
||||
|
||||
本文档定义 `SyncNode` 的状态字段语义,并说明状态机如何把这些语义形式化为可执行规则。
|
||||
|
||||
## 1. 节点状态字段
|
||||
|
||||
### `binding_status`
|
||||
- 含义:绑定判定结果,决定节点是否允许进入 create/update。
|
||||
- 取值:`UNCHECKED | NORMAL | MISSING | PEER_CREATING | WARNING | ABNORMAL | DEPENDENCY_ERROR`
|
||||
|
||||
### `action`
|
||||
- 含义:本轮同步意图。
|
||||
- 取值:`NONE | CREATE | UPDATE | DELETE`
|
||||
- 说明:本项目当前主路径使用 `NONE/CREATE/UPDATE`。
|
||||
|
||||
### `status`
|
||||
- 含义:执行状态。
|
||||
- 取值:`PENDING | IN_PROGRESS | SUCCESS | FAILED | SKIPPED | PRECONDITION_FAILED`
|
||||
|
||||
### `data_id`
|
||||
- 含义:目标系统业务主键是否就绪。
|
||||
- 关键语义:`UPDATE` 路径要求目标节点具备 `data_id`,否则进入 `PRECONDITION_FAILED`。
|
||||
|
||||
### `error`
|
||||
- 含义:阻断/失败原因。
|
||||
- 说明:依赖错误与执行错误都在该字段记录可读原因。
|
||||
|
||||
## 2. 绑定状态业务语义
|
||||
|
||||
| binding_status | 业务语义 | 是否允许进入 create/update |
|
||||
|---|---|---|
|
||||
| `UNCHECKED` | 尚未执行 bind 判定 | 否 |
|
||||
| `NORMAL` | 绑定链路健康,可做差异检测与同步 | 是 |
|
||||
| `MISSING` | 无绑定记录,需做依赖检查与自动绑定/创建策略 | 有条件 |
|
||||
| `PEER_CREATING` | 对侧创建进行中(等待创建提交) | 否(等待 E20 提交) |
|
||||
| `WARNING` | 风险态(如自动绑定歧义/关闭) | 否 |
|
||||
| `ABNORMAL` | 数据损坏或核心绑定异常 | 否 |
|
||||
| `DEPENDENCY_ERROR` | 依赖不可满足 | 否 |
|
||||
|
||||
## 3. create / update 语义
|
||||
|
||||
### create
|
||||
- 来源:`MISSING` 节点在策略允许时生成目标 `CREATE` 节点。
|
||||
- 形式化:`E20/E21` + `spawn_target_node_state: S06`。
|
||||
|
||||
### update
|
||||
- 来源:`NORMAL` 节点差异检测后生成目标 `UPDATE`。
|
||||
- 形式化:`E30`。
|
||||
- 前置失败:目标缺 `data_id` 或依赖 ID 解析失败时进入 `S08`,且必须 `action=UPDATE`。
|
||||
|
||||
## 4. 执行状态语义(datasource)
|
||||
|
||||
- `SUCCESS`:执行成功,必要时回填 `data_id`。
|
||||
- `FAILED`:执行失败,保留错误。
|
||||
- `IN_PROGRESS`:异步任务进行中。
|
||||
- `SKIPPED`:handler 决策跳过;若由 UPDATE 降级为 NONE,则进入 `S14`。
|
||||
- `PRECONDITION_FAILED`:准备阶段失败,不进入实际 API 提交。
|
||||
|
||||
## 5. 状态机形式化映射
|
||||
|
||||
状态机把“业务语义”变成“可执行迁移”:
|
||||
- 语义输入:strategy/datasource 提供 `context`。
|
||||
- 迁移判断:`StateMachineRuntime.dispatch(current_state, event_id, context)`。
|
||||
- 状态落地:`Decision.to_state` 对应 YAML `states[Sxx]`。
|
||||
- 不变量:每次落地后执行 `runtime.check_invariants()`。
|
||||
|
||||
## 6. 关键一致性约束
|
||||
|
||||
- `UNCHECKED` 不允许带动作(`INV-1_UNCHECKED_ACTION_NONE`)。
|
||||
- 阻断态(`ABNORMAL/WARNING/DEPENDENCY_ERROR`)不允许动作(`INV-2_BLOCKING_NO_ACTION`)。
|
||||
- `PRECONDITION_FAILED` 必须 `action=UPDATE`(`INV-3_PRECOND_IMPLIES_UPDATE`)。
|
||||
|
||||
## 7. 当前默认依赖语义(业务取向)
|
||||
|
||||
以下是当前代码默认策略,不是单纯命名差异:
|
||||
- 空依赖引用允许放行(`allow_empty_dependency_ref=True`)。
|
||||
- 依赖节点缺失 `data_id` 默认允许放行(`allow_missing_dep_data_id=True`)。
|
||||
|
||||
这意味着:`DEPENDENCY_ERROR` 的触发更偏向“依赖节点不存在或状态异常”,而不是“依赖节点尚未回填 data_id”。如果要改为严格模式,需要在 strategy 语义层切换该策略并同步文档。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 运维与排障手册(业务视角)
|
||||
|
||||
本文档聚焦两件事:
|
||||
- 出错时先看哪里。
|
||||
- 日志里的状态到底代表什么业务问题。
|
||||
|
||||
## 1. 先看什么(快速定位)
|
||||
|
||||
1. 看运行日志(`sync_log`):
|
||||
- 重点关注 `状态机流转`、`reason=`、`depends=[...]`。
|
||||
2. 看持久化数据库:
|
||||
- 重点字段:`binding_status`、`action`、`status`、`error`。
|
||||
3. 对照状态机文档:
|
||||
- `docs/state_machine.md`
|
||||
- `docs/node_state_definition.md`
|
||||
|
||||
## 2. 阶段化排障
|
||||
|
||||
### 2.1 bind 阶段
|
||||
|
||||
常见表现:节点停在 `S03/S04/S05/S13`。
|
||||
|
||||
- `S03(ABNORMAL)`:节点数据损坏或核心绑定异常。
|
||||
- `S04(WARNING)`:自动绑定关闭、键缺失、歧义等不可自动处理情况。
|
||||
- `S05(DEPENDENCY_ERROR)`:依赖节点不存在或依赖状态不正常。
|
||||
- `S13(PEER_CREATING)`:对侧创建进行中,尚未提交完成。
|
||||
|
||||
典型日志:
|
||||
- `reason=依赖项不满足 | depends=[company_id status=peer_creating -> not_normal]`
|
||||
|
||||
业务解释:
|
||||
- 上游依赖还没进入 `NORMAL`,下游业务会被严格阻断。
|
||||
|
||||
### 2.2 create 阶段
|
||||
|
||||
常见表现:节点长时间停在 `S13` 或 `S10C`。
|
||||
|
||||
- `S13`:已判定需要创建,但创建提交未完成。
|
||||
- `S10C`:CREATE 异步执行中。
|
||||
- `S12C`:CREATE 失败。
|
||||
|
||||
优先检查:
|
||||
- API 侧是否返回任务 ID 与成功回执。
|
||||
- 轮询参数(`poll_max_retries` / `poll_interval`)是否过小。
|
||||
|
||||
### 2.3 update 阶段
|
||||
|
||||
常见表现:节点进入 `S08` 或 `S12U`。
|
||||
|
||||
- `S08(PRECONDITION_FAILED)`:前置条件失败(如缺 data_id、依赖 ID 解析失败)。
|
||||
- `S12U`:UPDATE 调用失败。
|
||||
- `S14`:UPDATE 被 handler 降级为 NONE 并跳过。
|
||||
|
||||
优先检查:
|
||||
- `update_direction` 是否符合预期。
|
||||
- 目标节点是否具备 `data_id`。
|
||||
|
||||
## 3. 常见配置误区与症状
|
||||
|
||||
- `auto_bind=false`
|
||||
- 症状:MISSING 节点更容易进入 `WARNING`,不会走自动创建识别。
|
||||
- `skip_sync=true`
|
||||
- 症状:该类型只加载不执行 bind/create/update,常被误以为“没生效”。
|
||||
- `load_mode=persisted_only`
|
||||
- 症状:数据只来自持久化,不主动拉取新数据。
|
||||
- `update_direction=none`
|
||||
- 症状:不做 update,即使有差异也不会提交更新。
|
||||
|
||||
## 4. 推荐操作顺序
|
||||
|
||||
1. 先用 `run_profiles/preset/jsonl_to_jsonl.default.yaml` 跑通基线。
|
||||
2. 再切到 `run_profiles/*.local.yaml` 做最小覆盖。
|
||||
3. 每次只改一类字段(比如只改 `project` 的依赖策略)。
|
||||
4. 改完看一次 `sync_log`,确认状态流转与预期一致。
|
||||
|
||||
## 5. UI 排障(可选)
|
||||
|
||||
1. 启动:`python -m ui.main --host 127.0.0.1 --port 8765`
|
||||
2. 打开:`http://127.0.0.1:8765`
|
||||
3. 在“运行与日志”查看实时状态变化。
|
||||
4. 在“记录与数据库”检查节点状态与绑定记录。
|
||||
@@ -0,0 +1,143 @@
|
||||
# 运行配置业务说明
|
||||
|
||||
本文档从业务视角说明配置字段含义、对阶段行为的影响,以及推荐的覆盖方式。
|
||||
|
||||
## 1. 推荐使用方式(先 default,再覆盖)
|
||||
|
||||
1. 先使用基线配置运行:
|
||||
- `run_profiles/preset/jsonl_to_jsonl.default.yaml`
|
||||
2. 若需项目定制,再复制为:
|
||||
- `run_profiles/jsonl_to_jsonl.local.yaml`
|
||||
3. 仅改有业务差异的字段,避免一次改太多导致排障困难。
|
||||
|
||||
占位符:
|
||||
- `${PROJECT_ROOT}` 会在运行时替换为项目根目录绝对路径。
|
||||
|
||||
## 2. 顶层字段(你在改什么)
|
||||
|
||||
- `node_types`
|
||||
- 本轮参与同步的业务类型列表。
|
||||
- 顺序会影响依赖链执行时机(当前按 node_type 串行执行 bind/create/update)。
|
||||
- `target_project_ids`
|
||||
- 兼容字段。作为两侧 datasource 的项目白名单兜底值。
|
||||
- 若 datasource 自身配置了 `target_project_ids`,则优先使用 datasource 级配置。
|
||||
- `local_datasource` / `remote_datasource`
|
||||
- 分别定义两侧数据来源与执行端。
|
||||
- `strategies`
|
||||
- 每个业务类型的行为策略(依赖、自动绑定、创建方向、更新方向、跳过等)。
|
||||
- `persist`
|
||||
- 持久化开关与数据库位置。
|
||||
- `logging`
|
||||
- 运行日志输出位置与级别。
|
||||
|
||||
## 3. 数据源字段(DataSource)
|
||||
|
||||
### 3.1 JSONL 数据源
|
||||
|
||||
- `type=jsonl`:使用本地 JSONL 文件作为数据源。
|
||||
- `jsonl_dir`:JSONL 目录路径。
|
||||
- `read_only=false`:允许该侧执行 create/update 回写;若为 true,通常用于只读加载。
|
||||
- `target_project_ids=[]`:该侧项目白名单;为空时不进行项目筛选。
|
||||
- `handler_configs`:预留给各业务 handler 的扩展参数。
|
||||
|
||||
### 3.2 API 数据源
|
||||
|
||||
- `type=api`:对接 HTTP API。
|
||||
- `api_base_url` / `api_uid` / `api_secret`:接口访问参数。
|
||||
- `api_debug`:开启后输出更多调试信息。
|
||||
- `poll_max_retries` / `poll_interval`:异步任务轮询次数与间隔。
|
||||
- `target_project_ids`:**必填**,该侧项目白名单;至少需要指定一个项目ID,以避免加载过多远程数据。
|
||||
- `handler_configs`:业务 handler 扩展参数。
|
||||
|
||||
## 4. 策略字段(Strategy)
|
||||
|
||||
每个 node_type 都建议显式写出以下字段,便于审阅与排障。
|
||||
|
||||
### 4.1 `update_direction`
|
||||
|
||||
- `push`:本地变更推送到远端。
|
||||
- `pull`:远端变更拉取到本地。
|
||||
- `bidirectional`:双向更新(需要明确冲突策略,谨慎使用)。
|
||||
- `none`:关闭 update 阶段。
|
||||
|
||||
### 4.2 `local_orphan_action` / `remote_orphan_action`
|
||||
|
||||
- `create_remote`:本地孤儿创建到远端。
|
||||
- `create_local`:远端孤儿创建到本地。
|
||||
- `none`:不自动创建。
|
||||
- `delete_local` / `delete_remote`:当前主流程未作为默认路径,谨慎使用。
|
||||
|
||||
### 4.3 `auto_bind` / `auto_bind_fields` / `pre_bind_data_id`
|
||||
|
||||
- `auto_bind=true`:在 `S02(MISSING)` 上尝试自动匹配。
|
||||
- `auto_bind=false`:不会做自动匹配,也不会走自动创建识别路径;通常会进入阻断态等待人工处理。
|
||||
- `auto_bind_fields`:自动匹配使用的业务键字段。
|
||||
- `pre_bind_data_id`:显式 data_id 预绑定清单(通用能力),格式为:
|
||||
- `- local_data_id: "..."`
|
||||
` remote_data_id: "..."`
|
||||
- 规则:若两侧数据源都存在这对 data_id 对应节点,且当前无绑定记录,则直接绑定。
|
||||
- 不依赖 `auto_bind/auto_bind_fields`,即使 `auto_bind=false` 也会执行。
|
||||
- 不做业务键一对一匹配检查,按你给定的映射对优先执行。
|
||||
|
||||
### 4.4 `depend_fields`
|
||||
|
||||
- 定义依赖字段到依赖 node_type 的映射,例如:`company_id -> company`。
|
||||
- 依赖节点状态非 `NORMAL` 时,会在 bind 的依赖检查阶段进入 `DEPENDENCY_ERROR`。
|
||||
|
||||
### 4.5 `skip_sync`
|
||||
|
||||
- 运行配置中已不建议使用 `skip_sync`。
|
||||
- 推荐使用显式组合:
|
||||
- `local_orphan_action: none`
|
||||
- `remote_orphan_action: none`
|
||||
- `update_direction: none`
|
||||
- 当三者同时为 `none/none/none` 时,pipeline 会跳过该 node_type 的 bind/create/update。
|
||||
|
||||
### 4.6 Handler 级参数(`handler_configs`)
|
||||
|
||||
- 建议将 handler 特有参数放在 datasource 的 `handler_configs.<node_type>` 下。
|
||||
- 示例:
|
||||
- `remote_datasource.handler_configs.supplier.load_mode: persisted_only`
|
||||
- `persisted_only` 表示只使用已持久化数据,不主动从外部拉新数据(具体以对应 handler 实现为准)。
|
||||
|
||||
## 5. 阶段行为与配置关系
|
||||
|
||||
- bind 阶段
|
||||
- `depend_fields` 决定是否做依赖校验。
|
||||
- `pre_bind_data_id` 先按显式 local/remote data_id 映射做预绑定。
|
||||
- `auto_bind/auto_bind_fields` 决定是否继续进行业务键自动绑定。
|
||||
- create 阶段
|
||||
- `local_orphan_action` / `remote_orphan_action` 决定孤儿是否创建。
|
||||
- update 阶段
|
||||
- `update_direction` 决定是否和向哪侧更新。
|
||||
- 写入后 reload(Pipeline 内置)
|
||||
- 当某个 node_type 的 create 或 update 发生实际写入成功后,pipeline 会自动对该 node_type 重新执行两侧 `load`。
|
||||
- 目的:让后续 update 判断基于最新远端/本地数据,支持“create 后再补一次 update”的链路。
|
||||
- 同步后一致性检查(Pipeline 内置)
|
||||
- 每个 node_type 在本轮同步后会基于绑定对执行数据一致性校验(比较时忽略 `id/_id` 字段)。
|
||||
- 若发现差异,会在日志中打印差异样本,并在汇总表新增 `Consistent` 列展示 `Y` / `N(差异数)`。
|
||||
- 全局跳过
|
||||
- `local_orphan_action=none` + `remote_orphan_action=none` + `update_direction=none`
|
||||
可直接跳过该类型的 bind/create/update。
|
||||
|
||||
## 6. 覆盖配置建议
|
||||
|
||||
- 先在 default 跑通一轮,再做 local 覆盖。
|
||||
- 每次只改一类问题相关字段(例如只改 `project` 的依赖与自动绑定)。
|
||||
- 覆盖后优先看 `sync_log` 是否出现预期状态迁移。
|
||||
|
||||
## 7. 持久化与日志字段
|
||||
|
||||
### `persist`
|
||||
|
||||
- `enable`:是否开启持久化。
|
||||
- `db_path`:SQLite 路径。
|
||||
- `wipe_on_start`:启动是否清理历史持久化。
|
||||
|
||||
### `logging`
|
||||
|
||||
- `file_dir`:日志目录。
|
||||
- `file_name_pattern`:文件命名模板。
|
||||
- `level`:日志级别。
|
||||
|
||||
排障建议:运行失败时优先看日志中的 `状态机流转` 与 `reason=`,再对照 `docs/state_machine.md` 与 `docs/node_state_definition.md` 理解含义。
|
||||
@@ -0,0 +1,93 @@
|
||||
# 状态机形式化说明(与代码一致)
|
||||
|
||||
## 1. 真源与执行入口
|
||||
- 配置真源:`sync_state_machine/config/node_state_machine.yaml`
|
||||
- 事件入口:`sync_state_machine/engine/events.py`
|
||||
- 状态落地:`sync_state_machine/engine/state_apply.py`
|
||||
- 不变量校验:`events._apply_decision()` 内调用 `runtime.check_invariants()`
|
||||
|
||||
## 2. 状态集合(Sxx)
|
||||
- `S00`:bind 起点(`UNCHECKED/NONE/PENDING`)
|
||||
- `S01`:NORMAL,待后续 create/update 判定
|
||||
- `S02`:MISSING,待依赖检查/自动绑定/create
|
||||
- `S03`:ABNORMAL,阻断
|
||||
- `S04`:WARNING,阻断
|
||||
- `S05`:DEPENDENCY_ERROR,阻断
|
||||
- `S06`:CREATE 待执行(目标节点)
|
||||
- `S07`:UPDATE 待执行(`action=UPDATE,status=PENDING`)
|
||||
- `S08`:UPDATE 前置失败(`action=UPDATE,status=PRECONDITION_FAILED`)
|
||||
- `S10C/S10U/S10D`:按动作拆分的执行中状态
|
||||
- `S11C/S11U/S11D`:按动作拆分的执行成功状态
|
||||
- `S12C/S12U/S12D`:按动作拆分的执行失败状态
|
||||
- `S13`:对侧创建等待态(`PEER_CREATING/NONE/PENDING`)
|
||||
- `S14`:UPDATE 运行时降级为 `NONE` 并跳过
|
||||
|
||||
## 3. 事件到代码映射
|
||||
|
||||
### bootstrap
|
||||
- `E01`:`engine/events.py::e01_bootstrap()`
|
||||
|
||||
### bind
|
||||
- `E10`:`engine/events.py::e10_bind_core()`
|
||||
- `E15`:`engine/events.py::e15_bind_dependency()`
|
||||
- `E16`:`engine/events.py::e16_bind_auto()`(自动绑定判定;`ZERO+create_enabled=true` 时进入 `S13`,等待创建副作用提交)
|
||||
|
||||
### create/update prepare
|
||||
- `E20`:`engine/events.py::e20_create_prepare()`(spawn 成功从 `S13` 提交到 `S01`;失败保持 `S13`)
|
||||
- `E21`:`engine/events.py::e21_create_prepare()`
|
||||
- `E30`:`engine/events.py::e30_update_prepare()`
|
||||
|
||||
### sync execute
|
||||
- `E40C_START/E40C`:CREATE 执行入口与结果回写
|
||||
- `E40U_START/E40U`:UPDATE 执行入口与结果回写
|
||||
- `E40D_START/E40D`:DELETE 执行入口与结果回写
|
||||
|
||||
## 4. strategy 调用链(当前)
|
||||
- bind 主入口:`sync_system/strategy.py::DefaultSyncStrategy.bind()` -> `strategy_ops/bind_ops.py::run_bind()`
|
||||
- create 主入口:`sync_system/strategy.py::DefaultSyncStrategy.create()` -> `strategy_ops/create_ops.py::run_create()`
|
||||
- update 主入口:`sync_system/strategy.py::DefaultSyncStrategy.update()` -> `strategy_ops/update_ops.py::run_update()`
|
||||
- reset 主入口:`sync_system/strategy.py::BaseSyncStrategy.run_reset()` -> `strategy_ops/reset_ops.py::run_phase2_cleanup()`
|
||||
|
||||
## 5. context 字段(以 YAML 为准)
|
||||
|
||||
### bind 相关
|
||||
- `has_binding_record`
|
||||
- `core_binding_result` / `peer_core_binding_result` (`NORMAL|WARNING|ABNORMAL`)
|
||||
- `data_present`
|
||||
- `has_depend_fields`
|
||||
- `dep_satisfied`
|
||||
- `auto_bind_enabled`
|
||||
- `auto_bind_outcome` (`ONE_TO_ONE|AMBIGUOUS|ZERO|KEY_MISSING|KEY_ID_RESOLVE_FAIL|DATA_MISSING`)
|
||||
|
||||
### create/update 相关
|
||||
- `create_enabled`
|
||||
- `create_id_resolve_ok`
|
||||
- `spawn_success`
|
||||
- `update_enabled`
|
||||
- `target_has_data_id`
|
||||
- `update_id_resolve_ok`
|
||||
- `has_diff`
|
||||
|
||||
### execute 相关
|
||||
- `handler_result` (`SUCCESS|FAILED|SKIPPED|IN_PROGRESS`)
|
||||
- `poll_timeout`
|
||||
|
||||
## 6. 语义边界
|
||||
- strategy 负责业务语义计算(如依赖是否满足、自动绑定结果、差异检测)。
|
||||
- 状态机只负责迁移判定与不变量校验,不直接访问数据源。
|
||||
|
||||
## 7. 不变量
|
||||
- `INV-1_UNCHECKED_ACTION_NONE`
|
||||
- `INV-2_BLOCKING_NO_ACTION`
|
||||
- `INV-3_PRECOND_IMPLIES_UPDATE`
|
||||
|
||||
当状态落地后违反 invariant,运行时会直接抛错。
|
||||
|
||||
## 8. 校验与出图
|
||||
1. `python tools/validate_config.py --config sync_state_machine/config/node_state_machine.yaml`
|
||||
2. `python tools/render_graph.py --config sync_state_machine/config/node_state_machine.yaml --format mermaid --out docs/state_machine.mmd`
|
||||
|
||||
## 9. 事件意图命名(图示层)
|
||||
- 为了让流程意图更直观,图示可使用 `E01.reset.* / E10.core.* / E20.create.* / E30.update.* / E40<action>.* / E41.create.*` 这类别名。
|
||||
- 别名仅用于图标签;运行时事件 ID 仍以 YAML 中 `E01/E10/E15/E16/E20/E21/E30/E40C_START/E40C/E40U_START/E40U/E40D_START/E40D/E41` 为准。
|
||||
- `S10C/S10U/S10D` 与 `S11C/S11U/S11D` 现已是配置中的真实状态 ID(不再是图示别名)。
|
||||
@@ -0,0 +1,68 @@
|
||||
flowchart LR
|
||||
|
||||
subgraph BOOT["Bootstrap / Reset"]
|
||||
S00["S00<br/>进入 bind 前的唯一起点<br/>binding_status: UNCHECKED<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S15(["S15<br/>节点被清理删除(zombie cleanup)"])
|
||||
S16(["S16<br/>持久化加载输入态:本轮 E01 的输入节点集合(可包含上轮遗留与已稳定节点)"])
|
||||
S17(["S17<br/>加载异常隔离态:持久化枚举非法,保留错误供人工处理,不进入自动流程"])
|
||||
end
|
||||
|
||||
subgraph BIND["Binding"]
|
||||
S01["S01<br/>绑定正常(待后续 update 检查)<br/>binding_status: NORMAL<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S02["S02<br/>孤儿节点(可能进入 create / auto_bind / delete)<br/>binding_status: MISSING<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S03["S03<br/>数据损坏/缺失(阻断态,需人工)<br/>binding_status: ABNORMAL<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S04["S04<br/>不可自动处理(阻断态,需人工)<br/>binding_status: WARNING<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S05["S05<br/>依赖项不满足/ID解析失败(阻断态)<br/>binding_status: DEPENDENCY_ERROR<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S13["S13<br/>对侧创建进行中(本侧等待提交)<br/>binding_status: PEER_CREATING<br/>action: NONE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
end
|
||||
|
||||
subgraph CREATE["Create"]
|
||||
S06(["S06<br/>待执行 CREATE(执行入口)<br/>binding_status: NORMAL<br/>action: CREATE<br/>status: PENDING<br/>data_id_exist: false<br/>"])
|
||||
S10C["S10C<br/>CREATE 异步执行中<br/>binding_status: NORMAL<br/>action: CREATE<br/>status: IN_PROGRESS<br/>data_id_exist: false<br/>"]
|
||||
S11C["S11C<br/>CREATE 执行成功<br/>binding_status: NORMAL<br/>action: CREATE<br/>status: SUCCESS<br/>data_id_exist: true<br/>"]
|
||||
S12C["S12C<br/>CREATE 执行失败<br/>binding_status: NORMAL<br/>action: CREATE<br/>status: FAILED<br/>data_id_exist: false | true<br/>"]
|
||||
end
|
||||
|
||||
subgraph UPDATE["Update"]
|
||||
S07["S07<br/>准备更新<br/>binding_status: NORMAL<br/>action: UPDATE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S08["S08<br/>更新前置失败(缺 data_id / 依赖ID未解析等)<br/>binding_status: NORMAL<br/>action: UPDATE<br/>status: PRECONDITION_FAILED<br/>data_id_exist: false | true<br/>"]
|
||||
S10U["S10U<br/>UPDATE 异步执行中<br/>binding_status: NORMAL<br/>action: UPDATE<br/>status: IN_PROGRESS<br/>data_id_exist: true<br/>"]
|
||||
S11U["S11U<br/>UPDATE 执行成功<br/>binding_status: NORMAL<br/>action: UPDATE<br/>status: SUCCESS<br/>data_id_exist: true<br/>"]
|
||||
S12U["S12U<br/>UPDATE 执行失败<br/>binding_status: NORMAL<br/>action: UPDATE<br/>status: FAILED<br/>data_id_exist: false | true<br/>"]
|
||||
S14["S14<br/>更新被跳过(执行时降级为 NONE)<br/>binding_status: NORMAL<br/>action: NONE<br/>status: SKIPPED<br/>data_id_exist: true<br/>"]
|
||||
end
|
||||
|
||||
subgraph DELETE["Delete"]
|
||||
S09["S09<br/>待执行 DELETE(执行入口)<br/>binding_status: NORMAL<br/>action: DELETE<br/>status: PENDING<br/>data_id_exist: true<br/>"]
|
||||
S10D["S10D<br/>DELETE 异步执行中<br/>binding_status: NORMAL<br/>action: DELETE<br/>status: IN_PROGRESS<br/>data_id_exist: true<br/>"]
|
||||
S11D["S11D<br/>DELETE 执行成功(随后删除节点)<br/>binding_status: NORMAL<br/>action: DELETE<br/>status: SUCCESS<br/>data_id_exist: true<br/>"]
|
||||
S12D["S12D<br/>DELETE 执行失败<br/>binding_status: NORMAL<br/>action: DELETE<br/>status: FAILED<br/>data_id_exist: false | true<br/>"]
|
||||
end
|
||||
|
||||
S00 -- "E10a 核心绑定判定" --> S01
|
||||
S00 -- "E10e 核心绑定判定" --> S02
|
||||
S00 -- "E10c 核心绑定判定, E10d 核心绑定判定" --> S03
|
||||
S00 -- "E10b 核心绑定判定" --> S04
|
||||
S01 -- "E30c 更新准备" --> S07
|
||||
S01 -- "E30a 更新准备, E30b 更新准备" --> S08
|
||||
S02 -- "E16c 自动绑定(1:1)" --> S01
|
||||
S02 -- "E15b 依赖检查" --> S03
|
||||
S02 -- "E15a 依赖检查, E16a 自动绑定, E16b 自动绑定, E16d 自动绑定, E16e 自动绑定, E16h 自动绑定(N:0)" --> S04
|
||||
S02 -- "E15c 依赖检查, E16f 自动绑定, E21a 创建依赖解析失败" --> S05
|
||||
S02 -- "E22a 孤儿自删准备" --> S09
|
||||
S02 -- "E16g 自动绑定(N:0)" --> S13
|
||||
S06 -- "E40C_STARTa CREATE 执行入口" --> S10C
|
||||
S07 -- "E40U_STARTa UPDATE 执行入口" --> S10U
|
||||
S09 -- "E40D_STARTa DELETE 执行入口" --> S10D
|
||||
S10C -- "E40Ca CREATE 执行结果回写" --> S11C
|
||||
S10C -- "E40Cb CREATE 执行结果回写" --> S12C
|
||||
S10D -- "E40Da DELETE 执行结果回写" --> S11D
|
||||
S10D -- "E40Db DELETE 执行结果回写" --> S12D
|
||||
S10U -- "E40Ua UPDATE 执行结果回写" --> S11U
|
||||
S10U -- "E40Ub UPDATE 执行结果回写" --> S12U
|
||||
S10U -- "E40Uc UPDATE 执行结果回写" --> S14
|
||||
S11C -- "E41a 创建成功后归并到 update …" --> S01
|
||||
S13 -- "E20a 创建准备阶段2" --> S01
|
||||
S16 -- "E01b 初始化:删CREATE僵尸;其余…" --> S00
|
||||
S16 -- "E01a 初始化:删CREATE僵尸;其余…" --> S15
|
||||
S13 -. "副作用: E16识别需创建后,对侧spawn S06" .-> S06
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 587 KiB |
@@ -0,0 +1,107 @@
|
||||
# 同步流程(pipeline -> strategy -> datasource)
|
||||
|
||||
## 1. 全流程主线
|
||||
1. pipeline 装配组件并加载持久化。
|
||||
2. pipeline 调用 `strategy.bind()`。
|
||||
3. pipeline 调用 `strategy.create()` / `strategy.update()` 生成动作节点。
|
||||
4. pipeline 调用 `datasource.sync_all()` 执行动作并回写执行状态。
|
||||
5. pipeline 输出统计并持久化。
|
||||
|
||||
## 2. reset 时序
|
||||
|
||||
### 2.1 加载时 reset(phase1 语义)
|
||||
- 在 `common/collection.py::load_from_persistence()` 执行。
|
||||
- 语义:仅恢复持久化状态(不在此阶段做状态归并)。
|
||||
|
||||
### 2.2 加载后 cleanup(phase2 语义)
|
||||
- pipeline 调用 `BaseSyncStrategy.run_reset()`。
|
||||
- 实际实现:`strategy_ops/reset_ops.py::run_phase2_cleanup()`。
|
||||
- 核心事件:`E01`(`S16 -> S00/S15`)。
|
||||
- `S16` 表示“持久化加载输入态”:本轮从持久化恢复出的节点集合(可包含上轮遗留节点,也可包含上轮已稳定节点)。
|
||||
- 判定:
|
||||
- `is_create_zombie=true` -> `S15`,节点删除(并尝试解绑);
|
||||
- `is_create_zombie=false` -> `S00`,节点归并为 `UNCHECKED/NONE/PENDING` 并清空 `error`。
|
||||
|
||||
### 2.2.1 `enable_persistence` 与 bootstrap 关系
|
||||
- `enable_persistence=true`:先 `load_from_persistence()`,再对加载出的节点执行 `E01`(`S16` 域内处理)。
|
||||
- `enable_persistence=false`:本轮没有持久化输入,`S16/E01` 不参与;随后 Stage1 新加载节点直接进入本轮 bind/create/update/sync 主流程。
|
||||
|
||||
### 2.3 加载异常隔离(S17)
|
||||
- 持久化中若出现非法枚举值(`action/status/binding_status`),节点标记为 `bootstrap_blocked`(`S17` 语义)。
|
||||
- 这类节点保留 `error`,用于人工排查。
|
||||
- 这类节点不会进入 `E01`,也不会参与 bind/create/update/sync 自动流程。
|
||||
|
||||
## 3. bind 流程(strategy_ops)
|
||||
- 入口:`strategy_ops/bind_ops.py::run_bind()`
|
||||
- 入口状态:`S00`
|
||||
|
||||
### 核心绑定判定
|
||||
- `phase_core_state()` 内触发 `E10`。
|
||||
- 有绑定记录:根据本端/对端有效性进入 `S01/S03/S04`。
|
||||
- 无绑定记录:进入 `S02`。
|
||||
|
||||
### 依赖检查
|
||||
- `phase_dependency_check()` -> `check_dependencies_for_nodes()` 内触发 `E15`。
|
||||
- `data_present=false` -> `S03`。
|
||||
- `dep_satisfied=false` -> `S05`。
|
||||
- `depend_fields` 为空 -> `S04`(无法自动处理,阻断后续自动创建)。
|
||||
|
||||
### 自动绑定
|
||||
- `phase_auto_binding()` 内触发 `E16`。
|
||||
- `ONE_TO_ONE` -> `S01`;`AMBIGUOUS/KEY_MISSING/DATA_MISSING` -> `S04`;`KEY_ID_RESOLVE_FAIL` -> `S05`。
|
||||
- `ZERO + create_enabled=true` -> `S13`(`PEER_CREATING/NONE/PENDING`,待对侧创建中间态);`ZERO + create_enabled=false` -> `S04`。
|
||||
- `auto_bind=false` 时,`E16` 直接落 `S04`(自动绑定未启用),不会进入自动创建路径。
|
||||
|
||||
## 4. create/update 准备
|
||||
|
||||
### create
|
||||
- 入口:`strategy_ops/create_ops.py::run_create()`
|
||||
- 运行时事件:`E20`(`E21/E22` 为配置保留分支,当前 strategy 未接入自动执行)
|
||||
- 运行时入口状态:`S13`
|
||||
- 说明:`E16` 负责识别“需自动创建”并将源节点置为 `S13`;业务代码执行对侧 spawn+bind 后,再通过 `E20` 将 `S13` 提交到 `S01`(成功);若 spawn 失败,`E20` 不迁移并保留在 `S13` 等待人工/重试。
|
||||
- `data_id` 约束:
|
||||
- `S13/S01` 要求源节点 `data_id` 存在(`data_id_exist=true`)。
|
||||
- 新创建目标节点在 `S06/S10C` 阶段允许 `data_id` 为空;`CREATE SUCCESS`(`S11C`)必须携带非空 `result_data_id`,否则事件直接报错。
|
||||
|
||||
### update
|
||||
- 入口:`strategy_ops/update_ops.py::run_update()`
|
||||
- 事件:`E30`
|
||||
- 入口状态(source 节点):`S01`
|
||||
- `target_has_data_id=false` 或 `update_id_resolve_ok=false` -> `S08`
|
||||
- `has_diff=true` -> `S07`
|
||||
|
||||
### post-create 归并
|
||||
- 入口:`pipeline/full_sync_pipeline.py::normalize_create_success_to_update_entry()`
|
||||
- 事件:`E41`
|
||||
- 语义:`CREATE` 执行成功后,节点先落 `S11C`(SUCCESS),再归并 `S11C -> S01`,作为同轮 update 起点。
|
||||
|
||||
## 5. 执行回写(datasource)
|
||||
- 入口:`datasource/datasource.py`
|
||||
- 事件:`E40C_START/E40C`、`E40U_START/E40U`、`E40D_START/E40D`
|
||||
- 当前约束:delete stage 尚未接入 strategy 主流程;若运行时检测到 `S09/DELETE` 节点,DataSource 会显式抛错阻断(避免散落删除执行)。
|
||||
- 执行准备入口状态:
|
||||
- `CREATE`: `S06`
|
||||
- `UPDATE`: `S07`
|
||||
- `DELETE`: `S09`
|
||||
- `S10` 为执行中状态(由本轮执行产生),执行流转固定为“入口 -> S10 -> 终态”,不再出现入口直接到成功/失败。
|
||||
- 结果:
|
||||
- `SUCCESS -> S11C | S11U | S11D`(按动作)
|
||||
- `FAILED -> S12C | S12U | S12D`(按动作)
|
||||
- `IN_PROGRESS -> S10C | S10U | S10D`(按动作,支持异步 polling 闭环)
|
||||
- `SKIPPED + action=UPDATE -> S14`
|
||||
|
||||
## 6. 各阶段入口状态总表
|
||||
|
||||
| 阶段 | 入口状态 | 说明 |
|
||||
|---|---|---|
|
||||
| Reset(E01) | `S16` | create_zombie -> `S15`;其余 -> `S00` |
|
||||
| Bind(E10/E15/E16) | `S00` | bind 三段统一从 `S00` 进入 |
|
||||
| Create Prepare(E20/E21/E22) | `S02` / `S13` | 配置入口含 `S02/S13`;当前 strategy 运行时只处理 `S13` 的 `E20` 提交,`S02` 分支保留未接入 |
|
||||
| Post-create Ready(E41) | `S11C` | `CREATE` 成功节点归并到 `S01`,进入同轮 update |
|
||||
| Update Prepare(E30) | `S01` | 仅从 NORMAL 继续判定(create 成功先经 E41 归并) |
|
||||
| Sync Execute(E40C/U/D) | `S06` / `S07` / `S09` / `S10C` / `S10U` / `S10D` | 执行与回写:按动作分事件;入口先进入 `S10*`,再由 `S10*` 进入 `S11*`/`S12*`;轮询返回 `IN_PROGRESS` 时只记录任务进度,不新增状态迁移;仅 UPDATE 可走 `S14` |
|
||||
| 隔离(人工) | `S17` | 不参与自动阶段,仅人工处理 |
|
||||
|
||||
## 7. 不变量保障
|
||||
- 每次事件落地后,`engine/events.py::_apply_decision()` 会执行 `runtime.check_invariants()`。
|
||||
- 违反 invariant 直接抛错,终止当前路径。
|
||||
@@ -0,0 +1,158 @@
|
||||
# 同步问题分析(2026-03-05)
|
||||
|
||||
## 背景
|
||||
|
||||
- 同步方式:先用 `working_local_datasource/filtered` 初始化远程数据库,再基于 `run_profiles/jsonl_to_api.yaml` 执行 `jsonl -> api` 同步。
|
||||
- 本文基于日志 `test_results/sync_state_machine/simple_pipeline_sm_20260305_102926.log` 与状态库 `test_results/sync_state_machine/simple_pipeline_sm.db`。
|
||||
|
||||
## 总体结果
|
||||
|
||||
- Pipeline 统计:`loaded=311, synced=204, failed=8, post_check_passed=True, mismatch_types=4`
|
||||
- 不一致类型:`company`, `construction_task`, `material`, `contract_settlement`
|
||||
- 其中失败(`failed=8`)全部集中在 `contract_settlement` 的 `UPDATE` 阶段。
|
||||
|
||||
## 自动绑定问题
|
||||
|
||||
> 注:本轮日志已降噪,自动绑定明细多数为 debug 级;以下结论来自 sqlite 状态库查询。
|
||||
|
||||
### 非 normal 绑定状态
|
||||
|
||||
1. `local.construction_task_detail` 1 条 `dep_err`
|
||||
- 错误:依赖 `parent_id -> construction_task` 未找到
|
||||
- 现象:`depends=[parent_id:construction_task data_id=... -> not_found]`
|
||||
- 结论:这是明确的依赖绑定失败(上游父节点缺失或 data_id 不可达)。
|
||||
|
||||
2. `local.project_detail_data` 2 条 `warning`
|
||||
- 错误:`未匹配到候选节点且不允许创建`
|
||||
- 结论:策略配置不允许创建,且自动匹配未命中,属于预期拦截,不是执行异常。
|
||||
|
||||
除上述 3 条外,主要业务类型绑定状态均为 `normal`。
|
||||
|
||||
## 更新后仍不一致(按类型)
|
||||
|
||||
### 1) company(1/1 不一致)
|
||||
|
||||
- 差异字段:`city_name`, `province_name`, `parent_name`
|
||||
- 模式:本地有值,远端为 `None`
|
||||
- 结论:远端 list 返回字段缺失/置空,导致 post-check 一定失败。
|
||||
|
||||
### 2) construction_task(12/32 不一致)
|
||||
|
||||
- 典型残留字段:
|
||||
- `status`
|
||||
- `complete_quantity`, `complete_rate`
|
||||
- `actual_complete_date`, `statistic_date`, `delay_days`
|
||||
- `plan_node`(特别是 `is_audit` 值)
|
||||
- 模式:远端回读为 `None`/`0.0`/默认值,而本地有业务值。
|
||||
- 结论:该类字段主要受后端计算/回写策略影响,当前 update API 无法完全收敛到本地口径。
|
||||
|
||||
### 3) material(3/20 不一致)
|
||||
|
||||
- 典型残留字段:
|
||||
- `complete_quantity`, `complete_rate`
|
||||
- `actual_start_date`, `status`, `delay_days`
|
||||
- `plan_node.is_audit`
|
||||
- 模式与 construction_task 一致:部分字段被后端默认化或不回写。
|
||||
- 结论:属于“可更新字段集合与回读字段集合不一致”导致的残留差异。
|
||||
|
||||
### 4) contract_settlement(34/34 不一致,最严重)
|
||||
|
||||
- 全量普遍差异:`contract_type`(本地 `None`,远端有值如 `DP/GP/...`)
|
||||
- 伴随差异:`complete_price`, `contract_price`, `actual_start_date`, `plan_complete_date`, `plan_node`, `status` 等
|
||||
- 执行结果(remote):`UPDATE SUCCESS=1, FAILED=8, SKIPPED=25`
|
||||
- 8 条失败统一报错:`API error: 计划节点时间必须 晚于计划开始时间`
|
||||
- 结论:
|
||||
- 一部分差异来自字段语义不一致(如 `contract_type`)
|
||||
- 一部分差异来自计划节点 API 校验失败,导致更新无法落库
|
||||
- 因此该类型短期内无法靠重跑自动收敛
|
||||
|
||||
## 根因归类
|
||||
|
||||
### A. 后端返回口径与本地模型口径不一致
|
||||
|
||||
- 典型:`company` 的名称类字段、`construction/material` 的状态/累计/统计字段。
|
||||
|
||||
### B. 更新 schema/API 覆盖范围有限
|
||||
|
||||
- 某些字段在 response 中存在,但不在 update schema 或后端 update 逻辑中生效。
|
||||
|
||||
### C. 计划节点约束导致更新失败
|
||||
|
||||
- `contract_settlement` 明确出现计划节点时间校验失败,直接阻断更新。
|
||||
|
||||
### D. 依赖链局部断裂
|
||||
|
||||
- `construction_task_detail` 存在 parent 依赖找不到,导致绑定 `dep_err`。
|
||||
|
||||
## 建议优先级(不改代码版)
|
||||
|
||||
1. **先处理 contract_settlement 的计划节点校验失败**
|
||||
- 这是唯一明确执行失败来源(8 条),修复后可显著降低不一致。
|
||||
|
||||
2. **明确“应比对字段白名单”与“只读/回写字段”**
|
||||
- 对 `construction_task/material/company` 区分“业务必须一致字段”与“后端派生/可忽略字段”。
|
||||
|
||||
3. **修复依赖断裂样本**
|
||||
- 针对 `construction_task_detail` 的 `parent_id` 失配,核对父节点是否在过滤阶段被裁掉或 data_id 不一致。
|
||||
|
||||
4. **针对 `project_detail_data` 的 warning 决策**
|
||||
- 确认是否允许创建;若业务上应存在,需调整策略配置。
|
||||
|
||||
---
|
||||
|
||||
如果需要,我可以基于本文再拆成一份“可执行排障清单”(按 SQL/API/配置三条线),每条给出具体核对命令与验收标准。
|
||||
|
||||
## 外部后端实现核对结论(只分析,不改代码)
|
||||
|
||||
以下结论来自对 `GroupSideProjectManagementSystem` 后端 `third-v2` 路由、schema、operation、service 的实现阅读。
|
||||
|
||||
### 1) company 名称字段为何会空
|
||||
|
||||
- `/company/list` 并非直接返回库里的 `city_name/province_name/parent_name`,而是服务层在查询后“二次计算”这三个字段。
|
||||
- 计算依赖两张映射:
|
||||
- 行政区映射(`province/city code -> name`)
|
||||
- 公司映射(`parent_id -> parent_name`)
|
||||
- 如果编码不在映射中、或 `parent_id` 未命中,最终会返回 `None`/空字符串。
|
||||
- 因此该类差异并不一定是“推送失败”,更像是“回读时依赖映射补全失败或口径不一致”。
|
||||
|
||||
### 2) construction_task/material 的 plan_node 差异为何顽固
|
||||
|
||||
- 在 `third-v2` 的 `PlanUpdate` schema 中,存在 `model_validator`:会先自动清理边界节点(等于 `plan_start_date` 或 `plan_complete_date` 的首尾节点)。
|
||||
- 随后在 operation 的 `push(plan)` 中,又会把边界节点重新插回库里,并且统一写入 `is_audit=True`。
|
||||
- 这意味着:
|
||||
- 请求入参和最终落库结构并不完全一致;
|
||||
- 如果本地源数据不含边界节点,或 `is_audit` 口径不同,post-check 很容易出现稳定残差。
|
||||
|
||||
### 3) contract_settlement 为何最严重(34/34 + 8条失败)
|
||||
|
||||
- 计划更新接口的业务规则是“开始时间与合同金额来自合同表”,不是来自推送体:
|
||||
- `plan_start_date` 强制取合同 `sign_date`
|
||||
- `contract_price` 强制取合同 `total_price`
|
||||
- 即便推送体带了这两个值,也会在 operation 中被覆盖。
|
||||
- 同时仍有严格校验:计划节点必须晚于开始时间、早于结束时间、数量递增等;不满足直接 400。
|
||||
- 这正对应日志里的 8 条失败(计划节点时间校验)与大量更新后不一致(字段被后端覆盖到“合同口径”)。
|
||||
|
||||
### 4) list 回读与单条回读口径不一致(会放大不一致)
|
||||
|
||||
- 多个类型的 `get_by_id` 会裁掉 `plan_node` 首尾节点,但 `get_list_by_project_id` 通常返回主表原始节点(含首尾)。
|
||||
- 同步系统做 post-check 用的是 list 拉取结果时,这种“单条/列表口径不一致”会放大 plan_node 相关差异。
|
||||
|
||||
### 5) status/complete_rate/delay_days 等字段为何容易残留差异
|
||||
|
||||
- 这些字段主要由明细驱动的主表同步逻辑计算并回写,不是 plan/base update 的直接输入字段。
|
||||
- 因此“推送成功”并不等价于“这些派生字段立即与源数据一致”。
|
||||
|
||||
## 建议(仍不改代码)
|
||||
|
||||
1. **先按后端口径预处理结算计划数据**
|
||||
- 以合同 `sign_date/total_price` 作为 settlement 比对基准和推送基准;
|
||||
- 推送前先做节点时间边界校验,避免 400。
|
||||
|
||||
2. **对 plan_node 建立统一比较口径**
|
||||
- 在 post-check 里对 construction/material/settlement 统一做“边界节点归一化 + `is_audit` 口径归一化”后再比较。
|
||||
|
||||
3. **区分“业务真差异”与“后端派生差异”**
|
||||
- 对 `status/complete_rate/delay_days/actual_*` 这类派生字段设为可选比对或延迟比对。
|
||||
|
||||
4. **company 名称字段先做映射可用性核查**
|
||||
- 重点核对 `province/city` 编码是否在后端行政区字典可解析、`parent_id` 是否可命中公司映射。
|
||||
Reference in New Issue
Block a user