添加了重构文档

This commit is contained in:
strepsiades
2026-04-07 11:25:19 +08:00
parent aa31243bae
commit e3eab5ade1
+627
View File
@@ -0,0 +1,627 @@
# 多项目同步重构方案
## 1. 背景和目标
### 背景
当前 `multi_project_pipeline` 的主要问题:
1. 顶层概念过重,在单项目同步之上又叠了一层特殊编排。
2. 通过共享快照传递 `collection` / `binding` / 运行态,边界不清晰。
3. 配置入口过多,存在 `global_config` / `default_project_config` / `config_path` 多层模型。
4. 项目完成后哪些对象可以释放,没有明确生命周期。
### 目标
新方案只解决四件事:
1. 顶层仍然是 pipeline,但直接写新的 `ProjectBatchPipeline`
2. `global` 数据只同步一次,不复制到每个项目,不在每个项目中重复校验。
3. 改动尽量限制在 `persistence / collection / binding_manager / datasource / pipeline` 这些基础层。
4. 项目级运行态支持 `load -> sync -> persist -> unload -> close`
### 一个明确结论
这里不建议先做复杂的 session 框架。
第一阶段最合适的方式是:
1.`sync_project(...)` 里创建一个很薄的 `ProjectScopeRuntime`
2. 它只是把“当前项目 scope 下的一组对象”收拢起来。
3. 如果你临时不想单独建类,也可以先写成函数内局部变量。
但从清理顺序和可读性考虑,推荐保留 `ProjectScopeRuntime` 这个薄对象。
它不是复杂 session 系统,本质上就是“单个项目的一次运行上下文”。
## 2. 新的 Batch Project Pipeline 和伪代码
### 结构结论
顶层对象建议是:`ProjectBatchPipeline`
它负责:
1. 跑一次 global phase。
2. 并发调度所有项目。
3. 管理 batch 级共享对象。
4. 管理项目级对象的创建和清理。
它不负责:
1. 业务字段映射。
2. handler 细节。
3. strategy 逻辑。
### 共享对象和项目级对象
#### 整个 batch 共享的对象
1. `BatchConfig`
2. `StateMachineRuntime`
3. `Logger`
4. `PersistenceService`
5. `global` 只读运行态
6. datasource 的共享静态资源,例如 API client、认证信息、限流器、handler registry
7. semaphore 和任务调度器
#### 每个项目内创建的对象
1. `ProjectScopeRuntime`
2. `local_collection`
3. `remote_collection`
4. `binding_manager`
5. 当前项目 scope 对应的 persistence view
6. 当前项目的 local datasource runtime
7. 当前项目的 remote datasource runtime
8. 当前项目统计对象
### Global 数据处理原则
这点直接定死:
1. `global` 数据不复制到每个 project scope。
2. `global` 数据不在每个项目里重复 bootstrap。
3. `global` 数据不在每个项目里重复 post-check。
项目运行时如果需要共享节点或绑定:
1. 先查当前 project scope。
2. 未命中再回退到 `global` 只读运行态。
### 伪代码
```python
class ProjectBatchPipeline:
def __init__(self, config: BatchConfig):
self.config = config
self.runtime = build_state_machine_runtime()
self.logger = build_logger(config.logging)
self.persistence = PersistenceService(config.persist)
self.global_runtime = None
self.shared_local_ds = build_shared_local_datasource_resources(config.local_datasource)
self.shared_remote_ds = build_shared_remote_datasource_resources(config.remote_datasource)
async def run(self) -> None:
self.global_runtime = await self.sync_global()
await self.dispatch_projects()
async def sync_global(self) -> "GlobalReadonlyRuntime":
...
async def dispatch_projects(self) -> None:
...
async def sync_project(self, local_project_id: str, remote_project_id: str) -> None:
scope_runtime = await self.create_project_scope_runtime(local_project_id, remote_project_id)
try:
await scope_runtime.load()
await run_project_sync(scope_runtime, self.config.project_node_types)
await scope_runtime.persist()
finally:
await scope_runtime.unload()
await scope_runtime.close()
```
```python
class ProjectScopeRuntime:
def __init__(self, scope: str, persistence_view, global_runtime, shared_local_ds, shared_remote_ds):
self.scope = scope
self.persistence_view = persistence_view
self.local_collection = DataCollection(...)
self.remote_collection = DataCollection(...)
self.binding_manager = BindingManager(...)
self.local_datasource = build_local_datasource_runtime(shared_local_ds, self.local_collection)
self.remote_datasource = build_remote_datasource_runtime(shared_remote_ds, self.remote_collection)
self.global_runtime = global_runtime
async def load(self) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
async def close(self) -> None: ...
```
## 3. 组件说明
### 3.1 Persistence
#### 角色
`PersistenceService` 是 batch 共享对象。
它共享的是:
1. 后端类型。
2. DB 路径或连接串。
3. 连接池或底层连接能力。
它不共享的是:
1. 当前项目的内存缓存。
2. 当前 scope 的脏状态。
#### 关键语义
建议把 persistence 理解成两层:
1. `PersistenceService`
- 共享后端服务。
2. `ScopedPersistenceView`
- 绑定某个 `scope` 的轻量 view。
所以“scope 属于 persistence 的 scope view”的意思是:
1. 整个 batch 共享一个 persistence service。
2. 每个项目拿一个绑定了当前 `scope` 的轻量操作视图。
不是说每个项目都新建一套 persistence backend。
#### 主要方法伪代码
```python
class PersistenceService:
async def initialize(self) -> None: ...
def view(self, scope: str) -> ScopedPersistenceView: ...
async def close(self) -> None: ...
class ScopedPersistenceView:
async def load_nodes(self, collection_id: str) -> list[dict]: ...
async def save_nodes(self, collection_id: str, nodes: list[SyncNode]) -> None: ...
async def delete_nodes(self, collection_id: str, node_ids: list[str]) -> None: ...
async def load_bindings(self, node_type: str) -> list[dict]: ...
async def save_bindings(self, node_type: str, bindings: dict[str, str | None]) -> None: ...
```
### 3.2 Collection
#### 角色
`Collection` 必须是 scope 级对象,不能做单例。
原因:
1. 它内部天然持有当前 scope 的节点集合。
2. 它持有 data_id 索引。
3. 它持有删除集合和脏状态。
#### 关键语义
project scope 的 `Collection` 只存项目域数据。
对于共享数据,不复制 global,而是支持只读回退:
1. 先查当前 scope。
2. 未命中再查 global 只读 collection。
#### 主要方法伪代码
```python
class DataCollection:
async def load_from_persistence(self) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
def get(self, node_id: str) -> SyncNode | None: ...
def get_by_data_id(self, node_type: str, data_id: str) -> SyncNode | None:
node = self._get_local(node_type, data_id)
if node is not None:
return node
return self._get_global_fallback(node_type, data_id)
```
### 3.3 BindingManager
#### 角色
`BindingManager` 也是 scope 级对象,不应共享。
它负责:
1. 当前 scope 的绑定运行态。
2. 当前 scope 的绑定持久化。
3. 当前 scope 未命中时对 global 只读绑定做回退查询。
#### 主要方法伪代码
```python
class BindingManager:
async def load_from_persistence(self, node_type: str) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
async def get_remote_id(self, node_type: str, local_id: str) -> str | None:
remote_id = self._get_local_remote_id(node_type, local_id)
if remote_id is not None:
return remote_id
return await self._get_global_remote_id(node_type, local_id)
```
### 3.4 DataSource
#### 角色
`local_datasource / remote_datasource / logging` 不应按项目变化,这一点成立。
但按当前实现,`BaseDataSource` 不适合直接做单例,因为它内部持有:
1. `_collection`
2. `_stats`
3. `_action_summary`
4. `_action_by_node_id`
#### 推荐的最小改法
第一阶段不强行拆成两个正式类,而是采用下面的语义:
1. datasource 的静态配置和共享 client 是 batch 级共享的。
2. datasource 的运行态对象仍然是项目级创建的。
也就是说:
1. 配置不按项目变化。
2. runtime 仍然按项目隔离。
这样改动范围最小,也更不容易污染现有 handler 行为。
#### 主要方法伪代码
```python
class ScopedDataSourceRuntime(BaseDataSource):
def set_collection(self, collection: DataCollection) -> None: ...
async def load_all(self, order: list[str]) -> None: ...
async def sync_all(self, order: list[str]) -> None: ...
def reset_runtime_state(self) -> None: ...
async def close(self) -> None: ...
```
## 4. 改进路线图
### 第一阶段:先收敛架构边界
只改基础层,不动业务层:
1. 新建 `ProjectBatchPipeline`
2. 明确 `global``project_id:<local_project_id>` 语义。
3. 引入 `ProjectScopeRuntime` 这个薄的项目运行上下文。
4.`Collection``BindingManager` 增加 global fallback 语义。
5. persistence 侧增加共享 service + scope view 语义。
### 第二阶段:清理生命周期
把项目级清理流程固定下来:
1. `load`
2. `sync`
3. `persist`
4. `unload`
5. `close`
### 第三阶段:再决定 datasource 是否正式拆层
如果第一阶段跑通,再决定是否把 datasource 正式拆成:
1. 共享静态层。
2. scope runtime 层。
这一步不是第一批必须完成。
### 最终落点
最终希望达到的状态:
1. global 数据只同步一次,只读共享。
2. project 数据按 scope 独立运行。
3. 项目跑完即可卸载运行态。
4. 大部分改动都限制在基础设施层。
8. 当前项目统计对象
### 2.3 为什么推荐 `ProjectScopeRuntime`
你问的是:要不要做 session 对象,还是做子 pipeline,还是都放函数里。
建议是:
1. 不做新的复杂 session 框架。
2. 不做 `FullSyncPipeline` 风格的子 pipeline 继承树。
3. 做一个很薄的 `ProjectScopeRuntime`
它的作用只有两个:
1. 把当前项目的一组 scope 对象收拢到一起。
2. 让清理顺序明确,不要把资源散在 `sync_project(...)` 的局部变量里。
如果你坚持更简单,也可以第一版直接写在 `sync_project(...)` 里;但文档层面仍建议用 `ProjectScopeRuntime` 表达这个层次。
### 2.4 伪代码
```python
class ProjectBatchPipeline:
def __init__(self, config: BatchConfig):
self.config = config
self.runtime = build_state_machine_runtime()
self.logger = build_logger(config.logging)
self.persistence = PersistenceService(config.persist)
self.global_runtime = None
self.shared_local_ds = build_shared_local_datasource_resources(config.local_datasource)
self.shared_remote_ds = build_shared_remote_datasource_resources(config.remote_datasource)
async def run(self) -> None:
self.global_runtime = await self.sync_global()
await self.dispatch_projects()
async def sync_global(self) -> "GlobalReadonlyRuntime":
# 只同步 shared_node_types
...
async def dispatch_projects(self) -> None:
# semaphore 控制并发
...
async def sync_project(self, local_project_id: str, remote_project_id: str) -> None:
scope_runtime = await self.create_project_scope_runtime(local_project_id, remote_project_id)
try:
await scope_runtime.load()
await run_project_sync(scope_runtime, self.config.project_node_types)
await scope_runtime.persist()
finally:
await scope_runtime.unload()
await scope_runtime.close()
```
```python
class ProjectScopeRuntime:
def __init__(self, scope: str, persistence_view, global_runtime, shared_local_ds, shared_remote_ds):
self.scope = scope
self.persistence_view = persistence_view
self.local_collection = DataCollection(...)
self.remote_collection = DataCollection(...)
self.binding_manager = BindingManager(...)
self.local_datasource = build_local_datasource_runtime(shared_local_ds, self.local_collection)
self.remote_datasource = build_remote_datasource_runtime(shared_remote_ds, self.remote_collection)
self.global_runtime = global_runtime
async def load(self) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
async def close(self) -> None: ...
```
### 2.5 Global 数据的处理原则
这点现在可以直接定死:
1. `global` 数据不复制到每个 project scope。
2. `global` 数据不在每个项目里重复 bootstrap。
3. `global` 数据不在每个项目里重复 post-check。
项目级运行时如果需要共享节点或绑定,采用:
1. 当前 project scope 先查本地运行态。
2. 未命中时回退到 `global` 只读运行态。
这样可以避免复制大体量 global 数据。
2. `preload_shared_state(...)` 把项目 pipeline 变成了“半冷启动、半热注入”的特殊运行态。
3. `ephemeral_node_types` / `bootstrap_binding_node_types` 这些参数开始承担原本不属于它们的职责。
## 3. 组件说明
### 3.1 Persistence
#### 角色
`PersistenceService` 是 batch 共享对象。
它共享的是:
1. 后端类型。
2. DB 路径或连接串。
3. 连接池或底层连接能力。
它不共享的是:
1. 当前项目的内存缓存。
2. 当前 scope 的脏状态。
#### 关键语义
建议把 `persistence` 理解成两层:
1. `PersistenceService`
- 共享后端服务。
2. `ScopedPersistenceView`
- 绑定某个 `scope` 的轻量 view。
所以“scope 属于 persistence 的 scope view”这句话的意思是:
1. 整个 batch 共享一个 persistence service。
2. 每个项目拿一个绑定了当前 `scope` 的轻量操作视图。
不是说每个项目都新建一套 persistence backend。
#### 主要方法伪代码
```python
class PersistenceService:
async def initialize(self) -> None: ...
def view(self, scope: str) -> ScopedPersistenceView: ...
async def close(self) -> None: ...
class ScopedPersistenceView:
async def load_nodes(self, collection_id: str) -> list[dict]: ...
async def save_nodes(self, collection_id: str, nodes: list[SyncNode]) -> None: ...
async def delete_nodes(self, collection_id: str, node_ids: list[str]) -> None: ...
async def load_bindings(self, node_type: str) -> list[dict]: ...
async def save_bindings(self, node_type: str, bindings: dict[str, str | None]) -> None: ...
```
### 3.2 Collection
#### 角色
`Collection` 必须是 scope 级对象,不能做单例。
原因很简单:
1. 它内部天然持有当前 scope 的节点集合。
2. 它持有 data_id 索引。
3. 它持有删除集合和脏状态。
#### 关键语义
project scope 的 `Collection` 只存项目域数据。
对于共享数据,不复制 global,而是支持只读回退:
1. 先查当前 scope。
2. 未命中再查 global 只读 collection。
#### 主要方法伪代码
```python
class DataCollection:
async def load_from_persistence(self) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
def get(self, node_id: str) -> SyncNode | None: ...
def get_by_data_id(self, node_type: str, data_id: str) -> SyncNode | None:
node = self._get_local(node_type, data_id)
if node is not None:
return node
return self._get_global_fallback(node_type, data_id)
```
### 3.3 BindingManager
#### 角色
`BindingManager` 也是 scope 级对象,不应共享。
它负责:
1. 当前 scope 的绑定运行态。
2. 当前 scope 的绑定持久化。
3. 当前 scope 未命中时对 global 只读绑定做回退查询。
#### 主要方法伪代码
```python
class BindingManager:
async def load_from_persistence(self, node_type: str) -> None: ...
async def persist(self) -> None: ...
async def unload(self) -> None: ...
async def get_remote_id(self, node_type: str, local_id: str) -> str | None:
remote_id = self._get_local_remote_id(node_type, local_id)
if remote_id is not None:
return remote_id
return await self._get_global_remote_id(node_type, local_id)
```
### 3.4 DataSource
#### 角色
`local_datasource / remote_datasource / logging` 不应按项目变化,这一点是对的。
但按当前实现,`BaseDataSource` 也不适合直接做单例,因为它内部持有:
1. `_collection`
2. `_stats`
3. `_action_summary`
4. `_action_by_node_id`
所以第一阶段不建议“直接把当前 `BaseDataSource` 改成单例 + 注入 scope”。
#### 推荐的最小改法
第一阶段先不强行拆成两个正式类,而是采用下面的语义:
1. datasource 的静态配置和共享 client 是 batch 级共享的。
2. datasource 的运行态对象仍然是项目级创建的。
也就是说:
1. 配置不按项目变化。
2. runtime 仍然按项目隔离。
这样改动范围最小,也更不容易污染现有 handler 行为。
#### 主要方法伪代码
```python
class ScopedDataSourceRuntime(BaseDataSource):
def set_collection(self, collection: DataCollection) -> None: ...
async def load_all(self, order: list[str]) -> None: ...
async def sync_all(self, order: list[str]) -> None: ...
def reset_runtime_state(self) -> None: ...
async def close(self) -> None: ...
```
当前多项目配置要求:
1. `global_config`
### 第二阶段:清理生命周期
把项目级清理流程固定下来:
1. `load`
2. `sync`
3. `persist`
4. `unload`
5. `close`
### 第三阶段:再决定 datasource 是否正式拆层
如果第一阶段跑通,可以再决定是否把 datasource 正式拆成:
1. 共享静态层。
2. scope runtime 层。
这一步不是必须第一批完成。
### 最终落点
最终希望达到的状态是:
1. global 数据只同步一次,只读共享。
2. project 数据按 scope 独立运行。
3. 项目跑完即可卸载运行态。
4. 大部分改动都限制在基础设施层。
1. 全局阶段一份配置。
2. 项目阶段一份默认配置。
3. 每个项目还能再覆盖一份配置。
这对于少量项目还能接受,但项目数到了几百个就不可维护了。
如果每个项目都允许自带一份 pipeline 配置,系统就失去“批量同步”的基本前提:
1. 无法判断哪些差异是业务差异,哪些是配置漂移。
2. 无法稳定复用并发调度逻辑。
3. 无法保证结果可复现。
结论是:多项目批量同步不应允许“每项目一套 pipeline 定义”。