Files
ecm_sync_system/docs/multi_project_sync_redesign_plan.md
T
2026-04-07 11:25:19 +08:00

18 KiB

多项目同步重构方案

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 只读运行态。

伪代码

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()
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。

主要方法伪代码

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。

主要方法伪代码

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 只读绑定做回退查询。

主要方法伪代码

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 行为。

主要方法伪代码

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. 明确 globalproject_id:<local_project_id> 语义。
  3. 引入 ProjectScopeRuntime 这个薄的项目运行上下文。
  4. CollectionBindingManager 增加 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. 大部分改动都限制在基础设施层。
  5. 当前项目统计对象

2.3 为什么推荐 ProjectScopeRuntime

你问的是:要不要做 session 对象,还是做子 pipeline,还是都放函数里。

建议是:

  1. 不做新的复杂 session 框架。
  2. 不做 FullSyncPipeline 风格的子 pipeline 继承树。
  3. 做一个很薄的 ProjectScopeRuntime

它的作用只有两个:

  1. 把当前项目的一组 scope 对象收拢到一起。
  2. 让清理顺序明确,不要把资源散在 sync_project(...) 的局部变量里。

如果你坚持更简单,也可以第一版直接写在 sync_project(...) 里;但文档层面仍建议用 ProjectScopeRuntime 表达这个层次。

2.4 伪代码

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()
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 数据。

  1. preload_shared_state(...) 把项目 pipeline 变成了“半冷启动、半热注入”的特殊运行态。
  2. 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。

主要方法伪代码

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。

主要方法伪代码

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 只读绑定做回退查询。

主要方法伪代码

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 行为。

主要方法伪代码

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. 大部分改动都限制在基础设施层。
  5. 全局阶段一份配置。
  6. 项目阶段一份默认配置。
  7. 每个项目还能再覆盖一份配置。

这对于少量项目还能接受,但项目数到了几百个就不可维护了。

如果每个项目都允许自带一份 pipeline 配置,系统就失去“批量同步”的基本前提:

  1. 无法判断哪些差异是业务差异,哪些是配置漂移。
  2. 无法稳定复用并发调度逻辑。
  3. 无法保证结果可复现。

结论是:多项目批量同步不应允许“每项目一套 pipeline 定义”。