# 多项目同步重构方案 ## 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:` 语义。 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 定义”。