From 3a52aabe9022f63b5613e9157d4a2755a2bac213 Mon Sep 17 00:00:00 2001 From: Loning Date: Mon, 23 Feb 2026 13:52:47 +0800 Subject: [PATCH 01/46] Add Generic Event Sourcing and ReadModel Requirements Documentation - Introduced a comprehensive requirements document for Generic Event Sourcing and Index-Capable ReadModel, outlining the architecture, goals, and constraints. - Defined key concepts such as State, ReadModel, and Provider capabilities, along with their interactions and expected behaviors. - Specified functional and non-functional requirements, including event sourcing abstractions, state event models, and read model store semantics. - Established guidelines for provider capabilities, metadata-driven indexing, and developer experience paths for integrating read models. - Clarified the scope of the implementation, including in-scope and out-of-scope items, ensuring a clear understanding of the project's boundaries. --- ...ng-elasticsearch-readmodel-requirements.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md new file mode 100644 index 000000000..228d4b709 --- /dev/null +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -0,0 +1,340 @@ +# Generic Event Sourcing + Index-Capable ReadModel 需求文档(Document/Graph 双索引抽象) + +## 1. 文档元信息 +- 状态:Draft +- 目标版本:v1 +- 适用仓库:`aevatar` +- 编写日期:2026-02-22 +- 本次更新:去具体后端绑定,改为 ReadModel Provider 能力模型 + +## 2. 背景与问题 +当前仓库已具备: +- 基础 Event Sourcing 抽象:`IEventStore`、`EventSourcingBehavior` +- 有状态 Agent 抽象:`GAgentBase` + `IStateStore` +- 投影读模型抽象:`IProjectionReadModelStore` +- InMemory 默认实现 + +当前缺口: +- 缺少“默认 State -> ReadModel”镜像能力,开发者需要手写大量投影胶水。 +- ReadModel 存储语义与具体后端边界不清晰,容易把架构绑定到 Elasticsearch。 +- 缺少“ReadModel 元数据 -> 索引能力”统一模型,无法做后端能力匹配。 +- 缺少对两类索引形态的统一抽象:Document Index(Elasticsearch-like)与 Graph Index(Neo4j-like)。 +- `EventEnvelope` 与 EventStore 持久化事件边界不清晰。 +- persisted event 手写成本高,影响 Event Sourcing 落地。 + +## 3. 目标 +1. Event Sourcing 与 Snapshot 语义保持不变。 +2. 默认提供 State -> ReadModel 镜像投影(开发者无感接入),且 ReadModel 对开发者可选。 +3. ReadModel 存储采用 Provider 抽象,不绑定具体后端。 +4. ReadModel 元数据可描述“索引诉求”;仅索引能力后端可承接。 +5. 同时支持两类索引 Provider:Document Index 与 Graph Index。 +6. Elasticsearch 与 Neo4j 作为首批适配器,而不是架构绑定点。 +7. EventStore 默认存储状态语义事件,由框架自动生成。 +8. `EventEnvelope` 仅用于运行时处理与传播,不作为 EventStore 权威事件模型。 +9. CQRS 各层采用“通用壳 + 领域插件”模式:`Application/Infrastructure/Host` 可通用,`Domain` 保持领域语义。 + +## 4. 非目标 +- 不在本期引入新的业务编排模型。 +- 不在本期实现跨存储分布式事务。 +- 不在本期实现所有后端 Provider(先完成 InMemory + Elasticsearch + Neo4j)。 +- 不在本期定义完整 ILM/冷热分层策略(仅保留扩展位)。 +- 不在本期把领域不变量抽象成通用模板(领域规则仍由业务模块定义)。 + +## 5. 架构约束(强制) +- 严格分层:`Domain / Application / Infrastructure / Host`。 +- 统一投影链路:默认镜像与自定义投影都必须走同一 Projection Pipeline。 +- 读写分离:`Command -> Event`、`Query -> ReadModel`。 +- 中间层不得以进程内映射充当跨节点事实源。 +- 上层依赖抽象:业务代码不得依赖具体后端 SDK(如 Elasticsearch Client)。 +- Provider 能力校验必须在启动期执行,避免运行期隐式降级。 +- 分层通用化边界:`Application/Infrastructure/Host` 提供通用框架能力;`Domain` 仅通过契约插件接入,禁止把领域语义硬编码进通用层。 + +## 6. 术语 +- `State`:`GAgentBase` 领域状态,由 `IStateStore` 持久化快照。 +- `ReadModel`:查询视图模型。 +- `Runtime Envelope`:运行时消息载体(`EventEnvelope`)。 +- `Persisted State Event`:EventStore 中的状态语义事件。 +- `ReadModel Provider`:`IProjectionReadModelStore` 的具体后端实现。 +- `Index-Capable Provider`:声明支持索引建模能力(mapping/settings/alias)的 Provider。 +- `Document Index Provider`:面向文档索引模型(索引、mapping、alias)。 +- `Graph Index Provider`:面向图索引模型(节点、关系、约束、路径查询优化)。 + +## 7. 方案总览 + +### 7.1 默认主链路 +`Agent State` -> `StateMirrorProjection` -> `IProjectionReadModelStore` -> `ReadModel Provider` + +补充: +- 同一 `State` 可以扇出到多个 `ReadModel`(一对多)。 +- 每个 `State/ReadModel` 绑定由一个“最终写入 owner projector”负责落库。 + +### 7.2 Provider 能力闸门 +- ReadModel 元数据声明索引诉求(如主键、字段类型、索引别名)。 +- Provider 声明能力(如 `SupportsIndexing`、`IndexKind`、`SupportsAliases`)。 +- 启动期进行“ReadModel 诉求 vs Provider 能力”匹配: + - 匹配成功:注册并运行。 + - 不匹配:按策略 fail-fast(默认)或显式禁用该 ReadModel。 + +路由规则补充: +- `IndexKind` 对 ReadModel 是可选项(`Auto/None`)。 +- 当仅有一个索引型 Provider 可用时,可不标记 `IndexKind`,走默认路由。 +- 当存在多个候选 Provider 时,必须通过 `IndexKind` 或显式绑定配置完成消歧,否则启动 fail-fast。 + +### 7.2.1 索引类型统一抽象 +- `IndexKind=Document`:用于 Elasticsearch-like 后端。 +- `IndexKind=Graph`:用于 Neo4j-like 后端。 +- 统一抽象层负责: + - 元数据标准化(ReadModel Index Profile) + - 能力协商与注册校验 + - 将通用 ReadModel 变更翻译为后端特定写操作 + +### 7.3 开发者体验目标 +- 最小路径:仅定义 `State`(可不定义 `ReadModel`)。 +- 默认路径:不定义 `ReadModel` 时,框架可提供默认读视图(Default ReadModel)。 +- 进阶路径:定义自定义 `ReadModel` 并可替换 `IStateToReadModelProjector`。 +- 运维路径:通过元数据驱动索引初始化(仅限索引型 Provider)。 + +### 7.4 EventEnvelope 与 EventStore 边界 +- 运行时仍以 `EventEnvelope` 处理与分发。 +- EventStore 权威事件必须是 `Persisted State Event`。 +- 原始 `EventEnvelope` 可选旁路落库用于审计,但不参与回放权威语义。 + +### 7.5 全层通用化模型(Generic CQRS Kernel) +- `Domain`:定义业务状态、业务命令、业务查询、领域规则(不可被框架语义吞并)。 +- `Application`:提供通用 Command/Query 编排管道(校验、幂等、权限、审计、事务边界、投影触发)。 +- `Infrastructure`:提供可替换存储与索引 Provider(EventStore/StateStore/ReadModelStore)。 +- `Host`:提供统一模块装配、能力协商、配置绑定、健康检查。 +- 领域模块仅需注册 handler/projector/readmodel 契约,即可接入通用内核。 + +ReadModel 可选模式: +- `State-only`:仅写侧或最小查询场景,不要求开发者定义 ReadModel。 +- `Default ReadModel`:框架基于 State 生成默认读视图。 +- `Custom ReadModel`:开发者定义专用查询模型与投影逻辑。 + +## 8. 范围(Scope) + +### 8.1 In Scope +- Provider 抽象下的通用 ReadModel Store 语义。 +- 默认 State 镜像投影(可替换)。 +- ReadModel 元数据模型与能力匹配规则。 +- Elasticsearch 适配器(Document Index Provider)实现。 +- Neo4j 适配器(Graph Index Provider)实现。 +- 通用 Application Service 管道抽象(Command/Query 通用壳)。 +- 通用 Host 模块装配抽象(模块注册与能力协商)。 +- DI 扩展:Provider 切换、默认镜像器注册、自定义覆盖。 +- Workflow Projection 接入。 +- 单元、集成、分布式一致化测试。 + +### 8.2 Out of Scope +- 全量后端 Provider 一次性交付。 +- Elasticsearch 生产运维平台自动化。 + +## 9. 功能需求(Functional Requirements) + +### FR-1 Event Sourcing 抽象 +- append-only + `expectedVersion` 并发校验 + `fromVersion` 回放查询。 +- 事件记录结构包含:`streamId`、`eventId`、`eventType`、`eventData`、`version`、`timestamp`、`metadata`。 + +### FR-2 Persisted State Event 模型约束 +- EventStore 权威事件必须只表达状态变更语义。 +- 不得耦合运行时路由字段(例如 `Direction`、转发链)。 +- 原始 envelope 不作为回放权威源。 + +### FR-3 自动状态事件生成(开发者无感) +- 框架在统一处理管道内自动生成 `Persisted State Event`。 +- 一次处理结束后基于 State 变更检测决定是否 append。 +- 无状态变化不得产生冗余事件。 +- 支持 `Snapshot` / `Delta` 策略扩展。 + +### FR-4 有状态 Agent 与 Snapshot +- `GAgentBase` 继续通过 `IStateStore` 完成 Load/Save。 +- 启用 Event Sourcing 不改变 `IStateStore` 作为快照通道的语义。 + +### FR-5 通用 ReadModel Store(Provider 模式) +- 兼容 `IProjectionReadModelStore`: + - `UpsertAsync` + - `MutateAsync` + - `GetAsync` + - `ListAsync` +- API 语义不绑定具体后端。 + +### FR-6 Provider 能力声明与匹配 +- 定义 Provider 能力抽象(至少包含): + - `SupportsIndexing` + - `IndexKind`(`Document` / `Graph`) + - `SupportsAliases` + - `SupportsSchemaValidation` +- ReadModel 注册时执行能力匹配。 +- 对声明索引诉求的 ReadModel,必须由 `SupportsIndexing=true` 的 Provider 承接。 +- 当 ReadModel 显式声明 `IndexKind` 时,Provider 必须类型匹配。 +- 当 ReadModel 未声明 `IndexKind` 时,允许自动路由;若候选 Provider 不唯一则必须显式消歧。 + +### FR-7 默认 State 镜像投影 +- 提供默认 `IStateToReadModelProjector`: + - 默认策略:同名字段映射 + 可配置忽略字段。 + - 字段不匹配可由映射配置或自定义 projector 覆盖。 +- 当开发者未定义 `ReadModel` 时,允许落到框架默认读视图(Default ReadModel)。 + +### FR-7.1 ReadModel 可选化 +- 开发者不定义 `ReadModel` 时系统必须可运行(至少支持 `State-only` 模式)。 +- 可通过配置选择:`State-only` / `Default ReadModel` / `Custom ReadModel`。 +- 在 `State-only` 模式下,若调用依赖 ReadModel 的查询端点,应返回明确错误或能力不可用响应(按统一错误模型)。 + +### FR-8 自定义投影替换机制 +- 允许业务侧通过 DI 替换默认镜像器。 +- 同一 `State/ReadModel` 绑定仅允许一个“最终写入 owner projector”。 +- owner projector 内部允许组合多个 reducer/applier/module 协同完成映射与聚合。 +- `State -> ReadModel` 为一对多:一个 State 可以对应多个 ReadModel,但每个 ReadModel 绑定仍必须只有一个最终写入 owner。 +- 优先级:显式业务注册 > 默认注册。 + +### FR-9 ReadModel 元数据驱动索引(能力感知) +- ReadModel 元数据可声明: + - 通用:索引名/前缀、主键字段、版本、标签 + - Document Profile:字段 mapping(keyword/text/date/numeric/...)、settings、alias + - Graph Profile:节点标签、关系类型、关系方向、唯一约束字段、可选索引提示 +- 仅索引型 Provider 执行 metadata 建索引逻辑。 +- 非索引型 Provider 遇到索引诉求时按策略 fail-fast(默认)。 +- `IndexKind` 未声明时,可由 Provider 能力自动推断;推断不唯一时必须显式绑定。 + +### FR-10 双索引适配器实现 +- 必须提供 Document Index 适配器(Elasticsearch-like)。 +- 必须提供 Graph Index 适配器(Neo4j-like)。 +- 两者都必须遵循统一抽象层,不得在业务层分叉接口。 +- 双适配器是平台能力要求,不代表每个 ReadModel 都必须显式标记 `Document/Graph`。 + +### FR-11 Workflow Projection 接入 +- `WorkflowExecutionReport` 可由 Provider 切换承接。 +- 不改变上层 Query API 合同。 + +### FR-12 一致化验证 +- 在 3 节点脚本中加入跨节点一致性测试并纳入 CI。 + +### FR-13 通用 Application Service 层 +- 提供通用 `ICommandApplicationService` / `IQueryApplicationService` 编排入口。 +- 通用应用层必须支持:校验、幂等、防重、审计、错误模型统一。 +- 业务侧仅实现领域 handler/mapper/spec,不重复实现管道横切逻辑。 + +### FR-14 通用 Host 装配层 +- 提供模块化注册机制,支持按模块装配: + - 领域命令/查询 handler + - 投影 projector/reducer + - Provider 适配器 +- 启动期执行统一能力协商与配置有效性校验。 + +## 10. 非功能需求(Non-Functional Requirements) + +### NFR-1 一致性 +- 读模型允许最终一致;同节点写后读在可配置窗口内收敛。 +- EventStore 单流版本单调递增。 + +### NFR-2 性能 +- Event append 与批量事件数量线性相关。 +- `ListAsync` 必须有硬上限。 + +### NFR-3 可观测性 +- 结构化日志至少包含:`provider`、`readModelType`、`documentId`、`stateVersion`、`elapsedMs`、`exceptionType`。 +- 索引型 Provider 额外记录:`index`、`alias`。 +- Graph Index Provider 额外记录:`nodeLabel`、`relationshipType`、`constraint`. + +### NFR-4 可运维性 +- Provider 配置支持 `appsettings` + 环境变量。 +- 索引型 Provider 的索引命名必须可环境隔离。 + +### NFR-5 安全 +- 日志不得输出凭据。 +- 保留认证/TLS 参数透传位。 + +## 11. 配置需求 + +### 11.1 Event Sourcing +- `EventSourcing:Provider` +- `EventSourcing:Snapshot:*` +- `EventSourcing:PersistedEvent:Mode`(`Snapshot` / `Delta`) +- `EventSourcing:PersistedEvent:AutoGenerate`(默认 `true`) + +### 11.2 ReadModel Provider +- `Projection:ReadModel:Provider`(示例:`InMemory` / `Elasticsearch` / future) +- `Projection:ReadModel:IndexKind`(可选:`Auto` / `Document` / `Graph`) +- `Projection:ReadModel:Mode`(`StateOnly` / `DefaultReadModel` / `CustomReadModel`) +- `Projection:ReadModel:DefaultMode`(`MirrorState` / `CustomProjector`,仅在非 `StateOnly` 下生效) +- `Projection:ReadModel:FailOnUnsupportedCapabilities`(默认 `true`) +- `Projection:ReadModel:Bindings:*`(可选,ReadModel 到 Provider 的显式绑定) + +### 11.3 Provider 专属配置(示例) +- `Projection:ReadModel:Providers:Elasticsearch:Endpoints` +- `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix` +- `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs` +- `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax` +- `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex` +- `Projection:ReadModel:Providers:Neo4j:Uri` +- `Projection:ReadModel:Providers:Neo4j:Database` +- `Projection:ReadModel:Providers:Neo4j:Username` +- `Projection:ReadModel:Providers:Neo4j:Password` +- `Projection:ReadModel:Providers:Neo4j:AutoCreateConstraints` + +### 11.4 ReadModel Metadata +- `Projection:ReadModel:Metadata:StrictMode` +- `Projection:ReadModel:Metadata:ApplyAliases` + +## 12. 验收标准(DoD) + +### 12.1 单元测试 +- 自动状态事件生成:变更检测、无变更不写、版本单调。 +- EventEnvelope 边界:路由字段不进入权威 persisted event。 +- Provider 能力匹配:支持/不支持索引能力的注册行为。 +- Provider 类型匹配:`Document` 元数据不能落到 `Graph` Provider,反之亦然。 +- 自动路由:单候选可自动承接,多候选未消歧必须 fail-fast。 +- 默认镜像器映射与自定义覆盖优先级。 +- Elasticsearch Provider 的 Upsert/Mutate/Get/List 与索引初始化。 +- Neo4j Provider 的节点/关系写入与约束初始化。 +- 通用 Application Service 管道:横切逻辑顺序与异常语义一致。 +- Host 装配:模块注册冲突与能力协商失败路径覆盖。 +- ReadModel 可选模式:`StateOnly`/`DefaultReadModel`/`CustomReadModel` 行为与能力边界覆盖。 + +### 12.2 集成测试 +- Docker Elasticsearch 下验证索引型 ReadModel 的端到端写读。 +- Docker Neo4j 下验证图模型 ReadModel 的端到端写读。 +- 验证切换 Provider 后 Query 语义一致。 +- 验证 `StateOnly` 模式下读取端点返回统一“能力不可用”语义。 + +### 12.3 分布式一致化测试 +- 3 节点下 `workflows/agents` 查询跨节点一致。 +- 测试纳入 CI 且可稳定判定失败。 + +### 12.4 合规门槛 +- `build/test` 全通过。 +- 架构 guard 与稳定性 guard 全通过。 +- 文档、配置示例、能力矩阵同步。 + +## 13. 与现状对比 +| 维度 | 现状 | 目标 | +|---|---|---| +| 后端绑定 | 容易向 Elasticsearch 语义靠拢 | Provider 抽象,不绑定具体后端 | +| 索引类型 | 仅文档索引思维 | Document/Graph 双索引统一抽象 | +| EventStore 事件语义 | 易混用 runtime envelope | 仅持久化状态语义事件 | +| Persisted Event 开发成本 | 业务手写为主 | 框架自动生成 | +| Application Service | 业务层重复实现横切逻辑 | 通用管道壳 + 领域 handler 插件 | +| Host 装配 | 模块能力协商分散 | 统一模块装配与能力校验 | +| ReadModel 定义要求 | 默认倾向开发者显式定义 | ReadModel 可选(StateOnly/Default/Custom) | +| 默认读模型构建 | 业务手写 reducer/projector | 默认提供 State 镜像投影 | +| 索引能力 | 无统一能力闸门 | ReadModel 元数据 + Provider 能力匹配 | + +## 14. 风险与缓解 +- 风险:能力不匹配导致运行时失败。 + - 缓解:启动期 fail-fast + 能力矩阵校验。 +- 风险:默认镜像不足以覆盖复杂业务。 + - 缓解:保持自定义 projector 可替换。 +- 风险:索引 mapping 演进兼容问题。 + - 缓解:版本化索引 + alias 切换策略(后续扩展)。 + +## 15. 里程碑建议 +1. M1:Provider 能力抽象 + 默认镜像抽象。 +2. M2:Document/Graph 元数据模型 + 能力匹配实现。 +3. M3:Elasticsearch + Neo4j 双适配器与测试覆盖。 +4. M4:Workflow 接入、3 节点一致化与 CI 收口。 + +## 16. 待确认项(Open Questions) +- 不支持索引能力时是否允许“显式降级”为无索引模式,还是一律 fail-fast? +- Provider 能力最小集合是否需要扩展到 `SupportsPartialUpdate`? +- ReadModel 元数据版本升级策略如何定义(兼容/阻断)? +- 同一 ReadModel 是否允许同时落 Document + Graph(双写),还是要求单一承接方? From 07c4668578385d27dccb65f3e37045971153239e Mon Sep 17 00:00:00 2001 From: Auric Date: Mon, 23 Feb 2026 21:22:39 +0800 Subject: [PATCH 02/46] Add Provider-Based ReadModel Architecture and Elasticsearch/InMemory Providers - Introduced a new architecture for Provider-Based ReadModel, decoupling read model storage from specific business domains. - Added Elasticsearch and InMemory providers for read model storage, supporting capabilities like indexing and schema validation. - Implemented registration and selection mechanisms for providers, enhancing flexibility in read model management. - Updated documentation to reflect the new architecture and provider capabilities, ensuring clarity for developers integrating read models. - Refactored existing abstractions to support the new provider model, including capability validation and runtime options. --- README.md | 2 +- aevatar.slnx | 2 + ...ng-elasticsearch-readmodel-requirements.md | 564 +++++++++--------- ...ider-based-readmodel-full-refactor-plan.md | 267 +++++++++ ...ateProjectionReadModelStoreRegistration.cs | 33 + ...rojectionReadModelStoreProviderMetadata.cs | 6 + .../IProjectionReadModelStoreRegistration.cs | 11 + ...nReadModelCapabilityValidationException.cs | 32 + .../ProjectionReadModelCapabilityValidator.cs | 56 ++ .../ProjectionReadModelIndexKind.cs | 8 + ...ProjectionReadModelProviderCapabilities.cs | 46 ++ .../ProjectionReadModelProviderNames.cs | 10 + .../ProjectionReadModelRequirements.cs | 34 ++ .../ProjectionReadModelRuntimeOptions.cs | 15 + ...rojectionReadModelStoreSelectionOptions.cs | 8 + .../ProjectionReadModelStoreSelector.cs | 64 ++ .../README.md | 4 + ....Projection.Providers.Elasticsearch.csproj | 16 + ...icsearchProjectionReadModelStoreOptions.cs | 20 + .../ServiceCollectionExtensions.cs | 40 ++ .../GlobalUsings.cs | 1 + .../README.md | 20 + .../ElasticsearchProjectionReadModelStore.cs | 310 ++++++++++ ....CQRS.Projection.Providers.InMemory.csproj | 16 + .../ServiceCollectionExtensions.cs | 32 + .../GlobalUsings.cs | 1 + .../README.md | 20 + .../InMemoryProjectionReadModelStore.cs | 125 ++++ .../Aevatar.Workflow.Infrastructure.csproj | 2 + ...owCapabilityServiceCollectionExtensions.cs | 42 +- .../WorkflowExecutionProjectionOptions.cs | 20 + .../ServiceCollectionExtensions.cs | 81 ++- .../Aevatar.Workflow.Projection/README.md | 29 +- ...InMemoryWorkflowExecutionReadModelStore.cs | 132 ---- ...flowExecutionReadModelNotFoundException.cs | 12 - .../ProjectionReadModelStoreSelectorTests.cs | 134 +++++ ...lowExecutionProjectionRegistrationTests.cs | 135 +++++ ...WorkflowExecutionProjectionServiceTests.cs | 11 +- ...orkflowExecutionReadModelProjectorTests.cs | 22 +- .../WorkflowHostingExtensionsCoverageTests.cs | 1 - ...owProjectionOrchestrationComponentTests.cs | 11 +- 41 files changed, 1930 insertions(+), 465 deletions(-) create mode 100644 docs/architecture/provider-based-readmodel-full-refactor-plan.md create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/README.md create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Stores/InMemoryWorkflowExecutionReadModelStore.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Stores/WorkflowExecutionReadModelNotFoundException.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs diff --git a/README.md b/README.md index 7506d1d0a..d26cc2eb5 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ curl -X POST http://localhost:5000/api/chat \ | Orleans Transport | `ActorRuntime:Provider=Orleans` 默认仍走内置链路;可选 `ActorRuntime:Transport=Kafka` 启用 MassTransit/Kafka 传输插件。 | 生产按部署拓扑启用可插拔 transport,并统一由 stream/queue 层承载跨节点转发。 | | Projection 启动并发(Ensure/Release) | 已由 `projection:{rootActorId}` 投影协调 Actor 串行裁决,不再依赖进程内 `SemaphoreSlim`。 | 分布式 Runtime 下继续依赖“同一 actorId 单激活 + 邮箱串行”保证并发互斥。 | | LiveSink 绑定(Attach/Detach) | 已通过 `workflow-run:{actorId}:{commandId}` 事件流订阅/退订;不再依赖 `ProjectionContext` 内存 sink 列表。 | 在分布式 stream provider 下天然支持跨节点推送;生产需保障 provider 可用性与顺序语义。 | -| ReadModel 存储 | Workflow 默认 `InMemoryWorkflowExecutionReadModelStore`,可替换。 | 生产默认切换到持久化读模型存储,实现跨节点一致读。 | +| ReadModel 存储 | 默认通过 `Aevatar.CQRS.Projection.Providers.InMemory` 注册通用 InMemory Store,可按 Provider 机制替换。 | 生产默认切换到持久化读模型 Provider,实现跨节点一致读。 | | 审计评分口径 | 以“当前已落地代码”为准评分。 | 目标态能力上线后,评分按实现结果重新审计。 | 下面这张图概括了「宿主(API + 运行时 + LLM + Connector)」与「Agent 树 + 工作流步骤」的关系。 diff --git a/aevatar.slnx b/aevatar.slnx index 9a09ada30..b384b725a 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -38,6 +38,8 @@ + + diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 228d4b709..c25664b4b 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -1,340 +1,312 @@ -# Generic Event Sourcing + Index-Capable ReadModel 需求文档(Document/Graph 双索引抽象) +# Generic Event Sourcing + Provider-Based ReadModel 需求文档(基线对齐版) ## 1. 文档元信息 -- 状态:Draft -- 目标版本:v1 +- 状态:In Progress(Revised) +- 目标版本:v1(对齐当前仓库基线后可实施) - 适用仓库:`aevatar` -- 编写日期:2026-02-22 -- 本次更新:去具体后端绑定,改为 ReadModel Provider 能力模型 - -## 2. 背景与问题 -当前仓库已具备: -- 基础 Event Sourcing 抽象:`IEventStore`、`EventSourcingBehavior` -- 有状态 Agent 抽象:`GAgentBase` + `IStateStore` -- 投影读模型抽象:`IProjectionReadModelStore` -- InMemory 默认实现 - -当前缺口: -- 缺少“默认 State -> ReadModel”镜像能力,开发者需要手写大量投影胶水。 -- ReadModel 存储语义与具体后端边界不清晰,容易把架构绑定到 Elasticsearch。 -- 缺少“ReadModel 元数据 -> 索引能力”统一模型,无法做后端能力匹配。 -- 缺少对两类索引形态的统一抽象:Document Index(Elasticsearch-like)与 Graph Index(Neo4j-like)。 -- `EventEnvelope` 与 EventStore 持久化事件边界不清晰。 -- persisted event 手写成本高,影响 Event Sourcing 落地。 - -## 3. 目标 -1. Event Sourcing 与 Snapshot 语义保持不变。 -2. 默认提供 State -> ReadModel 镜像投影(开发者无感接入),且 ReadModel 对开发者可选。 -3. ReadModel 存储采用 Provider 抽象,不绑定具体后端。 -4. ReadModel 元数据可描述“索引诉求”;仅索引能力后端可承接。 -5. 同时支持两类索引 Provider:Document Index 与 Graph Index。 -6. Elasticsearch 与 Neo4j 作为首批适配器,而不是架构绑定点。 -7. EventStore 默认存储状态语义事件,由框架自动生成。 -8. `EventEnvelope` 仅用于运行时处理与传播,不作为 EventStore 权威事件模型。 -9. CQRS 各层采用“通用壳 + 领域插件”模式:`Application/Infrastructure/Host` 可通用,`Domain` 保持领域语义。 - -## 4. 非目标 -- 不在本期引入新的业务编排模型。 -- 不在本期实现跨存储分布式事务。 -- 不在本期实现所有后端 Provider(先完成 InMemory + Elasticsearch + Neo4j)。 -- 不在本期定义完整 ILM/冷热分层策略(仅保留扩展位)。 -- 不在本期把领域不变量抽象成通用模板(领域规则仍由业务模块定义)。 - -## 5. 架构约束(强制) +- 首稿日期:2026-02-22 +- 本次修订:2026-02-23 +- 修订目的:将需求与当前代码/门禁/测试基线对齐,消除实现冲突 + +## 2. 当前仓库基线(截至 2026-02-23) +当前已具备能力: +- Event Sourcing 抽象:`IEventStore`、`IEventSourcingBehavior`、`EventSourcingBehavior`。 +- 状态快照抽象:`IStateStore`,默认 InMemory 实现。 +- 统一 Projection Pipeline:`ProjectionLifecycleService -> ProjectionSubscriptionRegistry -> ProjectionDispatcher -> ProjectionCoordinator`。 +- 统一读模型存储契约:`IProjectionReadModelStore`(`Upsert/Mutate/Get/List`)。 +- Provider 能力模型与校验:`ProjectionReadModelProviderCapabilities`、`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator`。 +- Provider 选择与注册抽象:`IProjectionReadModelStoreRegistration<,>`、`ProjectionReadModelStoreSelector`。 +- 通用 Provider 项目已落地:`Aevatar.CQRS.Projection.Providers.InMemory`、`Aevatar.CQRS.Projection.Providers.Elasticsearch`。 +- Workflow 读侧完整链路:`WorkflowExecutionProjectionService` + Activation/Release/Lease/QueryReader/SinkForwarder 组件化编排。 +- Workflow 读侧已切换 Provider 选择链路;Provider 注册在 Infrastructure 完成(InMemory + Elasticsearch)。 +- CQRS 与 AGUI 共用同一输入事件流(不同 projector 分支输出)。 +- 通用命令执行壳与 Capability Host 装配机制已存在。 +- 架构门禁与稳定性门禁已生效(`architecture_guards`、`projection_route_mapping_guard`、`test_stability_guards`)。 +- 分布式 3 节点一致性集成测试与 smoke 脚本已接入 CI。 + +当前未具备能力: +- Graph Provider(Neo4j-like)适配器落地。 +- “State -> Default ReadModel” 的通用镜像层。 +- 自动生成 Persisted State Event 的统一框架管道(当前为显式 `RaiseEvent/ConfirmEventsAsync`)。 +- `Neo4j.Driver` 仅在集中版本文件声明,业务项目尚未引用并落地实现。 + +### 2.1 基线证据(关键代码位置) +| 主题 | 现状结论 | 证据 | +|---|---|---| +| Projection 关闭语义 | Workflow 命令入口 fail-fast,返回 `ProjectionDisabled` | `src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs`;`test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs` | +| Query 关闭语义 | 关闭时应用层返回 `null/[]`,API 侧表现为 `404/200` | `src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs`;`src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs` | +| ReadModel Store 契约 | 仅 `Upsert/Mutate/Get/List` 四类操作 | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs` | +| Provider 选择与装配 | Workflow 通过 Provider 注册 + Selector 选择 ReadModel Store | `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs`;`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` | +| Persisted Event 模型 | `StateEvent` 无 `metadata` 字段 | `src/Aevatar.Foundation.Abstractions/agent_messages.proto` | +| ES 写入模式 | 仍是显式 `RaiseEvent/ConfirmEventsAsync` | `src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs`;`docs/EVENT_SOURCING.md` | +| 运行时注入 | Runtime 注入 `StateStore`,未统一注入 ES 行为 | `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` | +| Capability Host | 已有 capability 注册/映射防重复机制 | `src/Aevatar.Hosting/AevatarCapabilityHostExtensions.cs` | +| 分布式一致性基线 | 已有 3 节点集成测试 + smoke 脚本 | `test/Aevatar.Foundation.Runtime.Hosting.Tests/DistributedClusterConsistencyIntegrationTests.cs`;`tools/ci/distributed_3node_smoke.sh` | +| 架构约束自动化 | 路由精确匹配、禁止中间层 ID 映射事实态、lease/session 约束 | `tools/ci/architecture_guards.sh`;`tools/ci/projection_route_mapping_guard.sh` | + +## 3. 问题定义 +在当前基线上,仍有以下缺口: +- Provider 能力模型与选择链路已落地,但跨业务域统一治理(Registry/策略化选择/观测)仍不完整。 +- Document Provider 已可用,但生产级增强(异常分级、索引策略细化、观测字段收口)仍需补齐。 +- EventEnvelope 与 Persisted State Event 的边界在文档层仍需统一口径,避免误用。 +- 目标中包含的 `StateOnly`、Graph Index、自动 Persisted Event 与当前运行语义存在冲突,需要分期。 + +## 4. 目标与分期 + +### 4.1 总体目标 +1. 保持单一主链路:`Command -> Event -> Projection -> ReadModel`。 +2. 在不破坏现有 Workflow 语义的前提下,引入 Provider 能力抽象与启动期校验。 +3. v1 先交付 Document Index Provider(Elasticsearch-like)落地能力。 +4. Graph Index(Neo4j-like)进入 vNext,先沉淀抽象,不在 v1 强制交付完整能力。 +5. Event Sourcing 继续保持兼容,自动化增强采用显式分期与开关。 + +### 4.2 分期策略 +- P0(当前文档阶段):基线对齐、冲突消解、DoD 可执行化。 +- P1(v1):Provider 能力模型 + 启动期校验 + Document Index Provider 适配。 +- P2(vNext):Graph Index Provider 与通用 StateMirror/StateOnly 能力扩展。 + +### 4.3 v1 落地边界(结合现有代码) +- v1 以 Workflow 读侧为唯一落地点:`WorkflowExecutionReport` 先完成 Provider 化。 +- 不在 v1 引入新的“全局第二投影框架”,而是复用现有 `AddWorkflowExecutionProjectionCQRS` 与 Provider 注册/选择链路。 +- v1 不改写命令执行主链路,不变更 `WorkflowRunContextFactory` 的 Projection fail-fast 语义。 +- v1 不改 Query API 合同,只保证替换存储后语义一致。 + +## 5. 非目标(v1) +- 不引入第二套 CQRS/Projection 主链路。 +- 不破坏现有 Workflow 命令入口“投影关闭即 fail-fast”行为。 +- 不在 v1 完成 Graph 查询 DSL、路径优化执行器。 +- 不在 v1 引入跨存储分布式事务。 +- 不在 v1 改造所有 Event Sourcing 调用为全自动模式。 + +## 6. 架构与治理约束(强制) - 严格分层:`Domain / Application / Infrastructure / Host`。 -- 统一投影链路:默认镜像与自定义投影都必须走同一 Projection Pipeline。 -- 读写分离:`Command -> Event`、`Query -> ReadModel`。 -- 中间层不得以进程内映射充当跨节点事实源。 -- 上层依赖抽象:业务代码不得依赖具体后端 SDK(如 Elasticsearch Client)。 -- Provider 能力校验必须在启动期执行,避免运行期隐式降级。 -- 分层通用化边界:`Application/Infrastructure/Host` 提供通用框架能力;`Domain` 仅通过契约插件接入,禁止把领域语义硬编码进通用层。 - -## 6. 术语 -- `State`:`GAgentBase` 领域状态,由 `IStateStore` 持久化快照。 -- `ReadModel`:查询视图模型。 -- `Runtime Envelope`:运行时消息载体(`EventEnvelope`)。 -- `Persisted State Event`:EventStore 中的状态语义事件。 -- `ReadModel Provider`:`IProjectionReadModelStore` 的具体后端实现。 -- `Index-Capable Provider`:声明支持索引建模能力(mapping/settings/alias)的 Provider。 -- `Document Index Provider`:面向文档索引模型(索引、mapping、alias)。 -- `Graph Index Provider`:面向图索引模型(节点、关系、约束、路径查询优化)。 - -## 7. 方案总览 - -### 7.1 默认主链路 -`Agent State` -> `StateMirrorProjection` -> `IProjectionReadModelStore` -> `ReadModel Provider` - -补充: -- 同一 `State` 可以扇出到多个 `ReadModel`(一对多)。 -- 每个 `State/ReadModel` 绑定由一个“最终写入 owner projector”负责落库。 - -### 7.2 Provider 能力闸门 -- ReadModel 元数据声明索引诉求(如主键、字段类型、索引别名)。 -- Provider 声明能力(如 `SupportsIndexing`、`IndexKind`、`SupportsAliases`)。 -- 启动期进行“ReadModel 诉求 vs Provider 能力”匹配: - - 匹配成功:注册并运行。 - - 不匹配:按策略 fail-fast(默认)或显式禁用该 ReadModel。 - -路由规则补充: -- `IndexKind` 对 ReadModel 是可选项(`Auto/None`)。 -- 当仅有一个索引型 Provider 可用时,可不标记 `IndexKind`,走默认路由。 -- 当存在多个候选 Provider 时,必须通过 `IndexKind` 或显式绑定配置完成消歧,否则启动 fail-fast。 - -### 7.2.1 索引类型统一抽象 -- `IndexKind=Document`:用于 Elasticsearch-like 后端。 -- `IndexKind=Graph`:用于 Neo4j-like 后端。 -- 统一抽象层负责: - - 元数据标准化(ReadModel Index Profile) - - 能力协商与注册校验 - - 将通用 ReadModel 变更翻译为后端特定写操作 - -### 7.3 开发者体验目标 -- 最小路径:仅定义 `State`(可不定义 `ReadModel`)。 -- 默认路径:不定义 `ReadModel` 时,框架可提供默认读视图(Default ReadModel)。 -- 进阶路径:定义自定义 `ReadModel` 并可替换 `IStateToReadModelProjector`。 -- 运维路径:通过元数据驱动索引初始化(仅限索引型 Provider)。 - -### 7.4 EventEnvelope 与 EventStore 边界 -- 运行时仍以 `EventEnvelope` 处理与分发。 -- EventStore 权威事件必须是 `Persisted State Event`。 -- 原始 `EventEnvelope` 可选旁路落库用于审计,但不参与回放权威语义。 - -### 7.5 全层通用化模型(Generic CQRS Kernel) -- `Domain`:定义业务状态、业务命令、业务查询、领域规则(不可被框架语义吞并)。 -- `Application`:提供通用 Command/Query 编排管道(校验、幂等、权限、审计、事务边界、投影触发)。 -- `Infrastructure`:提供可替换存储与索引 Provider(EventStore/StateStore/ReadModelStore)。 -- `Host`:提供统一模块装配、能力协商、配置绑定、健康检查。 -- 领域模块仅需注册 handler/projector/readmodel 契约,即可接入通用内核。 - -ReadModel 可选模式: -- `State-only`:仅写侧或最小查询场景,不要求开发者定义 ReadModel。 -- `Default ReadModel`:框架基于 State 生成默认读视图。 -- `Custom ReadModel`:开发者定义专用查询模型与投影逻辑。 +- Host 仅做协议适配与能力装配,不承载业务编排。 +- CQRS 与 AGUI 必须共用统一 Projection Pipeline,禁止双轨。 +- 中间层禁止进程内 actor/run/session 事实态映射。 +- 投影生命周期必须基于 lease/session 显式句柄,不允许 `actorId -> context` 反查。 +- Event type 路由必须基于 `TypeUrl` 派生 + 精确键匹配(`TryGetValue`),禁止字符串模糊匹配。 +- 能力不匹配在启动期 fail-fast(默认策略),不得运行期隐式降级。 +- 所有变更必须通过既有门禁与测试。 + +## 7. 关键决策(本版定稿) + +### D1 `StateOnly` 语义(Workflow 现状) +- 在当前 Workflow 能力中,`ProjectionDisabled` 会阻断命令执行(现有行为保持)。 +- 因此 v1 不将 Workflow 场景纳入 `StateOnly` 支持范围;`StateOnly` 进入通用内核 vNext 议题。 + +### D2 Query API 合同兼容 +- v1 不改现有查询端点对外合同(现有 `null/[]/404/200` 语义维持)。 +- 若后续引入统一“能力不可用”错误模型,需单独版本化并提供迁移说明。 + +### D3 Event Sourcing 保持兼容 +- v1 继续支持显式 `RaiseEvent/ConfirmEventsAsync` 路径。 +- 自动 Persisted Event 仅作为扩展能力进入 P2,不作为 v1 合并前置条件。 + +### D4 Persisted Event 权威模型 +- `EventEnvelope` 仅用于运行时传播/投影输入。 +- EventStore 权威回放源是 `StateEvent`(Persisted State Event)。 + +### D5 Graph 能力分期 +- v1:仅保留 Graph 抽象扩展位,不要求完整 Neo4j 读写/查询能力。 +- vNext:Graph Provider 以单独 RFC + 里程碑方式落地。 + +### D6 v1 实施落点 +- v1 的 Provider 化仅要求在 Workflow Projection 模块闭环,不强制外溢到所有业务域。 +- 先确保“现有测试与门禁全绿 + 行为不回归”,再考虑通用化抽象下沉。 ## 8. 范围(Scope) -### 8.1 In Scope -- Provider 抽象下的通用 ReadModel Store 语义。 -- 默认 State 镜像投影(可替换)。 -- ReadModel 元数据模型与能力匹配规则。 -- Elasticsearch 适配器(Document Index Provider)实现。 -- Neo4j 适配器(Graph Index Provider)实现。 -- 通用 Application Service 管道抽象(Command/Query 通用壳)。 -- 通用 Host 模块装配抽象(模块注册与能力协商)。 -- DI 扩展:Provider 切换、默认镜像器注册、自定义覆盖。 -- Workflow Projection 接入。 -- 单元、集成、分布式一致化测试。 - -### 8.2 Out of Scope -- 全量后端 Provider 一次性交付。 -- Elasticsearch 生产运维平台自动化。 - -## 9. 功能需求(Functional Requirements) - -### FR-1 Event Sourcing 抽象 -- append-only + `expectedVersion` 并发校验 + `fromVersion` 回放查询。 -- 事件记录结构包含:`streamId`、`eventId`、`eventType`、`eventData`、`version`、`timestamp`、`metadata`。 - -### FR-2 Persisted State Event 模型约束 -- EventStore 权威事件必须只表达状态变更语义。 -- 不得耦合运行时路由字段(例如 `Direction`、转发链)。 -- 原始 envelope 不作为回放权威源。 - -### FR-3 自动状态事件生成(开发者无感) -- 框架在统一处理管道内自动生成 `Persisted State Event`。 -- 一次处理结束后基于 State 变更检测决定是否 append。 -- 无状态变化不得产生冗余事件。 -- 支持 `Snapshot` / `Delta` 策略扩展。 - -### FR-4 有状态 Agent 与 Snapshot -- `GAgentBase` 继续通过 `IStateStore` 完成 Load/Save。 -- 启用 Event Sourcing 不改变 `IStateStore` 作为快照通道的语义。 - -### FR-5 通用 ReadModel Store(Provider 模式) -- 兼容 `IProjectionReadModelStore`: +### 8.1 In Scope(v1) +- 定义 ReadModel Provider 能力模型与协商机制。 +- 启动期执行 “ReadModel 要求 vs Provider 能力” 校验。 +- 保持 `IProjectionReadModelStore` 兼容,不破坏现有 Workflow 读侧。 +- 提供 Document Index Provider(Elasticsearch-like)适配器。 +- Workflow `WorkflowExecutionReport` 可切换到 Document Provider 承接。 +- 通过 `IProjectionReadModelStoreRegistration` 注册 Provider,不新增平行投影链路。 +- 配置模型与 DI 扩展补齐(保留与现有 `WorkflowExecutionProjection:*` 兼容)。 +- 补齐单元/集成/门禁验证。 + +### 8.2 Out of Scope(v1) +- Graph Provider 的完整查询与关系建模能力。 +- 通用 `StateMirrorProjection` 自动覆盖所有 `GAgentBase` 类型。 +- 全量业务模块一次性迁移到新 Provider。 +- 生产 ILM/冷热分层自动化。 +- 新建独立“全局 ReadModel Infrastructure 大一统项目”并强制迁移全仓库。 + +## 9. 功能需求(FR) + +### FR-1 Event Sourcing 抽象保持兼容 +- 保持 `IEventStore` 追加、版本并发校验、按版本回放语义。 +- 保持 `IStateStore` 快照通道语义不变。 + +### FR-2 Persisted State Event 与 Envelope 边界 +- Persisted 回放源仅为 `StateEvent`。 +- `EventEnvelope` 中运行时路由/传播字段不得进入回放权威语义。 + +### FR-3 StateEvent 结构约束(v1) +- v1 继续使用现有 `StateEvent` 字段模型: + `event_id/timestamp/version/event_type/event_data/agent_id`。 +- 若需 `metadata` 字段,作为 vNext 的 proto 升级任务,不阻塞 v1。 + +### FR-4 ReadModel Store 兼容约束 +- `IProjectionReadModelStore` 契约保持兼容: - `UpsertAsync` - `MutateAsync` - `GetAsync` - `ListAsync` -- API 语义不绑定具体后端。 +- 既有 Workflow 读侧代码无需修改业务语义即可接入新 Provider。 +- v1 不在该接口上新增 Graph 专有方法,避免破坏现有实现与测试基线。 -### FR-6 Provider 能力声明与匹配 -- 定义 Provider 能力抽象(至少包含): +### FR-5 Provider 能力声明模型 +- 新增能力抽象(至少包含): - `SupportsIndexing` - - `IndexKind`(`Document` / `Graph`) + - `IndexKinds`(支持集合,至少可表达 `Document`) - `SupportsAliases` - `SupportsSchemaValidation` -- ReadModel 注册时执行能力匹配。 -- 对声明索引诉求的 ReadModel,必须由 `SupportsIndexing=true` 的 Provider 承接。 -- 当 ReadModel 显式声明 `IndexKind` 时,Provider 必须类型匹配。 -- 当 ReadModel 未声明 `IndexKind` 时,允许自动路由;若候选 Provider 不唯一则必须显式消歧。 - -### FR-7 默认 State 镜像投影 -- 提供默认 `IStateToReadModelProjector`: - - 默认策略:同名字段映射 + 可配置忽略字段。 - - 字段不匹配可由映射配置或自定义 projector 覆盖。 -- 当开发者未定义 `ReadModel` 时,允许落到框架默认读视图(Default ReadModel)。 - -### FR-7.1 ReadModel 可选化 -- 开发者不定义 `ReadModel` 时系统必须可运行(至少支持 `State-only` 模式)。 -- 可通过配置选择:`State-only` / `Default ReadModel` / `Custom ReadModel`。 -- 在 `State-only` 模式下,若调用依赖 ReadModel 的查询端点,应返回明确错误或能力不可用响应(按统一错误模型)。 - -### FR-8 自定义投影替换机制 -- 允许业务侧通过 DI 替换默认镜像器。 -- 同一 `State/ReadModel` 绑定仅允许一个“最终写入 owner projector”。 -- owner projector 内部允许组合多个 reducer/applier/module 协同完成映射与聚合。 -- `State -> ReadModel` 为一对多:一个 State 可以对应多个 ReadModel,但每个 ReadModel 绑定仍必须只有一个最终写入 owner。 -- 优先级:显式业务注册 > 默认注册。 - -### FR-9 ReadModel 元数据驱动索引(能力感知) -- ReadModel 元数据可声明: - - 通用:索引名/前缀、主键字段、版本、标签 - - Document Profile:字段 mapping(keyword/text/date/numeric/...)、settings、alias - - Graph Profile:节点标签、关系类型、关系方向、唯一约束字段、可选索引提示 -- 仅索引型 Provider 执行 metadata 建索引逻辑。 -- 非索引型 Provider 遇到索引诉求时按策略 fail-fast(默认)。 -- `IndexKind` 未声明时,可由 Provider 能力自动推断;推断不唯一时必须显式绑定。 - -### FR-10 双索引适配器实现 -- 必须提供 Document Index 适配器(Elasticsearch-like)。 -- 必须提供 Graph Index 适配器(Neo4j-like)。 -- 两者都必须遵循统一抽象层,不得在业务层分叉接口。 -- 双适配器是平台能力要求,不代表每个 ReadModel 都必须显式标记 `Document/Graph`。 - -### FR-11 Workflow Projection 接入 -- `WorkflowExecutionReport` 可由 Provider 切换承接。 -- 不改变上层 Query API 合同。 - -### FR-12 一致化验证 -- 在 3 节点脚本中加入跨节点一致性测试并纳入 CI。 - -### FR-13 通用 Application Service 层 -- 提供通用 `ICommandApplicationService` / `IQueryApplicationService` 编排入口。 -- 通用应用层必须支持:校验、幂等、防重、审计、错误模型统一。 -- 业务侧仅实现领域 handler/mapper/spec,不重复实现管道横切逻辑。 - -### FR-14 通用 Host 装配层 -- 提供模块化注册机制,支持按模块装配: - - 领域命令/查询 handler - - 投影 projector/reducer - - Provider 适配器 -- 启动期执行统一能力协商与配置有效性校验。 - -## 10. 非功能需求(Non-Functional Requirements) +- 能力模型可由 Store 实现直接声明,或由独立能力描述器声明。 +- 无论采用哪种声明方式,都不得破坏现有 Store 接口二进制兼容性。 + +### FR-6 启动期能力校验 +- ReadModel 注册/装配阶段执行能力匹配。 +- 默认策略:不匹配 fail-fast。 +- 必须输出结构化错误,包含 readModel/provider/requiredCapabilities。 +- 能力校验应接入现有 Workflow Projection DI 装配流程,而不是额外旁路启动器。 + +### FR-7 Workflow Provider 可替换承接 +- `WorkflowExecutionReport` 的存储后端支持通过 Provider 注册在 DI 中替换。 +- 切换 Provider 后,Query 结果语义与字段口径保持一致。 + +### FR-8 Document Index Provider(v1 必做) +- 实现 Elasticsearch-like Provider: + - 支持 `Upsert/Mutate/Get/List` 等价语义。 + - 支持索引命名环境隔离(如 prefix)。 + - 支持可配置索引初始化策略(create if missing)。 + +### FR-9 `StateOnly` 约束说明(v1) +- Workflow 能力 v1 保持现有行为:Projection 关闭时返回 `ProjectionDisabled`。 +- `StateOnly/DefaultReadModel/CustomReadModel` 通用模式进入 vNext RFC,不在 v1 作为可验收项。 + +### FR-10 Event Sourcing 自动化分期 +- v1 不强制实现“统一管道自动生成 Persisted Event”。 +- 如实现实验能力,必须开关控制且默认关闭,不改变现有显式路径行为。 + +### FR-11 可观测性 +- Provider 写入路径至少记录: + `provider/readModelType/key(state id)/elapsedMs/result/errorType`。 +- 启动期能力校验失败日志必须可定位到具体 ReadModel 与缺失能力。 + +### FR-12 分布式一致化验证 +- 保持并复用现有 3 节点一致性验证链路与脚本接入。 +- 新增 Provider 后,不得破坏现有 distributed smoke 稳定性。 + +### FR-13 复用现有通用壳能力 +- Command 侧复用现有 `ICommandExecutionService<...>` 抽象,不新增平行命令执行框架。 +- Host 侧复用既有 capability 注册机制,不新增第二套 capability 映射容器。 + +## 10. 非功能需求(NFR) ### NFR-1 一致性 -- 读模型允许最终一致;同节点写后读在可配置窗口内收敛。 -- EventStore 单流版本单调递增。 +- EventStore 单流版本必须单调递增。 +- ReadModel 允许最终一致,但需在测试中可稳定判定收敛/失败。 ### NFR-2 性能 -- Event append 与批量事件数量线性相关。 -- `ListAsync` 必须有硬上限。 +- `ListAsync` 必须强制硬上限(Provider 侧可配置上限)。 +- Provider 写入路径延迟应可观测(至少日志维度可统计)。 -### NFR-3 可观测性 -- 结构化日志至少包含:`provider`、`readModelType`、`documentId`、`stateVersion`、`elapsedMs`、`exceptionType`。 -- 索引型 Provider 额外记录:`index`、`alias`。 -- Graph Index Provider 额外记录:`nodeLabel`、`relationshipType`、`constraint`. +### NFR-3 可运维性 +- Provider 配置支持 `appsettings` + 环境变量覆盖。 +- 能力不匹配在启动期直接失败,不允许运行中静默降级。 -### NFR-4 可运维性 -- Provider 配置支持 `appsettings` + 环境变量。 -- 索引型 Provider 的索引命名必须可环境隔离。 +### NFR-4 安全 +- 日志不得输出明文凭据。 +- 凭据通过配置系统注入,支持环境变量替换。 -### NFR-5 安全 -- 日志不得输出凭据。 -- 保留认证/TLS 参数透传位。 +### NFR-5 门禁兼容性 +- 新增实现不得触发现有 `architecture_guards`、`projection_route_mapping_guard`、`test_stability_guards` 违规。 +- 新增测试不得引入未授权轮询等待;必要例外必须进入 allowlist 并给出理由。 ## 11. 配置需求 -### 11.1 Event Sourcing -- `EventSourcing:Provider` -- `EventSourcing:Snapshot:*` -- `EventSourcing:PersistedEvent:Mode`(`Snapshot` / `Delta`) -- `EventSourcing:PersistedEvent:AutoGenerate`(默认 `true`) - -### 11.2 ReadModel Provider -- `Projection:ReadModel:Provider`(示例:`InMemory` / `Elasticsearch` / future) -- `Projection:ReadModel:IndexKind`(可选:`Auto` / `Document` / `Graph`) -- `Projection:ReadModel:Mode`(`StateOnly` / `DefaultReadModel` / `CustomReadModel`) -- `Projection:ReadModel:DefaultMode`(`MirrorState` / `CustomProjector`,仅在非 `StateOnly` 下生效) +### 11.1 当前有效配置(已存在) +- `ActorRuntime:Provider`(`InMemory/MassTransit/Orleans`) +- `WorkflowExecutionProjection:Enabled` +- `WorkflowExecutionProjection:EnableActorQueryEndpoints` +- `WorkflowExecutionProjection:EnableRunReportArtifacts` +- `WorkflowExecutionProjection:RunProjectionCompletionWaitTimeoutMs` +- `WorkflowExecutionProjection:RunProjectionFinalizeGraceTimeoutMs` + +### 11.2 v1 新增配置(建议) +- `Projection:ReadModel:Provider`(`InMemory/Elasticsearch`) - `Projection:ReadModel:FailOnUnsupportedCapabilities`(默认 `true`) -- `Projection:ReadModel:Bindings:*`(可选,ReadModel 到 Provider 的显式绑定) +- `Projection:ReadModel:Bindings:*`(可选,ReadModel -> Provider 显式绑定) +- `WorkflowExecutionProjection:ReadModelProvider` / `FailOnUnsupportedCapabilities` / `ReadModelBindings` 保留为模块内覆盖位(可选) -### 11.3 Provider 专属配置(示例) +### 11.3 Document Provider 示例配置 - `Projection:ReadModel:Providers:Elasticsearch:Endpoints` - `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix` - `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs` - `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax` - `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex` -- `Projection:ReadModel:Providers:Neo4j:Uri` -- `Projection:ReadModel:Providers:Neo4j:Database` -- `Projection:ReadModel:Providers:Neo4j:Username` -- `Projection:ReadModel:Providers:Neo4j:Password` -- `Projection:ReadModel:Providers:Neo4j:AutoCreateConstraints` +- `Projection:ReadModel:Providers:Elasticsearch:Username` +- `Projection:ReadModel:Providers:Elasticsearch:Password` -### 11.4 ReadModel Metadata -- `Projection:ReadModel:Metadata:StrictMode` -- `Projection:ReadModel:Metadata:ApplyAliases` +### 11.4 预留(vNext) +- `Projection:ReadModel:Providers:Neo4j:*` +- `Projection:ReadModel:Mode`(`StateOnly/DefaultReadModel/CustomReadModel`) ## 12. 验收标准(DoD) ### 12.1 单元测试 -- 自动状态事件生成:变更检测、无变更不写、版本单调。 -- EventEnvelope 边界:路由字段不进入权威 persisted event。 -- Provider 能力匹配:支持/不支持索引能力的注册行为。 -- Provider 类型匹配:`Document` 元数据不能落到 `Graph` Provider,反之亦然。 -- 自动路由:单候选可自动承接,多候选未消歧必须 fail-fast。 -- 默认镜像器映射与自定义覆盖优先级。 -- Elasticsearch Provider 的 Upsert/Mutate/Get/List 与索引初始化。 -- Neo4j Provider 的节点/关系写入与约束初始化。 -- 通用 Application Service 管道:横切逻辑顺序与异常语义一致。 -- Host 装配:模块注册冲突与能力协商失败路径覆盖。 -- ReadModel 可选模式:`StateOnly`/`DefaultReadModel`/`CustomReadModel` 行为与能力边界覆盖。 +- Provider 能力声明与匹配(成功/失败/冲突路径)。 +- 启动期 fail-fast 错误语义。 +- Document Provider 的 `Upsert/Mutate/Get/List` 契约一致性。 +- Workflow 切换 Provider 后 Query 结果语义保持一致。 +- `ProjectionDisabled` 行为保持兼容(Workflow v1)。 +- 覆盖 Provider 注册与选择链路:`IProjectionReadModelStoreRegistration<,>` + `ProjectionReadModelStoreSelector`。 ### 12.2 集成测试 -- Docker Elasticsearch 下验证索引型 ReadModel 的端到端写读。 -- Docker Neo4j 下验证图模型 ReadModel 的端到端写读。 -- 验证切换 Provider 后 Query 语义一致。 -- 验证 `StateOnly` 模式下读取端点返回统一“能力不可用”语义。 +- Docker Elasticsearch 下验证 Workflow ReadModel 端到端写读。 +- Provider 切换前后,关键 Query API 返回语义一致。 ### 12.3 分布式一致化测试 -- 3 节点下 `workflows/agents` 查询跨节点一致。 -- 测试纳入 CI 且可稳定判定失败。 - -### 12.4 合规门槛 -- `build/test` 全通过。 -- 架构 guard 与稳定性 guard 全通过。 -- 文档、配置示例、能力矩阵同步。 - -## 13. 与现状对比 -| 维度 | 现状 | 目标 | +- 保持并通过现有 3 节点一致性脚本链路。 +- 测试稳定性门禁通过,不新增未授权轮询等待。 + +### 12.4 合规门槛(必须全绿) +- `dotnet build aevatar.slnx --nologo` +- `dotnet test aevatar.slnx --nologo` +- `bash tools/ci/architecture_guards.sh` +- `bash tools/ci/projection_route_mapping_guard.sh` +- `bash tools/ci/test_stability_guards.sh` +- `bash tools/ci/solution_split_test_guards.sh` + +## 13. 与原稿差异(本次修订) +| 维度 | 原稿 | 本版 | |---|---|---| -| 后端绑定 | 容易向 Elasticsearch 语义靠拢 | Provider 抽象,不绑定具体后端 | -| 索引类型 | 仅文档索引思维 | Document/Graph 双索引统一抽象 | -| EventStore 事件语义 | 易混用 runtime envelope | 仅持久化状态语义事件 | -| Persisted Event 开发成本 | 业务手写为主 | 框架自动生成 | -| Application Service | 业务层重复实现横切逻辑 | 通用管道壳 + 领域 handler 插件 | -| Host 装配 | 模块能力协商分散 | 统一模块装配与能力校验 | -| ReadModel 定义要求 | 默认倾向开发者显式定义 | ReadModel 可选(StateOnly/Default/Custom) | -| 默认读模型构建 | 业务手写 reducer/projector | 默认提供 State 镜像投影 | -| 索引能力 | 无统一能力闸门 | ReadModel 元数据 + Provider 能力匹配 | +| `StateOnly` | v1 必做 | 与当前 Workflow 冲突,改为 vNext | +| Graph Provider | v1 必做 | 改为 vNext(v1 仅保留扩展位) | +| Persisted Event 自动化 | v1 强要求 | 改为分期,v1 保持兼容 | +| 配置模型 | 以 `Projection:ReadModel:*` 为主 | 先兼容现有 `WorkflowExecutionProjection:*`,渐进演进 | +| DoD | 泛化描述 | 对齐现有 CI 门禁与可执行命令 | ## 14. 风险与缓解 -- 风险:能力不匹配导致运行时失败。 - - 缓解:启动期 fail-fast + 能力矩阵校验。 -- 风险:默认镜像不足以覆盖复杂业务。 - - 缓解:保持自定义 projector 可替换。 -- 风险:索引 mapping 演进兼容问题。 - - 缓解:版本化索引 + alias 切换策略(后续扩展)。 - -## 15. 里程碑建议 -1. M1:Provider 能力抽象 + 默认镜像抽象。 -2. M2:Document/Graph 元数据模型 + 能力匹配实现。 -3. M3:Elasticsearch + Neo4j 双适配器与测试覆盖。 -4. M4:Workflow 接入、3 节点一致化与 CI 收口。 - -## 16. 待确认项(Open Questions) -- 不支持索引能力时是否允许“显式降级”为无索引模式,还是一律 fail-fast? -- Provider 能力最小集合是否需要扩展到 `SupportsPartialUpdate`? -- ReadModel 元数据版本升级策略如何定义(兼容/阻断)? -- 同一 ReadModel 是否允许同时落 Document + Graph(双写),还是要求单一承接方? +- 风险:Provider 能力模型设计不当导致后续扩展困难。 + 缓解:v1 先覆盖 Document 必需能力,Graph 通过独立 RFC 引入。 +- 风险:切换后端导致 Query 语义漂移。 + 缓解:契约测试 + 端到端对照测试。 +- 风险:过早推进自动 Persisted Event 改动写侧行为。 + 缓解:v1 保持显式路径,自动化能力仅实验开关。 + +## 15. 待确认项(Open Questions) +- `ReadModelBindings` 的优先级规则(显式绑定 vs 默认路由)最终口径。 +- Graph RFC 的启动条件(抽象稳定性、测试基线、运维要求)。 +- 多业务域接入时,Provider 默认路由策略是否需要支持“按 ReadModel 类型自动选择”。 + +## 16. 需求-实现映射(v1) +| 需求主题 | 优先改动位置 | 复用现有扩展点 | 说明 | +|---|---|---|---| +| Provider 能力声明与校验 | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions` + `src/workflow/Aevatar.Workflow.Projection/DependencyInjection` | `AddWorkflowExecutionProjectionCQRS` | 通过 Selector + CapabilityValidator 在装配期做匹配与 fail-fast | +| Document Provider Store | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` | `IProjectionReadModelStoreRegistration<,>` | 通用 ES Provider,不绑定 Workflow 业务域 | +| Query 语义回归保障 | `src/workflow/Aevatar.Workflow.Projection/Orchestration` | `IWorkflowExecutionProjectionPort` | 不改 `null/[]/404/200` 现有合同 | +| 配置绑定扩展 | `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection` | `WorkflowExecutionProjection` + `Projection:ReadModel` 配置节 | 新增统一 Provider 配置并保持模块级覆盖位 | +| 测试与门禁收口 | `test/Aevatar.Workflow.Host.Api.Tests`、`test/Aevatar.Workflow.Application.Tests`、`tools/ci` | 现有 CI 脚本链路 | 先增量覆盖,再跑全量门禁 | diff --git a/docs/architecture/provider-based-readmodel-full-refactor-plan.md b/docs/architecture/provider-based-readmodel-full-refactor-plan.md new file mode 100644 index 000000000..347b42ce2 --- /dev/null +++ b/docs/architecture/provider-based-readmodel-full-refactor-plan.md @@ -0,0 +1,267 @@ +# Provider-Based ReadModel 全量重构计划(彻底版) + +## 1. 文档元信息 +- 状态:In Progress +- 目标:覆盖 `docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md` 的全部要求(含 v1 + vNext) +- 适用仓库:`aevatar` +- 编写日期:2026-02-23 +- 最近更新:2026-02-23 +- 备注:本计划按“可破坏式重构”制定,不以兼容历史实现为约束 + +## 1.1 当前进展快照(2026-02-23) +- 已完成:W0(Provider 纠偏清理,Workflow 不再持有 Provider Store 实现)。 +- 部分完成:W1(能力模型/选择器/校验器已落地,统一 Registry 与结构化观测仍待补齐)。 +- 部分完成:W2(通用 Elasticsearch Provider 项目与 DI 注册已落地,异常分级与 schema/mapping 策略仍待增强)。 +- 部分完成:W6(Workflow 已接入通用 Provider 注册与选择链路,其它业务域未迁移)。 +- 未开始:W3/W4/W5。 + +## 2. 执行原则(硬约束) +- 严格分层:`Domain / Application / Infrastructure / Host`。 +- Provider 必须在通用 CQRS 基建层,不得绑定具体业务域(例如 Workflow)。 +- CQRS 与 AGUI 继续走单一 Projection Pipeline,禁止平行链路。 +- 中间层禁止进程内 ID->事实态映射(遵守现有 guard)。 +- 能力不匹配默认启动期 fail-fast。 +- 变更必须满足现有 CI 门禁与新增测试门槛。 + +## 3. 当前关键问题(必须先修) +1. `Elasticsearch` 适配器被放在 `Aevatar.Workflow.Projection`,违反“通用能力下沉”原则。(已修复) +2. Provider 能力模型虽已引入,但尚未形成“跨业务域可复用”的统一 Registry/路由/校验治理框架。(进行中) +3. Graph Provider 尚未落地,StateMirror/StateOnly 通用能力未完成。 +4. Event Sourcing 自动 Persisted Event 仍未进入统一管道。 + +## 4. 目标架构(完成态) + +### 4.1 项目结构(目标) +- `src/Aevatar.CQRS.Projection.Abstractions` + - 保留并扩展通用契约:`IProjectionReadModelStore<,>`、Provider 能力模型、ReadModel 需求模型。 +- `src/Aevatar.CQRS.Projection.Providers` + - 新建通用 Provider 运行时:注册、选择、能力校验、错误模型、可观测性。 +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` + - Document Index Provider,通用实现,不含 Workflow 业务对象。 +- `src/Aevatar.CQRS.Projection.Providers.Neo4j` + - Graph Index Provider,通用实现,不含 Workflow 业务对象。 +- `src/Aevatar.CQRS.Projection.StateMirror` + - 通用 `State -> DefaultReadModel` 镜像能力(可选启用)。 +- `src/workflow/Aevatar.Workflow.Projection` + - 仅保留 Workflow 读模型、reducer/projector、port,不含后端 SDK/Provider 实现。 + +### 4.2 运行路径(目标) +1. 业务模块声明 ReadModel 与需求(IndexKind/Schema/Alias)。 +2. Host 装配通用 Provider Runtime。 +3. Provider Runtime 在启动期执行: + - ReadModel 绑定解析 + - Provider 选择 + - 能力校验 + - 失败即 fail-fast(默认) +4. Projection 写入通过统一 Store 契约执行,业务层无后端耦合。 + +## 5. 全量重构范围(对应需求文档) + +### 5.1 v1 范围(必须完成) +- Provider 能力模型、启动期校验、Document Provider 落地。 +- Workflow 切换 Provider 且 Query 语义不变。 +- 可观测性字段落地(provider/readModelType/key/elapsedMs/result/errorType)。 +- 配置模型落地与环境变量覆盖。 + +### 5.2 vNext 范围(本轮也纳入计划并执行) +- Graph Provider(Neo4j-like)完整落地。 +- 通用 `StateOnly / DefaultReadModel / CustomReadModel` 模式。 +- 通用 StateMirror 能力。 +- Event Sourcing 自动 Persisted Event 管道(开关化 + 默认策略定义)。 + +## 6. 详细实施计划(Workstreams) + +### W0 纠偏清理(先决) +目标:消除“Provider 绑定业务域”问题。 +状态:Completed(2026-02-23) + +任务: +1. 从 `Aevatar.Workflow.Projection` 移除 `Elasticsearch` 实现与配置细节。 +2. 在 Workflow 层保留 `IProjectionReadModelStore` 注入点,不感知具体后端。 +3. 删除/迁移 Workflow 内 Provider 专属类到通用 Provider 项目。 + +交付: +- Workflow 项目不再引用 Elasticsearch 相关 SDK/实现。 +- 相关 Workflow 测试已切换为通用 Provider Store 类型;Provider 专项测试集待补齐。 + +### W1 通用 Provider Runtime 基建 +目标:建立跨业务可复用的 Provider 选择与能力校验内核。 +状态:In Progress + +任务: +1. 设计并实现: + - `IProjectionReadModelProviderRegistry` + - `IProjectionReadModelProviderSelector` + - `IProjectionReadModelCapabilityValidator`(现有静态工具升级为可注入策略) + - `ProjectionReadModelBindingResolver` +2. 引入标准错误模型: + - `readModel` + - `provider` + - `requiredCapabilities` + - `actualCapabilities` + - `violations` +3. 统一日志接口与事件 ID,支持结构化查询。 + +交付: +- 任意业务模块可通过统一 API 注册 ReadModel 需求并由运行时自动选 Provider。 + +### W2 Document Provider(Elasticsearch) +目标:通用 Document Index Provider 完成生产可用版本。 +状态:In Progress + +任务: +1. 新建 `Aevatar.CQRS.Projection.Providers.Elasticsearch`。 +2. 提供通用 Store 适配机制: + - 支持 `Upsert/Mutate/Get/List` + - `ListAsync` 强上限 + - 索引前缀隔离 + - 自动建索引策略 +3. 引入 mapping/settings/alias 处理策略(与能力模型一致)。 +4. 完善异常分类(连接失败、索引不存在、版本冲突、认证失败)。 + +交付: +- 不依赖 Workflow 类型的通用 ES Provider。 + +### W3 Graph Provider(Neo4j) +目标:完成 Graph Index Provider 能力闭环。 +状态:Not Started + +任务: +1. 新建 `Aevatar.CQRS.Projection.Providers.Neo4j`。 +2. 支持节点/关系写入与基础查询。 +3. 支持唯一约束与索引初始化策略。 +4. 将 Graph 能力纳入同一 Provider runtime 校验。 + +交付: +- `IndexKind.Graph` 可被真实 Provider 承接。 + +### W4 StateMirror / ReadModel 可选模式 +目标:完成 `StateOnly / DefaultReadModel / CustomReadModel` 三模式。 +状态:Not Started + +任务: +1. 新建 `Aevatar.CQRS.Projection.StateMirror`: + - 默认字段映射 + - 可配置忽略/重命名 + - 可插拔 projector 覆盖 +2. `StateOnly` 模式定义: + - 不创建 ReadModel + - 查询端点返回统一能力不可用错误模型 +3. `CustomReadModel` 与默认镜像并存规则、优先级规则落地。 + +交付: +- 框架层提供无业务耦合的默认读模型能力。 + +### W5 Event Sourcing 自动 Persisted Event 管道 +目标:把“手动 Raise/Confirm”为主的模式升级为可配置自动化。 +状态:Not Started + +任务: +1. 设计统一写侧提交管道: + - 变更检测 + - 事件生成(Snapshot/Delta) + - 版本推进与幂等控制 +2. 提供开关: + - 全局开关 + - 模块级覆盖 +3. 默认策略: + - 默认保持兼容行为或由本次重构统一切换(按最终决策) + +交付: +- 业务方无需手动拼装 persisted event 基础流程。 + +### W6 Workflow 与其他模块接入 +目标:业务域从“自带 Provider”转为“消费通用 Provider Runtime”。 +状态:In Progress + +任务: +1. Workflow 完整迁移到通用 runtime。 +2. 逐步接入 AI/Foundation/其他读侧模块(如存在)。 +3. 清理重复抽象、空转发层、历史兼容分支。 + +交付: +- 业务域仅保留领域语义与投影逻辑,不含后端实现细节。 + +## 7. 配置模型重构计划 + +### 7.1 目标配置(通用) +- `Projection:ReadModel:Provider` +- `Projection:ReadModel:FailOnUnsupportedCapabilities` +- `Projection:ReadModel:Bindings` +- `Projection:ReadModel:Providers:Elasticsearch:*` +- `Projection:ReadModel:Providers:Neo4j:*` +- `Projection:ReadModel:Mode`(`StateOnly/DefaultReadModel/CustomReadModel`) + +### 7.2 迁移策略(本次不保兼容) +- 删除业务域私有 Provider 配置键(如 `WorkflowExecutionProjection:Providers:*`)。 +- 全量切换到统一 `Projection:ReadModel:*`。 + +## 8. 测试与门禁计划 + +### 8.1 单元测试 +- Provider 能力匹配:成功/失败/冲突/歧义。 +- Provider 选择器:单候选自动路由、多候选强制显式绑定。 +- ReadModel 三模式行为测试。 +- 自动 Persisted Event 管道(变更检测、版本、幂等)。 + +### 8.2 集成测试 +- Elasticsearch 端到端写读(Docker)。 +- Neo4j 端到端写读(Docker)。 +- Workflow Provider 切换后 Query 合同一致性。 + +### 8.3 分布式测试 +- 保留并扩展 3 节点一致性链路。 +- 新增 Provider 后验证跨节点一致收敛。 + +### 8.4 强制门禁 +- `dotnet build aevatar.slnx --nologo` +- `dotnet test aevatar.slnx --nologo` +- `bash tools/ci/architecture_guards.sh` +- `bash tools/ci/projection_route_mapping_guard.sh` +- `bash tools/ci/test_stability_guards.sh` +- `bash tools/ci/solution_split_test_guards.sh` + +## 9. 里程碑与交付 + +### M1(纠偏 + 基建) +- 完成 W0 + W1。 +- 验收:Workflow 不再持有任何 Provider 实现。 +- 当前状态:W0 完成;W1 部分完成。 + +### M2(Document Provider) +- 完成 W2。 +- 验收:ES Provider 在通用层可被 Workflow/其他模块消费。 +- 当前状态:已可被 Workflow 消费,增强项进行中。 + +### M3(Graph Provider) +- 完成 W3。 +- 验收:Graph ReadModel 可真实写读,能力校验全链路可用。 +- 当前状态:未开始。 + +### M4(StateMirror + 模式化 + ES 自动化) +- 完成 W4 + W5。 +- 验收:ReadModel 三模式与自动 Persisted Event 能力全量可测。 +- 当前状态:未开始。 + +### M5(全域收口) +- 完成 W6 + 全量文档/测试/门禁收口。 +- 验收:需求文档条目全部闭环,无历史空壳实现。 +- 当前状态:Workflow 已接入,其它域待迁移。 + +## 10. 风险与应对 +- 风险:Provider Runtime 泛化过度导致复杂度爆炸。 + 应对:先冻结最小能力集合,按里程碑增量扩展。 +- 风险:Graph 与 Document 语义差异大,统一抽象失真。 + 应对:抽象仅覆盖公共最小集,复杂能力走 provider-specific extension。 +- 风险:重构期间回归面大。 + 应对:分阶段门禁 + 契约测试 + 分片测试强制执行。 +- 风险:自动 Persisted Event 改写写侧行为。 + 应对:先实验开关,明确默认策略后再全量切换。 + +## 11. 完成定义(Final DoD) +- 需求文档中 FR/NFR 全量有代码落地。 +- Provider 不再出现在业务域实现层。 +- Document + Graph Provider 均可被统一 runtime 装配并通过能力校验。 +- StateOnly/Default/Custom 三模式完整可用。 +- 自动 Persisted Event 管道可用并有完整测试。 +- 全量 CI 门禁通过,文档同步完成。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs new file mode 100644 index 000000000..f3d760bad --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs @@ -0,0 +1,33 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class DelegateProjectionReadModelStoreRegistration + : IProjectionReadModelStoreRegistration + where TReadModel : class +{ + private readonly Func> _factory; + + public DelegateProjectionReadModelStoreRegistration( + string providerName, + ProjectionReadModelProviderCapabilities capabilities, + Func> factory) + { + if (string.IsNullOrWhiteSpace(providerName)) + throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(factory); + + ProviderName = providerName.Trim(); + Capabilities = capabilities; + _factory = factory; + } + + public string ProviderName { get; } + + public ProjectionReadModelProviderCapabilities Capabilities { get; } + + public IProjectionReadModelStore Create(IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + return _factory(serviceProvider); + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs new file mode 100644 index 000000000..45561c08b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelStoreProviderMetadata +{ + ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs new file mode 100644 index 000000000..8902194c9 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs @@ -0,0 +1,11 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelStoreRegistration + where TReadModel : class +{ + string ProviderName { get; } + + ProjectionReadModelProviderCapabilities Capabilities { get; } + + IProjectionReadModelStore Create(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs new file mode 100644 index 000000000..290ee852b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs @@ -0,0 +1,32 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelCapabilityValidationException : InvalidOperationException +{ + public ProjectionReadModelCapabilityValidationException( + Type readModelType, + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities, + IReadOnlyList violations) + : base(BuildMessage(readModelType, capabilities.ProviderName, violations)) + { + ReadModelType = readModelType; + Requirements = requirements; + Capabilities = capabilities; + Violations = violations; + } + + public Type ReadModelType { get; } + + public ProjectionReadModelRequirements Requirements { get; } + + public ProjectionReadModelProviderCapabilities Capabilities { get; } + + public IReadOnlyList Violations { get; } + + private static string BuildMessage( + Type readModelType, + string providerName, + IReadOnlyList violations) => + $"ReadModel '{readModelType.FullName}' is not supported by provider '{providerName}'. " + + $"Violations: {string.Join("; ", violations)}"; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs new file mode 100644 index 000000000..bb6bb87ef --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs @@ -0,0 +1,56 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public static class ProjectionReadModelCapabilityValidator +{ + public static IReadOnlyList Validate( + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(capabilities); + + var violations = new List(); + + if (requirements.RequiresIndexing && !capabilities.SupportsIndexing) + violations.Add("requires indexing, but provider does not support indexing"); + + if (requirements.RequiredIndexKinds.Count > 0) + { + if (!capabilities.SupportsIndexing) + { + violations.Add( + $"requires index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}], but provider indexing is disabled"); + } + else if (!requirements.RequiredIndexKinds.Overlaps(capabilities.IndexKinds)) + { + violations.Add( + $"required index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}] are not supported by provider kinds [{string.Join(", ", capabilities.IndexKinds)}]"); + } + } + + if (requirements.RequiresAliases && !capabilities.SupportsAliases) + violations.Add("requires alias support, but provider does not support aliases"); + + if (requirements.RequiresSchemaValidation && !capabilities.SupportsSchemaValidation) + violations.Add("requires schema validation, but provider does not support schema validation"); + + return violations; + } + + public static void EnsureSupported( + Type readModelType, + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities) + { + ArgumentNullException.ThrowIfNull(readModelType); + var violations = Validate(requirements, capabilities); + if (violations.Count == 0) + return; + + throw new ProjectionReadModelCapabilityValidationException( + readModelType, + requirements, + capabilities, + violations); + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs new file mode 100644 index 000000000..bfc18fc2c --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public enum ProjectionReadModelIndexKind +{ + None = 0, + Document = 1, + Graph = 2, +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs new file mode 100644 index 000000000..bb5d50d18 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs @@ -0,0 +1,46 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelProviderCapabilities +{ + private static readonly IReadOnlySet EmptyIndexKinds = + new HashSet(); + + public ProjectionReadModelProviderCapabilities( + string providerName, + bool supportsIndexing, + IEnumerable? indexKinds = null, + bool supportsAliases = false, + bool supportsSchemaValidation = false) + { + if (string.IsNullOrWhiteSpace(providerName)) + throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); + + ProviderName = providerName.Trim(); + SupportsIndexing = supportsIndexing; + SupportsAliases = supportsAliases; + SupportsSchemaValidation = supportsSchemaValidation; + + var normalizedIndexKinds = (indexKinds ?? []) + .Where(x => x != ProjectionReadModelIndexKind.None) + .ToHashSet(); + + if (!supportsIndexing && normalizedIndexKinds.Count > 0) + throw new ArgumentException( + "Index kinds cannot be declared when supportsIndexing is false.", + nameof(indexKinds)); + + IndexKinds = normalizedIndexKinds.Count == 0 + ? EmptyIndexKinds + : normalizedIndexKinds; + } + + public string ProviderName { get; } + + public bool SupportsIndexing { get; } + + public IReadOnlySet IndexKinds { get; } + + public bool SupportsAliases { get; } + + public bool SupportsSchemaValidation { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs new file mode 100644 index 000000000..3f3aef17e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public static class ProjectionReadModelProviderNames +{ + public const string InMemory = "InMemory"; + + public const string Elasticsearch = "Elasticsearch"; + + public const string Neo4j = "Neo4j"; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs new file mode 100644 index 000000000..f17764aba --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs @@ -0,0 +1,34 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelRequirements +{ + private static readonly IReadOnlySet EmptyIndexKinds = + new HashSet(); + + public ProjectionReadModelRequirements( + bool requiresIndexing = false, + IEnumerable? requiredIndexKinds = null, + bool requiresAliases = false, + bool requiresSchemaValidation = false) + { + RequiresIndexing = requiresIndexing; + RequiresAliases = requiresAliases; + RequiresSchemaValidation = requiresSchemaValidation; + + var normalizedIndexKinds = (requiredIndexKinds ?? []) + .Where(x => x != ProjectionReadModelIndexKind.None) + .ToHashSet(); + + RequiredIndexKinds = normalizedIndexKinds.Count == 0 + ? EmptyIndexKinds + : normalizedIndexKinds; + } + + public bool RequiresIndexing { get; } + + public IReadOnlySet RequiredIndexKinds { get; } + + public bool RequiresAliases { get; } + + public bool RequiresSchemaValidation { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs new file mode 100644 index 000000000..4954b3bb7 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelRuntimeOptions +{ + public ProjectionReadModelRuntimeOptions() + { + Bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public string Provider { get; set; } = ProjectionReadModelProviderNames.InMemory; + + public bool FailOnUnsupportedCapabilities { get; set; } = true; + + public Dictionary Bindings { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs new file mode 100644 index 000000000..f144b3e72 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelStoreSelectionOptions +{ + public string RequestedProviderName { get; set; } = ""; + + public bool FailOnUnsupportedCapabilities { get; set; } = true; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs new file mode 100644 index 000000000..42f588206 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs @@ -0,0 +1,64 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public static class ProjectionReadModelStoreSelector +{ + public static IProjectionReadModelStoreRegistration Select( + IEnumerable> registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var candidates = registrations.ToList(); + if (candidates.Count == 0) + throw new InvalidOperationException( + $"No read-model provider registrations found for '{typeof(TReadModel).FullName}'."); + + var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; + var selected = ResolveRegistration(candidates, requestedProviderName); + + var violations = ProjectionReadModelCapabilityValidator.Validate(requirements, selected.Capabilities); + if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) + { + throw new ProjectionReadModelCapabilityValidationException( + typeof(TReadModel), + requirements, + selected.Capabilities, + violations); + } + + return selected; + } + + private static IProjectionReadModelStoreRegistration ResolveRegistration( + IReadOnlyList> registrations, + string requestedProviderName) + where TReadModel : class + { + if (requestedProviderName.Length == 0) + { + if (registrations.Count == 1) + return registrations[0]; + + throw new InvalidOperationException( + $"Multiple providers are registered for '{typeof(TReadModel).FullName}', but no explicit provider was requested. " + + $"Available: {string.Join(", ", registrations.Select(x => x.ProviderName))}."); + } + + var matched = registrations + .FirstOrDefault(x => string.Equals( + x.ProviderName, + requestedProviderName, + StringComparison.OrdinalIgnoreCase)); + + if (matched != null) + return matched; + + throw new InvalidOperationException( + $"Requested provider '{requestedProviderName}' is not registered for '{typeof(TReadModel).FullName}'. " + + $"Available: {string.Join(", ", registrations.Select(x => x.ProviderName))}."); + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Abstractions/README.md index d18d63241..1f265fbba 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Abstractions/README.md @@ -8,6 +8,10 @@ - 扩展抽象:`IProjectionProjector<,>`、`IProjectionEventReducer<,>` - 失败回传:`IProjectionDispatchFailureReporter<>` - 读模型存储:`IProjectionReadModelStore<,>` +- Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionReadModelStoreProviderMetadata` +- 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` +- Provider 注册与选择:`IProjectionReadModelStoreRegistration<,>`、`DelegateProjectionReadModelStoreRegistration<,>`、`ProjectionReadModelStoreSelector` +- Provider 运行配置:`ProjectionReadModelRuntimeOptions` - 运行时策略:`IProjectionRuntimeOptions`、`IProjectionClock` - 流订阅复用:`IActorStreamSubscriptionHub` - 投影上下文:`IProjectionContext` diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj new file mode 100644 index 000000000..b193b1845 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Providers.Elasticsearch + Aevatar.CQRS.Projection.Providers.Elasticsearch + + + + + + + + + diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs new file mode 100644 index 000000000..7628858fa --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs @@ -0,0 +1,20 @@ +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; + +public sealed class ElasticsearchProjectionReadModelStoreOptions +{ + public List Endpoints { get; set; } = []; + + public string IndexPrefix { get; set; } = "aevatar"; + + public int RequestTimeoutMs { get; set; } = 10000; + + public int ListTakeMax { get; set; } = 200; + + public bool AutoCreateIndex { get; set; } = true; + + public string Username { get; set; } = ""; + + public string Password { get; set; } = ""; + + public string ListSortField { get; set; } = ""; +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..ccf96aa14 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddElasticsearchReadModelStoreRegistration( + this IServiceCollection services, + Func optionsFactory, + string indexScope, + Func keySelector, + Func? keyFormatter = null, + string providerName = ProjectionReadModelProviderNames.Elasticsearch) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(optionsFactory); + ArgumentNullException.ThrowIfNull(indexScope); + ArgumentNullException.ThrowIfNull(keySelector); + + services.AddSingleton>( + new DelegateProjectionReadModelStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Document], + supportsAliases: true, + supportsSchemaValidation: true), + provider => new ElasticsearchProjectionReadModelStore( + optionsFactory(provider), + indexScope, + keySelector, + keyFormatter, + providerName))); + + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs new file mode 100644 index 000000000..b07fa3dbd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md new file mode 100644 index 000000000..57bb111f3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -0,0 +1,20 @@ +# Aevatar.CQRS.Projection.Providers.Elasticsearch + +通用 Elasticsearch Document ReadModel Provider。 + +- 不依赖任何业务域 read model。 +- 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 +- 能力声明:`Document` 索引、alias、schema validation。 + +## DI 注册 + +使用扩展方法: + +- `AddElasticsearchReadModelStoreRegistration(...)` + +关键参数: + +- `optionsFactory`:绑定 `Projection:ReadModel:Providers:Elasticsearch:*` 配置。 +- `indexScope`:按业务语义隔离索引(会与 `IndexPrefix` 组合)。 +- `keySelector/keyFormatter`:ReadModel 主键映射。 +- `providerName`:默认 `Elasticsearch`(与 `ProjectionReadModelProviderNames.Elasticsearch` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs new file mode 100644 index 000000000..3dcb3dc7d --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -0,0 +1,310 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +public sealed class ElasticsearchProjectionReadModelStore + : IProjectionReadModelStore, + IProjectionReadModelStoreProviderMetadata, + IDisposable + where TReadModel : class +{ + private readonly HttpClient _httpClient; + private readonly Func _keySelector; + private readonly Func _keyFormatter; + private readonly string _indexName; + private readonly int _listTakeMax; + private readonly bool _autoCreateIndex; + private readonly string _listSortField; + private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + private bool _indexInitialized; + + public ElasticsearchProjectionReadModelStore( + ElasticsearchProjectionReadModelStoreOptions options, + string indexScope, + Func keySelector, + Func? keyFormatter = null, + string providerName = ProjectionReadModelProviderNames.Elasticsearch) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(keySelector); + + var endpoint = ResolvePrimaryEndpoint(options.Endpoints); + _httpClient = new HttpClient + { + BaseAddress = endpoint, + Timeout = TimeSpan.FromMilliseconds(Math.Max(500, options.RequestTimeoutMs)), + }; + + if (!string.IsNullOrWhiteSpace(options.Username)) + { + var raw = $"{options.Username}:{options.Password}"; + var token = Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token); + } + + var normalizedScope = NormalizeToken(indexScope); + if (normalizedScope.Length == 0) + normalizedScope = "readmodel"; + + _indexName = BuildIndexName(options.IndexPrefix, normalizedScope); + _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; + _autoCreateIndex = options.AutoCreateIndex; + _keySelector = keySelector; + _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); + _listSortField = options.ListSortField?.Trim() ?? ""; + ProviderCapabilities = BuildCapabilities(providerName); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) => + UpsertCoreAsync(readModel, allowCreateIndex: true, ct); + + public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + var existing = await GetAsync(key, ct); + if (existing == null) + throw new InvalidOperationException($"ReadModel '{typeof(TReadModel).FullName}' with key '{FormatKey(key)}' was not found."); + + mutate(existing); + await UpsertCoreAsync(existing, allowCreateIndex: true, ct); + } + + public async Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + await EnsureIndexAsync(ct); + + var keyValue = FormatKey(key); + if (keyValue.Length == 0) + return null; + + using var response = await _httpClient.GetAsync($"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}", ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + + await EnsureSuccessAsync(response, "get", ct); + var payload = await response.Content.ReadAsStringAsync(ct); + using var jsonDoc = JsonDocument.Parse(payload); + if (!jsonDoc.RootElement.TryGetProperty("_source", out var sourceNode)) + return null; + + return DeserializeOrNull(sourceNode.GetRawText()); + } + + public async Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + await EnsureIndexAsync(ct); + var boundedTake = Math.Clamp(take, 1, _listTakeMax); + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{_indexName}/_search") + { + Content = new StringContent(BuildListPayloadJson(boundedTake), Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + if (response.StatusCode == HttpStatusCode.NotFound) + return []; + + await EnsureSuccessAsync(response, "list", ct); + var payload = await response.Content.ReadAsStringAsync(ct); + using var jsonDoc = JsonDocument.Parse(payload); + if (!jsonDoc.RootElement.TryGetProperty("hits", out var hitsNode) || + !hitsNode.TryGetProperty("hits", out var hitItems)) + return []; + + var items = new List(); + foreach (var hit in hitItems.EnumerateArray()) + { + if (!hit.TryGetProperty("_source", out var sourceNode)) + continue; + + var item = DeserializeOrNull(sourceNode.GetRawText()); + if (item != null) + items.Add(item); + } + + return items; + } + + private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + if (allowCreateIndex) + await EnsureIndexAsync(ct); + + var keyValue = ResolveReadModelKey(readModel); + var payload = JsonSerializer.Serialize(readModel, _jsonOptions); + using var request = new HttpRequestMessage(HttpMethod.Put, $"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + await EnsureSuccessAsync(response, "upsert", ct); + } + + private string ResolveReadModelKey(TReadModel readModel) + { + var key = _keySelector(readModel); + var keyValue = FormatKey(key); + if (keyValue.Length == 0) + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' resolved an empty key for Elasticsearch persistence."); + return keyValue; + } + + private string FormatKey(TKey key) + { + var keyValue = _keyFormatter(key)?.Trim() ?? ""; + return keyValue; + } + + private TReadModel? DeserializeOrNull(string json) + { + var value = JsonSerializer.Deserialize(json, _jsonOptions); + if (value == null) + return null; + + // Defensive copy to isolate caller-side mutation from cache/shared references. + var copyPayload = JsonSerializer.Serialize(value, _jsonOptions); + return JsonSerializer.Deserialize(copyPayload, _jsonOptions); + } + + private string BuildListPayloadJson(int size) + { + if (_listSortField.Length == 0) + return JsonSerializer.Serialize(new { size, query = new { match_all = new { } } }); + + return JsonSerializer.Serialize(new + { + size, + sort = new object[] + { + new Dictionary + { + [_listSortField] = new { order = "desc" }, + }, + }, + query = new + { + match_all = new { }, + }, + }); + } + + private async Task EnsureIndexAsync(CancellationToken ct) + { + if (!_autoCreateIndex || _indexInitialized) + return; + + await _indexInitializationLock.WaitAsync(ct); + try + { + if (_indexInitialized) + return; + + using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) + { + Content = new StringContent("{\"mappings\":{\"dynamic\":true}}", Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + _indexInitialized = true; + return; + } + + var payload = await response.Content.ReadAsStringAsync(ct); + if (response.StatusCode == HttpStatusCode.BadRequest && + payload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) + { + _indexInitialized = true; + return; + } + + throw new InvalidOperationException( + $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); + } + finally + { + _indexInitializationLock.Release(); + } + } + + private static async Task EnsureSuccessAsync( + HttpResponseMessage response, + string operation, + CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var payload = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Elasticsearch {operation} failed: {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); + } + + private static Uri ResolvePrimaryEndpoint(IReadOnlyList? endpoints) + { + if (endpoints == null || endpoints.Count == 0) + throw new InvalidOperationException("Elasticsearch provider requires at least one endpoint."); + + var endpoint = endpoints[0].Trim(); + if (endpoint.Length == 0) + throw new InvalidOperationException("Elasticsearch endpoint cannot be empty."); + if (!endpoint.Contains("://", StringComparison.Ordinal)) + endpoint = "http://" + endpoint; + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + throw new InvalidOperationException($"Invalid Elasticsearch endpoint '{endpoints[0]}'."); + + return uri; + } + + private static string BuildIndexName(string indexPrefix, string indexScope) + { + var prefix = NormalizeToken(indexPrefix); + if (prefix.Length == 0) + prefix = "aevatar"; + return $"{prefix}-{indexScope}"; + } + + private static string NormalizeToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return ""; + + var chars = token + .Trim() + .ToLowerInvariant() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray(); + return new string(chars).Trim('-'); + } + + private static ProjectionReadModelProviderCapabilities BuildCapabilities(string providerName) => + new( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Document], + supportsAliases: true, + supportsSchemaValidation: true); + + public void Dispose() + { + _httpClient.Dispose(); + _indexInitializationLock.Dispose(); + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj new file mode 100644 index 000000000..c97263c86 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Providers.InMemory + Aevatar.CQRS.Projection.Providers.InMemory + + + + + + + + + diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..a81b50987 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddInMemoryReadModelStoreRegistration( + this IServiceCollection services, + Func keySelector, + Func? keyFormatter = null, + Func? listSortSelector = null, + int listTakeMax = 200, + string providerName = ProjectionReadModelProviderNames.InMemory) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(keySelector); + + services.AddSingleton>( + new DelegateProjectionReadModelStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities(providerName, supportsIndexing: false), + _ => new InMemoryProjectionReadModelStore( + keySelector, + keyFormatter, + listSortSelector, + listTakeMax, + providerName))); + + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs new file mode 100644 index 000000000..b07fa3dbd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md new file mode 100644 index 000000000..d808968d4 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -0,0 +1,20 @@ +# Aevatar.CQRS.Projection.Providers.InMemory + +通用 InMemory ReadModel Provider。 + +- 不依赖业务域模型。 +- 支持按 keySelector 注册任意 `IProjectionReadModelStore`。 +- 默认能力:非索引型(`SupportsIndexing=false`)。 + +## DI 注册 + +使用扩展方法: + +- `AddInMemoryReadModelStoreRegistration(...)` + +关键参数: + +- `keySelector/keyFormatter`:ReadModel 主键映射。 +- `listSortSelector`:`ListAsync` 排序字段(可选)。 +- `listTakeMax`:`ListAsync` 硬上限。 +- `providerName`:默认 `InMemory`(与 `ProjectionReadModelProviderNames.InMemory` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs new file mode 100644 index 000000000..e54490f52 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -0,0 +1,125 @@ +using System.Text.Json; + +namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; + +public sealed class InMemoryProjectionReadModelStore + : IProjectionReadModelStore, + IProjectionReadModelStoreProviderMetadata + where TReadModel : class +{ + private readonly object _gate = new(); + private readonly Dictionary _itemsByKey = new(StringComparer.Ordinal); + private readonly Func _keySelector; + private readonly Func _keyFormatter; + private readonly Func? _listSortSelector; + private readonly int _listTakeMax; + private readonly JsonSerializerOptions _jsonOptions = new(); + + public InMemoryProjectionReadModelStore( + Func keySelector, + Func? keyFormatter = null, + Func? listSortSelector = null, + int listTakeMax = 200, + string providerName = ProjectionReadModelProviderNames.InMemory) + { + ArgumentNullException.ThrowIfNull(keySelector); + _keySelector = keySelector; + _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); + _listSortSelector = listSortSelector; + _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; + ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: false); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + + var key = ResolveReadModelKey(readModel); + lock (_gate) + _itemsByKey[key] = Clone(readModel); + + return Task.CompletedTask; + } + + public Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + lock (_gate) + { + var keyValue = FormatKey(key); + if (!_itemsByKey.TryGetValue(keyValue, out var existing)) + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); + + mutate(existing); + } + + return Task.CompletedTask; + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + { + var keyValue = FormatKey(key); + if (!_itemsByKey.TryGetValue(keyValue, out var existing)) + return Task.FromResult(null); + + return Task.FromResult(Clone(existing)); + } + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var boundedTake = Math.Clamp(take, 1, _listTakeMax); + + lock (_gate) + { + IEnumerable query = _itemsByKey.Values; + if (_listSortSelector != null) + query = query.OrderByDescending(_listSortSelector); + + var items = query + .Take(boundedTake) + .Select(Clone) + .ToList(); + + return Task.FromResult>(items); + } + } + + private string ResolveReadModelKey(TReadModel readModel) + { + var key = _keySelector(readModel); + var keyValue = FormatKey(key); + if (keyValue.Length == 0) + { + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' resolved an empty key for InMemory store."); + } + + return keyValue; + } + + private string FormatKey(TKey key) => _keyFormatter(key)?.Trim() ?? ""; + + private TReadModel Clone(TReadModel source) + { + var payload = JsonSerializer.Serialize(source, _jsonOptions); + var clone = JsonSerializer.Deserialize(payload, _jsonOptions); + if (clone == null) + throw new InvalidOperationException( + $"Failed to clone read model '{typeof(TReadModel).FullName}' in InMemory provider."); + + return clone; + } +} diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj index 8c3b9131a..27d6ad298 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj +++ b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index 628e62e02..c5fd4ac77 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -1,9 +1,15 @@ using Aevatar.Configuration; +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.Workflow.Application.DependencyInjection; using Aevatar.Workflow.Core; using Aevatar.Workflow.Presentation.AGUIAdapter; using Aevatar.Workflow.Presentation.AGUIAdapter.DependencyInjection; +using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.DependencyInjection; +using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -17,7 +23,25 @@ public static IServiceCollection AddWorkflowCapability( { services.AddAevatarWorkflow(); services.AddWorkflowExecutionProjectionCQRS(options => - configuration.GetSection("WorkflowExecutionProjection").Bind(options)); + { + configuration.GetSection("WorkflowExecutionProjection").Bind(options); + ApplyGlobalReadModelOptions(configuration, options); + }); + services.AddInMemoryReadModelStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt, + listTakeMax: 200); + services.AddElasticsearchReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); + return providerOptions; + }, + indexScope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); services.AddWorkflowExecutionAGUIAdapter(); services.AddWorkflowExecutionProjectionProjector(); services.AddWorkflowApplication(); @@ -32,4 +56,20 @@ public static IServiceCollection AddWorkflowCapability( configuration.GetSection("WorkflowExecutionReportArtifacts").Bind(options)); return services; } + + private static void ApplyGlobalReadModelOptions( + IConfiguration configuration, + WorkflowExecutionProjectionOptions options) + { + var readModelOptions = new ProjectionReadModelRuntimeOptions(); + configuration.GetSection("Projection:ReadModel").Bind(readModelOptions); + + if (!string.IsNullOrWhiteSpace(readModelOptions.Provider)) + options.ReadModelProvider = readModelOptions.Provider.Trim(); + + options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; + options.ReadModelBindings.Clear(); + foreach (var item in readModelOptions.Bindings) + options.ReadModelBindings[item.Key] = item.Value; + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index 5998efcc1..c0e1c932c 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -6,6 +6,11 @@ namespace Aevatar.Workflow.Projection.Configuration; public sealed class WorkflowExecutionProjectionOptions : IProjectionRuntimeOptions { + public WorkflowExecutionProjectionOptions() + { + ReadModelBindings = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + /// /// Enables projection pipeline registration. /// @@ -36,4 +41,19 @@ public bool EnableRunQueryEndpoints /// Extra grace wait before force-finalize when completion status is timeout. /// public int RunProjectionFinalizeGraceTimeoutMs { get; set; } = 1500; + + /// + /// Read-model store provider name, e.g. InMemory/Elasticsearch. + /// + public string ReadModelProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; + + /// + /// Whether unsupported provider capabilities should fail fast during startup registration. + /// + public bool FailOnUnsupportedCapabilities { get; set; } = true; + + /// + /// Optional read-model binding requirements (ReadModelName -> IndexKind). + /// + public Dictionary ReadModelBindings { get; } } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 5b3884013..91c1b02de 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,5 @@ using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.Orchestration; -using Aevatar.Workflow.Projection.Stores; using Aevatar.Workflow.Projection.ReadModels; using Aevatar.Workflow.Application.Abstractions.Projections; using Aevatar.Workflow.Application.Abstractions.Runs; @@ -34,8 +33,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); - - services.TryAddSingleton, InMemoryWorkflowExecutionReadModelStore>(); + RegisterWorkflowReadModelStoreSelector(services); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -117,6 +115,83 @@ private static void RegisterFromAssembly(IServiceCollection services, Assembly a ProjectionProjectorContract); } + private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton>(sp => + { + var options = sp.GetRequiredService(); + var requirements = ResolveReadModelRequirements(options, typeof(WorkflowExecutionReport)); + var selectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), + FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, + }; + + var registration = ProjectionReadModelStoreSelector.Select( + sp.GetServices>(), + selectionOptions, + requirements); + + return registration.Create(sp); + })); + } + + private static ProjectionReadModelRequirements ResolveReadModelRequirements( + WorkflowExecutionProjectionOptions options, + Type readModelType) + { + if (!TryResolveIndexKindBinding(options.ReadModelBindings, readModelType, out var requiredKind)) + return new ProjectionReadModelRequirements(); + + return new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [requiredKind]); + } + + private static bool TryResolveIndexKindBinding( + IReadOnlyDictionary readModelBindings, + Type readModelType, + out ProjectionReadModelIndexKind requiredKind) + { + requiredKind = ProjectionReadModelIndexKind.None; + if (readModelBindings.Count == 0) + return false; + + if (!TryGetBinding(readModelBindings, readModelType.Name, out var configuredIndexKind) && + !TryGetBinding(readModelBindings, readModelType.FullName ?? "", out configuredIndexKind)) + return false; + + if (!Enum.TryParse(configuredIndexKind, true, out requiredKind) || + requiredKind == ProjectionReadModelIndexKind.None) + { + throw new InvalidOperationException( + $"Invalid ReadModelBindings value '{configuredIndexKind}' for '{readModelType.FullName}'. " + + $"Allowed values: {ProjectionReadModelIndexKind.Document}, {ProjectionReadModelIndexKind.Graph}."); + } + + return true; + } + + private static bool TryGetBinding( + IReadOnlyDictionary readModelBindings, + string key, + out string value) + { + if (key.Length > 0 && readModelBindings.TryGetValue(key, out value!)) + return true; + + value = ""; + return false; + } + + private static string NormalizeProviderName(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + return ProjectionReadModelProviderNames.InMemory; + + return providerName.Trim(); + } + private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 7be2c0de0..faab7813c 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -16,8 +16,9 @@ - `WorkflowProjectionQueryReader`(query 映射读取) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) -- 领域投影实现:reducers、projectors、read model store +- 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` +- Provider 能力校验:基于 `ProjectionReadModelCapabilityValidator` 在装配期校验 `ReadModelBindings` 与 Provider 能力匹配 本项目依赖: @@ -73,9 +74,29 @@ FAQ: - 新增 projector: - 实现 `IProjectionProjector>` - 在 DI 中注册 -- 替换存储: - - 实现 `IProjectionReadModelStore` - - 使用自定义实现替换默认内存存储 +- 扩展 ReadModel Provider(推荐): + - 实现 `IProjectionReadModelStoreRegistration` + - 在 Infrastructure 侧注册(例如 `AddInMemoryReadModelStoreRegistration` / `AddElasticsearchReadModelStoreRegistration`) + - 通过 `WorkflowExecutionProjection:ReadModelProvider` 或 `Projection:ReadModel:Provider` 选择 Provider +- 直接替换 Store(仅测试/临时场景): + - 调用 `AddWorkflowExecutionProjectionReadModelStore()` 直接覆盖 `IProjectionReadModelStore` + - 该方式会绕过 Provider 选择与能力校验链路,不建议用于生产装配 + +## Provider 配置 + +- `WorkflowExecutionProjection:ReadModelProvider`:`InMemory`(默认)/`Elasticsearch` +- `WorkflowExecutionProjection:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) +- `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(如 `WorkflowExecutionReport: Document`) +- 推荐统一配置入口:`Projection:ReadModel:*`(由 Infrastructure 映射到 Workflow 投影选项) +- `Projection:ReadModel:Provider`:全局默认 Provider(当前由 `WorkflowCapabilityServiceCollectionExtensions` 覆盖到模块选项) +- `Projection:ReadModel:FailOnUnsupportedCapabilities`:全局 fail-fast 策略 +- `Projection:ReadModel:Bindings:*`:全局 ReadModel -> IndexKind 约束 +- `Projection:ReadModel:Providers:Elasticsearch:Endpoints`:Elasticsearch endpoint 列表 +- `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix`:索引前缀 +- `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs`:请求超时 +- `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax`:`ListAsync` 上限 +- `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex`:是否自动建索引 +- `Projection:ReadModel:Providers:Elasticsearch:Username/Password`:可选基础认证 - 扩展 run 输出协议: - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 - 不改 Application 用例编排代码 diff --git a/src/workflow/Aevatar.Workflow.Projection/Stores/InMemoryWorkflowExecutionReadModelStore.cs b/src/workflow/Aevatar.Workflow.Projection/Stores/InMemoryWorkflowExecutionReadModelStore.cs deleted file mode 100644 index a73cd78a8..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Stores/InMemoryWorkflowExecutionReadModelStore.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Aevatar.Workflow.Projection.ReadModels; - -namespace Aevatar.Workflow.Projection.Stores; - -/// -/// In-memory store for chat run read models. -/// -public sealed class InMemoryWorkflowExecutionReadModelStore - : IProjectionReadModelStore -{ - private readonly object _gate = new(); - private readonly Dictionary _reportsByActorId = new(StringComparer.Ordinal); - - public Task UpsertAsync(WorkflowExecutionReport report, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - lock (_gate) - _reportsByActorId[report.RootActorId] = CloneReport(report); - return Task.CompletedTask; - } - - public Task MutateAsync(string actorId, Action mutate, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - lock (_gate) - { - if (!_reportsByActorId.TryGetValue(actorId, out var report)) - throw new WorkflowExecutionReadModelNotFoundException(actorId); - - mutate(report); - } - - return Task.CompletedTask; - } - - public Task GetAsync(string actorId, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - lock (_gate) - { - if (!_reportsByActorId.TryGetValue(actorId, out var report)) - return Task.FromResult(null); - - return Task.FromResult(CloneReport(report)); - } - } - - public Task> ListAsync(int take = 50, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - var boundedTake = Math.Clamp(take, 1, 200); - lock (_gate) - { - var items = _reportsByActorId.Values - .OrderByDescending(x => x.StartedAt) - .Take(boundedTake) - .Select(CloneReport) - .ToList(); - return Task.FromResult>(items); - } - } - - private static WorkflowExecutionReport CloneReport(WorkflowExecutionReport source) => new() - { - ReportVersion = source.ReportVersion, - ProjectionScope = source.ProjectionScope, - TopologySource = source.TopologySource, - CompletionStatus = source.CompletionStatus, - WorkflowName = source.WorkflowName, - RootActorId = source.RootActorId, - CommandId = source.CommandId, - StateVersion = source.StateVersion, - LastEventId = source.LastEventId, - StartedAt = source.StartedAt, - EndedAt = source.EndedAt, - DurationMs = source.DurationMs, - Success = source.Success, - Input = source.Input, - FinalOutput = source.FinalOutput, - FinalError = source.FinalError, - Topology = source.Topology.Select(x => new WorkflowExecutionTopologyEdge(x.Parent, x.Child)).ToList(), - Steps = source.Steps.Select(CloneStep).ToList(), - RoleReplies = source.RoleReplies.Select(CloneRoleReply).ToList(), - Timeline = source.Timeline.Select(CloneTimelineEvent).ToList(), - Summary = CloneSummary(source.Summary), - }; - - private static WorkflowExecutionStepTrace CloneStep(WorkflowExecutionStepTrace source) => new() - { - StepId = source.StepId, - StepType = source.StepType, - TargetRole = source.TargetRole, - RequestedAt = source.RequestedAt, - CompletedAt = source.CompletedAt, - Success = source.Success, - WorkerId = source.WorkerId, - OutputPreview = source.OutputPreview, - Error = source.Error, - RequestParameters = source.RequestParameters.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal), - CompletionMetadata = source.CompletionMetadata.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal), - }; - - private static WorkflowExecutionRoleReply CloneRoleReply(WorkflowExecutionRoleReply source) => new() - { - Timestamp = source.Timestamp, - RoleId = source.RoleId, - SessionId = source.SessionId, - Content = source.Content, - ContentLength = source.ContentLength, - }; - - private static WorkflowExecutionTimelineEvent CloneTimelineEvent(WorkflowExecutionTimelineEvent source) => new() - { - Timestamp = source.Timestamp, - Stage = source.Stage, - Message = source.Message, - AgentId = source.AgentId, - StepId = source.StepId, - StepType = source.StepType, - EventType = source.EventType, - Data = source.Data.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal), - }; - - private static WorkflowExecutionSummary CloneSummary(WorkflowExecutionSummary source) => new() - { - TotalSteps = source.TotalSteps, - RequestedSteps = source.RequestedSteps, - CompletedSteps = source.CompletedSteps, - RoleReplyCount = source.RoleReplyCount, - StepTypeCounts = source.StepTypeCounts.ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase), - }; -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Stores/WorkflowExecutionReadModelNotFoundException.cs b/src/workflow/Aevatar.Workflow.Projection/Stores/WorkflowExecutionReadModelNotFoundException.cs deleted file mode 100644 index 54203f9c0..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Stores/WorkflowExecutionReadModelNotFoundException.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Aevatar.Workflow.Projection.Stores; - -public sealed class WorkflowExecutionReadModelNotFoundException : KeyNotFoundException -{ - public string ActorId { get; } - - public WorkflowExecutionReadModelNotFoundException(string actorId) - : base($"Workflow actor read model not found: '{actorId}'.") - { - ActorId = actorId; - } -} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs new file mode 100644 index 000000000..25746841d --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public class ProjectionReadModelStoreSelectorTests +{ + [Fact] + public void Select_WhenSingleProviderRegistered_ShouldReturnSingleProvider() + { + var registrations = new[] + { + CreateRegistration("inmemory", supportsIndexing: false), + }; + + var selected = ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions(), + new ProjectionReadModelRequirements()); + + selected.ProviderName.Should().Be("inmemory"); + } + + [Fact] + public void Select_WhenMultipleProvidersAndNoRequestedProvider_ShouldThrow() + { + var registrations = new[] + { + CreateRegistration("inmemory", supportsIndexing: false), + CreateRegistration("elasticsearch", supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document]), + }; + + Action act = () => ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions(), + new ProjectionReadModelRequirements()); + + act.Should().Throw() + .WithMessage("*Multiple providers are registered*"); + } + + [Fact] + public void Select_WhenRequestedProviderMissing_ShouldThrow() + { + var registrations = new[] + { + CreateRegistration("inmemory", supportsIndexing: false), + }; + + Action act = () => ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = "elasticsearch", + }, + new ProjectionReadModelRequirements()); + + act.Should().Throw() + .WithMessage("*Requested provider*is not registered*"); + } + + [Fact] + public void Select_WhenCapabilitiesUnsupportedAndFailFastEnabled_ShouldThrow() + { + var registrations = new[] + { + CreateRegistration("inmemory", supportsIndexing: false), + }; + + Action act = () => ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = "inmemory", + FailOnUnsupportedCapabilities = true, + }, + new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + + act.Should().Throw(); + } + + [Fact] + public void Select_WhenCapabilitiesUnsupportedAndFailFastDisabled_ShouldReturnProvider() + { + var registrations = new[] + { + CreateRegistration("inmemory", supportsIndexing: false), + }; + + var selected = ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = "inmemory", + FailOnUnsupportedCapabilities = false, + }, + new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + + selected.ProviderName.Should().Be("inmemory"); + } + + private static IProjectionReadModelStoreRegistration CreateRegistration( + string providerName, + bool supportsIndexing, + IEnumerable? indexKinds = null) + { + var capabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing, + indexKinds); + + return new DelegateProjectionReadModelStoreRegistration( + providerName, + capabilities, + _ => new NoopStore()); + } + + private sealed class NoopStore : IProjectionReadModelStore + { + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) => Task.CompletedTask; + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) => Task.CompletedTask; + + public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(null); + + public Task> ListAsync(int take = 50, CancellationToken ct = default) => + Task.FromResult>([]); + } + + private sealed class TestReadModel; +} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index ae709a4c6..eb3d69f1c 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -1,4 +1,9 @@ using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.AI.Projection.Reducers; using Aevatar.Workflow.Extensions.AIProjection; using Aevatar.Workflow.Projection; @@ -15,10 +20,96 @@ namespace Aevatar.Workflow.Host.Api.Tests; public class WorkflowExecutionProjectionRegistrationTests { + [Fact] + public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefault() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(); + + using var provider = services.BuildServiceProvider(); + var store = provider.GetRequiredService>(); + + store.Should().BeOfType>(); + var metadata = store.Should().BeAssignableTo().Subject; + metadata.ProviderCapabilities.ProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); + } + + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfigured_ShouldResolveElasticsearchStore() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + RegisterElasticsearchProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch); + + using var provider = services.BuildServiceProvider(); + var store = provider.GetRequiredService>(); + + store.Should().BeOfType>(); + var metadata = store.Should().BeAssignableTo().Subject; + metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); + metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); + } + + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenProviderUnsupported_ShouldThrow() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + options.ReadModelProvider = "UnknownProvider"); + using var provider = services.BuildServiceProvider(); + + Action act = () => provider.GetRequiredService>(); + + act.Should().Throw() + .WithMessage("*Requested provider*is not registered*"); + } + + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenBindingRequiresUnsupportedCapabilities_ShouldFailFast() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + { + options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; + options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.FailOnUnsupportedCapabilities = true; + }); + using var provider = services.BuildServiceProvider(); + + Action act = () => provider.GetRequiredService>(); + + act.Should().Throw() + .Where(ex => ex.ReadModelType == typeof(WorkflowExecutionReport)); + } + + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenFailFastDisabled_ShouldAllowUnsupportedCapabilities() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + { + options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; + options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.FailOnUnsupportedCapabilities = false; + }); + using var provider = services.BuildServiceProvider(); + + Action act = () => provider.GetRequiredService>(); + + act.Should().NotThrow(); + } + [Fact] public async Task AddWorkflowExecutionProjectionReducer_ShouldSupportExternalReducer() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(); services.AddWorkflowExecutionProjectionReducer(); @@ -49,6 +140,7 @@ public async Task AddWorkflowExecutionProjectionReducer_ShouldSupportExternalRed public async Task AddWorkflowExecutionProjectionExtensionsFromAssembly_ShouldAutoRegisterReducer() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(); services.AddWorkflowExecutionProjectionExtensionsFromAssembly(typeof(CustomChatRequestReducer).Assembly); @@ -79,6 +171,7 @@ public async Task AddWorkflowExecutionProjectionExtensionsFromAssembly_ShouldAut public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastOptions() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(options => options.Enabled = true); services.AddWorkflowExecutionProjectionCQRS(options => options.Enabled = false); @@ -92,10 +185,28 @@ public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastOption store.Should().NotBeNull(); } + [Fact] + public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastProvider() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + RegisterElasticsearchProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory); + services.AddWorkflowExecutionProjectionCQRS(options => + options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch); + + using var provider = services.BuildServiceProvider(); + var store = provider.GetRequiredService>(); + + store.Should().BeOfType>(); + } + [Fact] public void AddWorkflowExecutionProjectionCQRS_ShouldExposeGenericProjectionAbstractions() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); @@ -114,6 +225,7 @@ public void AddWorkflowExecutionProjectionCQRS_ShouldExposeGenericProjectionAbst public void AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldRegisterDefaultAIReducers() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(); services.AddWorkflowAIProjectionExtensions(); @@ -144,6 +256,7 @@ public void AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldRegisterDe public async Task AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldProjectAIEventsWithoutWorkflowApplier() { var services = new ServiceCollection(); + RegisterInMemoryProvider(services); services.AddWorkflowExecutionProjectionCQRS(); services.AddWorkflowAIProjectionExtensions(); @@ -188,6 +301,28 @@ public async Task AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldProj Direction = EventDirection.Down, }; + private static void RegisterElasticsearchProvider(IServiceCollection services) + { + services.AddElasticsearchReadModelStoreRegistration( + optionsFactory: _ => new ElasticsearchProjectionReadModelStoreOptions + { + Endpoints = ["http://localhost:9200"], + IndexPrefix = "aevatar-test", + AutoCreateIndex = false, + }, + indexScope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + } + + private static void RegisterInMemoryProvider(IServiceCollection services) + { + services.AddInMemoryReadModelStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt); + } + public sealed class CustomChatRequestReducer : WorkflowExecutionEventReducerBase { protected override bool Reduce( diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 0f35fb61a..e9c6f5e97 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -3,6 +3,7 @@ using Aevatar.CQRS.Projection.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.Foundation.Runtime.Actors; @@ -19,7 +20,6 @@ using Aevatar.Workflow.Projection.Projectors; using Aevatar.Workflow.Projection.ReadModels; using Aevatar.Workflow.Projection.Reducers; -using Aevatar.Workflow.Projection.Stores; using FluentAssertions; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; @@ -597,7 +597,7 @@ private static WorkflowExecutionProjectionService CreateServiceForStartFailure( IProjectionOwnershipCoordinator ownershipCoordinator, IProjectionLifecycleService> lifecycle) { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); var clock = new SystemProjectionClock(); var runEventHub = new NoOpWorkflowRunEventHub(); var mapper = new WorkflowExecutionReadModelMapper(); @@ -650,6 +650,11 @@ [new AIToolResultProjectionApplier CreateStore() => new( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt); + private static EventEnvelope Wrap(IMessage evt, string publisherId = "root") => new() { Id = Guid.NewGuid().ToString("N"), @@ -704,7 +709,7 @@ public MutableProjectionClock(DateTimeOffset utcNow) private sealed class ObservableWorkflowExecutionReadModelStore : IProjectionReadModelStore { - private readonly InMemoryWorkflowExecutionReadModelStore _inner = new(); + private readonly InMemoryProjectionReadModelStore _inner = CreateStore(); private readonly object _gate = new(); private readonly List _waiters = []; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index 15b875aa2..f6858d54d 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -2,13 +2,13 @@ using Aevatar.AI.Projection.Appliers; using Aevatar.CQRS.Projection.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.ReadModels; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.Projectors; using Aevatar.Workflow.Projection.Reducers; -using Aevatar.Workflow.Projection.Stores; using Aevatar.Workflow.Core; using FluentAssertions; using Google.Protobuf; @@ -20,6 +20,10 @@ namespace Aevatar.Workflow.Host.Api.Tests; public class WorkflowExecutionReadModelProjectorTests { private static IEventDeduplicator CreateDeduplicator() => new TestEventDeduplicator(); + private static InMemoryProjectionReadModelStore CreateStore() => new( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt); private static IReadOnlyList> BuildReducers() => [ @@ -55,7 +59,7 @@ private static EventEnvelope Wrap( [Fact] public async Task Projector_ShouldBuildRunReadModel_EndToEnd() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); @@ -117,7 +121,7 @@ await coordinator.ProjectAsync(context, Wrap(new WorkflowCompletedEvent [Fact] public async Task Projector_ShouldIgnoreUnknownEvents() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); @@ -147,7 +151,7 @@ await coordinator.ProjectAsync(context, Wrap(new ChatRequestEvent [Fact] public async Task Projector_ShouldDeduplicateByEnvelopeId() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); @@ -183,7 +187,7 @@ public async Task Projector_ShouldDeduplicateByEnvelopeId() [Fact] public async Task Projector_NoOpReducer_ShouldNotAdvanceStateVersion() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); IProjectionEventReducer[] reducers = [ new TextMessageStartProjectionReducer([]), @@ -219,7 +223,7 @@ await coordinator.ProjectAsync(context, Wrap(new AIEvents.TextMessageStartEvent [Fact] public async Task Projector_ShouldUseEnvelopeTimestamp_WhenProvided() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); @@ -252,7 +256,7 @@ await coordinator.ProjectAsync(context, Wrap(new StartWorkflowEvent [Fact] public async Task Store_List_ShouldReturnNewestFirst() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); await store.UpsertAsync(new WorkflowExecutionReport { WorkflowName = "w", @@ -279,9 +283,9 @@ await store.UpsertAsync(new WorkflowExecutionReport [Fact] public async Task Store_MutateMissingRun_ShouldThrow() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); Func act = () => store.MutateAsync("missing", _ => { }); - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } private sealed class TestEventDeduplicator : IEventDeduplicator diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index e2d38efea..2ddfd7f7f 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -9,7 +9,6 @@ using Aevatar.Workflow.Extensions.Hosting; using Aevatar.Workflow.Infrastructure.Runs; using Aevatar.Workflow.Projection.ReadModels; -using Aevatar.Workflow.Projection.Stores; using FluentAssertions; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index 698d568d5..dc94ce5f6 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -1,9 +1,9 @@ using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.ReadModels; -using Aevatar.Workflow.Projection.Stores; using FluentAssertions; using System.Runtime.CompilerServices; @@ -79,7 +79,7 @@ public async Task ReadModelUpdater_ShouldRefreshMetadataAndMarkStopped() { var startedAt = new DateTimeOffset(2026, 2, 21, 12, 0, 0, TimeSpan.Zero); var stoppedAt = startedAt.AddMinutes(3); - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); await store.UpsertAsync(new WorkflowExecutionReport { RootActorId = "actor-2", @@ -123,7 +123,7 @@ await store.MutateAsync("actor-2", report => [Fact] public async Task QueryReader_ShouldMapSnapshotsAndSortTimeline() { - var store = new InMemoryWorkflowExecutionReadModelStore(); + var store = CreateStore(); await store.UpsertAsync(new WorkflowExecutionReport { RootActorId = "actor-3", @@ -328,6 +328,11 @@ public async Task LiveSinkForwarder_WhenPolicyDoesNotHandle_ShouldRethrowOrigina policy.Calls.Should().ContainSingle(); } + private static InMemoryProjectionReadModelStore CreateStore() => new( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt); + private static WorkflowExecutionRuntimeLease CreateLease(string actorId, string commandId) => new( new WorkflowExecutionProjectionContext { From 08c1ce2b23224ec955ea247626b4d433e6d4a509 Mon Sep 17 00:00:00 2001 From: Auric Date: Mon, 23 Feb 2026 22:55:33 +0800 Subject: [PATCH 03/46] Enhance Event Sourcing and Add Neo4j Provider Support - Updated the Event Sourcing documentation to clarify the architecture and usage guidelines, ensuring consistency with the latest implementation. - Introduced a new Neo4j provider for read models, enabling graph-based storage and retrieval capabilities. - Added abstractions for provider registration, selection, and capability validation, enhancing the flexibility of the read model architecture. - Implemented structured logging for write operations in the Neo4j provider, improving observability and debugging. - Refactored existing abstractions to accommodate the new provider model and updated related documentation for clarity. --- aevatar.slnx | 3 + docs/EVENT_SOURCING.md | 230 +++------ ...ng-elasticsearch-readmodel-requirements.md | 442 ++++++------------ ...ider-based-readmodel-full-refactor-plan.md | 267 ----------- .../IProjectionReadModelBindingResolver.cs | 8 + ...IProjectionReadModelCapabilityValidator.cs | 13 + .../IProjectionReadModelProviderRegistry.cs | 8 + .../IProjectionReadModelProviderSelector.cs | 10 + .../IProjectionReadModelStoreFactory.cs | 10 + .../ProjectionProviderSelectionException.cs | 37 ++ .../ProjectionReadModelBindingException.cs | 33 ++ .../Abstractions/ProjectionReadModelMode.cs | 8 + .../ProjectionReadModelRuntimeOptions.cs | 2 + .../README.md | 5 +- ....Projection.Providers.Elasticsearch.csproj | 1 + .../ServiceCollectionExtensions.cs | 4 +- .../README.md | 1 + .../ElasticsearchProjectionReadModelStore.cs | 44 +- ...tar.CQRS.Projection.Providers.Neo4j.csproj | 18 + .../Neo4jProjectionReadModelStoreOptions.cs | 20 + .../ServiceCollectionExtensions.cs | 42 ++ .../GlobalUsings.cs | 1 + .../README.md | 22 + .../Stores/Neo4jProjectionReadModelStore.cs | 293 ++++++++++++ .../Aevatar.CQRS.Projection.Runtime.csproj | 17 + .../ServiceCollectionExtensions.cs | 18 + .../GlobalUsings.cs | 1 + src/Aevatar.CQRS.Projection.Runtime/README.md | 19 + .../ProjectionReadModelBindingResolver.cs | 60 +++ ...tionReadModelCapabilityValidatorService.cs | 15 + .../ProjectionReadModelProviderRegistry.cs | 16 + .../ProjectionReadModelProviderSelector.cs | 114 +++++ .../ProjectionReadModelStoreFactory.cs | 63 +++ .../Abstractions/IStateMirrorProjection.cs | 8 + ...Aevatar.CQRS.Projection.StateMirror.csproj | 16 + .../StateMirrorProjectionOptions.cs | 14 + .../ServiceCollectionExtensions.cs | 25 + .../GlobalUsings.cs | 1 + .../README.md | 8 + .../Services/JsonStateMirrorProjection.cs | 67 +++ .../EventSourcing/EventSourcingBehavior.cs | 143 +++++- .../EventSourcing/EventSourcingSnapshot.cs | 6 + .../EventSourcing/IEventSourcingBehavior.cs | 16 +- .../IEventSourcingSnapshotStore.cs | 14 + .../GAgentBase.TState.cs | 41 +- .../Grains/RuntimeActorGrain.cs | 28 +- .../Actor/LocalActorRuntime.cs | 18 - .../Aevatar.Workflow.Infrastructure.csproj | 1 + ...owCapabilityServiceCollectionExtensions.cs | 13 + .../Aevatar.Workflow.Projection.csproj | 1 + .../WorkflowExecutionProjectionOptions.cs | 6 + .../ServiceCollectionExtensions.cs | 71 +-- .../Aevatar.CQRS.Projection.Core.Tests.csproj | 2 + .../ProjectionReadModelRuntimeTests.cs | 123 +++++ .../StateMirrorProjectionTests.cs | 80 ++++ .../Bdd/AgentLifecycleBddTests.cs | 109 +++-- .../EventSourcingTests.cs | 194 ++++++-- .../OrleansDistributedCoverageTests.cs | 4 +- ...sRuntimeActorStateStoreIntegrationTests.cs | 4 +- ...lowExecutionProjectionRegistrationTests.cs | 54 +++ ...WorkflowExecutionProjectionServiceTests.cs | 1 + tools/ci/architecture_guards.sh | 48 ++ 62 files changed, 2008 insertions(+), 953 deletions(-) delete mode 100644 docs/architecture/provider-based-readmodel-full-refactor-plan.md create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj create mode 100644 src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/README.md create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/README.md create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/EventSourcingSnapshot.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingSnapshotStore.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index b384b725a..e8a10a101 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -38,8 +38,11 @@ + + + diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index c2514f2f3..1ed5c725e 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -1,167 +1,83 @@ -# Event Sourcing 使用指南 - -本文说明如何在 Aevatar 中**开启并使用 Event Sourcing(事件溯源)**:状态不直接持久化快照,而是通过「追加状态变更事件 + 重放」来恢复与演进状态。 - -## 何时使用 - -- **需要完整变更历史**:可审计、可重放、可按时间点重建状态。 -- **需要乐观并发**:多实例写同一 Agent 时,通过版本号冲突检测避免覆盖。 -- **与 CQRS 配合**:写侧只追加事件,读侧通过投影或快照查询。 - -不启用时,有状态 Agent 使用 `IStateStore` 的 Load/Save 即可,无需 Event Sourcing。 - -## 架构要点 - -- **ES 以 Mixin 方式提供**:不要求继承额外基类,通过 DI 注入 `IEventSourcingBehavior`,在 Agent 内显式调用。 -- **存储**:`IEventStore` 负责事件的追加与按版本查询;运行时默认提供 `InMemoryEventStore`,生产可替换为持久化实现。 -- **状态恢复**:激活时从 `IEventStore` 重放事件,通过 `TransitionState` 得到当前状态;变更时先 `RaiseEvent`,再 `ConfirmEventsAsync` 持久化。 -- **口径说明**:`InMemoryEventStore` 仅用于开发/测试,不作为生产容量治理目标;生产风险评估应基于 Redis/数据库等持久化实现。 - -## 1. 注册服务 - -确保运行时已注册 `IEventStore`(`AddAevatarRuntime()` 已默认注册 `InMemoryEventStore`)。若使用自定义存储,先替换为你的实现: - -```csharp -// 使用默认内存事件存储(开发/测试) -services.AddAevatarRuntime(); // 已包含 TryAddSingleton - -// 或替换为持久化实现 -services.Replace(ServiceDescriptor.Singleton()); -``` - -为**每个有状态 Agent 类型**提供 `IEventSourcingBehavior`。因构造需要 `agentId`,多 Agent 实例时需按 Agent 创建行为实例,例如通过工厂或在使用处解析: - -```csharp -// 方式 A:单例(仅当该类型只有一个 Agent 实例时适用) -services.AddSingleton>(sp => -{ - var store = sp.GetRequiredService(); - return new EventSourcingBehavior(store, "my-agent-id"); -}); - -// 方式 B:多实例时在创建 Agent 处按 agentId 构造(推荐) -// 在 Actor/宿主创建 Agent 时:var behavior = new EventSourcingBehavior(eventStore, agent.Id); -// 再通过构造函数或属性注入到该 Agent 实例。 -``` - -即:每个 Agent 实例对应一个 `EventSourcingBehavior` 实例(同一 `agentId`),由创建 Agent 的代码负责构造并注入。 - -## 2. 在有状态 Agent 中使用 - -假设你有一个 `GAgentBase` 的 Agent,状态类型为 Protobuf 消息 `MyState`。 - -### 2.1 注入行为 - -在 Agent 中注入 `IEventSourcingBehavior`(通过构造函数或可写属性,由创建 Agent 的宿主/Actor 在实例化后赋值): - -```csharp -public class MyAgent : GAgentBase -{ - private readonly IEventSourcingBehavior? _es; - - public MyAgent(IEventSourcingBehavior? es) => _es = es; - // 或不注入时 _es 为 null,则退化为仅用 StateStore 的普通有状态 Agent -} -``` - -### 2.2 激活时从事件重放恢复状态 - -在 `OnActivateAsync` 中,若启用了 ES,优先从事件存储重放得到初始状态;否则仍从 `StateStore` 加载(与现有逻辑兼容): - -```csharp -protected override async Task OnActivateAsync(CancellationToken ct) -{ - if (_es != null) - { - var replayed = await _es.ReplayAsync(Id, ct); - if (replayed != null) - State = replayed; // 在 StateGuard 写范围内设置 - } - // 若未启用 ES,基类已从 StateStore 加载,此处可做其它初始化 -} -``` - -注意:若使用 ES 恢复状态,通常不再依赖 `StateStore.LoadAsync` 的 snapshot;可在同一 `ActivateAsync` 作用域内只做 Replay,或配合快照策略(见后文)减少重放长度。 - -### 2.3 在事件处理中记录并确认变更 - -在 `[EventHandler]` 中,不直接改 `State` 后依赖 `StateStore.Save`,而是通过 ES 记录变更并确认: +# Event Sourcing 基线文档(2026-02-23) + +## 1. 目标与范围 +- 目标:统一 Aevatar 有状态 Actor 的写侧事实源,强制 `Command -> Domain Event -> Apply -> State`。 +- 适用范围:`Aevatar.Foundation.Core`、`Aevatar.Foundation.Runtime`、`Aevatar.Foundation.Runtime.Implementations.Orleans`。 +- 非目标:本文件不定义 ReadModel Provider 细节;统一要求与重构计划见 `docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md`。 + +## 2. 当前强制语义 +1. `EventStore` 是唯一业务事实源。 +2. `StateStore` 只能用于快照优化,不是业务真相。 +3. 领域事件必须由开发者显式构建并持久化,不允许在线自动反推事件。 +4. 有状态 Actor 激活必须 Replay;停用必须 flush pending events。 +5. ES 行为构造走静态泛型路径,不走 Runtime 反射注入。 + +## 3. 当前代码事实(权威路径) +- ES 行为契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` +- ES 默认实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 有状态生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- Local Runtime 注入边界:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` +- Orleans Runtime 注入边界:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` +- 防回退门禁:`tools/ci/architecture_guards.sh` + +## 4. 生命周期语义(按当前实现) +### 4.1 Activate +- `GAgentBase.ActivateAsync` 先调用 `base.ActivateAsync` 恢复模块。 +- 然后调用 `EnsureEventSourcingConfigured()`: + - 若已设置 `EventSourcing`,直接使用。 + - 若未设置,则通过 `Services.GetService(typeof(IEventStore))` 解析 `IEventStore`,并静态构造 `EventSourcingBehavior(eventStore, actorId)`。 +- 最后执行 `ReplayAsync(actorId)`,以 Replay 结果恢复 `State`。 + +### 4.2 Deactivate +- `GAgentBase.DeactivateAsync` 顺序: + - `OnDeactivateAsync` + - `ConfirmEventsAsync` + - `PersistSnapshotAsync` +- 不再调用 `StateStore.SaveAsync` 写事实态。 + +### 4.3 Fail-Fast 条件 +- 未预设 `EventSourcing` 且容器中无 `IEventStore`:激活失败(`InvalidOperationException`)。 +- 持久化 `TState` 快照事件到事件流:提交失败(禁止快照冒充领域事件)。 + +## 5. 开发者实现规范 +1. 命令处理代码必须显式构建领域事件:`RaiseEvent(domainEvent)`。 +2. 必须调用 `ConfirmEventsAsync` 提交 pending events。 +3. 必须保证“可重放同态”:`Replay` 后状态与在线运行状态一致。 +4. 推荐通过重写 `TransitionState` 明确定义事件到状态转换。 + +示例(简化): ```csharp [EventHandler] -public async Task HandleCountRequest(CountRequest evt, IEventHandlerContext ctx, CancellationToken ct) +public async Task Handle(IncrementRequested evt) { - if (_es == null) { /* 回退:直接改 State + 依赖 Deactivate 时 StateStore.Save */ return; } + EventSourcing!.RaiseEvent(new IncrementApplied { Amount = evt.Amount }); + await EventSourcing.ConfirmEventsAsync(); - _es.RaiseEvent(new CountChanged { Delta = evt.Delta }); - await _es.ConfirmEventsAsync(ct); - - // 可选:立即反映到内存状态,便于本 Agent 后续逻辑或查询 - var replayed = await _es.ReplayAsync(Id, ct); + // 当前基线下,最直接的一致性方式是回放后更新内存状态 + var replayed = await EventSourcing.ReplayAsync(Id); if (replayed != null) State = replayed; } ``` -也可以在一次处理中多次 `RaiseEvent`,最后统一调用一次 `ConfirmEventsAsync`。 - -### 2.4 实现状态转换(重放时生效) - -`ReplayAsync` 会按顺序将已持久化的事件应用到状态上,转换逻辑由 `TransitionState` 定义。默认的 `EventSourcingBehavior.TransitionState` 返回 `current` 不变;要真正从事件恢复状态,需要提供转换逻辑。 - -**方式 A:派生 EventSourcingBehavior 并重写 TransitionState** - -```csharp -public class MyEventSourcingBehavior : EventSourcingBehavior -{ - public override MyState TransitionState(MyState current, IMessage evt) - { - switch (evt) - { - case CountChanged e: - return new MyState { Count = current.Count + e.Delta }; - default: - return current; - } - } -} -``` - -注册时使用 `MyEventSourcingBehavior` 替代 `EventSourcingBehavior`。 - -**方式 B:在 Agent 内使用自定义行为类型** - -若行为由你自定义类实现 `IEventSourcingBehavior`,在工厂或创建处返回该实现即可,重放时即使用你实现的 `TransitionState`。 - -事件在存储中以 `StateEvent` 形式保存,`EventData` 为 `Any`;重放时传入的是 `Any`(实现 `IMessage`),若在 `TransitionState` 中需要具体类型,可对 `evt` 做类型判断或使用 `Any.Unpack()` 解出具体消息类型再处理。 - -## 3. 停用时确认未持久化事件(可选) - -若希望在 Deactivate 时把尚未确认的 pending 事件写盘,可在 `OnDeactivateAsync` 中调用: - -```csharp -protected override async Task OnDeactivateAsync(CancellationToken ct) -{ - if (_es != null) - await _es.ConfirmEventsAsync(ct); -} -``` - -这样未在 handler 中调用 `ConfirmEventsAsync` 的变更也会在停用前被持久化。 - -## 4. 快照策略(可选) - -重放大量事件可能较慢,可配合快照减少重放长度:定期将当前状态写入 `IStateStore`,重放时先加载最近快照再只重放该版本之后的事件。当前 Core 提供快照策略类型(如 `ISnapshotStrategy`、`IntervalSnapshotStrategy`),尚未在 `EventSourcingBehavior` 内自动衔接;若需,可在你的 Agent 或自定义 Behavior 中在 `ConfirmEventsAsync` 后根据策略调用 `StateStore.SaveAsync` 做快照,并在 `ReplayAsync` 中先尝试从 `StateStore` 加载再只拉取该版本之后的事件重放。 - -## 5. 小结 - -| 步骤 | 说明 | -|------|------| -| 注册 `IEventStore` | 运行时默认已注册 `InMemoryEventStore`,可按需替换。 | -| 注册 / 创建 `IEventSourcingBehavior` | 按 Agent 类型/实例注册或通过工厂按 `agentId` 创建。 | -| 激活时重放 | 在 `OnActivateAsync` 中调用 `_es.ReplayAsync(Id, ct)` 并赋值 `State`。 | -| 处理中记录并确认 | 在 Handler 中 `_es.RaiseEvent(evt)`,再 `await _es.ConfirmEventsAsync(ct)`。 | -| 实现 `TransitionState` | 在自定义 Behavior 中重写,使重放时事件能正确应用到状态。 | -| 停用时确认(可选) | 在 `OnDeactivateAsync` 中调用 `ConfirmEventsAsync` 刷盘 pending 事件。 | - -不注入 `IEventSourcingBehavior` 时,Agent 行为与现有有状态 Agent 一致,仅使用 `IStateStore` 的 Load/Save,无需改动现有代码即可保持兼容。 +## 6. DI 与容器约定 +- `AddAevatarRuntime()` 默认注册 `IEventStore -> InMemoryEventStore`(开发/测试)。 +- 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 +- 如需自定义 ES 行为,可直接为 Agent 预设 `EventSourcing`,但必须保持相同语义契约。 + +## 7. 快照语义 +1. 快照仅用于减少回放开销。 +2. 快照写入失败不得影响已提交事件事实。 +3. 恢复顺序:先快照,再从快照版本之后回放事件增量。 + +## 8. 明确禁止项 +1. 把 `TState` 本体当事件写入 `EventStore`。 +2. 在核心路径恢复 `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` 旧模型。 +3. 在 `GAgentBase` 恢复 `StateStore.LoadAsync/SaveAsync` 事实通道。 +4. 在 Runtime 恢复反射注入 ES(`MakeGenericType` / `GetProperty("EventSourcing")` / `GetProperty("StateStore")`)。 + +## 9. 验证命令 +- `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` +- `dotnet test test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj --nologo` +- `bash tools/ci/architecture_guards.sh` diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index c25664b4b..7c5648dee 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -1,312 +1,134 @@ -# Generic Event Sourcing + Provider-Based ReadModel 需求文档(基线对齐版) - -## 1. 文档元信息 -- 状态:In Progress(Revised) -- 目标版本:v1(对齐当前仓库基线后可实施) -- 适用仓库:`aevatar` -- 首稿日期:2026-02-22 -- 本次修订:2026-02-23 -- 修订目的:将需求与当前代码/门禁/测试基线对齐,消除实现冲突 - -## 2. 当前仓库基线(截至 2026-02-23) -当前已具备能力: -- Event Sourcing 抽象:`IEventStore`、`IEventSourcingBehavior`、`EventSourcingBehavior`。 -- 状态快照抽象:`IStateStore`,默认 InMemory 实现。 -- 统一 Projection Pipeline:`ProjectionLifecycleService -> ProjectionSubscriptionRegistry -> ProjectionDispatcher -> ProjectionCoordinator`。 -- 统一读模型存储契约:`IProjectionReadModelStore`(`Upsert/Mutate/Get/List`)。 -- Provider 能力模型与校验:`ProjectionReadModelProviderCapabilities`、`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator`。 -- Provider 选择与注册抽象:`IProjectionReadModelStoreRegistration<,>`、`ProjectionReadModelStoreSelector`。 -- 通用 Provider 项目已落地:`Aevatar.CQRS.Projection.Providers.InMemory`、`Aevatar.CQRS.Projection.Providers.Elasticsearch`。 -- Workflow 读侧完整链路:`WorkflowExecutionProjectionService` + Activation/Release/Lease/QueryReader/SinkForwarder 组件化编排。 -- Workflow 读侧已切换 Provider 选择链路;Provider 注册在 Infrastructure 完成(InMemory + Elasticsearch)。 -- CQRS 与 AGUI 共用同一输入事件流(不同 projector 分支输出)。 -- 通用命令执行壳与 Capability Host 装配机制已存在。 -- 架构门禁与稳定性门禁已生效(`architecture_guards`、`projection_route_mapping_guard`、`test_stability_guards`)。 -- 分布式 3 节点一致性集成测试与 smoke 脚本已接入 CI。 - -当前未具备能力: -- Graph Provider(Neo4j-like)适配器落地。 -- “State -> Default ReadModel” 的通用镜像层。 -- 自动生成 Persisted State Event 的统一框架管道(当前为显式 `RaiseEvent/ConfirmEventsAsync`)。 -- `Neo4j.Driver` 仅在集中版本文件声明,业务项目尚未引用并落地实现。 - -### 2.1 基线证据(关键代码位置) -| 主题 | 现状结论 | 证据 | -|---|---|---| -| Projection 关闭语义 | Workflow 命令入口 fail-fast,返回 `ProjectionDisabled` | `src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs`;`test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs` | -| Query 关闭语义 | 关闭时应用层返回 `null/[]`,API 侧表现为 `404/200` | `src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs`;`src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs` | -| ReadModel Store 契约 | 仅 `Upsert/Mutate/Get/List` 四类操作 | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs` | -| Provider 选择与装配 | Workflow 通过 Provider 注册 + Selector 选择 ReadModel Store | `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs`;`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` | -| Persisted Event 模型 | `StateEvent` 无 `metadata` 字段 | `src/Aevatar.Foundation.Abstractions/agent_messages.proto` | -| ES 写入模式 | 仍是显式 `RaiseEvent/ConfirmEventsAsync` | `src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs`;`docs/EVENT_SOURCING.md` | -| 运行时注入 | Runtime 注入 `StateStore`,未统一注入 ES 行为 | `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` | -| Capability Host | 已有 capability 注册/映射防重复机制 | `src/Aevatar.Hosting/AevatarCapabilityHostExtensions.cs` | -| 分布式一致性基线 | 已有 3 节点集成测试 + smoke 脚本 | `test/Aevatar.Foundation.Runtime.Hosting.Tests/DistributedClusterConsistencyIntegrationTests.cs`;`tools/ci/distributed_3node_smoke.sh` | -| 架构约束自动化 | 路由精确匹配、禁止中间层 ID 映射事实态、lease/session 约束 | `tools/ci/architecture_guards.sh`;`tools/ci/projection_route_mapping_guard.sh` | - -## 3. 问题定义 -在当前基线上,仍有以下缺口: -- Provider 能力模型与选择链路已落地,但跨业务域统一治理(Registry/策略化选择/观测)仍不完整。 -- Document Provider 已可用,但生产级增强(异常分级、索引策略细化、观测字段收口)仍需补齐。 -- EventEnvelope 与 Persisted State Event 的边界在文档层仍需统一口径,避免误用。 -- 目标中包含的 `StateOnly`、Graph Index、自动 Persisted Event 与当前运行语义存在冲突,需要分期。 - -## 4. 目标与分期 - -### 4.1 总体目标 -1. 保持单一主链路:`Command -> Event -> Projection -> ReadModel`。 -2. 在不破坏现有 Workflow 语义的前提下,引入 Provider 能力抽象与启动期校验。 -3. v1 先交付 Document Index Provider(Elasticsearch-like)落地能力。 -4. Graph Index(Neo4j-like)进入 vNext,先沉淀抽象,不在 v1 强制交付完整能力。 -5. Event Sourcing 继续保持兼容,自动化增强采用显式分期与开关。 - -### 4.2 分期策略 -- P0(当前文档阶段):基线对齐、冲突消解、DoD 可执行化。 -- P1(v1):Provider 能力模型 + 启动期校验 + Document Index Provider 适配。 -- P2(vNext):Graph Index Provider 与通用 StateMirror/StateOnly 能力扩展。 - -### 4.3 v1 落地边界(结合现有代码) -- v1 以 Workflow 读侧为唯一落地点:`WorkflowExecutionReport` 先完成 Provider 化。 -- 不在 v1 引入新的“全局第二投影框架”,而是复用现有 `AddWorkflowExecutionProjectionCQRS` 与 Provider 注册/选择链路。 -- v1 不改写命令执行主链路,不变更 `WorkflowRunContextFactory` 的 Projection fail-fast 语义。 -- v1 不改 Query API 合同,只保证替换存储后语义一致。 - -## 5. 非目标(v1) -- 不引入第二套 CQRS/Projection 主链路。 -- 不破坏现有 Workflow 命令入口“投影关闭即 fail-fast”行为。 -- 不在 v1 完成 Graph 查询 DSL、路径优化执行器。 -- 不在 v1 引入跨存储分布式事务。 -- 不在 v1 改造所有 Event Sourcing 调用为全自动模式。 - -## 6. 架构与治理约束(强制) -- 严格分层:`Domain / Application / Infrastructure / Host`。 -- Host 仅做协议适配与能力装配,不承载业务编排。 -- CQRS 与 AGUI 必须共用统一 Projection Pipeline,禁止双轨。 -- 中间层禁止进程内 actor/run/session 事实态映射。 -- 投影生命周期必须基于 lease/session 显式句柄,不允许 `actorId -> context` 反查。 -- Event type 路由必须基于 `TypeUrl` 派生 + 精确键匹配(`TryGetValue`),禁止字符串模糊匹配。 -- 能力不匹配在启动期 fail-fast(默认策略),不得运行期隐式降级。 -- 所有变更必须通过既有门禁与测试。 - -## 7. 关键决策(本版定稿) - -### D1 `StateOnly` 语义(Workflow 现状) -- 在当前 Workflow 能力中,`ProjectionDisabled` 会阻断命令执行(现有行为保持)。 -- 因此 v1 不将 Workflow 场景纳入 `StateOnly` 支持范围;`StateOnly` 进入通用内核 vNext 议题。 - -### D2 Query API 合同兼容 -- v1 不改现有查询端点对外合同(现有 `null/[]/404/200` 语义维持)。 -- 若后续引入统一“能力不可用”错误模型,需单独版本化并提供迁移说明。 - -### D3 Event Sourcing 保持兼容 -- v1 继续支持显式 `RaiseEvent/ConfirmEventsAsync` 路径。 -- 自动 Persisted Event 仅作为扩展能力进入 P2,不作为 v1 合并前置条件。 - -### D4 Persisted Event 权威模型 -- `EventEnvelope` 仅用于运行时传播/投影输入。 -- EventStore 权威回放源是 `StateEvent`(Persisted State Event)。 - -### D5 Graph 能力分期 -- v1:仅保留 Graph 抽象扩展位,不要求完整 Neo4j 读写/查询能力。 -- vNext:Graph Provider 以单独 RFC + 里程碑方式落地。 - -### D6 v1 实施落点 -- v1 的 Provider 化仅要求在 Workflow Projection 模块闭环,不强制外溢到所有业务域。 -- 先确保“现有测试与门禁全绿 + 行为不回归”,再考虑通用化抽象下沉。 - -## 8. 范围(Scope) - -### 8.1 In Scope(v1) -- 定义 ReadModel Provider 能力模型与协商机制。 -- 启动期执行 “ReadModel 要求 vs Provider 能力” 校验。 -- 保持 `IProjectionReadModelStore` 兼容,不破坏现有 Workflow 读侧。 -- 提供 Document Index Provider(Elasticsearch-like)适配器。 -- Workflow `WorkflowExecutionReport` 可切换到 Document Provider 承接。 -- 通过 `IProjectionReadModelStoreRegistration` 注册 Provider,不新增平行投影链路。 -- 配置模型与 DI 扩展补齐(保留与现有 `WorkflowExecutionProjection:*` 兼容)。 -- 补齐单元/集成/门禁验证。 - -### 8.2 Out of Scope(v1) -- Graph Provider 的完整查询与关系建模能力。 -- 通用 `StateMirrorProjection` 自动覆盖所有 `GAgentBase` 类型。 -- 全量业务模块一次性迁移到新 Provider。 -- 生产 ILM/冷热分层自动化。 -- 新建独立“全局 ReadModel Infrastructure 大一统项目”并强制迁移全仓库。 - -## 9. 功能需求(FR) - -### FR-1 Event Sourcing 抽象保持兼容 -- 保持 `IEventStore` 追加、版本并发校验、按版本回放语义。 -- 保持 `IStateStore` 快照通道语义不变。 - -### FR-2 Persisted State Event 与 Envelope 边界 -- Persisted 回放源仅为 `StateEvent`。 -- `EventEnvelope` 中运行时路由/传播字段不得进入回放权威语义。 - -### FR-3 StateEvent 结构约束(v1) -- v1 继续使用现有 `StateEvent` 字段模型: - `event_id/timestamp/version/event_type/event_data/agent_id`。 -- 若需 `metadata` 字段,作为 vNext 的 proto 升级任务,不阻塞 v1。 - -### FR-4 ReadModel Store 兼容约束 -- `IProjectionReadModelStore` 契约保持兼容: - - `UpsertAsync` - - `MutateAsync` - - `GetAsync` - - `ListAsync` -- 既有 Workflow 读侧代码无需修改业务语义即可接入新 Provider。 -- v1 不在该接口上新增 Graph 专有方法,避免破坏现有实现与测试基线。 - -### FR-5 Provider 能力声明模型 -- 新增能力抽象(至少包含): - - `SupportsIndexing` - - `IndexKinds`(支持集合,至少可表达 `Document`) - - `SupportsAliases` - - `SupportsSchemaValidation` -- 能力模型可由 Store 实现直接声明,或由独立能力描述器声明。 -- 无论采用哪种声明方式,都不得破坏现有 Store 接口二进制兼容性。 - -### FR-6 启动期能力校验 -- ReadModel 注册/装配阶段执行能力匹配。 -- 默认策略:不匹配 fail-fast。 -- 必须输出结构化错误,包含 readModel/provider/requiredCapabilities。 -- 能力校验应接入现有 Workflow Projection DI 装配流程,而不是额外旁路启动器。 - -### FR-7 Workflow Provider 可替换承接 -- `WorkflowExecutionReport` 的存储后端支持通过 Provider 注册在 DI 中替换。 -- 切换 Provider 后,Query 结果语义与字段口径保持一致。 - -### FR-8 Document Index Provider(v1 必做) -- 实现 Elasticsearch-like Provider: - - 支持 `Upsert/Mutate/Get/List` 等价语义。 - - 支持索引命名环境隔离(如 prefix)。 - - 支持可配置索引初始化策略(create if missing)。 - -### FR-9 `StateOnly` 约束说明(v1) -- Workflow 能力 v1 保持现有行为:Projection 关闭时返回 `ProjectionDisabled`。 -- `StateOnly/DefaultReadModel/CustomReadModel` 通用模式进入 vNext RFC,不在 v1 作为可验收项。 - -### FR-10 Event Sourcing 自动化分期 -- v1 不强制实现“统一管道自动生成 Persisted Event”。 -- 如实现实验能力,必须开关控制且默认关闭,不改变现有显式路径行为。 - -### FR-11 可观测性 -- Provider 写入路径至少记录: - `provider/readModelType/key(state id)/elapsedMs/result/errorType`。 -- 启动期能力校验失败日志必须可定位到具体 ReadModel 与缺失能力。 - -### FR-12 分布式一致化验证 -- 保持并复用现有 3 节点一致性验证链路与脚本接入。 -- 新增 Provider 后,不得破坏现有 distributed smoke 稳定性。 - -### FR-13 复用现有通用壳能力 -- Command 侧复用现有 `ICommandExecutionService<...>` 抽象,不新增平行命令执行框架。 -- Host 侧复用既有 capability 注册机制,不新增第二套 capability 映射容器。 - -## 10. 非功能需求(NFR) - -### NFR-1 一致性 -- EventStore 单流版本必须单调递增。 -- ReadModel 允许最终一致,但需在测试中可稳定判定收敛/失败。 - -### NFR-2 性能 -- `ListAsync` 必须强制硬上限(Provider 侧可配置上限)。 -- Provider 写入路径延迟应可观测(至少日志维度可统计)。 - -### NFR-3 可运维性 -- Provider 配置支持 `appsettings` + 环境变量覆盖。 -- 能力不匹配在启动期直接失败,不允许运行中静默降级。 - -### NFR-4 安全 -- 日志不得输出明文凭据。 -- 凭据通过配置系统注入,支持环境变量替换。 - -### NFR-5 门禁兼容性 -- 新增实现不得触发现有 `architecture_guards`、`projection_route_mapping_guard`、`test_stability_guards` 违规。 -- 新增测试不得引入未授权轮询等待;必要例外必须进入 allowlist 并给出理由。 - -## 11. 配置需求 - -### 11.1 当前有效配置(已存在) -- `ActorRuntime:Provider`(`InMemory/MassTransit/Orleans`) -- `WorkflowExecutionProjection:Enabled` -- `WorkflowExecutionProjection:EnableActorQueryEndpoints` -- `WorkflowExecutionProjection:EnableRunReportArtifacts` -- `WorkflowExecutionProjection:RunProjectionCompletionWaitTimeoutMs` -- `WorkflowExecutionProjection:RunProjectionFinalizeGraceTimeoutMs` - -### 11.2 v1 新增配置(建议) -- `Projection:ReadModel:Provider`(`InMemory/Elasticsearch`) -- `Projection:ReadModel:FailOnUnsupportedCapabilities`(默认 `true`) -- `Projection:ReadModel:Bindings:*`(可选,ReadModel -> Provider 显式绑定) -- `WorkflowExecutionProjection:ReadModelProvider` / `FailOnUnsupportedCapabilities` / `ReadModelBindings` 保留为模块内覆盖位(可选) - -### 11.3 Document Provider 示例配置 -- `Projection:ReadModel:Providers:Elasticsearch:Endpoints` -- `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix` -- `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs` -- `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax` -- `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex` -- `Projection:ReadModel:Providers:Elasticsearch:Username` -- `Projection:ReadModel:Providers:Elasticsearch:Password` - -### 11.4 预留(vNext) -- `Projection:ReadModel:Providers:Neo4j:*` -- `Projection:ReadModel:Mode`(`StateOnly/DefaultReadModel/CustomReadModel`) - -## 12. 验收标准(DoD) - -### 12.1 单元测试 -- Provider 能力声明与匹配(成功/失败/冲突路径)。 -- 启动期 fail-fast 错误语义。 -- Document Provider 的 `Upsert/Mutate/Get/List` 契约一致性。 -- Workflow 切换 Provider 后 Query 结果语义保持一致。 -- `ProjectionDisabled` 行为保持兼容(Workflow v1)。 -- 覆盖 Provider 注册与选择链路:`IProjectionReadModelStoreRegistration<,>` + `ProjectionReadModelStoreSelector`。 - -### 12.2 集成测试 -- Docker Elasticsearch 下验证 Workflow ReadModel 端到端写读。 -- Provider 切换前后,关键 Query API 返回语义一致。 - -### 12.3 分布式一致化测试 -- 保持并通过现有 3 节点一致性脚本链路。 -- 测试稳定性门禁通过,不新增未授权轮询等待。 - -### 12.4 合规门槛(必须全绿) -- `dotnet build aevatar.slnx --nologo` -- `dotnet test aevatar.slnx --nologo` +# Generic Event Sourcing + Provider ReadModel 需求与重构计划(必要文档) + +## 1. 文档定位 +- 状态:Active +- 日期:2026-02-23 +- 目的:作为 Event Sourcing 与 Provider-Based ReadModel 的唯一执行文档(需求 + 计划一体化)。 +- 范围:Foundation(ES)+ CQRS Projection(Provider Runtime)+ Workflow(接入层)。 +- 兼容策略:以清晰正确为第一目标,不保留历史兼容壳。 + +## 2. 架构硬约束 +1. 有状态 Actor 必须 Event Sourcing,`EventStore` 是事实源。 +2. `Command -> Domain Event -> Apply -> State`,开发者显式构建 event。 +3. ReadModel Provider 必须通用化,不绑定 Workflow 业务域。 +4. CQRS 与 AGUI 必须共用同一 Projection Pipeline,不得双轨。 +5. 中间层不得维护 `actor/run/session` 事实态进程内映射。 +6. Runtime 不得通过反射注入 ES(`MakeGenericType` / `GetProperty().SetValue`)。 + +## 3. 当前代码基线(已验证) +### 3.1 Event Sourcing +- 契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` +- 实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- 运行时注入边界: + - `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` + - `src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` + +当前语义: +1. `ActivateAsync` 强制 Replay 恢复状态。 +2. `DeactivateAsync` 强制 `ConfirmEventsAsync + PersistSnapshotAsync`。 +3. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `EventSourcingBehavior`。 +4. 缺失 `IEventStore` 时 fail-fast。 +5. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 + +### 3.2 Provider Runtime +- 抽象:`src/Aevatar.CQRS.Projection.Abstractions` +- 运行时:`src/Aevatar.CQRS.Projection.Runtime` +- Provider: + - InMemory:`src/Aevatar.CQRS.Projection.Providers.InMemory` + - Elasticsearch:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch` + - Neo4j:`src/Aevatar.CQRS.Projection.Providers.Neo4j` +- StateMirror:`src/Aevatar.CQRS.Projection.StateMirror` + +当前语义: +1. Provider 通过 `IProjectionReadModelStoreRegistration` 注册。 +2. Store 由 `ProviderRegistry + ProviderSelector + BindingResolver + StoreFactory` 统一创建。 +3. 多 Provider 并存时必须显式指定 provider;否则选择失败。 +4. 能力不匹配默认 fail-fast(`FailOnUnsupportedCapabilities=true`)。 + +### 3.3 Workflow 接入 +- 组合入口:`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` +- 投影 DI:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` + +当前语义: +1. Workflow 已接入 InMemory + Elasticsearch + Neo4j 三类 Provider 注册。 +2. `Projection:ReadModel:*` 全局选项会映射到 Workflow 投影选项。 +3. `ReadModelMode=StateOnly` 在 Workflow 组合阶段被拒绝(明确 fail-fast)。 + +## 4. 需求清单(必须满足) + +### R-ES-01 强制事件优先 +- 所有 `GAgentBase` 子类必须基于领域事件恢复状态,不得以快照替代事实。 + +### R-ES-02 显式事件构建 +- 命令处理逻辑必须显式 `RaiseEvent`,不得依赖自动事件推导。 + +### R-ES-03 可重放同态 +- 在线状态变更必须可通过 Replay 重建到同一结果。 + +### R-ES-04 静态泛型装配 +- ES 行为构造必须在泛型上下文完成,不得回退到 Runtime 反射注入。 + +### R-RM-01 Provider 解耦业务 +- Provider 项目不得引用 Workflow/AI 业务读模型类型。 + +### R-RM-02 能力协商 +- Provider 必须声明能力(索引类型、alias、schema 校验),ReadModel 需求必须在启动期校验。 + +### R-RM-03 路由确定性 +- 多 Provider 并存时必须可预测选择,不允许隐式随机或“最后注册覆盖”语义。 + +### R-RM-04 统一观测 +- Provider 写路径必须记录结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 + +### R-WF-01 Workflow 仅消费抽象 +- Workflow 层只依赖 Provider Runtime 抽象,不实现后端存储细节。 + +### R-WF-02 单链路投影 +- Workflow CQRS 与 AGUI 必须从同一订阅与分发链路进入,不得维护平行投影系统。 + +### R-GOV-01 门禁强制 +- 必须通过: + - `bash tools/ci/architecture_guards.sh` + - `bash tools/ci/projection_route_mapping_guard.sh` + - `bash tools/ci/test_stability_guards.sh` + +## 5. 重构计划(按优先级) + +### P1(已完成)ES 强制化主链路 +1. 移除自动反推事件接口与实现。 +2. `GAgentBase` 生命周期切换到 Replay + Confirm。 +3. Runtime 去除 ES 反射注入路径。 + +### P2(已完成)Provider Runtime 主干 +1. 建立 Provider 注册/选择/校验/创建主链路。 +2. 落地 InMemory/Elasticsearch/Neo4j 三类 Provider。 +3. Workflow 接入统一 Provider 选择逻辑。 + +### P3(进行中)一致性与可维护性收口 +1. 清理文档与代码中的历史双轨口径。 +2. 补齐跨模块契约测试:`Command -> Events -> Replay -> State`。 +3. 统一配置示例与错误合同说明(启动失败与能力不匹配)。 + +### P4(待执行)性能与生产化增强 +1. 为持久化 `IEventStore` 提供生产落地方案与压测基线。 +2. 补齐 Elasticsearch/Neo4j 端到端集成脚本与回归套件。 +3. 细化快照策略与回放窗口控制。 + +## 6. 验收标准(DoD) +1. 有状态 Actor 恢复路径全部来自 Replay,不存在 `StateStore.Load/Save` 事实回路。 +2. Runtime/Core 不存在 ES 反射注入路径。 +3. Provider 由统一 Runtime 选择并可校验能力。 +4. Workflow 仅作为 Provider Runtime 消费方,不绑定 ES/Neo4j 实现细节。 +5. 架构门禁、核心测试全绿。 + +## 7. 验证命令 +- `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` +- `dotnet test test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj --nologo` +- `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` - `bash tools/ci/architecture_guards.sh` -- `bash tools/ci/projection_route_mapping_guard.sh` -- `bash tools/ci/test_stability_guards.sh` -- `bash tools/ci/solution_split_test_guards.sh` - -## 13. 与原稿差异(本次修订) -| 维度 | 原稿 | 本版 | -|---|---|---| -| `StateOnly` | v1 必做 | 与当前 Workflow 冲突,改为 vNext | -| Graph Provider | v1 必做 | 改为 vNext(v1 仅保留扩展位) | -| Persisted Event 自动化 | v1 强要求 | 改为分期,v1 保持兼容 | -| 配置模型 | 以 `Projection:ReadModel:*` 为主 | 先兼容现有 `WorkflowExecutionProjection:*`,渐进演进 | -| DoD | 泛化描述 | 对齐现有 CI 门禁与可执行命令 | - -## 14. 风险与缓解 -- 风险:Provider 能力模型设计不当导致后续扩展困难。 - 缓解:v1 先覆盖 Document 必需能力,Graph 通过独立 RFC 引入。 -- 风险:切换后端导致 Query 语义漂移。 - 缓解:契约测试 + 端到端对照测试。 -- 风险:过早推进自动 Persisted Event 改动写侧行为。 - 缓解:v1 保持显式路径,自动化能力仅实验开关。 - -## 15. 待确认项(Open Questions) -- `ReadModelBindings` 的优先级规则(显式绑定 vs 默认路由)最终口径。 -- Graph RFC 的启动条件(抽象稳定性、测试基线、运维要求)。 -- 多业务域接入时,Provider 默认路由策略是否需要支持“按 ReadModel 类型自动选择”。 -## 16. 需求-实现映射(v1) -| 需求主题 | 优先改动位置 | 复用现有扩展点 | 说明 | -|---|---|---|---| -| Provider 能力声明与校验 | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions` + `src/workflow/Aevatar.Workflow.Projection/DependencyInjection` | `AddWorkflowExecutionProjectionCQRS` | 通过 Selector + CapabilityValidator 在装配期做匹配与 fail-fast | -| Document Provider Store | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` | `IProjectionReadModelStoreRegistration<,>` | 通用 ES Provider,不绑定 Workflow 业务域 | -| Query 语义回归保障 | `src/workflow/Aevatar.Workflow.Projection/Orchestration` | `IWorkflowExecutionProjectionPort` | 不改 `null/[]/404/200` 现有合同 | -| 配置绑定扩展 | `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection` | `WorkflowExecutionProjection` + `Projection:ReadModel` 配置节 | 新增统一 Provider 配置并保持模块级覆盖位 | -| 测试与门禁收口 | `test/Aevatar.Workflow.Host.Api.Tests`、`test/Aevatar.Workflow.Application.Tests`、`tools/ci` | 现有 CI 脚本链路 | 先增量覆盖,再跑全量门禁 | +## 8. 变更原则 +1. 删除优先于兼容。 +2. 文档必须与当前代码语义一致,不保留“未来可能”但无代码支撑的条目。 +3. 任何新扩展(Provider/ES)都必须接入现有主链路,不得开第二系统。 diff --git a/docs/architecture/provider-based-readmodel-full-refactor-plan.md b/docs/architecture/provider-based-readmodel-full-refactor-plan.md deleted file mode 100644 index 347b42ce2..000000000 --- a/docs/architecture/provider-based-readmodel-full-refactor-plan.md +++ /dev/null @@ -1,267 +0,0 @@ -# Provider-Based ReadModel 全量重构计划(彻底版) - -## 1. 文档元信息 -- 状态:In Progress -- 目标:覆盖 `docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md` 的全部要求(含 v1 + vNext) -- 适用仓库:`aevatar` -- 编写日期:2026-02-23 -- 最近更新:2026-02-23 -- 备注:本计划按“可破坏式重构”制定,不以兼容历史实现为约束 - -## 1.1 当前进展快照(2026-02-23) -- 已完成:W0(Provider 纠偏清理,Workflow 不再持有 Provider Store 实现)。 -- 部分完成:W1(能力模型/选择器/校验器已落地,统一 Registry 与结构化观测仍待补齐)。 -- 部分完成:W2(通用 Elasticsearch Provider 项目与 DI 注册已落地,异常分级与 schema/mapping 策略仍待增强)。 -- 部分完成:W6(Workflow 已接入通用 Provider 注册与选择链路,其它业务域未迁移)。 -- 未开始:W3/W4/W5。 - -## 2. 执行原则(硬约束) -- 严格分层:`Domain / Application / Infrastructure / Host`。 -- Provider 必须在通用 CQRS 基建层,不得绑定具体业务域(例如 Workflow)。 -- CQRS 与 AGUI 继续走单一 Projection Pipeline,禁止平行链路。 -- 中间层禁止进程内 ID->事实态映射(遵守现有 guard)。 -- 能力不匹配默认启动期 fail-fast。 -- 变更必须满足现有 CI 门禁与新增测试门槛。 - -## 3. 当前关键问题(必须先修) -1. `Elasticsearch` 适配器被放在 `Aevatar.Workflow.Projection`,违反“通用能力下沉”原则。(已修复) -2. Provider 能力模型虽已引入,但尚未形成“跨业务域可复用”的统一 Registry/路由/校验治理框架。(进行中) -3. Graph Provider 尚未落地,StateMirror/StateOnly 通用能力未完成。 -4. Event Sourcing 自动 Persisted Event 仍未进入统一管道。 - -## 4. 目标架构(完成态) - -### 4.1 项目结构(目标) -- `src/Aevatar.CQRS.Projection.Abstractions` - - 保留并扩展通用契约:`IProjectionReadModelStore<,>`、Provider 能力模型、ReadModel 需求模型。 -- `src/Aevatar.CQRS.Projection.Providers` - - 新建通用 Provider 运行时:注册、选择、能力校验、错误模型、可观测性。 -- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` - - Document Index Provider,通用实现,不含 Workflow 业务对象。 -- `src/Aevatar.CQRS.Projection.Providers.Neo4j` - - Graph Index Provider,通用实现,不含 Workflow 业务对象。 -- `src/Aevatar.CQRS.Projection.StateMirror` - - 通用 `State -> DefaultReadModel` 镜像能力(可选启用)。 -- `src/workflow/Aevatar.Workflow.Projection` - - 仅保留 Workflow 读模型、reducer/projector、port,不含后端 SDK/Provider 实现。 - -### 4.2 运行路径(目标) -1. 业务模块声明 ReadModel 与需求(IndexKind/Schema/Alias)。 -2. Host 装配通用 Provider Runtime。 -3. Provider Runtime 在启动期执行: - - ReadModel 绑定解析 - - Provider 选择 - - 能力校验 - - 失败即 fail-fast(默认) -4. Projection 写入通过统一 Store 契约执行,业务层无后端耦合。 - -## 5. 全量重构范围(对应需求文档) - -### 5.1 v1 范围(必须完成) -- Provider 能力模型、启动期校验、Document Provider 落地。 -- Workflow 切换 Provider 且 Query 语义不变。 -- 可观测性字段落地(provider/readModelType/key/elapsedMs/result/errorType)。 -- 配置模型落地与环境变量覆盖。 - -### 5.2 vNext 范围(本轮也纳入计划并执行) -- Graph Provider(Neo4j-like)完整落地。 -- 通用 `StateOnly / DefaultReadModel / CustomReadModel` 模式。 -- 通用 StateMirror 能力。 -- Event Sourcing 自动 Persisted Event 管道(开关化 + 默认策略定义)。 - -## 6. 详细实施计划(Workstreams) - -### W0 纠偏清理(先决) -目标:消除“Provider 绑定业务域”问题。 -状态:Completed(2026-02-23) - -任务: -1. 从 `Aevatar.Workflow.Projection` 移除 `Elasticsearch` 实现与配置细节。 -2. 在 Workflow 层保留 `IProjectionReadModelStore` 注入点,不感知具体后端。 -3. 删除/迁移 Workflow 内 Provider 专属类到通用 Provider 项目。 - -交付: -- Workflow 项目不再引用 Elasticsearch 相关 SDK/实现。 -- 相关 Workflow 测试已切换为通用 Provider Store 类型;Provider 专项测试集待补齐。 - -### W1 通用 Provider Runtime 基建 -目标:建立跨业务可复用的 Provider 选择与能力校验内核。 -状态:In Progress - -任务: -1. 设计并实现: - - `IProjectionReadModelProviderRegistry` - - `IProjectionReadModelProviderSelector` - - `IProjectionReadModelCapabilityValidator`(现有静态工具升级为可注入策略) - - `ProjectionReadModelBindingResolver` -2. 引入标准错误模型: - - `readModel` - - `provider` - - `requiredCapabilities` - - `actualCapabilities` - - `violations` -3. 统一日志接口与事件 ID,支持结构化查询。 - -交付: -- 任意业务模块可通过统一 API 注册 ReadModel 需求并由运行时自动选 Provider。 - -### W2 Document Provider(Elasticsearch) -目标:通用 Document Index Provider 完成生产可用版本。 -状态:In Progress - -任务: -1. 新建 `Aevatar.CQRS.Projection.Providers.Elasticsearch`。 -2. 提供通用 Store 适配机制: - - 支持 `Upsert/Mutate/Get/List` - - `ListAsync` 强上限 - - 索引前缀隔离 - - 自动建索引策略 -3. 引入 mapping/settings/alias 处理策略(与能力模型一致)。 -4. 完善异常分类(连接失败、索引不存在、版本冲突、认证失败)。 - -交付: -- 不依赖 Workflow 类型的通用 ES Provider。 - -### W3 Graph Provider(Neo4j) -目标:完成 Graph Index Provider 能力闭环。 -状态:Not Started - -任务: -1. 新建 `Aevatar.CQRS.Projection.Providers.Neo4j`。 -2. 支持节点/关系写入与基础查询。 -3. 支持唯一约束与索引初始化策略。 -4. 将 Graph 能力纳入同一 Provider runtime 校验。 - -交付: -- `IndexKind.Graph` 可被真实 Provider 承接。 - -### W4 StateMirror / ReadModel 可选模式 -目标:完成 `StateOnly / DefaultReadModel / CustomReadModel` 三模式。 -状态:Not Started - -任务: -1. 新建 `Aevatar.CQRS.Projection.StateMirror`: - - 默认字段映射 - - 可配置忽略/重命名 - - 可插拔 projector 覆盖 -2. `StateOnly` 模式定义: - - 不创建 ReadModel - - 查询端点返回统一能力不可用错误模型 -3. `CustomReadModel` 与默认镜像并存规则、优先级规则落地。 - -交付: -- 框架层提供无业务耦合的默认读模型能力。 - -### W5 Event Sourcing 自动 Persisted Event 管道 -目标:把“手动 Raise/Confirm”为主的模式升级为可配置自动化。 -状态:Not Started - -任务: -1. 设计统一写侧提交管道: - - 变更检测 - - 事件生成(Snapshot/Delta) - - 版本推进与幂等控制 -2. 提供开关: - - 全局开关 - - 模块级覆盖 -3. 默认策略: - - 默认保持兼容行为或由本次重构统一切换(按最终决策) - -交付: -- 业务方无需手动拼装 persisted event 基础流程。 - -### W6 Workflow 与其他模块接入 -目标:业务域从“自带 Provider”转为“消费通用 Provider Runtime”。 -状态:In Progress - -任务: -1. Workflow 完整迁移到通用 runtime。 -2. 逐步接入 AI/Foundation/其他读侧模块(如存在)。 -3. 清理重复抽象、空转发层、历史兼容分支。 - -交付: -- 业务域仅保留领域语义与投影逻辑,不含后端实现细节。 - -## 7. 配置模型重构计划 - -### 7.1 目标配置(通用) -- `Projection:ReadModel:Provider` -- `Projection:ReadModel:FailOnUnsupportedCapabilities` -- `Projection:ReadModel:Bindings` -- `Projection:ReadModel:Providers:Elasticsearch:*` -- `Projection:ReadModel:Providers:Neo4j:*` -- `Projection:ReadModel:Mode`(`StateOnly/DefaultReadModel/CustomReadModel`) - -### 7.2 迁移策略(本次不保兼容) -- 删除业务域私有 Provider 配置键(如 `WorkflowExecutionProjection:Providers:*`)。 -- 全量切换到统一 `Projection:ReadModel:*`。 - -## 8. 测试与门禁计划 - -### 8.1 单元测试 -- Provider 能力匹配:成功/失败/冲突/歧义。 -- Provider 选择器:单候选自动路由、多候选强制显式绑定。 -- ReadModel 三模式行为测试。 -- 自动 Persisted Event 管道(变更检测、版本、幂等)。 - -### 8.2 集成测试 -- Elasticsearch 端到端写读(Docker)。 -- Neo4j 端到端写读(Docker)。 -- Workflow Provider 切换后 Query 合同一致性。 - -### 8.3 分布式测试 -- 保留并扩展 3 节点一致性链路。 -- 新增 Provider 后验证跨节点一致收敛。 - -### 8.4 强制门禁 -- `dotnet build aevatar.slnx --nologo` -- `dotnet test aevatar.slnx --nologo` -- `bash tools/ci/architecture_guards.sh` -- `bash tools/ci/projection_route_mapping_guard.sh` -- `bash tools/ci/test_stability_guards.sh` -- `bash tools/ci/solution_split_test_guards.sh` - -## 9. 里程碑与交付 - -### M1(纠偏 + 基建) -- 完成 W0 + W1。 -- 验收:Workflow 不再持有任何 Provider 实现。 -- 当前状态:W0 完成;W1 部分完成。 - -### M2(Document Provider) -- 完成 W2。 -- 验收:ES Provider 在通用层可被 Workflow/其他模块消费。 -- 当前状态:已可被 Workflow 消费,增强项进行中。 - -### M3(Graph Provider) -- 完成 W3。 -- 验收:Graph ReadModel 可真实写读,能力校验全链路可用。 -- 当前状态:未开始。 - -### M4(StateMirror + 模式化 + ES 自动化) -- 完成 W4 + W5。 -- 验收:ReadModel 三模式与自动 Persisted Event 能力全量可测。 -- 当前状态:未开始。 - -### M5(全域收口) -- 完成 W6 + 全量文档/测试/门禁收口。 -- 验收:需求文档条目全部闭环,无历史空壳实现。 -- 当前状态:Workflow 已接入,其它域待迁移。 - -## 10. 风险与应对 -- 风险:Provider Runtime 泛化过度导致复杂度爆炸。 - 应对:先冻结最小能力集合,按里程碑增量扩展。 -- 风险:Graph 与 Document 语义差异大,统一抽象失真。 - 应对:抽象仅覆盖公共最小集,复杂能力走 provider-specific extension。 -- 风险:重构期间回归面大。 - 应对:分阶段门禁 + 契约测试 + 分片测试强制执行。 -- 风险:自动 Persisted Event 改写写侧行为。 - 应对:先实验开关,明确默认策略后再全量切换。 - -## 11. 完成定义(Final DoD) -- 需求文档中 FR/NFR 全量有代码落地。 -- Provider 不再出现在业务域实现层。 -- Document + Graph Provider 均可被统一 runtime 装配并通过能力校验。 -- StateOnly/Default/Custom 三模式完整可用。 -- 自动 Persisted Event 管道可用并有完整测试。 -- 全量 CI 门禁通过,文档同步完成。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs new file mode 100644 index 000000000..61630e519 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelBindingResolver +{ + ProjectionReadModelRequirements Resolve( + IReadOnlyDictionary readModelBindings, + Type readModelType); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs new file mode 100644 index 000000000..c18b16f62 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelCapabilityValidator +{ + IReadOnlyList Validate( + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities); + + void EnsureSupported( + Type readModelType, + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs new file mode 100644 index 000000000..a33e22da7 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelProviderRegistry +{ + IReadOnlyList> GetRegistrations( + IServiceProvider serviceProvider) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs new file mode 100644 index 000000000..6bda674a9 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelProviderSelector +{ + IProjectionReadModelStoreRegistration Select( + IReadOnlyList> registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs new file mode 100644 index 000000000..4d7e88640 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionReadModelStoreFactory +{ + IProjectionReadModelStore Create( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs new file mode 100644 index 000000000..93b470eb7 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs @@ -0,0 +1,37 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionProviderSelectionException : InvalidOperationException +{ + public ProjectionProviderSelectionException( + Type readModelType, + string requestedProviderName, + IReadOnlyList availableProviders, + string reason) + : base(BuildMessage(readModelType, requestedProviderName, availableProviders, reason)) + { + ReadModelType = readModelType; + RequestedProviderName = requestedProviderName; + AvailableProviders = availableProviders; + Reason = reason; + } + + public Type ReadModelType { get; } + + public string RequestedProviderName { get; } + + public IReadOnlyList AvailableProviders { get; } + + public string Reason { get; } + + private static string BuildMessage( + Type readModelType, + string requestedProviderName, + IReadOnlyList availableProviders, + string reason) + { + var requested = requestedProviderName.Length == 0 ? "" : requestedProviderName; + var available = availableProviders.Count == 0 ? "" : string.Join(", ", availableProviders); + return $"Provider selection failed for read-model '{readModelType.FullName}'. " + + $"requested={requested}; available={available}; reason={reason}."; + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs new file mode 100644 index 000000000..f43b5efef --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs @@ -0,0 +1,33 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionReadModelBindingException : InvalidOperationException +{ + public ProjectionReadModelBindingException( + Type readModelType, + string bindingKey, + string bindingValue, + string reason) + : base(BuildMessage(readModelType, bindingKey, bindingValue, reason)) + { + ReadModelType = readModelType; + BindingKey = bindingKey; + BindingValue = bindingValue; + Reason = reason; + } + + public Type ReadModelType { get; } + + public string BindingKey { get; } + + public string BindingValue { get; } + + public string Reason { get; } + + private static string BuildMessage( + Type readModelType, + string bindingKey, + string bindingValue, + string reason) => + $"Read-model binding is invalid for '{readModelType.FullName}'. " + + $"key='{bindingKey}', value='{bindingValue}', reason='{reason}'."; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs new file mode 100644 index 000000000..c6fcbdacc --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public enum ProjectionReadModelMode +{ + CustomReadModel = 0, + DefaultReadModel = 1, + StateOnly = 2, +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs index 4954b3bb7..de1ca1ac1 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs @@ -7,6 +7,8 @@ public ProjectionReadModelRuntimeOptions() Bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); } + public ProjectionReadModelMode Mode { get; set; } = ProjectionReadModelMode.CustomReadModel; + public string Provider { get; set; } = ProjectionReadModelProviderNames.InMemory; public bool FailOnUnsupportedCapabilities { get; set; } = true; diff --git a/src/Aevatar.CQRS.Projection.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Abstractions/README.md index 1f265fbba..6b3efd136 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Abstractions/README.md @@ -10,8 +10,9 @@ - 读模型存储:`IProjectionReadModelStore<,>` - Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionReadModelStoreProviderMetadata` - 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` -- Provider 注册与选择:`IProjectionReadModelStoreRegistration<,>`、`DelegateProjectionReadModelStoreRegistration<,>`、`ProjectionReadModelStoreSelector` -- Provider 运行配置:`ProjectionReadModelRuntimeOptions` +- Provider 注册与选择契约:`IProjectionReadModelStoreRegistration<,>`、`DelegateProjectionReadModelStoreRegistration<,>` +- Provider Runtime 契约:`IProjectionReadModelProviderRegistry`、`IProjectionReadModelProviderSelector`、`IProjectionReadModelBindingResolver`、`IProjectionReadModelStoreFactory` +- Provider 运行配置:`ProjectionReadModelRuntimeOptions`、`ProjectionReadModelMode` - 运行时策略:`IProjectionRuntimeOptions`、`IProjectionClock` - 流订阅复用:`IActorStreamSubscriptionHub` - 投影上下文:`IProjectionContext` diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj index b193b1845..772146682 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj @@ -12,5 +12,6 @@ + diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index ccf96aa14..496fa1b4f 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; @@ -33,7 +34,8 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration>>()))); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 57bb111f3..12d6d1cff 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -5,6 +5,7 @@ - 不依赖任何业务域 read model。 - 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 - 能力声明:`Document` 索引、alias、schema validation。 +- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 ## DI 注册 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index 3dcb3dc7d..a7c9e41fa 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -3,6 +3,8 @@ using System.Text; using System.Text.Json; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; @@ -19,6 +21,7 @@ public sealed class ElasticsearchProjectionReadModelStore private readonly int _listTakeMax; private readonly bool _autoCreateIndex; private readonly string _listSortField; + private readonly ILogger> _logger; private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() { @@ -31,7 +34,8 @@ public ElasticsearchProjectionReadModelStore( string indexScope, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionReadModelProviderNames.Elasticsearch) + string providerName = ProjectionReadModelProviderNames.Elasticsearch, + ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(keySelector); @@ -60,6 +64,7 @@ public ElasticsearchProjectionReadModelStore( _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortField = options.ListSortField?.Trim() ?? ""; + _logger = logger ?? NullLogger>.Instance; ProviderCapabilities = BuildCapabilities(providerName); } @@ -147,12 +152,39 @@ private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, var keyValue = ResolveReadModelKey(readModel); var payload = JsonSerializer.Serialize(readModel, _jsonOptions); - using var request = new HttpRequestMessage(HttpMethod.Put, $"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}") + var startedAt = DateTimeOffset.UtcNow; + try { - Content = new StringContent(payload, Encoding.UTF8, "application/json"), - }; - using var response = await _httpClient.SendAsync(request, ct); - await EnsureSuccessAsync(response, "upsert", ct); + using var request = new HttpRequestMessage(HttpMethod.Put, $"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}") + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + await EnsureSuccessAsync(response, "upsert", ct); + + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + elapsedMs, + "ok"); + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + elapsedMs, + "failed", + ex.GetType().Name); + throw; + } } private string ResolveReadModelKey(TReadModel readModel) diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj new file mode 100644 index 000000000..9dbda873a --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj @@ -0,0 +1,18 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Providers.Neo4j + Aevatar.CQRS.Projection.Providers.Neo4j + + + + + + + + + + + diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs new file mode 100644 index 000000000..7c1feb453 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs @@ -0,0 +1,20 @@ +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; + +public sealed class Neo4jProjectionReadModelStoreOptions +{ + public string Uri { get; set; } = "bolt://localhost:7687"; + + public string Username { get; set; } = "neo4j"; + + public string Password { get; set; } = ""; + + public string Database { get; set; } = ""; + + public int RequestTimeoutMs { get; set; } = 5000; + + public int ListTakeMax { get; set; } = 200; + + public bool AutoCreateConstraints { get; set; } = true; + + public string NodeLabel { get; set; } = "ProjectionReadModel"; +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..c8ea3ce45 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Aevatar.CQRS.Projection.Providers.Neo4j.Stores; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddNeo4jReadModelStoreRegistration( + this IServiceCollection services, + Func optionsFactory, + string scope, + Func keySelector, + Func? keyFormatter = null, + string providerName = ProjectionReadModelProviderNames.Neo4j) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(optionsFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(keySelector); + + services.AddSingleton>( + new DelegateProjectionReadModelStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph], + supportsAliases: false, + supportsSchemaValidation: true), + provider => new Neo4jProjectionReadModelStore( + optionsFactory(provider), + scope, + keySelector, + keyFormatter, + providerName, + provider.GetService>>()))); + + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs new file mode 100644 index 000000000..b07fa3dbd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md new file mode 100644 index 000000000..b5d96d5cd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -0,0 +1,22 @@ +# Aevatar.CQRS.Projection.Providers.Neo4j + +通用 Neo4j Graph ReadModel Provider。 + +- 不依赖任何业务域 read model。 +- 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 +- 能力声明:`Graph` 索引、schema validation。 +- 基于官方 `Neo4j.Driver` 实现连接与会话管理。 +- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 + +## DI 注册 + +使用扩展方法: + +- `AddNeo4jReadModelStoreRegistration(...)` + +关键参数: + +- `optionsFactory`:绑定 `Projection:ReadModel:Providers:Neo4j:*` 配置。 +- `scope`:图存储作用域(等价于 document provider 的 indexScope)。 +- `keySelector/keyFormatter`:ReadModel 主键映射。 +- `providerName`:默认 `Neo4j`(与 `ProjectionReadModelProviderNames.Neo4j` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs new file mode 100644 index 000000000..d255ac9d6 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -0,0 +1,293 @@ +using System.Text.Json; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Neo4j.Driver; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +public sealed class Neo4jProjectionReadModelStore + : IProjectionReadModelStore, + IProjectionReadModelStoreProviderMetadata, + IAsyncDisposable + where TReadModel : class +{ + private readonly IDriver _driver; + private readonly Func _keySelector; + private readonly Func _keyFormatter; + private readonly string _scope; + private readonly string _database; + private readonly int _listTakeMax; + private readonly string _label; + private readonly bool _autoCreateConstraints; + private readonly ILogger> _logger; + private readonly SemaphoreSlim _schemaLock = new(1, 1); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + private bool _schemaInitialized; + + public Neo4jProjectionReadModelStore( + Neo4jProjectionReadModelStoreOptions options, + string scope, + Func keySelector, + Func? keyFormatter = null, + string providerName = ProjectionReadModelProviderNames.Neo4j, + ILogger>? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(keySelector); + + _scope = scope.Trim(); + _database = options.Database?.Trim() ?? ""; + _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; + _label = NormalizeLabel(options.NodeLabel); + _autoCreateConstraints = options.AutoCreateConstraints; + _keySelector = keySelector; + _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); + _logger = logger ?? NullLogger>.Instance; + + var auth = string.IsNullOrWhiteSpace(options.Username) + ? AuthTokens.None + : AuthTokens.Basic(options.Username.Trim(), options.Password ?? ""); + _driver = GraphDatabase.Driver(options.Uri, auth, config => + config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); + + ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph], + supportsAliases: false, + supportsSchemaValidation: true); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + var startedAt = DateTimeOffset.UtcNow; + var key = ""; + try + { + await EnsureSchemaAsync(ct); + + key = ResolveReadModelKey(readModel); + var payload = JsonSerializer.Serialize(readModel, _jsonOptions); + var updatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var cypher = $"MERGE (n:{_label} {{scope: $scope, id: $id}}) " + + "SET n.payload = $payload, n.updatedAtEpochMs = $updatedAtEpochMs"; + var parameters = new Dictionary + { + ["scope"] = _scope, + ["id"] = key, + ["payload"] = payload, + ["updatedAtEpochMs"] = updatedAtEpochMs, + }; + + await ExecuteWriteAsync(cypher, parameters, ct); + + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + key, + elapsedMs, + "ok"); + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + key, + elapsedMs, + "failed", + ex.GetType().Name); + throw; + } + } + + public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + var existing = await GetAsync(key, ct); + if (existing == null) + throw new InvalidOperationException($"ReadModel '{typeof(TReadModel).FullName}' with key '{FormatKey(key)}' was not found."); + + mutate(existing); + await UpsertAsync(existing, ct); + } + + public async Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + await EnsureSchemaAsync(ct); + + var keyValue = FormatKey(key); + if (keyValue.Length == 0) + return null; + + var cypher = $"MATCH (n:{_label} {{scope: $scope, id: $id}}) " + + "RETURN n.payload AS payload LIMIT 1"; + var parameters = new Dictionary + { + ["scope"] = _scope, + ["id"] = keyValue, + }; + var rows = await ExecuteReadAsync(cypher, parameters, ct); + if (rows.Count == 0) + return null; + + if (!rows[0].TryGetValue("payload", out var payloadValue)) + return null; + + var payload = payloadValue.As(); + return Deserialize(payload); + } + + public async Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + await EnsureSchemaAsync(ct); + var boundedTake = Math.Clamp(take, 1, _listTakeMax); + var cypher = $"MATCH (n:{_label} {{scope: $scope}}) " + + "RETURN n.payload AS payload ORDER BY n.updatedAtEpochMs DESC LIMIT $take"; + var parameters = new Dictionary + { + ["scope"] = _scope, + ["take"] = boundedTake, + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var readModels = new List(rows.Count); + foreach (var row in rows) + { + if (!row.TryGetValue("payload", out var payloadValue)) + continue; + + var item = Deserialize(payloadValue.As()); + if (item != null) + readModels.Add(item); + } + + return readModels; + } + + public async ValueTask DisposeAsync() + { + _schemaLock.Dispose(); + await _driver.DisposeAsync(); + } + + private async Task EnsureSchemaAsync(CancellationToken ct) + { + if (!_autoCreateConstraints || _schemaInitialized) + return; + + await _schemaLock.WaitAsync(ct); + try + { + if (_schemaInitialized) + return; + + var constraintName = NormalizeConstraintName($"projection_readmodel_scope_id_{_label}"); + var cypher = $"CREATE CONSTRAINT {constraintName} IF NOT EXISTS " + + $"FOR (n:{_label}) REQUIRE (n.scope, n.id) IS UNIQUE"; + await ExecuteWriteAsync(cypher, new Dictionary(), ct); + _schemaInitialized = true; + } + finally + { + _schemaLock.Release(); + } + } + + private async Task ExecuteWriteAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Write); + var cursor = await session.RunAsync(cypher, parameters); + await cursor.ConsumeAsync(); + ct.ThrowIfCancellationRequested(); + } + + private async Task>> ExecuteReadAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Read); + var cursor = await session.RunAsync(cypher, parameters); + var rows = await cursor.ToListAsync(); + ct.ThrowIfCancellationRequested(); + return rows; + } + + private IAsyncSession CreateSession(AccessMode accessMode) + { + return _driver.AsyncSession(options => + { + options.WithDefaultAccessMode(accessMode); + if (_database.Length > 0) + options.WithDatabase(_database); + }); + } + + private string ResolveReadModelKey(TReadModel readModel) + { + var key = _keySelector(readModel); + var keyValue = FormatKey(key); + if (keyValue.Length == 0) + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' resolved an empty key for Neo4j persistence."); + return keyValue; + } + + private string FormatKey(TKey key) => _keyFormatter(key)?.Trim() ?? ""; + + private TReadModel? Deserialize(string payload) + { + var value = JsonSerializer.Deserialize(payload, _jsonOptions); + if (value == null) + return null; + + var copied = JsonSerializer.Serialize(value, _jsonOptions); + return JsonSerializer.Deserialize(copied, _jsonOptions); + } + + private static string NormalizeLabel(string rawLabel) + { + var label = (rawLabel ?? "").Trim(); + if (label.Length == 0) + label = "ProjectionReadModel"; + + var chars = label + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') + .ToArray(); + return new string(chars); + } + + private static string NormalizeConstraintName(string rawName) + { + var chars = rawName + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + return "projection_constraint"; + if (char.IsDigit(normalized[0])) + normalized = $"c_{normalized}"; + return normalized; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj new file mode 100644 index 000000000..a60881234 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Runtime + Aevatar.CQRS.Projection.Runtime + + + + + + + + + + diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..0b3dfc039 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.CQRS.Projection.Runtime.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs new file mode 100644 index 000000000..b07fa3dbd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md new file mode 100644 index 000000000..308cc8e98 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -0,0 +1,19 @@ +# Aevatar.CQRS.Projection.Runtime + +通用 ReadModel Provider Runtime 组装层。 + +## 职责 + +- 收敛 Provider 注册查询(`IProjectionReadModelProviderRegistry`)。 +- 执行 Provider 选择策略(`IProjectionReadModelProviderSelector`)。 +- 解析 ReadModel 绑定需求(`IProjectionReadModelBindingResolver`)。 +- 统一创建 Store 并输出结构化创建日志(`IProjectionReadModelStoreFactory`)。 + +## DI 入口 + +- `services.AddProjectionReadModelRuntime()` + +## 设计约束 + +- 不承载业务 ReadModel 类型,不引用 Workflow/AI 等业务模块。 +- 仅依赖抽象与 DI,具体 Provider 由上层模块按需注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs new file mode 100644 index 000000000..08f811cd4 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs @@ -0,0 +1,60 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionReadModelBindingResolver : IProjectionReadModelBindingResolver +{ + public ProjectionReadModelRequirements Resolve( + IReadOnlyDictionary readModelBindings, + Type readModelType) + { + ArgumentNullException.ThrowIfNull(readModelBindings); + ArgumentNullException.ThrowIfNull(readModelType); + + if (!TryGetBinding(readModelBindings, readModelType, out var bindingKey, out var bindingValue)) + return new ProjectionReadModelRequirements(); + + if (!Enum.TryParse(bindingValue, true, out var indexKind) || + indexKind == ProjectionReadModelIndexKind.None) + { + throw new ProjectionReadModelBindingException( + readModelType, + bindingKey, + bindingValue, + $"Allowed values are {ProjectionReadModelIndexKind.Document} or {ProjectionReadModelIndexKind.Graph}."); + } + + return new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [indexKind]); + } + + private static bool TryGetBinding( + IReadOnlyDictionary readModelBindings, + Type readModelType, + out string bindingKey, + out string bindingValue) + { + if (readModelBindings.Count == 0) + { + bindingKey = ""; + bindingValue = ""; + return false; + } + + if (readModelBindings.TryGetValue(readModelType.Name, out bindingValue!)) + { + bindingKey = readModelType.Name; + return true; + } + + var fullName = readModelType.FullName ?? ""; + if (fullName.Length > 0 && readModelBindings.TryGetValue(fullName, out bindingValue!)) + { + bindingKey = fullName; + return true; + } + + bindingKey = ""; + bindingValue = ""; + return false; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs new file mode 100644 index 000000000..ffc731b26 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionReadModelCapabilityValidatorService : IProjectionReadModelCapabilityValidator +{ + public IReadOnlyList Validate( + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities) => + ProjectionReadModelCapabilityValidator.Validate(requirements, capabilities); + + public void EnsureSupported( + Type readModelType, + ProjectionReadModelRequirements requirements, + ProjectionReadModelProviderCapabilities capabilities) => + ProjectionReadModelCapabilityValidator.EnsureSupported(readModelType, requirements, capabilities); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs new file mode 100644 index 000000000..909106d55 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionReadModelProviderRegistry : IProjectionReadModelProviderRegistry +{ + public IReadOnlyList> GetRegistrations( + IServiceProvider serviceProvider) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(serviceProvider); + return serviceProvider + .GetServices>() + .ToList(); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs new file mode 100644 index 000000000..85334b91e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionReadModelProviderSelector + : IProjectionReadModelProviderSelector +{ + private readonly IProjectionReadModelCapabilityValidator _capabilityValidator; + private readonly ILogger _logger; + + public ProjectionReadModelProviderSelector( + IProjectionReadModelCapabilityValidator capabilityValidator, + ILogger? logger = null) + { + _capabilityValidator = capabilityValidator; + _logger = logger ?? NullLogger.Instance; + } + + public IProjectionReadModelStoreRegistration Select( + IReadOnlyList> registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + if (registrations.Count == 0) + { + throw new ProjectionProviderSelectionException( + typeof(TReadModel), + selectionOptions.RequestedProviderName?.Trim() ?? "", + [], + "No provider registrations were found."); + } + + var requestedProvider = selectionOptions.RequestedProviderName?.Trim() ?? ""; + var selected = ResolveRegistration(registrations, requestedProvider); + var violations = _capabilityValidator.Validate(requirements, selected.Capabilities); + if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) + { + _logger.LogError( + "Projection provider capability validation failed. readModel={ReadModel} provider={Provider} requiredCapabilities={RequiredCapabilities} actualCapabilities={ActualCapabilities} violations={Violations}", + typeof(TReadModel).FullName, + selected.ProviderName, + FormatRequirements(requirements), + FormatCapabilities(selected.Capabilities), + string.Join("; ", violations)); + throw new ProjectionReadModelCapabilityValidationException( + typeof(TReadModel), + requirements, + selected.Capabilities, + violations); + } + + _logger.LogInformation( + "Projection provider selected. readModel={ReadModel} provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", + typeof(TReadModel).FullName, + selected.ProviderName, + selectionOptions.FailOnUnsupportedCapabilities); + return selected; + } + + private static IProjectionReadModelStoreRegistration ResolveRegistration( + IReadOnlyList> registrations, + string requestedProvider) + where TReadModel : class + { + if (requestedProvider.Length == 0) + { + if (registrations.Count == 1) + return registrations[0]; + + throw new ProjectionProviderSelectionException( + typeof(TReadModel), + requestedProvider, + registrations.Select(x => x.ProviderName).ToList(), + "Multiple providers are registered but no explicit provider was requested."); + } + + var matched = registrations + .FirstOrDefault(x => string.Equals( + x.ProviderName, + requestedProvider, + StringComparison.OrdinalIgnoreCase)); + + if (matched != null) + return matched; + + throw new ProjectionProviderSelectionException( + typeof(TReadModel), + requestedProvider, + registrations.Select(x => x.ProviderName).ToList(), + "Requested provider is not registered."); + } + + private static string FormatRequirements(ProjectionReadModelRequirements requirements) + { + return $"requiresIndexing={requirements.RequiresIndexing};" + + $"requiredIndexKinds=[{string.Join(",", requirements.RequiredIndexKinds)}];" + + $"requiresAliases={requirements.RequiresAliases};" + + $"requiresSchemaValidation={requirements.RequiresSchemaValidation}"; + } + + private static string FormatCapabilities(ProjectionReadModelProviderCapabilities capabilities) + { + return $"supportsIndexing={capabilities.SupportsIndexing};" + + $"indexKinds=[{string.Join(",", capabilities.IndexKinds)}];" + + $"supportsAliases={capabilities.SupportsAliases};" + + $"supportsSchemaValidation={capabilities.SupportsSchemaValidation}"; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs new file mode 100644 index 000000000..303b5c8ae --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionReadModelStoreFactory + : IProjectionReadModelStoreFactory +{ + private readonly IProjectionReadModelProviderRegistry _providerRegistry; + private readonly IProjectionReadModelProviderSelector _providerSelector; + private readonly ILogger _logger; + + public ProjectionReadModelStoreFactory( + IProjectionReadModelProviderRegistry providerRegistry, + IProjectionReadModelProviderSelector providerSelector, + ILogger? logger = null) + { + _providerRegistry = providerRegistry; + _providerSelector = providerSelector; + _logger = logger ?? NullLogger.Instance; + } + + public IProjectionReadModelStore Create( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var registrations = _providerRegistry.GetRegistrations(serviceProvider); + var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + + var startedAt = DateTimeOffset.UtcNow; + try + { + var store = selected.Create(serviceProvider); + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection read-model store created. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result}", + selected.ProviderName, + typeof(TReadModel).FullName, + elapsedMs, + "ok"); + return store; + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model store creation failed. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + selected.ProviderName, + typeof(TReadModel).FullName, + elapsedMs, + "failed", + ex.GetType().Name); + throw; + } + } +} diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs b/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs new file mode 100644 index 000000000..78a1093cc --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.StateMirror.Abstractions; + +public interface IStateMirrorProjection + where TState : class + where TReadModel : class +{ + TReadModel Project(TState state); +} diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj new file mode 100644 index 000000000..aa8535575 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.StateMirror + Aevatar.CQRS.Projection.StateMirror + + + + + + + + + diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs b/src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs new file mode 100644 index 000000000..d04dc14a3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.StateMirror.Configuration; + +public sealed class StateMirrorProjectionOptions +{ + public StateMirrorProjectionOptions() + { + IgnoredFields = new HashSet(StringComparer.OrdinalIgnoreCase); + RenamedFields = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + public HashSet IgnoredFields { get; } + + public Dictionary RenamedFields { get; } +} diff --git a/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..6785135df --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Aevatar.CQRS.Projection.StateMirror.Abstractions; +using Aevatar.CQRS.Projection.StateMirror.Configuration; +using Aevatar.CQRS.Projection.StateMirror.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Aevatar.CQRS.Projection.StateMirror.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddJsonStateMirrorProjection( + this IServiceCollection services, + Action? configure = null) + where TState : class + where TReadModel : class + { + var options = new StateMirrorProjectionOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(options); + services.TryAddSingleton, + JsonStateMirrorProjection>(); + return services; + } +} diff --git a/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs new file mode 100644 index 000000000..b07fa3dbd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.StateMirror/README.md b/src/Aevatar.CQRS.Projection.StateMirror/README.md new file mode 100644 index 000000000..368e24c87 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/README.md @@ -0,0 +1,8 @@ +# Aevatar.CQRS.Projection.StateMirror + +通用 `State -> ReadModel` 镜像组件。 + +- 默认实现:`JsonStateMirrorProjection`。 +- 支持字段忽略:`StateMirrorProjectionOptions.IgnoredFields`。 +- 支持字段重命名:`StateMirrorProjectionOptions.RenamedFields`。 +- 可作为 `DefaultReadModel` 模式的基础设施组件复用。 diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs b/src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs new file mode 100644 index 000000000..bc25e507e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Aevatar.CQRS.Projection.StateMirror.Abstractions; +using Aevatar.CQRS.Projection.StateMirror.Configuration; + +namespace Aevatar.CQRS.Projection.StateMirror.Services; + +public sealed class JsonStateMirrorProjection + : IStateMirrorProjection + where TState : class + where TReadModel : class +{ + private readonly StateMirrorProjectionOptions _options; + private readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public JsonStateMirrorProjection(StateMirrorProjectionOptions options) + { + _options = options; + } + + public TReadModel Project(TState state) + { + ArgumentNullException.ThrowIfNull(state); + + var rootNode = JsonSerializer.SerializeToNode(state, _serializerOptions); + if (rootNode is not JsonObject sourceObject) + { + throw new InvalidOperationException( + $"State '{typeof(TState).FullName}' cannot be projected to '{typeof(TReadModel).FullName}'."); + } + + var mirroredObject = new JsonObject(); + foreach (var property in sourceObject) + { + if (property.Key == null) + continue; + if (_options.IgnoredFields.Contains(property.Key)) + continue; + + var targetName = ResolveTargetName(property.Key); + mirroredObject[targetName] = property.Value?.DeepClone(); + } + + var projected = mirroredObject.Deserialize(_serializerOptions); + if (projected == null) + { + throw new InvalidOperationException( + $"State projection failed from '{typeof(TState).FullName}' to '{typeof(TReadModel).FullName}'."); + } + + return projected; + } + + private string ResolveTargetName(string sourceName) + { + if (_options.RenamedFields.TryGetValue(sourceName, out var targetName) && + !string.IsNullOrWhiteSpace(targetName)) + { + return targetName.Trim(); + } + + return sourceName; + } +} diff --git a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs index caf0ae0d0..a350bc4cf 100644 --- a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs +++ b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs @@ -1,12 +1,15 @@ // ───────────────────────────────────────────────────────────── -// EventSourcingBehavior — ES mixin default implementation. -// Provides RaiseEvent / ConfirmEventsAsync / ReplayAsync. +// EventSourcingBehavior — explicit event-first default implementation. +// Provides RaiseEvent / ConfirmEventsAsync / PersistSnapshotAsync / ReplayAsync. // ───────────────────────────────────────────────────────────── using Aevatar.Foundation.Abstractions.Helpers; using Aevatar.Foundation.Abstractions.Persistence; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Diagnostics; namespace Aevatar.Foundation.Core.EventSourcing; @@ -17,14 +20,25 @@ public class EventSourcingBehavior : IEventSourcingBehavior where TState : class, IMessage, new() { private readonly IEventStore _eventStore; + private readonly IEventSourcingSnapshotStore? _snapshotStore; + private readonly ISnapshotStrategy _snapshotStrategy; + private readonly ILogger> _logger; private readonly List _pending = []; private readonly string _agentId; private long _currentVersion; - public EventSourcingBehavior(IEventStore eventStore, string agentId) + public EventSourcingBehavior( + IEventStore eventStore, + string agentId, + IEventSourcingSnapshotStore? snapshotStore = null, + ISnapshotStrategy? snapshotStrategy = null, + ILogger>? logger = null) { _eventStore = eventStore; _agentId = agentId; + _snapshotStore = snapshotStore; + _snapshotStrategy = snapshotStrategy ?? NeverSnapshotStrategy.Instance; + _logger = logger ?? NullLogger>.Instance; } /// @@ -37,8 +51,14 @@ public void RaiseEvent(TEvent evt) where TEvent : IMessage => /// public async Task ConfirmEventsAsync(CancellationToken ct = default) { - if (_pending.Count == 0) return; + if (_pending.Count == 0) + return; + EnsureNoStateSnapshotEvents(); + + var fromVersion = _currentVersion; + var eventType = JoinEventTypes(_pending); + var startedAt = Stopwatch.GetTimestamp(); var stateEvents = _pending.Select((evt, i) => new StateEvent { EventId = Guid.NewGuid().ToString("N"), @@ -49,18 +69,83 @@ public async Task ConfirmEventsAsync(CancellationToken ct = default) AgentId = _agentId, }); - _currentVersion = await _eventStore.AppendAsync( - _agentId, stateEvents, _currentVersion, ct); - _pending.Clear(); + try + { + _currentVersion = await _eventStore.AppendAsync( + _agentId, stateEvents, _currentVersion, ct); + _pending.Clear(); + _logger.LogInformation( + "Event sourcing commit completed. agentId={AgentId} eventType={EventType} version={Version} elapsedMs={ElapsedMs} result={Result}", + _agentId, + eventType, + _currentVersion, + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, + "ok"); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Event sourcing commit failed. agentId={AgentId} eventType={EventType} version={Version} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + _agentId, + eventType, + fromVersion, + Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, + "failed", + ex.GetType().Name); + throw; + } + } + + /// + public async Task PersistSnapshotAsync( + TState currentState, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(currentState); + ct.ThrowIfCancellationRequested(); + + if (_snapshotStore == null) + return; + + if (!_snapshotStrategy.ShouldCreateSnapshot(_currentVersion)) + return; + + try + { + await _snapshotStore.SaveAsync( + _agentId, + new EventSourcingSnapshot(currentState.Clone(), _currentVersion), + ct); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Event sourcing snapshot save failed and will be ignored. agentId={AgentId} version={Version} result={Result} errorType={ErrorType}", + _agentId, + _currentVersion, + "ignored", + ex.GetType().Name); + } } /// public async Task ReplayAsync(string agentId, CancellationToken ct = default) { - var events = await _eventStore.GetEventsAsync(agentId, fromVersion: null, ct); - if (events.Count == 0) return null; + var snapshot = await TryLoadSnapshotAsync(agentId, ct); + long? fromVersion = snapshot?.Version; + var events = await _eventStore.GetEventsAsync(agentId, fromVersion, ct); + if (events.Count == 0) + { + if (snapshot == null) + return null; + + _currentVersion = snapshot.Version; + return snapshot.State; + } - var state = new TState(); + var state = snapshot?.State ?? new TState(); foreach (var stateEvent in events) { if (stateEvent.EventData != null) @@ -74,5 +159,41 @@ public async Task ConfirmEventsAsync(CancellationToken ct = default) /// /// Default: returns current unchanged. Override in derived behavior or agent to apply events. /// - public virtual TState TransitionState(TState current, IMessage evt) => current; + public virtual TState TransitionState(TState current, IMessage evt) + => current; + + private void EnsureNoStateSnapshotEvents() + { + var stateTypeFullName = new TState().Descriptor.FullName; + if (_pending.Any(evt => + string.Equals(evt.Descriptor.FullName, stateTypeFullName, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"Persisting state snapshot events is forbidden for state '{typeof(TState).FullName}'. " + + "Emit domain events instead."); + } + } + + private async Task?> TryLoadSnapshotAsync( + string agentId, + CancellationToken ct) + { + if (_snapshotStore == null) + return null; + + var snapshot = await _snapshotStore.LoadAsync(agentId, ct); + if (snapshot == null) + return null; + + return new EventSourcingSnapshot(snapshot.State.Clone(), snapshot.Version); + } + + private static string JoinEventTypes(IEnumerable events) + { + var eventTypes = events + .Select(evt => evt.Descriptor.FullName) + .Distinct(StringComparer.Ordinal) + .ToArray(); + return eventTypes.Length == 0 ? "" : string.Join(",", eventTypes); + } } diff --git a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingSnapshot.cs b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingSnapshot.cs new file mode 100644 index 000000000..83ef5d8c9 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingSnapshot.cs @@ -0,0 +1,6 @@ +namespace Aevatar.Foundation.Core.EventSourcing; + +public sealed record EventSourcingSnapshot( + TState State, + long Version) + where TState : class; diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs index 90c3a1fe7..457e6e95b 100644 --- a/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs +++ b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs @@ -1,6 +1,6 @@ // ───────────────────────────────────────────────────────────── -// IEventSourcingBehavior — Event Sourcing mixin interface. -// Agents enable ES by having this behavior injected via DI; no extra inheritance. +// IEventSourcingBehavior — explicit event-first behavior contract. +// Stateful agents must persist domain events and replay them for recovery. // ───────────────────────────────────────────────────────────── using Google.Protobuf; @@ -8,8 +8,8 @@ namespace Aevatar.Foundation.Core.EventSourcing; /// -/// Event Sourcing behavior. Agents enable ES by having this interface injected via DI. -/// If not injected, ES is not used — pure mixin, no extra base class required. +/// Event Sourcing behavior. +/// Stateful agents persist explicit domain events and recover state from replay. /// public interface IEventSourcingBehavior where TState : class, IMessage { @@ -22,6 +22,14 @@ public interface IEventSourcingBehavior where TState : class, IMessage /// Persist all pending events to IEventStore. Task ConfirmEventsAsync(CancellationToken ct = default); + /// + /// Persist a snapshot for replay optimization. + /// Snapshot failure must not affect committed event facts. + /// + Task PersistSnapshotAsync( + TState currentState, + CancellationToken ct = default); + /// Replay events from IEventStore to rebuild state. Task ReplayAsync(string agentId, CancellationToken ct = default); diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingSnapshotStore.cs b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingSnapshotStore.cs new file mode 100644 index 000000000..c163a7aec --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingSnapshotStore.cs @@ -0,0 +1,14 @@ +namespace Aevatar.Foundation.Core.EventSourcing; + +public interface IEventSourcingSnapshotStore + where TState : class +{ + Task?> LoadAsync( + string agentId, + CancellationToken ct = default); + + Task SaveAsync( + string agentId, + EventSourcingSnapshot snapshot, + CancellationToken ct = default); +} diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index f3dafe63d..ea19c04e6 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -1,15 +1,16 @@ // ───────────────────────────────────────────────────────────── // GAgentBase - stateful base class for GAgent. -// State + StateStore + OnStateChanged Hook +// State + mandatory EventSourcing + OnStateChanged Hook // ───────────────────────────────────────────────────────────── using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; namespace Aevatar.Foundation.Core; /// -/// Stateful GAgent base class with Protobuf state storage, StateStore persistence, and lifecycle hooks. +/// Stateful GAgent base class with Protobuf state and mandatory Event Sourcing lifecycle. /// /// Protobuf-generated state type. public abstract class GAgentBase : GAgentBase, IAgent @@ -27,25 +28,27 @@ public TState State /// State persistence store injected by runtime. public IStateStore? StateStore { get; set; } - /// Activates agent, restores state from StateStore, then calls OnActivateAsync. + /// Event Sourcing behavior injected by runtime; required for state recovery and commit. + public IEventSourcingBehavior? EventSourcing { get; set; } + + /// Activates agent, replays events to restore state, then calls OnActivateAsync. public override async Task ActivateAsync(CancellationToken ct = default) { await base.ActivateAsync(ct); // Restore modules using var guard = StateGuard.BeginWriteScope(); - if (StateStore != null) - { - var loaded = await StateStore.LoadAsync(Id, ct); - if (loaded != null) _state = loaded; - } + var eventSourcing = EnsureEventSourcingConfigured(); + var replayed = await eventSourcing.ReplayAsync(Id, ct); + _state = replayed ?? new TState(); await OnActivateAsync(ct); } - /// Deactivates agent, calls OnDeactivateAsync, and persists state. + /// Deactivates agent, flushes pending events, and optionally persists snapshot optimization. public override async Task DeactivateAsync(CancellationToken ct = default) { + var eventSourcing = EnsureEventSourcingConfigured(); await OnDeactivateAsync(ct); - if (StateStore != null) - await StateStore.SaveAsync(Id, _state, ct); + await eventSourcing.ConfirmEventsAsync(ct); + await eventSourcing.PersistSnapshotAsync(_state, ct); } /// Hook invoked after state changes, useful for CQRS projection. @@ -57,4 +60,20 @@ protected virtual Task OnStateChangedAsync(TState state, CancellationToken ct) = /// Deactivation hook for subclass cleanup. protected virtual Task OnDeactivateAsync(CancellationToken ct) => Task.CompletedTask; + + private IEventSourcingBehavior EnsureEventSourcingConfigured() + { + if (EventSourcing != null) + return EventSourcing; + + if (Services?.GetService(typeof(IEventStore)) is IEventStore eventStore) + { + EventSourcing = new EventSourcingBehavior(eventStore, Id); + return EventSourcing; + } + + throw new InvalidOperationException( + $"Stateful agent '{GetType().FullName}' requires '{typeof(IEventSourcingBehavior).FullName}' " + + $"for actor '{Id}'."); + } } diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs index 749a150ff..0dbb40894 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs @@ -11,7 +11,6 @@ namespace Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; public sealed class RuntimeActorGrain : Grain, IRuntimeActorGrain { private readonly IPersistentState _state; - private readonly IRuntimeActorStateBindingAccessor _stateBindingAccessor; private IAgent? _agent; private IEventDeduplicator? _deduplicator; private IEnvelopePropagationPolicy _propagationPolicy = @@ -22,12 +21,9 @@ public sealed class RuntimeActorGrain : Grain, IRuntimeActorGrain private StreamSubscriptionHandle? _selfStreamHandle; public RuntimeActorGrain( - [PersistentState("agent", OrleansRuntimeConstants.GrainStateStorageName)] - IPersistentState state, - IRuntimeActorStateBindingAccessor stateBindingAccessor) + [PersistentState("agent", OrleansRuntimeConstants.GrainStateStorageName)] IPersistentState state) { _state = state; - _stateBindingAccessor = stateBindingAccessor; } public override async Task OnActivateAsync(CancellationToken cancellationToken) @@ -270,28 +266,6 @@ private void InjectDependencies(IAgent agent, string actorId) gAgent.Logger = agentLogger; gAgent.Services = ServiceProvider; gAgent.ManifestStore = ServiceProvider.GetService(); - - InjectStateStore(agent); - } - - private void InjectStateStore(IAgent agent) - { - var type = agent.GetType(); - while (type != null) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(GAgentBase<>)) - { - var stateType = type.GetGenericArguments()[0]; - var stateStoreType = typeof(IStateStore<>).MakeGenericType(stateType); - using var binding = _stateBindingAccessor.Bind(_state); - var stateStore = ServiceProvider.GetService(stateStoreType); - if (stateStore != null) - type.GetProperty("StateStore")?.SetValue(agent, stateStore); - break; - } - - type = type.BaseType; - } } private async Task SubscribeSelfStreamAsync() diff --git a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs index 06da275d1..5369af602 100644 --- a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs +++ b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs @@ -156,23 +156,5 @@ private void InjectDependencies(IAgent agent, IEventPublisher publisher, string gab.Logger = logger; gab.Services = _services; gab.ManifestStore = _services.GetService(); - InjectStateStore(agent); - } - - private void InjectStateStore(IAgent agent) - { - var type = agent.GetType(); - while (type != null) - { - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(GAgentBase<>)) - { - var stateType = type.GetGenericArguments()[0]; - var storeType = typeof(IStateStore<>).MakeGenericType(stateType); - var store = _services.GetService(storeType); - if (store != null) type.GetProperty("StateStore")?.SetValue(agent, store); - break; - } - type = type.BaseType; - } } } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj index 27d6ad298..60ffa6891 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj +++ b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj @@ -15,6 +15,7 @@ + diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index c5fd4ac77..88316bcfe 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -3,6 +3,8 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; using Aevatar.Workflow.Application.DependencyInjection; using Aevatar.Workflow.Core; using Aevatar.Workflow.Presentation.AGUIAdapter; @@ -42,6 +44,16 @@ public static IServiceCollection AddWorkflowCapability( indexScope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); + services.AddNeo4jReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new Neo4jProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + return providerOptions; + }, + scope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); services.AddWorkflowExecutionAGUIAdapter(); services.AddWorkflowExecutionProjectionProjector(); services.AddWorkflowApplication(); @@ -67,6 +79,7 @@ private static void ApplyGlobalReadModelOptions( if (!string.IsNullOrWhiteSpace(readModelOptions.Provider)) options.ReadModelProvider = readModelOptions.Provider.Trim(); + options.ReadModelMode = readModelOptions.Mode; options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; options.ReadModelBindings.Clear(); foreach (var item in readModelOptions.Bindings) diff --git a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj index be6288b75..2136b2526 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj +++ b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj @@ -10,6 +10,7 @@ + diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index c0e1c932c..fa5d4ccc8 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -56,4 +56,10 @@ public bool EnableRunQueryEndpoints /// Optional read-model binding requirements (ReadModelName -> IndexKind). /// public Dictionary ReadModelBindings { get; } + + /// + /// Read-model runtime mode. + /// Workflow keeps CustomReadModel as default; StateOnly is rejected during DI composition. + /// + public ProjectionReadModelMode ReadModelMode { get; set; } = ProjectionReadModelMode.CustomReadModel; } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 91c1b02de..4c1dcb375 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Aevatar.Workflow.Application.Abstractions.Projections; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Foundation.Abstractions.Deduplication; +using Aevatar.CQRS.Projection.Runtime.DependencyInjection; using Aevatar.CQRS.Projection.Core.DependencyInjection; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; @@ -33,6 +34,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); + services.AddProjectionReadModelRuntime(); RegisterWorkflowReadModelStoreSelector(services); services.TryAddSingleton(); services.TryAddSingleton(); @@ -120,70 +122,23 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se services.Replace(ServiceDescriptor.Singleton>(sp => { var options = sp.GetRequiredService(); - var requirements = ResolveReadModelRequirements(options, typeof(WorkflowExecutionReport)); + EnsureReadModelModeSupported(options); + var bindingResolver = sp.GetRequiredService(); + var storeFactory = sp.GetRequiredService(); + var requirements = bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); var selectionOptions = new ProjectionReadModelStoreSelectionOptions { RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; - var registration = ProjectionReadModelStoreSelector.Select( - sp.GetServices>(), + return storeFactory.Create( + sp, selectionOptions, requirements); - - return registration.Create(sp); })); } - private static ProjectionReadModelRequirements ResolveReadModelRequirements( - WorkflowExecutionProjectionOptions options, - Type readModelType) - { - if (!TryResolveIndexKindBinding(options.ReadModelBindings, readModelType, out var requiredKind)) - return new ProjectionReadModelRequirements(); - - return new ProjectionReadModelRequirements( - requiresIndexing: true, - requiredIndexKinds: [requiredKind]); - } - - private static bool TryResolveIndexKindBinding( - IReadOnlyDictionary readModelBindings, - Type readModelType, - out ProjectionReadModelIndexKind requiredKind) - { - requiredKind = ProjectionReadModelIndexKind.None; - if (readModelBindings.Count == 0) - return false; - - if (!TryGetBinding(readModelBindings, readModelType.Name, out var configuredIndexKind) && - !TryGetBinding(readModelBindings, readModelType.FullName ?? "", out configuredIndexKind)) - return false; - - if (!Enum.TryParse(configuredIndexKind, true, out requiredKind) || - requiredKind == ProjectionReadModelIndexKind.None) - { - throw new InvalidOperationException( - $"Invalid ReadModelBindings value '{configuredIndexKind}' for '{readModelType.FullName}'. " + - $"Allowed values: {ProjectionReadModelIndexKind.Document}, {ProjectionReadModelIndexKind.Graph}."); - } - - return true; - } - - private static bool TryGetBinding( - IReadOnlyDictionary readModelBindings, - string key, - out string value) - { - if (key.Length > 0 && readModelBindings.TryGetValue(key, out value!)) - return true; - - value = ""; - return false; - } - private static string NormalizeProviderName(string providerName) { if (string.IsNullOrWhiteSpace(providerName)) @@ -192,6 +147,16 @@ private static string NormalizeProviderName(string providerName) return providerName.Trim(); } + private static void EnsureReadModelModeSupported(WorkflowExecutionProjectionOptions options) + { + if (options.ReadModelMode != ProjectionReadModelMode.StateOnly) + return; + + throw new InvalidOperationException( + "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + + "Use CustomReadModel or DefaultReadModel."); + } + private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj index fda99aa6e..9145f0f38 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj +++ b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj @@ -11,6 +11,8 @@ + + diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs new file mode 100644 index 000000000..c0adf5814 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -0,0 +1,123 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public class ProjectionReadModelRuntimeTests +{ + [Fact] + public void BindingResolver_ShouldResolveRequirement_ByReadModelName() + { + var resolver = new ProjectionReadModelBindingResolver(); + var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [nameof(TestReadModel)] = ProjectionReadModelIndexKind.Graph.ToString(), + }; + + var requirements = resolver.Resolve(bindings, typeof(TestReadModel)); + + requirements.RequiresIndexing.Should().BeTrue(); + requirements.RequiredIndexKinds.Should().ContainSingle() + .Which.Should().Be(ProjectionReadModelIndexKind.Graph); + } + + [Fact] + public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() + { + var selector = new ProjectionReadModelProviderSelector( + new ProjectionReadModelCapabilityValidatorService()); + var registrations = new List> + { + CreateRegistration( + "InMemory", + supportsIndexing: false, + indexKinds: []), + }; + + var selected = selector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = "InMemory", + FailOnUnsupportedCapabilities = false, + }, + new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + + selected.ProviderName.Should().Be("InMemory"); + } + + [Fact] + public void ProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() + { + var selector = new ProjectionReadModelProviderSelector( + new ProjectionReadModelCapabilityValidatorService()); + var registrations = new List> + { + CreateRegistration("InMemory", supportsIndexing: false, indexKinds: []), + CreateRegistration("Elasticsearch", supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document]), + }; + + Action act = () => selector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions(), + new ProjectionReadModelRequirements()); + + act.Should().Throw() + .Where(ex => ex.ReadModelType == typeof(TestReadModel)); + } + + private static IProjectionReadModelStoreRegistration CreateRegistration( + string providerName, + bool supportsIndexing, + IReadOnlyList indexKinds) + { + var capabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing, + indexKinds); + + return new DelegateProjectionReadModelStoreRegistration( + providerName, + capabilities, + _ => new NoopStore()); + } + + public sealed class TestReadModel + { + public string Id { get; set; } = ""; + } + + private sealed class NoopStore : IProjectionReadModelStore + { + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) + { + _ = readModel; + _ = ct; + return Task.CompletedTask; + } + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) + { + _ = key; + _ = mutate; + _ = ct; + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken ct = default) + { + _ = key; + _ = ct; + return Task.FromResult(null); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + _ = take; + _ = ct; + return Task.FromResult>([]); + } + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs new file mode 100644 index 000000000..f67f4b323 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs @@ -0,0 +1,80 @@ +using Aevatar.CQRS.Projection.StateMirror.Abstractions; +using Aevatar.CQRS.Projection.StateMirror.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public class StateMirrorProjectionTests +{ + [Fact] + public void AddJsonStateMirrorProjection_ShouldProjectStateByDefault() + { + var services = new ServiceCollection(); + services.AddJsonStateMirrorProjection(); + + using var provider = services.BuildServiceProvider(); + var projection = provider.GetRequiredService>(); + var projected = projection.Project(new SampleState + { + ActorId = "actor-1", + Count = 3, + InternalNote = "note", + }); + + projected.ActorId.Should().Be("actor-1"); + projected.Count.Should().Be(3); + projected.InternalNote.Should().Be("note"); + } + + [Fact] + public void AddJsonStateMirrorProjection_WithRenameAndIgnore_ShouldApplyOptions() + { + var services = new ServiceCollection(); + services.AddJsonStateMirrorProjection(options => + { + options.IgnoredFields.Add(nameof(SampleState.InternalNote)); + options.RenamedFields[nameof(SampleState.ActorId)] = nameof(RenamedReadModel.Id); + }); + + using var provider = services.BuildServiceProvider(); + var projection = provider.GetRequiredService>(); + var projected = projection.Project(new SampleState + { + ActorId = "actor-2", + Count = 9, + InternalNote = "secret", + }); + + projected.Id.Should().Be("actor-2"); + projected.Count.Should().Be(9); + projected.InternalNote.Should().BeNull(); + } + + public sealed class SampleState + { + public string ActorId { get; set; } = ""; + + public int Count { get; set; } + + public string InternalNote { get; set; } = ""; + } + + public sealed class SampleReadModel + { + public string ActorId { get; set; } = ""; + + public int Count { get; set; } + + public string InternalNote { get; set; } = ""; + } + + public sealed class RenamedReadModel + { + public string Id { get; set; } = ""; + + public int Count { get; set; } + + public string? InternalNote { get; set; } + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs b/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs index d56934c77..ae43c90db 100644 --- a/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs @@ -1,10 +1,15 @@ // ───────────────────────────────────────────────────────────── -// BDD: Agent lifecycle behavior -// Feature: Agent activation, deactivation, state loading/saving +// BDD: Agent lifecycle behavior (mandatory Event Sourcing) +// Feature: Agent activation/deactivation with replay-first recovery // ───────────────────────────────────────────────────────────── -using Shouldly; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Abstractions.Helpers; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; +using Shouldly; namespace Aevatar.Foundation.Core.Tests.Bdd; @@ -12,11 +17,16 @@ namespace Aevatar.Foundation.Core.Tests.Bdd; [Trait("Feature", "AgentLifecycle")] public class AgentLifecycleBddTests { - [Fact(DisplayName = "Given a new Agent, when activated, State should be initialized to default values")] - public async Task Given_NewAgent_When_Activated_Then_StateIsDefault() + [Fact(DisplayName = "Given a new Agent with EventSourcing, when activated, State should be initialized to default values")] + public async Task Given_NewAgentWithEventSourcing_When_Activated_Then_StateIsDefault() { // Given - var agent = new CounterAgent(); + var store = new InMemoryEventStore(); + var behavior = new CounterReplayBehavior(store, "lifecycle-1"); + var agent = new CounterAgent + { + EventSourcing = behavior, + }; agent.SetId("lifecycle-1"); agent.Services = new ServiceCollection().BuildServiceProvider(); @@ -29,16 +39,30 @@ public async Task Given_NewAgent_When_Activated_Then_StateIsDefault() agent.State.Name.ShouldBeEmpty(); } - [Fact(DisplayName = "Given an Agent configured with StateStore, when activated, should load existing state from Store")] - public async Task Given_AgentWithStore_When_Activated_Then_StateLoadedFromStore() + [Fact(DisplayName = "Given an Agent with EventStore history, when activated, should recover state by replay")] + public async Task Given_AgentWithHistory_When_Activated_Then_StateRecoveredFromReplay() { // Given - var store = new InMemoryStateStore(); - await store.SaveAsync("lifecycle-2", new CounterState { Count = 42, Name = "saved" }); - - var agent = new CounterAgent(); + var store = new InMemoryEventStore(); + await store.AppendAsync( + "lifecycle-2", + [new StateEvent + { + EventId = Guid.NewGuid().ToString("N"), + Timestamp = TimestampHelper.Now(), + Version = 1, + EventType = typeof(IncrementEvent).FullName ?? nameof(IncrementEvent), + EventData = Any.Pack(new IncrementEvent { Amount = 42 }), + AgentId = "lifecycle-2", + }], + expectedVersion: 0); + + var behavior = new CounterReplayBehavior(store, "lifecycle-2"); + var agent = new CounterAgent + { + EventSourcing = behavior, + }; agent.SetId("lifecycle-2"); - agent.StateStore = store; agent.Services = new ServiceCollection().BuildServiceProvider(); // When @@ -46,46 +70,65 @@ public async Task Given_AgentWithStore_When_Activated_Then_StateLoadedFromStore( // Then agent.State.Count.ShouldBe(42); - agent.State.Name.ShouldBe("saved"); } - [Fact(DisplayName = "Given an active Agent, when deactivated, State should be saved to Store")] - public async Task Given_ActiveAgent_When_Deactivated_Then_StateSavedToStore() + [Fact(DisplayName = "Given an active Agent, when deactivated, pending events should be committed")] + public async Task Given_ActiveAgent_When_Deactivated_Then_PendingEventsCommitted() { // Given - var store = new InMemoryStateStore(); - var agent = new CounterAgent(); + var store = new InMemoryEventStore(); + var behavior = new CounterReplayBehavior(store, "lifecycle-3"); + var agent = new CounterAgent + { + EventSourcing = behavior, + }; agent.SetId("lifecycle-3"); - agent.StateStore = store; agent.Services = new ServiceCollection().BuildServiceProvider(); - await agent.ActivateAsync(); - // Modify state through event - var envelope = TestHelper.Envelope(new IncrementEvent { Amount = 7 }); - await agent.HandleEventAsync(envelope); + behavior.RaiseEvent(new IncrementEvent { Amount = 7 }); // When await agent.DeactivateAsync(); // Then - var saved = await store.LoadAsync("lifecycle-3"); - saved.ShouldNotBeNull(); - saved!.Count.ShouldBe(7); + var events = await store.GetEventsAsync("lifecycle-3"); + events.Count.ShouldBe(1); + events[0].EventType.ShouldContain(nameof(IncrementEvent)); } - [Fact(DisplayName = "Given an Agent without StateStore, when completing full lifecycle, should not throw exception")] - public async Task Given_AgentWithoutStore_When_FullLifecycle_Then_NoException() + [Fact(DisplayName = "Given an Agent without EventSourcing, when activated, should fail fast")] + public async Task Given_AgentWithoutEventSourcing_When_Activated_Then_FailFast() { // Given - var agent = new CounterAgent(); + var agent = new CounterAgent + { + EventSourcing = null, + }; agent.SetId("lifecycle-4"); agent.Services = new ServiceCollection().BuildServiceProvider(); // When / Then - await agent.ActivateAsync(); - await agent.HandleEventAsync(TestHelper.Envelope(new IncrementEvent { Amount = 1 })); - await agent.DeactivateAsync(); + var act = () => agent.ActivateAsync(); + await act.ShouldThrowAsync(); + } - agent.State.Count.ShouldBe(1); + private sealed class CounterReplayBehavior : EventSourcingBehavior + { + public CounterReplayBehavior(IEventStore eventStore, string agentId) + : base(eventStore, agentId) { } + + public override CounterState TransitionState(CounterState current, IMessage evt) + { + if (evt is Any any && any.TryUnpack(out var inc)) + { + return new CounterState + { + Count = current.Count + inc.Amount, + Name = current.Name, + }; + } + + return current; + } } } diff --git a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs index c619d5ad6..d08dcba7d 100644 --- a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs @@ -1,6 +1,7 @@ // ─── Event Sourcing (IEventSourcingBehavior / EventSourcingBehavior / SnapshotStrategy) tests ─── using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Abstractions.Helpers; using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Abstractions.Hooks; using Aevatar.Foundation.Abstractions.Persistence; @@ -37,6 +38,17 @@ public async Task ConfirmEventsAsync_WhenNoPending_DoesNothing() events.Count.ShouldBe(0); } + [Fact] + public async Task ConfirmEventsAsync_WhenStateSnapshotEventRaised_ShouldFailFast() + { + var (_, behavior) = Create(); + behavior.RaiseEvent(new CounterState { Count = 1, Name = "snapshot" }); + + Func act = async () => await behavior.ConfirmEventsAsync(); + + await act.ShouldThrowAsync(); + } + [Fact] public async Task RaiseEvent_And_ConfirmEventsAsync_PersistsAndUpdatesVersion() { @@ -105,6 +117,84 @@ public async Task MultipleConfirmEventsAsync_AppendsInOrder() state!.Count.ShouldBe(3); } + [Fact] + public async Task PersistSnapshotAsync_WhenSnapshotStrategyMatches_ShouldPersistSnapshot() + { + var store = new InMemoryEventStore(); + var snapshotStore = new InMemoryEventSourcingSnapshotStore(); + var behavior = new CounterEventSourcingBehavior( + store, + "agent-snapshot", + snapshotStore: snapshotStore, + snapshotStrategy: new IntervalSnapshotStrategy(1)); + + behavior.RaiseEvent(new IncrementEvent { Amount = 9 }); + await behavior.ConfirmEventsAsync(); + await behavior.PersistSnapshotAsync(new CounterState { Count = 9, Name = "snapshot" }); + + var snapshot = await snapshotStore.LoadAsync("agent-snapshot"); + snapshot.ShouldNotBeNull(); + snapshot!.Version.ShouldBe(1); + snapshot.State.Count.ShouldBe(9); + } + + [Fact] + public async Task PersistSnapshotAsync_WhenSnapshotSaveFails_ShouldNotThrowAndShouldKeepCommittedEvents() + { + var store = new InMemoryEventStore(); + var snapshotStore = new ThrowingSnapshotStore(); + var behavior = new CounterEventSourcingBehavior( + store, + "agent-snapshot-fail", + snapshotStore: snapshotStore, + snapshotStrategy: new IntervalSnapshotStrategy(1)); + + behavior.RaiseEvent(new IncrementEvent { Amount = 3 }); + await behavior.ConfirmEventsAsync(); + + await behavior.PersistSnapshotAsync(new CounterState { Count = 3, Name = "snapshot-fail" }); + + behavior.CurrentVersion.ShouldBe(1); + var events = await store.GetEventsAsync("agent-snapshot-fail"); + events.Count.ShouldBe(1); + } + + [Fact] + public async Task ReplayAsync_WhenSnapshotExists_ShouldReplayOnlyDeltaEvents() + { + var store = new InMemoryEventStore(); + var snapshotStore = new InMemoryEventSourcingSnapshotStore(); + await snapshotStore.SaveAsync( + "agent-replay-with-snapshot", + new EventSourcingSnapshot( + new CounterState { Count = 10, Name = "snap" }, + 2)); + + await store.AppendAsync( + "agent-replay-with-snapshot", + [new StateEvent + { + EventId = "e-3", + Timestamp = TimestampHelper.Now(), + Version = 3, + EventType = typeof(IncrementEvent).FullName ?? nameof(IncrementEvent), + EventData = Any.Pack(new IncrementEvent { Amount = 4 }), + AgentId = "agent-replay-with-snapshot", + }], + expectedVersion: 0); + + var behavior = new CounterEventSourcingBehavior( + store, + "agent-replay-with-snapshot", + snapshotStore: snapshotStore); + + var replayed = await behavior.ReplayAsync("agent-replay-with-snapshot"); + + replayed.ShouldNotBeNull(); + replayed!.Count.ShouldBe(14); + behavior.CurrentVersion.ShouldBe(3); + } + [Fact] public async Task DefaultTransitionState_ReturnsCurrentUnchanged() { @@ -121,8 +211,12 @@ public async Task DefaultTransitionState_ReturnsCurrentUnchanged() /// Test behavior that applies IncrementEvent/DecrementEvent to CounterState. internal sealed class CounterEventSourcingBehavior : EventSourcingBehavior { - public CounterEventSourcingBehavior(IEventStore eventStore, string agentId) - : base(eventStore, agentId) { } + public CounterEventSourcingBehavior( + IEventStore eventStore, + string agentId, + IEventSourcingSnapshotStore? snapshotStore = null, + ISnapshotStrategy? snapshotStrategy = null) + : base(eventStore, agentId, snapshotStore, snapshotStrategy) { } public override CounterState TransitionState(CounterState current, IMessage evt) { @@ -131,65 +225,71 @@ public override CounterState TransitionState(CounterState current, IMessage evt) return new CounterState { Count = current.Count + inc.Amount, Name = current.Name }; if (any.TryUnpack(out var dec)) return new CounterState { Count = current.Count - dec.Amount, Name = current.Name }; - return current; + return base.TransitionState(current, evt); } } -} -/// Event Sourcing agent: state restored from event replay on activate, handlers RaiseEvent + ConfirmEventsAsync. -public class EventSourcingCounterAgent : GAgentBase -{ - /// Injected by test or runtime; when set, activate replays from store and handlers persist via ES. - public IEventSourcingBehavior? EventSourcing { get; set; } - - protected override async Task OnActivateAsync(CancellationToken ct) + private sealed class InMemoryEventSourcingSnapshotStore : IEventSourcingSnapshotStore + where TState : class { - if (EventSourcing != null) + private readonly Dictionary> _snapshots = new(StringComparer.Ordinal); + + public Task?> LoadAsync(string agentId, CancellationToken ct = default) { - var replayed = await EventSourcing.ReplayAsync(Id, ct); - if (replayed != null) - State = replayed; + _ = ct; + _snapshots.TryGetValue(agentId, out var snapshot); + return Task.FromResult(snapshot); } - } - protected override async Task OnDeactivateAsync(CancellationToken ct) - { - if (EventSourcing != null) - await EventSourcing.ConfirmEventsAsync(ct); + public Task SaveAsync(string agentId, EventSourcingSnapshot snapshot, CancellationToken ct = default) + { + _ = ct; + _snapshots[agentId] = snapshot; + return Task.CompletedTask; + } } - [EventHandler] - public async Task HandleIncrement(IncrementEvent evt) + private sealed class ThrowingSnapshotStore : IEventSourcingSnapshotStore + where TState : class { - if (EventSourcing != null) + public Task?> LoadAsync(string agentId, CancellationToken ct = default) { - EventSourcing.RaiseEvent(evt); - await EventSourcing.ConfirmEventsAsync(); - var replayed = await EventSourcing.ReplayAsync(Id); - if (replayed != null) - State = replayed; + _ = agentId; + _ = ct; + return Task.FromResult?>(null); } - else + + public Task SaveAsync(string agentId, EventSourcingSnapshot snapshot, CancellationToken ct = default) { - State = new CounterState { Count = State.Count + evt.Amount, Name = State.Name }; + _ = agentId; + _ = snapshot; + _ = ct; + throw new InvalidOperationException("snapshot-store-failure"); } } +} + +/// Event Sourcing agent: state restored from event replay on activate, handlers RaiseEvent + ConfirmEventsAsync. +public class EventSourcingCounterAgent : GAgentBase +{ + [EventHandler] + public async Task HandleIncrement(IncrementEvent evt) + { + EventSourcing!.RaiseEvent(evt); + await EventSourcing.ConfirmEventsAsync(); + var replayed = await EventSourcing.ReplayAsync(Id); + if (replayed != null) + State = replayed; + } [EventHandler(Priority = 10)] public async Task HandleDecrement(DecrementEvent evt) { - if (EventSourcing != null) - { - EventSourcing.RaiseEvent(evt); - await EventSourcing.ConfirmEventsAsync(); - var replayed = await EventSourcing.ReplayAsync(Id); - if (replayed != null) - State = replayed; - } - else - { - State = new CounterState { Count = State.Count - evt.Amount, Name = State.Name }; - } + EventSourcing!.RaiseEvent(evt); + await EventSourcing.ConfirmEventsAsync(); + var replayed = await EventSourcing.ReplayAsync(Id); + if (replayed != null) + State = replayed; } } @@ -277,17 +377,13 @@ public async Task EventSourcingAgent_DeactivateThenReactivate_RestoresFromReplay } [Fact] - public async Task EventSourcingAgent_WithoutBehavior_WorksInMemoryOnly() + public async Task EventSourcingAgent_WithoutBehavior_ShouldFailFastOnActivate() { var agent = new EventSourcingCounterAgent { EventSourcing = null }; agent.SetId("agent-null-es"); WireAgent(agent); - await agent.ActivateAsync(); - - await agent.HandleEventAsync(TestHelper.Envelope(new IncrementEvent { Amount = 7 })); - agent.State.Count.ShouldBe(7); - await agent.HandleEventAsync(TestHelper.Envelope(new DecrementEvent { Amount = 2 })); - agent.State.Count.ShouldBe(5); + var act = () => agent.ActivateAsync(); + await act.ShouldThrowAsync(); } } diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansDistributedCoverageTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansDistributedCoverageTests.cs index 9e6e34c28..b162d3e68 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansDistributedCoverageTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansDistributedCoverageTests.cs @@ -342,7 +342,7 @@ public async Task RuntimeActorGrain_ShouldManageStateWithoutActivationContext() { var state = DispatchProxy.Create, RuntimeActorPersistentStateProxy>(); var stateProxy = (RuntimeActorPersistentStateProxy)(object)state; - var grain = new RuntimeActorGrain(state, new AsyncLocalRuntimeActorStateBindingAccessor()); + var grain = new RuntimeActorGrain(state); (await grain.IsInitializedAsync()).Should().BeFalse(); stateProxy.State.AgentTypeName = "Known.Type"; @@ -381,7 +381,7 @@ public async Task RuntimeActorGrain_ShouldManageStateWithoutActivationContext() public async Task RuntimeActorGrain_ShouldCoverExceptionalBranches() { var state = DispatchProxy.Create, RuntimeActorPersistentStateProxy>(); - var grain = new RuntimeActorGrain(state, new AsyncLocalRuntimeActorStateBindingAccessor()); + var grain = new RuntimeActorGrain(state); var envelopeAct = () => grain.HandleEnvelopeAsync(new byte[] { 1, 2, 3 }); await envelopeAct.Should().ThrowAsync() diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeActorStateStoreIntegrationTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeActorStateStoreIntegrationTests.cs index 07950e985..b9007474f 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeActorStateStoreIntegrationTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeActorStateStoreIntegrationTests.cs @@ -16,7 +16,7 @@ namespace Aevatar.Foundation.Runtime.Hosting.Tests; public sealed class OrleansRuntimeActorStateStoreIntegrationTests { [Fact] - public async Task RuntimeActorGrain_ShouldRestoreStatefulAgentSnapshot_WhenReinitialized() + public async Task RuntimeActorGrain_ShouldNotRestoreTransientStateWithoutEvents_WhenReinitialized() { var actorId = $"actor-{Guid.NewGuid():N}"; var siloPort = ReserveTcpPort(); @@ -35,7 +35,7 @@ public async Task RuntimeActorGrain_ShouldRestoreStatefulAgentSnapshot_WhenReini await grain.DeactivateAsync(); (await grain.InitializeAgentAsync(agentType)).Should().BeTrue(); - (await grain.GetDescriptionAsync()).Should().Be("activation-count:2"); + (await grain.GetDescriptionAsync()).Should().Be("activation-count:1"); } finally { diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index eb3d69f1c..5a8e11dd4 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -4,6 +4,9 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.Neo4j.Stores; using Aevatar.AI.Projection.Reducers; using Aevatar.Workflow.Extensions.AIProjection; using Aevatar.Workflow.Projection; @@ -53,6 +56,24 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfigured_Shoul metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); } + [Fact] + public async Task AddWorkflowExecutionProjectionCQRS_WhenNeo4jConfigured_ShouldResolveNeo4jStore() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + RegisterNeo4jProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + options.ReadModelProvider = ProjectionReadModelProviderNames.Neo4j); + + await using var provider = services.BuildServiceProvider(); + var store = provider.GetRequiredService>(); + + store.Should().BeOfType>(); + var metadata = store.Should().BeAssignableTo().Subject; + metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); + metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Graph); + } + [Fact] public void AddWorkflowExecutionProjectionCQRS_WhenProviderUnsupported_ShouldThrow() { @@ -105,6 +126,24 @@ public void AddWorkflowExecutionProjectionCQRS_WhenFailFastDisabled_ShouldAllowU act.Should().NotThrow(); } + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenStateOnlyModeConfigured_ShouldThrow() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + { + options.ReadModelMode = ProjectionReadModelMode.StateOnly; + options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; + }); + + using var provider = services.BuildServiceProvider(); + Action act = () => provider.GetRequiredService>(); + + act.Should().Throw() + .WithMessage("*does not support*StateOnly*"); + } + [Fact] public async Task AddWorkflowExecutionProjectionReducer_ShouldSupportExternalReducer() { @@ -323,6 +362,21 @@ private static void RegisterInMemoryProvider(IServiceCollection services) listSortSelector: report => report.StartedAt); } + private static void RegisterNeo4jProvider(IServiceCollection services) + { + services.AddNeo4jReadModelStoreRegistration( + optionsFactory: _ => new Neo4jProjectionReadModelStoreOptions + { + Uri = "bolt://localhost:7687", + Username = "neo4j", + Password = "test", + AutoCreateConstraints = false, + }, + scope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + } + public sealed class CustomChatRequestReducer : WorkflowExecutionEventReducerBase { protected override bool Reduce( diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index e9c6f5e97..9ad65b192 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -549,6 +549,7 @@ private static WorkflowExecutionProjectionService CreateService( // Use a dedicated local actor runtime for projection coordinator actors. var runtimeServices = new ServiceCollection(); runtimeServices.AddSingleton(); + runtimeServices.AddSingleton(); var runtimeProvider = runtimeServices.BuildServiceProvider(); var runtime = new LocalActorRuntime( streams, diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index bdc0fa7a5..ea2d44abf 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -52,6 +52,54 @@ if rg -n "GetAwaiter\(\)\.GetResult\(\)" src; then exit 1 fi +if [ -f "src/Aevatar.Foundation.Core/EventSourcing/DefaultAutoPersistedStateEventFactory.cs" ]; then + echo "DefaultAutoPersistedStateEventFactory is forbidden. EventStore must persist domain events, not snapshot-state events." + exit 1 +fi + +if [ -f "src/Aevatar.Foundation.Core/EventSourcing/IAutoPersistedStateEventFactory.cs" ]; then + echo "IAutoPersistedStateEventFactory is forbidden. Use IDomainEventDeriver for semantic event derivation." + exit 1 +fi + +if [ -f "src/Aevatar.Foundation.Core/EventSourcing/EventSourcingAutoPersistenceOptions.cs" ]; then + echo "EventSourcingAutoPersistenceOptions is forbidden. Stateful actors must emit explicit domain events." + exit 1 +fi + +if [ -f "src/Aevatar.Foundation.Core/EventSourcing/IDomainEventDeriver.cs" ]; then + echo "IDomainEventDeriver is forbidden on runtime path. Domain events must be produced explicitly by command handlers." + exit 1 +fi + +if rg -n "ConfirmStateAsync\(" src/Aevatar.Foundation.Core/EventSourcing; then + echo "ConfirmStateAsync is forbidden." + exit 1 +fi + +if rg -n "ConfirmDerivedEventsAsync\(" src/Aevatar.Foundation.Core/EventSourcing; then + echo "ConfirmDerivedEventsAsync is forbidden. Domain events must be raised explicitly via RaiseEvent + ConfirmEventsAsync." + exit 1 +fi + +if rg -n "TryUnpack|Unpack" src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs; then + echo "EventSourcingBehavior must not unpack TState snapshots from persisted events." + exit 1 +fi + +if rg -n "StateStore\.LoadAsync|StateStore\.SaveAsync" src/Aevatar.Foundation.Core/GAgentBase.TState.cs; then + echo "GAgentBase must not use StateStore as the fact source. Recovery must come from EventStore replay." + exit 1 +fi + +if rg -n "IEventSourcingBehavior<>\\)\\.MakeGenericType|EventSourcingBehavior<>\\)\\.MakeGenericType|GetProperty\\(\"EventSourcing\"\\)|GetProperty\\(\"StateStore\"\\)" \ + src/Aevatar.Foundation.Runtime \ + src/Aevatar.Foundation.Runtime.Implementations.Orleans +then + echo "Runtime must not use reflection-based stateful ES binding. Use static generic construction in GAgentBase." + exit 1 +fi + if rg -n "TypeUrl\.Contains|typeUrl\.Contains\(" src demos; then echo "Found string-based event type matching." exit 1 From fb9a1133f5e3d7e22440e7c6847826fc8e9e94ff Mon Sep 17 00:00:00 2001 From: Auric Date: Mon, 23 Feb 2026 23:22:13 +0800 Subject: [PATCH 04/46] Enhance Event Sourcing with State Event Appliers and Transition Matchers - Introduced `IStateEventApplier` interface and `StateEventApplierBase` class to facilitate modular state transitions in event sourcing. - Added `StateTransitionMatcher` for deterministic event-to-state transitions, improving clarity and maintainability of state management. - Updated `GAgentBase` to utilize new appliers for event handling, enhancing the flexibility of state transitions. - Refactored existing event handling logic in `ProjectionOwnershipCoordinatorGAgent` and `WorkflowGAgent` to leverage the new state transition mechanisms. - Enhanced documentation to reflect the new abstractions and usage patterns for developers. --- docs/EVENT_SOURCING.md | 21 +-- ...ng-elasticsearch-readmodel-requirements.md | 14 +- .../ProjectionOwnershipCoordinatorGAgent.cs | 55 +++++-- .../EventSourcing/IStateEventApplier.cs | 21 +++ .../EventSourcing/StateEventApplierBase.cs | 31 ++++ .../EventSourcing/StateTransitionMatcher.cs | 72 +++++++++ .../GAgentBase.TState.cs | 99 +++++++++++- .../Aevatar.Workflow.Core/WorkflowGAgent.cs | 146 +++++++++++------- .../ProjectionOwnershipAndSessionHubTests.cs | 83 +++++++++- .../Bdd/AgentLifecycleBddTests.cs | 18 +-- .../EventSourcingTests.cs | 88 ++++++++++- .../WorkflowGAgentCoverageTests.cs | 49 +++--- 12 files changed, 574 insertions(+), 123 deletions(-) create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index 1ed5c725e..672d55700 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -15,6 +15,9 @@ ## 3. 当前代码事实(权威路径) - ES 行为契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` - ES 默认实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 状态事件 applier 抽象:`src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs` +- Typed applier 基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` +- 状态事件匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 有状态生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` - Local Runtime 注入边界:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - Orleans Runtime 注入边界:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` @@ -25,7 +28,7 @@ - `GAgentBase.ActivateAsync` 先调用 `base.ActivateAsync` 恢复模块。 - 然后调用 `EnsureEventSourcingConfigured()`: - 若已设置 `EventSourcing`,直接使用。 - - 若未设置,则通过 `Services.GetService(typeof(IEventStore))` 解析 `IEventStore`,并静态构造 `EventSourcingBehavior(eventStore, actorId)`。 + - 若未设置,则通过 `Services.GetService(typeof(IEventStore))` 解析 `IEventStore`,并静态构造 `AgentBackedEventSourcingBehavior`(继承 `EventSourcingBehavior`)。 - 最后执行 `ReplayAsync(actorId)`,以 Replay 结果恢复 `State`。 ### 4.2 Deactivate @@ -41,9 +44,11 @@ ## 5. 开发者实现规范 1. 命令处理代码必须显式构建领域事件:`RaiseEvent(domainEvent)`。 -2. 必须调用 `ConfirmEventsAsync` 提交 pending events。 +2. 推荐直接使用 `PersistDomainEventAsync(...)` / `PersistDomainEventsAsync(...)` 完成“提交 + apply”。 3. 必须保证“可重放同态”:`Replay` 后状态与在线运行状态一致。 -4. 推荐通过重写 `TransitionState` 明确定义事件到状态转换。 +4. 推荐通过以下两种方式之一定义 `event -> state`: + - 在 Agent 中重写 `TransitionState` + - 通过 DI 注册 `IStateEventApplier`(复杂领域推荐) 示例(简化): @@ -51,13 +56,7 @@ [EventHandler] public async Task Handle(IncrementRequested evt) { - EventSourcing!.RaiseEvent(new IncrementApplied { Amount = evt.Amount }); - await EventSourcing.ConfirmEventsAsync(); - - // 当前基线下,最直接的一致性方式是回放后更新内存状态 - var replayed = await EventSourcing.ReplayAsync(Id); - if (replayed != null) - State = replayed; + await PersistDomainEventAsync(new IncrementApplied { Amount = evt.Amount }); } ``` @@ -65,6 +64,8 @@ public async Task Handle(IncrementRequested evt) - `AddAevatarRuntime()` 默认注册 `IEventStore -> InMemoryEventStore`(开发/测试)。 - 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 - 如需自定义 ES 行为,可直接为 Agent 预设 `EventSourcing`,但必须保持相同语义契约。 +- 如需解耦 Agent 里的 `TransitionState` 逻辑,可注册多个 `IStateEventApplier`,按 `Order` 升序匹配应用。 +- Agent 侧推荐使用 `StateTransitionMatcher.Match(...).On(...).OrCurrent()`,避免重复 `Any + switch` 样板代码。 ## 7. 快照语义 1. 快照仅用于减少回放开销。 diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 7c5648dee..f797028cd 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -19,6 +19,9 @@ ### 3.1 Event Sourcing - 契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` - 实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 状态转换扩展:`src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs` +- Typed 状态转换基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` +- 状态匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` - 运行时注入边界: - `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` @@ -27,9 +30,11 @@ 当前语义: 1. `ActivateAsync` 强制 Replay 恢复状态。 2. `DeactivateAsync` 强制 `ConfirmEventsAsync + PersistSnapshotAsync`。 -3. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `EventSourcingBehavior`。 +3. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `AgentBackedEventSourcingBehavior`(继承 `EventSourcingBehavior`)。 4. 缺失 `IEventStore` 时 fail-fast。 5. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 +6. 运行期通过 `PersistDomainEventAsync` / `PersistDomainEventsAsync` 执行“持久化 + 顺序 apply”;Replay 主要用于激活恢复。 +7. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 ### 3.2 Provider Runtime - 抽象:`src/Aevatar.CQRS.Projection.Abstractions` @@ -69,6 +74,10 @@ ### R-ES-04 静态泛型装配 - ES 行为构造必须在泛型上下文完成,不得回退到 Runtime 反射注入。 +### R-ES-05 状态转换可组合 +- `event -> state` 转换必须支持模块化拆分,避免在单个 Agent 中膨胀式 `switch`。 +- 支持 `IStateEventApplier` 组合式 apply,顺序由 `Order` 控制。 + ### R-RM-01 Provider 解耦业务 - Provider 项目不得引用 Workflow/AI 业务读模型类型。 @@ -108,7 +117,8 @@ ### P3(进行中)一致性与可维护性收口 1. 清理文档与代码中的历史双轨口径。 2. 补齐跨模块契约测试:`Command -> Events -> Replay -> State`。 -3. 统一配置示例与错误合同说明(启动失败与能力不匹配)。 +3. 收敛 state transition 模型(Agent override + applier 组合)。 +4. 统一配置示例与错误合同说明(启动失败与能力不匹配)。 ### P4(待执行)性能与生产化增强 1. 为持久化 `IEventStore` 提供生产落地方案与压测基线。 diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs index 005687a2f..87ca11516 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs @@ -1,5 +1,7 @@ using Aevatar.Foundation.Abstractions.Attributes; using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; namespace Aevatar.CQRS.Projection.Core.Orchestration; @@ -21,7 +23,7 @@ public static string BuildActorId(string scopeId) } [EventHandler] - public Task HandleAcquireAsync(ProjectionOwnershipAcquireEvent evt) + public async Task HandleAcquireAsync(ProjectionOwnershipAcquireEvent evt) { ArgumentNullException.ThrowIfNull(evt); if (string.IsNullOrWhiteSpace(evt.ScopeId)) @@ -35,20 +37,16 @@ public Task HandleAcquireAsync(ProjectionOwnershipAcquireEvent evt) $"Projection ownership for scope '{State.ScopeId}' is already active (session '{State.SessionId}')."); } - State.ScopeId = evt.ScopeId; - State.SessionId = evt.SessionId; - State.Active = true; - State.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); - return Task.CompletedTask; + await PersistDomainEventAsync(evt); } [EventHandler] - public Task HandleReleaseAsync(ProjectionOwnershipReleaseEvent evt) + public async Task HandleReleaseAsync(ProjectionOwnershipReleaseEvent evt) { ArgumentNullException.ThrowIfNull(evt); if (!State.Active) - return Task.CompletedTask; + return; if (!string.IsNullOrWhiteSpace(evt.ScopeId) && !string.Equals(evt.ScopeId, State.ScopeId, StringComparison.Ordinal)) @@ -64,9 +62,42 @@ public Task HandleReleaseAsync(ProjectionOwnershipReleaseEvent evt) $"Projection ownership coordinator '{Id}' session mismatch. expected='{State.SessionId}', actual='{evt.SessionId}'."); } - State.Active = false; - State.SessionId = string.Empty; - State.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); - return Task.CompletedTask; + await PersistDomainEventAsync(evt); + } + + protected override ProjectionOwnershipCoordinatorState TransitionState( + ProjectionOwnershipCoordinatorState current, + IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyAcquire) + .On(ApplyRelease) + .OrCurrent(); + + private static ProjectionOwnershipCoordinatorState ApplyAcquire( + ProjectionOwnershipCoordinatorState current, + ProjectionOwnershipAcquireEvent evt) + { + var next = current.Clone(); + next.ScopeId = evt.ScopeId; + next.SessionId = evt.SessionId; + next.Active = true; + next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); + return next; + } + + private static ProjectionOwnershipCoordinatorState ApplyRelease( + ProjectionOwnershipCoordinatorState current, + ProjectionOwnershipReleaseEvent evt) + { + _ = evt; + if (!current.Active) + return current; + + var next = current.Clone(); + next.Active = false; + next.SessionId = string.Empty; + next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); + return next; } } diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs b/src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs new file mode 100644 index 000000000..1a5087572 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs @@ -0,0 +1,21 @@ +using Google.Protobuf; + +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Stateful event applier abstraction for replay and runtime state transitions. +/// +public interface IStateEventApplier + where TState : class, IMessage, new() +{ + /// + /// Execution order for multiple appliers; lower value executes first. + /// + int Order { get; } + + /// + /// Tries to apply one event to current state. + /// Returns true when handled and provides the next state. + /// + bool TryApply(TState current, IMessage evt, out TState next); +} diff --git a/src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs b/src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs new file mode 100644 index 000000000..bdbf3730a --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs @@ -0,0 +1,31 @@ +using Google.Protobuf; +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Typed base class for state event appliers. +/// Supports both raw TEvent and Any-packed TEvent payloads. +/// +public abstract class StateEventApplierBase + : IStateEventApplier + where TState : class, IMessage, new() + where TEvent : class, IMessage, new() +{ + public virtual int Order => 0; + + public bool TryApply(TState current, IMessage evt, out TState next) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(evt); + + if (StateTransitionMatcher.TryExtract(evt, out var typed)) + { + next = Apply(current, typed); + return true; + } + + next = current; + return false; + } + + protected abstract TState Apply(TState current, TEvent evt); +} diff --git a/src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs b/src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs new file mode 100644 index 000000000..5d2c67964 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs @@ -0,0 +1,72 @@ +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Helper for deterministic event-to-state transitions. +/// Supports both raw event instances and Any-packed payloads. +/// +public static class StateTransitionMatcher +{ + public static StateTransitionBuilder Match(TState current, IMessage evt) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentNullException.ThrowIfNull(evt); + return new StateTransitionBuilder(current, evt); + } + + public static bool TryExtract(IMessage evt, out TEvent extracted) + where TEvent : class, IMessage, new() + { + ArgumentNullException.ThrowIfNull(evt); + + if (evt is TEvent typed) + { + extracted = typed; + return true; + } + + if (evt is Any any && any.TryUnpack(out var unpacked)) + { + extracted = unpacked; + return true; + } + + extracted = null!; + return false; + } +} + +public sealed class StateTransitionBuilder +{ + private readonly TState _current; + private readonly IMessage _evt; + private bool _matched; + private TState _next; + + internal StateTransitionBuilder(TState current, IMessage evt) + { + _current = current; + _evt = evt; + _next = current; + } + + public StateTransitionBuilder On(Func applier) + where TEvent : class, IMessage, new() + { + ArgumentNullException.ThrowIfNull(applier); + + if (_matched) + return this; + + if (!StateTransitionMatcher.TryExtract(_evt, out var typedEvent)) + return this; + + _next = applier(_current, typedEvent); + _matched = true; + return this; + } + + public TState OrCurrent() => _matched ? _next : _current; +} diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index ea19c04e6..f1054f731 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -6,6 +6,7 @@ using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.Foundation.Core; @@ -17,6 +18,8 @@ public abstract class GAgentBase : GAgentBase, IAgent where TState : class, IMessage, new() { private TState _state = new(); + private IServiceProvider? _applierServiceProvider; + private IReadOnlyList> _appliers = []; /// Mutable agent state, writable only in EventHandler/OnActivateAsync scopes. public TState State @@ -25,7 +28,7 @@ public TState State protected set { StateGuard.EnsureWritable(); _state = value; } } - /// State persistence store injected by runtime. + /// State persistence store reserved for snapshot optimization. public IStateStore? StateStore { get; set; } /// Event Sourcing behavior injected by runtime; required for state recovery and commit. @@ -39,6 +42,7 @@ public override async Task ActivateAsync(CancellationToken ct = default) var eventSourcing = EnsureEventSourcingConfigured(); var replayed = await eventSourcing.ReplayAsync(Id, ct); _state = replayed ?? new TState(); + await OnStateChangedAsync(_state, ct); await OnActivateAsync(ct); } @@ -61,6 +65,61 @@ protected virtual Task OnStateChangedAsync(TState state, CancellationToken ct) = /// Deactivation hook for subclass cleanup. protected virtual Task OnDeactivateAsync(CancellationToken ct) => Task.CompletedTask; + /// + /// Applies one persisted domain event to state. + /// Default behavior delegates to registered instances. + /// Override this method for agent-local transition logic. + /// + protected virtual TState TransitionState(TState current, IMessage evt) + { + foreach (var applier in ResolveStateEventAppliers()) + { + if (applier.TryApply(current, evt, out var next)) + return next; + } + + return current; + } + + /// + /// Persist one domain event, then apply it to in-memory state. + /// + protected Task PersistDomainEventAsync(TEvent evt, CancellationToken ct = default) + where TEvent : IMessage + { + ArgumentNullException.ThrowIfNull(evt); + return PersistDomainEventsAsync([evt], ct); + } + + /// + /// Persist domain events as one commit, then apply them to in-memory state in order. + /// + protected async Task PersistDomainEventsAsync( + IEnumerable events, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(events); + + var domainEvents = events as IMessage[] ?? events.ToArray(); + if (domainEvents.Length == 0) + return; + + for (var i = 0; i < domainEvents.Length; i++) + ArgumentNullException.ThrowIfNull(domainEvents[i]); + + var eventSourcing = EnsureEventSourcingConfigured(); + foreach (var evt in domainEvents) + eventSourcing.RaiseEvent(evt); + + await eventSourcing.ConfirmEventsAsync(ct); + + using var guard = StateGuard.BeginWriteScope(); + foreach (var evt in domainEvents) + _state = eventSourcing.TransitionState(_state, evt); + + await OnStateChangedAsync(_state, ct); + } + private IEventSourcingBehavior EnsureEventSourcingConfigured() { if (EventSourcing != null) @@ -68,7 +127,7 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() if (Services?.GetService(typeof(IEventStore)) is IEventStore eventStore) { - EventSourcing = new EventSourcingBehavior(eventStore, Id); + EventSourcing = new AgentBackedEventSourcingBehavior(eventStore, Id, this); return EventSourcing; } @@ -76,4 +135,40 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() $"Stateful agent '{GetType().FullName}' requires '{typeof(IEventSourcingBehavior).FullName}' " + $"for actor '{Id}'."); } + + private IReadOnlyList> ResolveStateEventAppliers() + { + if (ReferenceEquals(_applierServiceProvider, Services)) + return _appliers; + + _applierServiceProvider = Services; + if (Services == null) + { + _appliers = []; + return _appliers; + } + + _appliers = Services + .GetServices>() + .OrderBy(x => x.Order) + .ToArray(); + return _appliers; + } + + private sealed class AgentBackedEventSourcingBehavior : EventSourcingBehavior + { + private readonly GAgentBase _owner; + + public AgentBackedEventSourcingBehavior( + IEventStore eventStore, + string agentId, + GAgentBase owner) + : base(eventStore, agentId) + { + _owner = owner; + } + + public override TState TransitionState(TState current, IMessage evt) => + _owner.TransitionState(current, evt); + } } diff --git a/src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs b/src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs index 64eadaf05..aee02c1ed 100644 --- a/src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs +++ b/src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs @@ -16,6 +16,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Abstractions.Attributes; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Workflow.Abstractions; @@ -24,6 +25,7 @@ using Aevatar.Workflow.Core.Validation; using Aevatar.Workflow.Core.Composition; using Aevatar.Foundation.Abstractions.EventModules; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; @@ -78,8 +80,7 @@ public WorkflowGAgent( /// protected override async Task OnActivateAsync(CancellationToken ct) { - if (!string.IsNullOrEmpty(State.WorkflowYaml)) - TryCompile(State.WorkflowYaml); + RebuildCompiledWorkflowCache(); InstallCognitiveModules(); await base.OnActivateAsync(ct); @@ -88,37 +89,22 @@ protected override async Task OnActivateAsync(CancellationToken ct) /// /// 配置工作流 YAML 并立即编译、重装模块。 /// - public void ConfigureWorkflow(string workflowYaml, string workflowName) + public async Task ConfigureWorkflowAsync( + string workflowYaml, + string? workflowName, + CancellationToken ct = default) { - var incomingWorkflowName = string.IsNullOrWhiteSpace(workflowName) ? string.Empty : workflowName.Trim(); - var currentWorkflowName = string.IsNullOrWhiteSpace(State.WorkflowName) ? string.Empty : State.WorkflowName.Trim(); - if (!string.IsNullOrWhiteSpace(currentWorkflowName) && - !string.IsNullOrWhiteSpace(incomingWorkflowName) && - !string.Equals(currentWorkflowName, incomingWorkflowName, StringComparison.OrdinalIgnoreCase)) + EnsureWorkflowNameCanBind(workflowName); + await PersistDomainEventAsync(new ConfigureWorkflowEvent { - throw new InvalidOperationException( - $"WorkflowGAgent '{Id}' is already bound to workflow '{State.WorkflowName}' and cannot switch to '{workflowName}'."); - } - - State.WorkflowYaml = workflowYaml ?? string.Empty; - if (!string.IsNullOrWhiteSpace(incomingWorkflowName)) - State.WorkflowName = incomingWorkflowName; - + WorkflowName = workflowName ?? string.Empty, + WorkflowYaml = workflowYaml ?? string.Empty, + }, ct); + RebuildCompiledWorkflowCache(); _childAgentIds.Clear(); - if (string.IsNullOrWhiteSpace(State.WorkflowYaml)) - { - State.Compiled = false; - State.CompilationError = "workflow yaml is empty"; - _compiledWorkflow = null; - } - else - { - TryCompile(State.WorkflowYaml); - } - InstallCognitiveModules(); - SchedulePersistWorkflowBinding(workflowName); + await PersistWorkflowBindingAsync(workflowName ?? string.Empty, ct); } /// @@ -155,8 +141,7 @@ await PublishAsync(new StartWorkflowEvent [EventHandler] public async Task HandleConfigureWorkflow(ConfigureWorkflowEvent request) { - ConfigureWorkflow(request.WorkflowYaml, request.WorkflowName); - await PersistWorkflowBindingAsync(request.WorkflowName); + await ConfigureWorkflowAsync(request.WorkflowYaml, request.WorkflowName); } // ─── 工作流完成 ─── @@ -165,10 +150,8 @@ public async Task HandleConfigureWorkflow(ConfigureWorkflowEvent request) [EventHandler] public async Task HandleWorkflowCompleted(WorkflowCompletedEvent evt) { + await PersistDomainEventAsync(evt); Logger.LogInformation("工作流 {Name} 完成: {Success}", evt.WorkflowName, evt.Success); - State.TotalExecutions++; - if (evt.Success) State.SuccessfulExecutions++; - else State.FailedExecutions++; // 使用 AG-UI 事件发布最终结果 await PublishAsync(new TextMessageEndEvent @@ -252,48 +235,103 @@ private void ConfigureModule(IEventModule module) // ─── 编译 + 验证 ─── - private void TryCompile(string yaml) + protected override WorkflowState TransitionState(WorkflowState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigureWorkflow) + .On(ApplyWorkflowCompleted) + .OrCurrent(); + + private WorkflowState ApplyConfigureWorkflow(WorkflowState current, ConfigureWorkflowEvent evt) { + var next = current.Clone(); + next.WorkflowYaml = evt.WorkflowYaml ?? string.Empty; + + var incomingWorkflowName = string.IsNullOrWhiteSpace(evt.WorkflowName) + ? string.Empty + : evt.WorkflowName.Trim(); + if (!string.IsNullOrWhiteSpace(incomingWorkflowName)) + next.WorkflowName = incomingWorkflowName; + + var compileResult = EvaluateWorkflowCompilation(next.WorkflowYaml); + next.Compiled = compileResult.Compiled; + next.CompilationError = compileResult.CompilationError; + next.Version = current.Version + 1; + return next; + } + + private static WorkflowState ApplyWorkflowCompleted(WorkflowState current, WorkflowCompletedEvent evt) + { + var next = current.Clone(); + next.TotalExecutions++; + if (evt.Success) + next.SuccessfulExecutions++; + else + next.FailedExecutions++; + return next; + } + + private WorkflowCompilationResult EvaluateWorkflowCompilation(string yaml) + { + if (string.IsNullOrWhiteSpace(yaml)) + return WorkflowCompilationResult.Invalid("workflow yaml is empty"); + try { var workflow = _parser.Parse(yaml); var errors = WorkflowValidator.Validate(workflow); if (errors.Count > 0) - { - State.Compiled = false; - State.CompilationError = string.Join("; ", errors); - _compiledWorkflow = null; - return; - } - _compiledWorkflow = workflow; - State.Compiled = true; - State.CompilationError = ""; + return WorkflowCompilationResult.Invalid(string.Join("; ", errors)); + + return WorkflowCompilationResult.Success; } catch (Exception ex) { - State.Compiled = false; - State.CompilationError = ex.Message; - _compiledWorkflow = null; + return WorkflowCompilationResult.Invalid(ex.Message); } } - private void SchedulePersistWorkflowBinding(string workflowName) + private void RebuildCompiledWorkflowCache() { - _ = PersistWorkflowBindingSafeAsync(workflowName); - } + if (string.IsNullOrWhiteSpace(State.WorkflowYaml)) + { + _compiledWorkflow = null; + return; + } - private async Task PersistWorkflowBindingSafeAsync(string workflowName) - { try { - await PersistWorkflowBindingAsync(workflowName); + var workflow = _parser.Parse(State.WorkflowYaml); + var errors = WorkflowValidator.Validate(workflow); + _compiledWorkflow = errors.Count == 0 ? workflow : null; } - catch (Exception ex) + catch + { + _compiledWorkflow = null; + } + } + + private void EnsureWorkflowNameCanBind(string? workflowName) + { + var incomingWorkflowName = string.IsNullOrWhiteSpace(workflowName) ? string.Empty : workflowName.Trim(); + var currentWorkflowName = string.IsNullOrWhiteSpace(State.WorkflowName) ? string.Empty : State.WorkflowName.Trim(); + if (!string.IsNullOrWhiteSpace(currentWorkflowName) && + !string.IsNullOrWhiteSpace(incomingWorkflowName) && + !string.Equals(currentWorkflowName, incomingWorkflowName, StringComparison.OrdinalIgnoreCase)) { - Logger.LogWarning(ex, "Failed to persist workflow binding metadata for actor {ActorId}", Id); + throw new InvalidOperationException( + $"WorkflowGAgent '{Id}' is already bound to workflow '{State.WorkflowName}' and cannot switch to '{workflowName}'."); } } + private readonly record struct WorkflowCompilationResult(bool Compiled, string CompilationError) + { + public static WorkflowCompilationResult Success => new(true, string.Empty); + + public static WorkflowCompilationResult Invalid(string error) => + new(false, error ?? string.Empty); + } + private async Task PersistWorkflowBindingAsync(string workflowName, CancellationToken ct = default) { if (ManifestStore == null || string.IsNullOrWhiteSpace(Id)) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index f10f62f59..9f0762d3b 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -1,11 +1,12 @@ using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; -using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Foundation.Abstractions.TypeSystem; using Aevatar.Foundation.Core.TypeSystem; using FluentAssertions; using Google.Protobuf; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.CQRS.Projection.Core.Tests; @@ -133,10 +134,17 @@ private static ActorProjectionOwnershipCoordinator CreateCoordinator( public class ProjectionOwnershipCoordinatorGAgentTests { + private static IServiceProvider CreateStatefulAgentServices() + { + var services = new ServiceCollection(); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + [Fact] public async Task HandleAcquireAsync_ShouldActivateOwnershipState() { - var agent = new ProjectionOwnershipCoordinatorGAgent(); + var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { @@ -153,7 +161,7 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent [Fact] public async Task HandleAcquireAsync_ShouldThrow_WhenOwnershipAlreadyActive() { - var agent = new ProjectionOwnershipCoordinatorGAgent(); + var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -172,7 +180,7 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent [Fact] public async Task HandleReleaseAsync_ShouldDeactivate_WhenScopeAndSessionMatch() { - var agent = new ProjectionOwnershipCoordinatorGAgent(); + var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -192,7 +200,7 @@ await agent.HandleReleaseAsync(new ProjectionOwnershipReleaseEvent [Fact] public async Task HandleReleaseAsync_ShouldThrow_WhenScopeDoesNotMatch() { - var agent = new ProjectionOwnershipCoordinatorGAgent(); + var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -400,6 +408,71 @@ internal sealed class StringSessionEventCodec : IProjectionSessionEventCodec> _streams = new(StringComparer.Ordinal); + private readonly object _sync = new(); + + public Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_sync) + { + if (!_streams.TryGetValue(agentId, out var stream)) + { + stream = []; + _streams[agentId] = stream; + } + + var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + if (currentVersion != expectedVersion) + { + throw new InvalidOperationException( + $"Version mismatch for stream '{agentId}'. expected={expectedVersion}, actual={currentVersion}."); + } + + var appended = events.ToList(); + stream.AddRange(appended.Select(x => x.Clone())); + var latest = stream.Count == 0 ? 0 : stream[^1].Version; + return Task.FromResult(latest); + } + } + + public Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_sync) + { + if (!_streams.TryGetValue(agentId, out var stream)) + return Task.FromResult>([]); + + var filtered = fromVersion.HasValue + ? stream.Where(x => x.Version > fromVersion.Value).ToList() + : stream.ToList(); + return Task.FromResult>(filtered.Select(x => x.Clone()).ToList()); + } + } + + public Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_sync) + { + if (!_streams.TryGetValue(agentId, out var stream) || stream.Count == 0) + return Task.FromResult(0L); + + return Task.FromResult(stream[^1].Version); + } + } +} + internal sealed class SessionHubStreamProvider : IStreamProvider { private readonly Dictionary _streams = new(StringComparer.Ordinal); diff --git a/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs b/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs index ae43c90db..876569ff7 100644 --- a/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/Bdd/AgentLifecycleBddTests.cs @@ -118,17 +118,13 @@ public CounterReplayBehavior(IEventStore eventStore, string agentId) : base(eventStore, agentId) { } public override CounterState TransitionState(CounterState current, IMessage evt) - { - if (evt is Any any && any.TryUnpack(out var inc)) - { - return new CounterState + => StateTransitionMatcher + .Match(current, evt) + .On((state, inc) => new CounterState { - Count = current.Count + inc.Amount, - Name = current.Name, - }; - } - - return current; - } + Count = state.Count + inc.Amount, + Name = state.Name, + }) + .OrCurrent(); } } diff --git a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs index d08dcba7d..004aec006 100644 --- a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs @@ -219,14 +219,19 @@ public CounterEventSourcingBehavior( : base(eventStore, agentId, snapshotStore, snapshotStrategy) { } public override CounterState TransitionState(CounterState current, IMessage evt) - { - if (evt is not Any any) return current; - if (any.TryUnpack(out var inc)) - return new CounterState { Count = current.Count + inc.Amount, Name = current.Name }; - if (any.TryUnpack(out var dec)) - return new CounterState { Count = current.Count - dec.Amount, Name = current.Name }; - return base.TransitionState(current, evt); - } + => StateTransitionMatcher + .Match(current, evt) + .On((state, inc) => new CounterState + { + Count = state.Count + inc.Amount, + Name = state.Name, + }) + .On((state, dec) => new CounterState + { + Count = state.Count - dec.Amount, + Name = state.Name, + }) + .OrCurrent(); } private sealed class InMemoryEventSourcingSnapshotStore : IEventSourcingSnapshotStore @@ -387,6 +392,73 @@ public async Task EventSourcingAgent_WithoutBehavior_ShouldFailFastOnActivate() } } +public class StateEventApplierIntegrationTests +{ + [Fact] + public async Task PersistDomainEventAsync_ShouldUseRegisteredAppliers_ForRuntimeAndReplay() + { + var store = new InMemoryEventStore(); + var services = new ServiceCollection() + .AddSingleton(store) + .AddSingleton, CounterIncrementApplier>() + .AddSingleton, CounterDecrementApplier>() + .AddSingleton>(Array.Empty()) + .BuildServiceProvider(); + + var agent1 = new ApplierBackedCounterAgent + { + Services = services, + }; + agent1.SetId("applier-agent"); + await agent1.ActivateAsync(); + await agent1.HandleEventAsync(TestHelper.Envelope(new IncrementEvent { Amount = 8 })); + await agent1.HandleEventAsync(TestHelper.Envelope(new DecrementEvent { Amount = 3 })); + agent1.State.Count.ShouldBe(5); + await agent1.DeactivateAsync(); + + var agent2 = new ApplierBackedCounterAgent + { + Services = services, + }; + agent2.SetId("applier-agent"); + await agent2.ActivateAsync(); + agent2.State.Count.ShouldBe(5); + } + + private sealed class ApplierBackedCounterAgent : GAgentBase + { + [EventHandler] + public Task HandleIncrement(IncrementEvent evt) => + PersistDomainEventAsync(evt); + + [EventHandler] + public Task HandleDecrement(DecrementEvent evt) => + PersistDomainEventAsync(evt); + } + + private sealed class CounterIncrementApplier + : StateEventApplierBase + { + protected override CounterState Apply(CounterState current, IncrementEvent evt) => + new() + { + Count = current.Count + evt.Amount, + Name = current.Name, + }; + } + + private sealed class CounterDecrementApplier + : StateEventApplierBase + { + protected override CounterState Apply(CounterState current, DecrementEvent evt) => + new() + { + Count = current.Count - evt.Amount, + Name = current.Name, + }; + } +} + public class SnapshotStrategyTests { [Fact] diff --git a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs index 9aa5f18ee..30948a6db 100644 --- a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs +++ b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs @@ -2,6 +2,8 @@ using Aevatar.AI.Abstractions.Agents; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.EventModules; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Runtime.Persistence; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Core; using Aevatar.Workflow.Core.Composition; @@ -15,14 +17,14 @@ namespace Aevatar.Integration.Tests; public class WorkflowGAgentCoverageTests { [Fact] - public void ConfigureWorkflow_WhenSwitchingWorkflowName_ShouldThrow() + public async Task ConfigureWorkflow_WhenSwitchingWorkflowName_ShouldThrow() { var agent = CreateAgent(); - agent.ConfigureWorkflow(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_a"); + await agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_a"); - Action act = () => agent.ConfigureWorkflow(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_b"); + Func act = () => agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_b"); - act.Should().Throw() + await act.Should().ThrowAsync() .WithMessage("*cannot switch*"); } @@ -31,7 +33,7 @@ public async Task ConfigureWorkflow_WithEmptyYaml_ShouldMarkInvalidAndDescribe() { var agent = CreateAgent(); - agent.ConfigureWorkflow("", "wf_empty"); + await agent.ConfigureWorkflowAsync("", "wf_empty"); var description = await agent.GetDescriptionAsync(); agent.State.Compiled.Should().BeFalse(); @@ -47,7 +49,7 @@ public async Task HandleChatRequest_WhenNotCompiled_ShouldPublishFailureResponse var runtime = new RecordingActorRuntime(); var agent = CreateAgent(runtime: runtime); agent.EventPublisher = publisher; - agent.ConfigureWorkflow("", "wf_invalid"); + await agent.ConfigureWorkflowAsync("", "wf_invalid"); await agent.HandleChatRequest(new ChatRequestEvent { @@ -70,7 +72,7 @@ public async Task HandleChatRequest_WhenCompiled_ShouldCreateRoleActorsOnlyOnceA var resolver = new StaticRoleAgentTypeResolver(typeof(FakeRoleAgent)); var agent = CreateAgent(runtime, resolver); agent.EventPublisher = publisher; - agent.ConfigureWorkflow(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_ok"); + await agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_ok"); await agent.HandleChatRequest(new ChatRequestEvent { Prompt = "first", SessionId = "s1" }); await agent.HandleChatRequest(new ChatRequestEvent { Prompt = "second", SessionId = "s2" }); @@ -96,7 +98,7 @@ public async Task HandleChatRequest_WhenResolvedAgentNotIRoleAgent_ShouldThrow() var runtime = new RecordingActorRuntime(); var resolver = new StaticRoleAgentTypeResolver(typeof(FakeNonRoleAgent)); var agent = CreateAgent(runtime, resolver); - agent.ConfigureWorkflow(BuildValidWorkflowYaml("role_x", "RoleX"), "wf_error"); + await agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_x", "RoleX"), "wf_error"); var act = async () => await agent.HandleChatRequest(new ChatRequestEvent { Prompt = "x", SessionId = "s" }); @@ -110,7 +112,7 @@ public async Task HandleChatRequest_WhenRoleIdMissing_ShouldThrow() var runtime = new RecordingActorRuntime(); var resolver = new StaticRoleAgentTypeResolver(typeof(FakeRoleAgent)); var agent = CreateAgent(runtime, resolver); - agent.ConfigureWorkflow(BuildValidWorkflowYaml("", "RoleNoId"), "wf_missing_role"); + await agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("", "RoleNoId"), "wf_missing_role"); var act = async () => await agent.HandleChatRequest(new ChatRequestEvent { Prompt = "x", SessionId = "s" }); @@ -149,7 +151,7 @@ await agent.HandleWorkflowCompleted(new WorkflowCompletedEvent } [Fact] - public void ConfigureWorkflow_ShouldInstallAndConfigureModules() + public async Task ConfigureWorkflow_ShouldInstallAndConfigureModules() { var factory = new RecordingEventModuleFactory(); var expander = new StaticDependencyExpander(10, "module_a", "module_b"); @@ -157,7 +159,7 @@ public void ConfigureWorkflow_ShouldInstallAndConfigureModules() var pack = new TestModulePack([expander], [configurator]); var agent = CreateAgent(eventModuleFactory: factory, packs: [pack]); - agent.ConfigureWorkflow(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_modules"); + await agent.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_modules"); agent.GetModules().Select(x => x.Name).Should().BeEquivalentTo(["module_a", "module_b"]); configurator.Configured.Should().BeEquivalentTo(["module_a:wf_valid", "module_b:wf_valid"]); @@ -171,29 +173,38 @@ public async Task ActivateAsync_WhenStateContainsWorkflowYaml_ShouldCompileAndIn var expander = new StaticDependencyExpander(0, "module_on_activate"); var configurator = new RecordingModuleConfigurator(); var pack = new TestModulePack([expander], [configurator]); - var agent = CreateAgent(eventModuleFactory: factory, packs: [pack]); - agent.State.WorkflowYaml = BuildValidWorkflowYaml("role_a", "RoleA"); + var sharedEventStore = new InMemoryEventStore(); + + var agent1 = CreateAgent(eventModuleFactory: factory, packs: [pack], eventStore: sharedEventStore); + await agent1.ActivateAsync(); + await agent1.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_activate"); + await agent1.DeactivateAsync(); - await agent.ActivateAsync(); + var agent2 = CreateAgent(eventModuleFactory: factory, packs: [pack], eventStore: sharedEventStore); + await agent2.ActivateAsync(); - agent.State.Compiled.Should().BeTrue(); - agent.GetModules().Should().ContainSingle(x => x.Name == "module_on_activate"); - configurator.Configured.Should().ContainSingle(x => x == "module_on_activate:wf_valid"); + agent2.State.Compiled.Should().BeTrue(); + agent2.GetModules().Should().ContainSingle(x => x.Name == "module_on_activate"); + configurator.Configured.Should().Contain(x => x == "module_on_activate:wf_valid"); } private static WorkflowGAgent CreateAgent( RecordingActorRuntime? runtime = null, IRoleAgentTypeResolver? roleResolver = null, IEventModuleFactory? eventModuleFactory = null, - IEnumerable? packs = null) + IEnumerable? packs = null, + IEventStore? eventStore = null) { runtime ??= new RecordingActorRuntime(); roleResolver ??= new StaticRoleAgentTypeResolver(typeof(FakeRoleAgent)); eventModuleFactory ??= new RecordingEventModuleFactory(); packs ??= []; + eventStore ??= new InMemoryEventStore(); var agent = new WorkflowGAgent(runtime, roleResolver, eventModuleFactory, packs) { - Services = new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(), + Services = new ServiceCollection() + .AddSingleton(eventStore) + .BuildServiceProvider(), }; return agent; } From b2d8763410bd54ba2bdeb2845470afe19387d8e6 Mon Sep 17 00:00:00 2001 From: Auric Date: Mon, 23 Feb 2026 23:35:18 +0800 Subject: [PATCH 05/46] Implement CI Guard for Direct State Mutation in GAgentBase - Added a CI guard to prevent direct mutations of `State.xxx` in any subclass of `GAgentBase`, enforcing the use of domain events and state transition mechanisms. - Updated documentation to reflect the new restriction on state management practices. - Enhanced existing architecture documentation to clarify the requirements for state transitions and event handling. --- docs/EVENT_SOURCING.md | 1 + ...ng-elasticsearch-readmodel-requirements.md | 1 + tools/ci/architecture_guards.sh | 146 ++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index 672d55700..210112193 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -77,6 +77,7 @@ public async Task Handle(IncrementRequested evt) 2. 在核心路径恢复 `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` 旧模型。 3. 在 `GAgentBase` 恢复 `StateStore.LoadAsync/SaveAsync` 事实通道。 4. 在 Runtime 恢复反射注入 ES(`MakeGenericType` / `GetProperty("EventSourcing")` / `GetProperty("StateStore")`)。 +5. 在任何继承链路(含间接继承)上的 `GAgentBase` 子类中直接写 `State.xxx`(`= / += / ++ / --`)。 ## 9. 验证命令 - `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index f797028cd..adfa6c4f2 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -77,6 +77,7 @@ ### R-ES-05 状态转换可组合 - `event -> state` 转换必须支持模块化拆分,避免在单个 Agent 中膨胀式 `switch`。 - 支持 `IStateEventApplier` 组合式 apply,顺序由 `Order` 控制。 +- CI 必须禁止 `GAgentBase` 全继承链(含间接继承)子类直接修改 `State.xxx`,强制通过领域事件 + apply 路径变更状态。 ### R-RM-01 Provider 解耦业务 - Provider 项目不得引用 Workflow/AI 业务读模型类型。 diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index ea2d44abf..4c3cd8a99 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -92,6 +92,152 @@ if rg -n "StateStore\.LoadAsync|StateStore\.SaveAsync" src/Aevatar.Foundation.Co exit 1 fi +set +e +state_direct_mutation_report="$( + rg --files -0 src -g '*.cs' -g '!*.g.cs' \ + | xargs -0 awk ' +function trim(value) +{ + gsub(/^[[:space:]]+/, "", value); + gsub(/[[:space:]]+$/, "", value); + return value; +} + +function normalize_base(value) +{ + value = trim(value); + sub(/<.*/, "", value); + sub(/^.*\./, "", value); + gsub(/[[:space:]]+/, "", value); + return value; +} + +function register_base(class_name, base_clause, parts, first_base) +{ + if (class_name == "") + return; + + split(base_clause, parts, ","); + first_base = normalize_base(parts[1]); + if (first_base != "") + class_base[class_name] = first_base; + + pending_class[FILENAME] = ""; +} + +{ + line = $0; + + if (pending_class[FILENAME] != "") + { + if (line ~ /^[[:space:]]*:/) + { + base_clause = line; + sub(/^[[:space:]]*:[[:space:]]*/, "", base_clause); + sub(/\{.*/, "", base_clause); + register_base(pending_class[FILENAME], base_clause); + } + else if (line ~ /\{/) + { + pending_class[FILENAME] = ""; + } + } + + if (match(line, /[[:space:]]class[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/)) + { + class_decl = substr(line, RSTART, RLENGTH); + sub(/^.*class[[:space:]]+/, "", class_decl); + class_name = class_decl; + file_class[FILENAME SUBSEP class_name] = 1; + + tail = substr(line, RSTART + RLENGTH); + if (tail ~ /:/) + { + base_clause = tail; + sub(/^.*:[[:space:]]*/, "", base_clause); + sub(/\{.*/, "", base_clause); + register_base(class_name, base_clause); + } + else if (tail !~ /\{/) + { + pending_class[FILENAME] = class_name; + } + else + { + pending_class[FILENAME] = ""; + } + } + + if (line ~ /^[[:space:]]*\/\//) + next; + + if (line ~ /(^|[[:space:](;])(this\.)?State\.[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(\+\+|--|[+*%\/-]?=)/) + { + state_mutation[FILENAME] = state_mutation[FILENAME] sprintf("%s:%d:%s\n", FILENAME, FNR, line); + } +} + +END { + stateful["GAgentBase"] = 1; + changed = 1; + while (changed) + { + changed = 0; + for (class_name in class_base) + { + base_name = class_base[class_name]; + if ((base_name in stateful) && !(class_name in stateful)) + { + stateful[class_name] = 1; + changed = 1; + } + } + } + + violations = ""; + for (file in state_mutation) + { + is_stateful_file = 0; + for (key in file_class) + { + split(key, tokens, SUBSEP); + if (tokens[1] != file) + continue; + + declared_class = tokens[2]; + if (declared_class in stateful) + { + is_stateful_file = 1; + break; + } + } + + if (is_stateful_file) + violations = violations "\n" file "\n" state_mutation[file]; + } + + if (violations != "") + { + printf "%s", violations; + printf "Stateful GAgent implementations must not mutate State directly. Emit domain events and apply state in TransitionState/appliers.\n"; + exit 1; + } +} +' +)" +state_direct_mutation_status=$? +set -e + +if [ "${state_direct_mutation_status}" -ne 0 ] && [ -z "${state_direct_mutation_report}" ]; then + echo "State direct mutation guard execution failed." + exit "${state_direct_mutation_status}" +fi + +if [ -n "${state_direct_mutation_report}" ]; then + echo "${state_direct_mutation_report}" + exit 1 +fi + if rg -n "IEventSourcingBehavior<>\\)\\.MakeGenericType|EventSourcingBehavior<>\\)\\.MakeGenericType|GetProperty\\(\"EventSourcing\"\\)|GetProperty\\(\"StateStore\"\\)" \ src/Aevatar.Foundation.Runtime \ src/Aevatar.Foundation.Runtime.Implementations.Orleans From c340f0e6ba8b9001749f9cb1eb751329e26a8052 Mon Sep 17 00:00:00 2001 From: Auric Date: Mon, 23 Feb 2026 23:57:26 +0800 Subject: [PATCH 06/46] Add File-Based Event Store and Update Docker Compose for Providers - Introduced a new `FileEventStore` for persistent event storage, allowing agents to maintain state across instances with optimistic concurrency checks. - Added `FileEventStoreOptions` to configure the root directory for event storage. - Updated `docker-compose.projection-providers.yml` to include Elasticsearch and Neo4j services, enhancing the development environment for testing projection providers. - Implemented structured logging for write operations in both Elasticsearch and InMemory providers, improving observability. - Enhanced tests for the new file-based event store and integration tests for Elasticsearch and Neo4j providers, ensuring robust functionality and reliability. - Updated documentation to reflect the new event store capabilities and provider configurations, aiding developer understanding and usage. --- docker-compose.projection-providers.yml | 23 +++ docs/EVENT_SOURCING.md | 4 +- ...ng-elasticsearch-readmodel-requirements.md | 22 ++- .../ElasticsearchProjectionReadModelStore.cs | 48 +++-- .../ServiceCollectionExtensions.cs | 6 +- .../README.md | 1 + .../InMemoryProjectionReadModelStore.cs | 88 +++++++-- .../Stores/Neo4jProjectionReadModelStore.cs | 48 +++-- .../GAgentBase.TState.cs | 3 - src/Aevatar.Foundation.Core/README.md | 6 +- .../ServiceCollectionExtensions.cs | 16 ++ .../Persistence/FileEventStore.cs | 187 ++++++++++++++++++ .../Persistence/FileEventStoreOptions.cs | 15 ++ src/Aevatar.Foundation.Runtime/README.md | 9 +- .../Aevatar.CQRS.Projection.Core.Tests.csproj | 2 + .../ElasticsearchIntegrationFactAttribute.cs | 13 ++ .../Neo4jIntegrationFactAttribute.cs | 15 ++ .../ProjectionOwnershipAndSessionHubTests.cs | 40 +++- .../ProjectionProviderE2EIntegrationTests.cs | 114 +++++++++++ .../FileEventStoreTests.cs | 133 +++++++++++++ .../RuntimeEventStoreRegistrationTests.cs | 30 +++ .../WorkflowGAgentCoverageTests.cs | 36 ++++ tools/ci/architecture_guards.sh | 58 ++++++ tools/ci/projection_provider_e2e_smoke.sh | 68 +++++++ 24 files changed, 923 insertions(+), 62 deletions(-) create mode 100644 docker-compose.projection-providers.yml create mode 100644 src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs create mode 100644 src/Aevatar.Foundation.Runtime/Persistence/FileEventStoreOptions.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchIntegrationFactAttribute.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/Neo4jIntegrationFactAttribute.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs create mode 100644 test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs create mode 100644 test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs create mode 100755 tools/ci/projection_provider_e2e_smoke.sh diff --git a/docker-compose.projection-providers.yml b/docker-compose.projection-providers.yml new file mode 100644 index 000000000..07137f708 --- /dev/null +++ b/docker-compose.projection-providers.yml @@ -0,0 +1,23 @@ +services: + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.15.2 + environment: + discovery.type: single-node + xpack.security.enabled: "false" + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + ports: + - "9200:9200" + - "9300:9300" + restart: unless-stopped + + neo4j: + image: neo4j:5.26 + environment: + NEO4J_AUTH: "neo4j/password" + NEO4J_server_default__listen__address: "0.0.0.0" + NEO4J_server_memory_heap_initial__size: "256m" + NEO4J_server_memory_heap_max__size: "256m" + ports: + - "7474:7474" + - "7687:7687" + restart: unless-stopped diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index 210112193..4ea9036fb 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -7,7 +7,7 @@ ## 2. 当前强制语义 1. `EventStore` 是唯一业务事实源。 -2. `StateStore` 只能用于快照优化,不是业务真相。 +2. `GAgentBase` 不提供 `StateStore` 事实通道;恢复仅允许来自 EventStore Replay。 3. 领域事件必须由开发者显式构建并持久化,不允许在线自动反推事件。 4. 有状态 Actor 激活必须 Replay;停用必须 flush pending events。 5. ES 行为构造走静态泛型路径,不走 Runtime 反射注入。 @@ -19,6 +19,7 @@ - Typed applier 基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` - 状态事件匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 有状态生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- 本地持久化 EventStore:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` - Local Runtime 注入边界:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - Orleans Runtime 注入边界:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` - 防回退门禁:`tools/ci/architecture_guards.sh` @@ -62,6 +63,7 @@ public async Task Handle(IncrementRequested evt) ## 6. DI 与容器约定 - `AddAevatarRuntime()` 默认注册 `IEventStore -> InMemoryEventStore`(开发/测试)。 +- 可通过 `AddFileEventStore(...)` 将 `IEventStore` 切换为本地持久化实现:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs`。 - 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 - 如需自定义 ES 行为,可直接为 Agent 预设 `EventSourcing`,但必须保持相同语义契约。 - 如需解耦 Agent 里的 `TransitionState` 逻辑,可注册多个 `IStateEventApplier`,按 `Order` 升序匹配应用。 diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index adfa6c4f2..50f119f2a 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -23,6 +23,7 @@ - Typed 状态转换基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` - 状态匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- 本地持久化 EventStore 基线:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` - 运行时注入边界: - `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - `src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` @@ -30,11 +31,12 @@ 当前语义: 1. `ActivateAsync` 强制 Replay 恢复状态。 2. `DeactivateAsync` 强制 `ConfirmEventsAsync + PersistSnapshotAsync`。 -3. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `AgentBackedEventSourcingBehavior`(继承 `EventSourcingBehavior`)。 -4. 缺失 `IEventStore` 时 fail-fast。 -5. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 -6. 运行期通过 `PersistDomainEventAsync` / `PersistDomainEventsAsync` 执行“持久化 + 顺序 apply”;Replay 主要用于激活恢复。 -7. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 +3. `GAgentBase` 不再暴露 `StateStore` 事实通道。 +4. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `AgentBackedEventSourcingBehavior`(继承 `EventSourcingBehavior`)。 +5. 缺失 `IEventStore` 时 fail-fast。 +6. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 +7. 运行期通过 `PersistDomainEventAsync` / `PersistDomainEventsAsync` 执行“持久化 + 顺序 apply”;Replay 主要用于激活恢复。 +8. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 ### 3.2 Provider Runtime - 抽象:`src/Aevatar.CQRS.Projection.Abstractions` @@ -50,6 +52,10 @@ 2. Store 由 `ProviderRegistry + ProviderSelector + BindingResolver + StoreFactory` 统一创建。 3. 多 Provider 并存时必须显式指定 provider;否则选择失败。 4. 能力不匹配默认 fail-fast(`FailOnUnsupportedCapabilities=true`)。 +5. InMemory / Elasticsearch / Neo4j 写路径均输出统一结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 +6. Provider 端到端回归支持环境变量门控集成测试与一键 smoke 脚本: + - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs` + - `tools/ci/projection_provider_e2e_smoke.sh` ### 3.3 Workflow 接入 - 组合入口:`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` @@ -122,8 +128,8 @@ 4. 统一配置示例与错误合同说明(启动失败与能力不匹配)。 ### P4(待执行)性能与生产化增强 -1. 为持久化 `IEventStore` 提供生产落地方案与压测基线。 -2. 补齐 Elasticsearch/Neo4j 端到端集成脚本与回归套件。 +1. 为持久化 `IEventStore` 提供生产落地方案与压测基线(已落地本地持久化基线:`FileEventStore`,生产级后端仍待接入)。 +2. 补齐 Elasticsearch/Neo4j 端到端集成脚本与回归套件(已落地基础 smoke + env-gated e2e,后续补 CI 常态化接入与更高负载回归)。 3. 细化快照策略与回放窗口控制。 ## 6. 验收标准(DoD) @@ -136,8 +142,10 @@ ## 7. 验证命令 - `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` - `dotnet test test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj --nologo` +- `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` - `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` - `bash tools/ci/architecture_guards.sh` +- `bash tools/ci/projection_provider_e2e_smoke.sh` ## 8. 变更原则 1. 删除优先于兼容。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index a7c9e41fa..77efa698f 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -78,11 +78,27 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT ArgumentNullException.ThrowIfNull(mutate); ct.ThrowIfCancellationRequested(); + var keyValue = FormatKey(key); + var startedAt = DateTimeOffset.UtcNow; var existing = await GetAsync(key, ct); if (existing == null) - throw new InvalidOperationException($"ReadModel '{typeof(TReadModel).FullName}' with key '{FormatKey(key)}' was not found."); + { + var notFound = new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); + LogWriteFailure(keyValue, startedAt, notFound); + throw notFound; + } + + try + { + mutate(existing); + } + catch (Exception ex) + { + LogWriteFailure(keyValue, startedAt, ex); + throw; + } - mutate(existing); await UpsertCoreAsync(existing, allowCreateIndex: true, ct); } @@ -173,20 +189,28 @@ private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, } catch (Exception ex) { - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogError( - ex, - "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, - typeof(TReadModel).FullName, - keyValue, - elapsedMs, - "failed", - ex.GetType().Name); + LogWriteFailure(keyValue, startedAt, ex); throw; } } + private void LogWriteFailure( + string keyValue, + DateTimeOffset startedAt, + Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + elapsedMs, + "failed", + ex.GetType().Name); + } + private string ResolveReadModelKey(TReadModel readModel) { var key = _keySelector(readModel); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index a81b50987..3aa3ea803 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; @@ -20,12 +21,13 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration( providerName, new ProjectionReadModelProviderCapabilities(providerName, supportsIndexing: false), - _ => new InMemoryProjectionReadModelStore( + provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, listSortSelector, listTakeMax, - providerName))); + providerName, + provider.GetService>>()))); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index d808968d4..68c950386 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -5,6 +5,7 @@ - 不依赖业务域模型。 - 支持按 keySelector 注册任意 `IProjectionReadModelStore`。 - 默认能力:非索引型(`SupportsIndexing=false`)。 +- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 ## DI 注册 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index e54490f52..9f33f36cb 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; @@ -13,6 +15,7 @@ public sealed class InMemoryProjectionReadModelStore private readonly Func _keyFormatter; private readonly Func? _listSortSelector; private readonly int _listTakeMax; + private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonOptions = new(); public InMemoryProjectionReadModelStore( @@ -20,13 +23,15 @@ public InMemoryProjectionReadModelStore( Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200, - string providerName = ProjectionReadModelProviderNames.InMemory) + string providerName = ProjectionReadModelProviderNames.InMemory, + ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(keySelector); _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortSelector = listSortSelector; _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; + _logger = logger ?? NullLogger>.Instance; ProviderCapabilities = new ProjectionReadModelProviderCapabilities( providerName, supportsIndexing: false); @@ -39,11 +44,38 @@ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) ArgumentNullException.ThrowIfNull(readModel); ct.ThrowIfCancellationRequested(); - var key = ResolveReadModelKey(readModel); - lock (_gate) - _itemsByKey[key] = Clone(readModel); - - return Task.CompletedTask; + var key = ""; + var startedAt = DateTimeOffset.UtcNow; + try + { + key = ResolveReadModelKey(readModel); + lock (_gate) + _itemsByKey[key] = Clone(readModel); + + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + key, + elapsedMs, + "ok"); + return Task.CompletedTask; + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + key, + elapsedMs, + "failed", + ex.GetType().Name); + throw; + } } public Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) @@ -51,17 +83,43 @@ public Task MutateAsync(TKey key, Action mutate, CancellationToken c ArgumentNullException.ThrowIfNull(mutate); ct.ThrowIfCancellationRequested(); - lock (_gate) + var keyValue = FormatKey(key); + var startedAt = DateTimeOffset.UtcNow; + try { - var keyValue = FormatKey(key); - if (!_itemsByKey.TryGetValue(keyValue, out var existing)) - throw new InvalidOperationException( - $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); - - mutate(existing); + lock (_gate) + { + if (!_itemsByKey.TryGetValue(keyValue, out var existing)) + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); + + mutate(existing); + } + + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + elapsedMs, + "ok"); + return Task.CompletedTask; + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + elapsedMs, + "failed", + ex.GetType().Name); + throw; } - - return Task.CompletedTask; } public Task GetAsync(TKey key, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index d255ac9d6..3e1fe3138 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -100,16 +100,7 @@ public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = defau } catch (Exception ex) { - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogError( - ex, - "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, - typeof(TReadModel).FullName, - key, - elapsedMs, - "failed", - ex.GetType().Name); + LogWriteFailure(key, startedAt, ex); throw; } } @@ -119,11 +110,27 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT ArgumentNullException.ThrowIfNull(mutate); ct.ThrowIfCancellationRequested(); + var keyValue = FormatKey(key); + var startedAt = DateTimeOffset.UtcNow; var existing = await GetAsync(key, ct); if (existing == null) - throw new InvalidOperationException($"ReadModel '{typeof(TReadModel).FullName}' with key '{FormatKey(key)}' was not found."); + { + var notFound = new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); + LogWriteFailure(keyValue, startedAt, notFound); + throw notFound; + } + + try + { + mutate(existing); + } + catch (Exception ex) + { + LogWriteFailure(keyValue, startedAt, ex); + throw; + } - mutate(existing); await UpsertAsync(existing, ct); } @@ -256,6 +263,23 @@ private string ResolveReadModelKey(TReadModel readModel) private string FormatKey(TKey key) => _keyFormatter(key)?.Trim() ?? ""; + private void LogWriteFailure( + string key, + DateTimeOffset startedAt, + Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + key, + elapsedMs, + "failed", + ex.GetType().Name); + } + private TReadModel? Deserialize(string payload) { var value = JsonSerializer.Deserialize(payload, _jsonOptions); diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index f1054f731..c0fb52182 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -28,9 +28,6 @@ public TState State protected set { StateGuard.EnsureWritable(); _state = value; } } - /// State persistence store reserved for snapshot optimization. - public IStateStore? StateStore { get; set; } - /// Event Sourcing behavior injected by runtime; required for state recovery and commit. public IEventSourcingBehavior? EventSourcing { get; set; } diff --git a/src/Aevatar.Foundation.Core/README.md b/src/Aevatar.Foundation.Core/README.md index 94198914c..4b059597b 100644 --- a/src/Aevatar.Foundation.Core/README.md +++ b/src/Aevatar.Foundation.Core/README.md @@ -12,7 +12,7 @@ ## 核心类型 - `GAgentBase`:无状态基类,统一事件分发、模块管理、Hook 生命周期 -- `GAgentBase`:状态型基类,集成 `IStateStore` +- `GAgentBase`:状态型基类,内建 EventSourcing 生命周期(Replay 恢复 + 事件提交) - `GAgentBase`:配置型基类,配置持久化到 manifest - `StateGuard`:限制状态写入时机 - `EventPipelineBuilder`:合并并排序静态/动态处理器 @@ -23,11 +23,11 @@ ## 典型流程 1. Runtime 创建 Agent 并注入依赖 -2. Agent 激活时恢复模块、状态和配置 +2. Agent 激活时恢复模块、配置,并通过 EventStore Replay 恢复状态 3. 事件到达后以 Raw `EventEnvelope` 构建统一 Pipeline 4. 按优先级依次执行处理器,并触发 Hook 5. 出站事件按传播策略自动继承 `correlation_id` 并写入 `metadata["trace.causation_id"]` -6. Agent 停用时保存状态 +6. Agent 停用时 flush pending events,并按策略持久化快照(可选) ## 依赖 diff --git a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 1c1a7a2ca..e046b30ba 100644 --- a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -77,4 +77,20 @@ public static IServiceCollection AddAevatarRuntime( return services; } + + /// + /// Replaces with file-backed persistence. + /// + public static IServiceCollection AddFileEventStore( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new FileEventStoreOptions(); + configure?.Invoke(options); + services.Replace(ServiceDescriptor.Singleton(options)); + services.Replace(ServiceDescriptor.Singleton()); + return services; + } } diff --git a/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs new file mode 100644 index 000000000..d6d25ad74 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs @@ -0,0 +1,187 @@ +using System.Collections.Concurrent; +using System.Text; +using Aevatar.Foundation.Abstractions.Persistence; +using Google.Protobuf; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Foundation.Runtime.Persistence; + +/// +/// File-backed event store. +/// Stores each agent stream in a local file with optimistic concurrency checks. +/// +public sealed class FileEventStore : IEventStore +{ + private readonly string _rootDirectory; + private readonly ConcurrentDictionary _agentLocks = new(StringComparer.Ordinal); + private readonly ILogger _logger; + + public FileEventStore( + FileEventStoreOptions? options = null, + ILogger? logger = null) + { + options ??= new FileEventStoreOptions(); + if (string.IsNullOrWhiteSpace(options.RootDirectory)) + { + throw new InvalidOperationException( + "FileEventStore requires a non-empty root directory."); + } + + _rootDirectory = Path.GetFullPath(options.RootDirectory); + Directory.CreateDirectory(_rootDirectory); + _logger = logger ?? NullLogger.Instance; + } + + public async Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(events); + ct.ThrowIfCancellationRequested(); + + var pendingEvents = events.Select(CloneEvent).ToList(); + if (pendingEvents.Count == 0) + return await GetVersionAsync(agentId, ct); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var stream = ReadStream(agentId, ct); + var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + if (currentVersion != expectedVersion) + { + throw new InvalidOperationException( + $"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); + } + + stream.AddRange(pendingEvents); + WriteStream(agentId, stream, ct); + + var latestVersion = stream[^1].Version; + _logger.LogDebug( + "File event-store append completed. agentId={AgentId} appended={Count} version={Version}", + agentId, + pendingEvents.Count, + latestVersion); + return latestVersion; + } + finally + { + gate.Release(); + } + } + + public async Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var stream = ReadStream(agentId, ct); + var filtered = fromVersion.HasValue + ? stream.Where(x => x.Version > fromVersion.Value) + : stream; + return filtered.Select(CloneEvent).ToList(); + } + finally + { + gate.Release(); + } + } + + public async Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var stream = ReadStream(agentId, ct); + return stream.Count == 0 ? 0 : stream[^1].Version; + } + finally + { + gate.Release(); + } + } + + private List ReadStream(string agentId, CancellationToken ct) + { + var path = GetStreamPath(agentId); + if (!File.Exists(path)) + return []; + + var result = new List(); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + + while (stream.Position < stream.Length) + { + ct.ThrowIfCancellationRequested(); + var payloadLength = reader.ReadInt32(); + if (payloadLength <= 0) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': invalid payload length {payloadLength}."); + } + + var payload = reader.ReadBytes(payloadLength); + if (payload.Length != payloadLength) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': truncated payload."); + } + + result.Add(StateEvent.Parser.ParseFrom(payload)); + } + + return result; + } + + private void WriteStream(string agentId, IReadOnlyList stream, CancellationToken ct) + { + var path = GetStreamPath(agentId); + var tempPath = path + ".tmp"; + + using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var writer = new BinaryWriter(fileStream, Encoding.UTF8, leaveOpen: false)) + { + foreach (var evt in stream) + { + ct.ThrowIfCancellationRequested(); + var payload = evt.ToByteArray(); + writer.Write(payload.Length); + writer.Write(payload); + } + } + + File.Move(tempPath, path, overwrite: true); + } + + private string GetStreamPath(string agentId) + { + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(agentId)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + return Path.Combine(_rootDirectory, encoded + ".events"); + } + + private static StateEvent CloneEvent(StateEvent evt) => evt.Clone(); +} diff --git a/src/Aevatar.Foundation.Runtime/Persistence/FileEventStoreOptions.cs b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStoreOptions.cs new file mode 100644 index 000000000..c712f98cb --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStoreOptions.cs @@ -0,0 +1,15 @@ +namespace Aevatar.Foundation.Runtime.Persistence; + +/// +/// File-backed event-store options. +/// +public sealed class FileEventStoreOptions +{ + /// + /// Root directory used to persist per-agent event streams. + /// + public string RootDirectory { get; set; } = Path.Combine( + AppContext.BaseDirectory, + ".aevatar", + "event-store"); +} diff --git a/src/Aevatar.Foundation.Runtime/README.md b/src/Aevatar.Foundation.Runtime/README.md index 2c34a9f49..265aa5687 100644 --- a/src/Aevatar.Foundation.Runtime/README.md +++ b/src/Aevatar.Foundation.Runtime/README.md @@ -8,7 +8,7 @@ - **组织 Agent**:按父子关系把 Agent 排成一棵树(例如一个工作流根节点 + 多个角色子节点)。 - **传递事件**:把「用户输入」「步骤请求」「LLM 回复」等事件准确送到对应的 Agent。 -- **存储与去重**:在内存(或可替换的存储)里保存 Agent 状态、事件记录、路由关系,并做事件去重。 +- **存储与去重**:在内存(或可替换的存储)里保存事件记录、路由关系与 Manifest,并做事件去重。 - **流式输出**:把运行过程以流的形式推送给调用方(例如 SSE)。 你不需要直接写 .NET 代码也能用 Aevatar;使用 **Aevatar.Workflow.Host.Api** 的 HTTP 接口即可。Runtime 主要面向「想理解系统结构」或「要二次开发、替换实现」的读者。 @@ -22,7 +22,7 @@ | **Actor 运行时** | `Actor/` | 创建和管理「Actor」(每个 Actor 里挂一个 Agent),提供本地实现 `LocalActorRuntime`。 | | **事件流** | `Streaming/` | 内存版事件流与订阅,用于把运行中的事件推送给前端或下游。 | | **路由** | `Routing/` | 维护 Agent 树的父子关系,按「方向」把事件发给当前节点、父节点或子节点。 | -| **持久化** | `Persistence/` | 状态存储、事件存储、Agent 配置(Manifest)的默认内存实现;可换成数据库等。 | +| **持久化** | `Persistence/` | 事件存储(EventStore)与 Agent 配置(Manifest)的默认实现;可替换为持久化后端。 | | **依赖注入** | `DependencyInjection/` | 一行配置注册整个运行时(`AddAevatarRuntime()`),供宿主程序使用。 | --- @@ -34,7 +34,7 @@ Runtime/ ├── Actor/ # 单个 Actor、事件发布、运行时入口 ├── Streaming/ # 内存流与订阅(如 SSE 推送) ├── Routing/ # 事件路由与层级存储 -├── Persistence/ # 状态、事件、Manifest 的内存实现与去重 +├── Persistence/ # EventStore、Manifest 与去重 ├── Context/ # 运行上下文 ├── Observability/ # 可观测性(如指标) └── DependencyInjection/ # 运行时注册入口 @@ -61,8 +61,7 @@ Runtime 维护一棵 **Agent 树**(父/子关系)。每个事件带一个** 当前默认实现都是**内存**的,适合开发、演示和单机部署: -- **状态存储**:Agent 的状态快照(如对话计数、工作流进度)。 -- **事件存储**:若开启 Event Sourcing,用于保存事件流。 +- **事件存储**:Event Sourcing 事实源,默认 `InMemoryEventStore`,可替换为 `FileEventStore`。 - **Manifest**:Agent 的配置与已挂载的模块列表。 - **路由层级**:父子关系。 diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj index 9145f0f38..3e769d0e1 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj +++ b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj @@ -13,6 +13,8 @@ + + diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchIntegrationFactAttribute.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchIntegrationFactAttribute.cs new file mode 100644 index 000000000..9f96d9710 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchIntegrationFactAttribute.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Core.Tests; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class ElasticsearchIntegrationFactAttribute : FactAttribute +{ + public ElasticsearchIntegrationFactAttribute() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AEVATAR_TEST_ELASTICSEARCH_ENDPOINT"))) + { + Skip = "Set AEVATAR_TEST_ELASTICSEARCH_ENDPOINT to run Elasticsearch projection integration tests."; + } + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/Neo4jIntegrationFactAttribute.cs b/test/Aevatar.CQRS.Projection.Core.Tests/Neo4jIntegrationFactAttribute.cs new file mode 100644 index 000000000..5e22cf55a --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/Neo4jIntegrationFactAttribute.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Core.Tests; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class Neo4jIntegrationFactAttribute : FactAttribute +{ + public Neo4jIntegrationFactAttribute() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AEVATAR_TEST_NEO4J_URI")) || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AEVATAR_TEST_NEO4J_USERNAME")) || + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("AEVATAR_TEST_NEO4J_PASSWORD"))) + { + Skip = "Set AEVATAR_TEST_NEO4J_URI / AEVATAR_TEST_NEO4J_USERNAME / AEVATAR_TEST_NEO4J_PASSWORD to run Neo4j projection integration tests."; + } + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index 9f0762d3b..25d80de7b 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -134,10 +134,13 @@ private static ActorProjectionOwnershipCoordinator CreateCoordinator( public class ProjectionOwnershipCoordinatorGAgentTests { - private static IServiceProvider CreateStatefulAgentServices() + private static IServiceProvider CreateStatefulAgentServices(IEventStore? eventStore = null) { var services = new ServiceCollection(); - services.AddSingleton(); + if (eventStore != null) + services.AddSingleton(eventStore); + else + services.AddSingleton(); return services.BuildServiceProvider(); } @@ -215,6 +218,39 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent await act.Should().ThrowAsync(); } + + [Fact] + public async Task AcquireRelease_ShouldPersistEvents_AndReplayStateAfterReactivate() + { + var store = new TestInMemoryEventStore(); + var services = CreateStatefulAgentServices(store); + + var agent1 = new ProjectionOwnershipCoordinatorGAgent { Services = services }; + await agent1.ActivateAsync(); + await agent1.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-replay", + SessionId = "session-replay", + }); + await agent1.HandleReleaseAsync(new ProjectionOwnershipReleaseEvent + { + ScopeId = "scope-replay", + SessionId = "session-replay", + }); + await agent1.DeactivateAsync(); + + var persisted = await store.GetEventsAsync(agent1.Id); + persisted.Should().HaveCount(2); + persisted.Should().Contain(x => x.EventType.Contains(nameof(ProjectionOwnershipAcquireEvent), StringComparison.Ordinal)); + persisted.Should().Contain(x => x.EventType.Contains(nameof(ProjectionOwnershipReleaseEvent), StringComparison.Ordinal)); + + var agent2 = new ProjectionOwnershipCoordinatorGAgent { Services = services }; + await agent2.ActivateAsync(); + + agent2.State.Active.Should().BeFalse(); + agent2.State.ScopeId.Should().Be("scope-replay"); + agent2.State.SessionId.Should().BeEmpty(); + } } public class ProjectionSessionEventHubTests diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs new file mode 100644 index 000000000..37a5de16b --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -0,0 +1,114 @@ +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Aevatar.CQRS.Projection.Providers.Neo4j.Stores; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public sealed class ProjectionProviderE2EIntegrationTests +{ + [ElasticsearchIntegrationFact] + public async Task ElasticsearchStore_ShouldRoundtripUpsertAndMutate() + { + var endpoint = GetRequiredEnvironmentVariable("AEVATAR_TEST_ELASTICSEARCH_ENDPOINT"); + var options = new ElasticsearchProjectionReadModelStoreOptions + { + Endpoints = [endpoint], + IndexPrefix = "aevatar-e2e", + AutoCreateIndex = true, + RequestTimeoutMs = 10000, + }; + var indexScope = "projection-provider-e2e-" + Guid.NewGuid().ToString("N"); + using var store = new ElasticsearchProjectionReadModelStore( + options, + indexScope, + model => model.Id); + + var readModel = new ProviderStoreSmokeReadModel + { + Id = Guid.NewGuid().ToString("N"), + Value = "v1", + UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + await store.UpsertAsync(readModel); + var fetched = await store.GetAsync(readModel.Id); + fetched.Should().NotBeNull(); + fetched!.Value.Should().Be("v1"); + + await store.MutateAsync(readModel.Id, model => + { + model.Value = "v2"; + model.UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + }); + + var mutated = await store.GetAsync(readModel.Id); + mutated.Should().NotBeNull(); + mutated!.Value.Should().Be("v2"); + } + + [Neo4jIntegrationFact] + public async Task Neo4jStore_ShouldRoundtripUpsertMutateAndList() + { + var uri = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_URI"); + var username = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_USERNAME"); + var password = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_PASSWORD"); + var options = new Neo4jProjectionReadModelStoreOptions + { + Uri = uri, + Username = username, + Password = password, + NodeLabel = "ProjectionReadModelE2E", + AutoCreateConstraints = true, + RequestTimeoutMs = 5000, + }; + var scope = "projection-provider-e2e-" + Guid.NewGuid().ToString("N"); + await using var store = new Neo4jProjectionReadModelStore( + options, + scope, + model => model.Id); + + var readModel = new ProviderStoreSmokeReadModel + { + Id = Guid.NewGuid().ToString("N"), + Value = "v1", + UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + + await store.UpsertAsync(readModel); + var fetched = await store.GetAsync(readModel.Id); + fetched.Should().NotBeNull(); + fetched!.Value.Should().Be("v1"); + + await store.MutateAsync(readModel.Id, model => + { + model.Value = "v2"; + model.UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + }); + var mutated = await store.GetAsync(readModel.Id); + mutated.Should().NotBeNull(); + mutated!.Value.Should().Be("v2"); + + var listed = await store.ListAsync(20); + listed.Select(model => model.Id).Should().Contain(readModel.Id); + } + + private static string GetRequiredEnvironmentVariable(string name) + { + var value = Environment.GetEnvironmentVariable(name); + if (!string.IsNullOrWhiteSpace(value)) + return value.Trim(); + + throw new InvalidOperationException($"Environment variable '{name}' is required."); + } + + private sealed class ProviderStoreSmokeReadModel + { + public string Id { get; set; } = ""; + + public string Value { get; set; } = ""; + + public long UpdatedAtEpochMs { get; set; } + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs b/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs new file mode 100644 index 000000000..8e0882a9e --- /dev/null +++ b/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs @@ -0,0 +1,133 @@ +using Aevatar.Foundation.Abstractions.Helpers; +using Aevatar.Foundation.Runtime.Persistence; +using Shouldly; + +namespace Aevatar.Foundation.Core.Tests; + +public class FileEventStoreTests +{ + [Fact] + public async Task AppendAndRead_ShouldPersistAcrossStoreInstances() + { + var root = CreateTempRoot(); + try + { + var store1 = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + var appendedVersion = await store1.AppendAsync("agent-1", + [ + new StateEvent + { + EventId = "e1", + Timestamp = TimestampHelper.Now(), + Version = 1, + EventType = "evt-1", + AgentId = "agent-1", + }, + new StateEvent + { + EventId = "e2", + Timestamp = TimestampHelper.Now(), + Version = 2, + EventType = "evt-2", + AgentId = "agent-1", + }, + ], + expectedVersion: 0); + + appendedVersion.ShouldBe(2); + + var store2 = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + var events = await store2.GetEventsAsync("agent-1"); + var version = await store2.GetVersionAsync("agent-1"); + + events.Count.ShouldBe(2); + events[0].EventId.ShouldBe("e1"); + events[1].EventId.ShouldBe("e2"); + version.ShouldBe(2); + } + finally + { + SafeDelete(root); + } + } + + [Fact] + public async Task AppendAsync_WithVersionConflict_ShouldThrow() + { + var root = CreateTempRoot(); + try + { + var store = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + await store.AppendAsync("agent-1", + [ + new StateEvent + { + EventId = "e1", + Timestamp = TimestampHelper.Now(), + Version = 1, + EventType = "evt-1", + AgentId = "agent-1", + }, + ], + expectedVersion: 0); + + var act = () => store.AppendAsync("agent-1", + [ + new StateEvent + { + EventId = "e2", + Timestamp = TimestampHelper.Now(), + Version = 2, + EventType = "evt-2", + AgentId = "agent-1", + }, + ], + expectedVersion: 0); + + await act.ShouldThrowAsync(); + } + finally + { + SafeDelete(root); + } + } + + [Fact] + public async Task GetEventsAsync_FromVersion_ShouldReturnOnlyNewerEvents() + { + var root = CreateTempRoot(); + try + { + var store = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + await store.AppendAsync("agent-1", + Enumerable.Range(1, 5).Select(i => new StateEvent + { + EventId = $"e{i}", + Timestamp = TimestampHelper.Now(), + Version = i, + EventType = "evt", + AgentId = "agent-1", + }), + expectedVersion: 0); + + var events = await store.GetEventsAsync("agent-1", fromVersion: 3); + + events.Count.ShouldBe(2); + events[0].Version.ShouldBe(4); + events[1].Version.ShouldBe(5); + } + finally + { + SafeDelete(root); + } + } + + private static string CreateTempRoot() => + Path.Combine(Path.GetTempPath(), "aevatar-eventstore-tests", Guid.NewGuid().ToString("N")); + + private static void SafeDelete(string path) + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs new file mode 100644 index 000000000..e861eb91e --- /dev/null +++ b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs @@ -0,0 +1,30 @@ +using Aevatar.Foundation.Abstractions.Persistence; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace Aevatar.Foundation.Core.Tests; + +public class RuntimeEventStoreRegistrationTests +{ + [Fact] + public void AddFileEventStore_ShouldReplaceDefaultInMemoryEventStore() + { + var root = Path.Combine(Path.GetTempPath(), "aevatar-eventstore-registration-tests", Guid.NewGuid().ToString("N")); + try + { + var services = new ServiceCollection(); + services.AddAevatarRuntime(); + services.AddFileEventStore(options => options.RootDirectory = root); + + using var provider = services.BuildServiceProvider(); + var eventStore = provider.GetRequiredService(); + + eventStore.ShouldBeOfType(); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs index 30948a6db..c00296206 100644 --- a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs +++ b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs @@ -150,6 +150,42 @@ await agent.HandleWorkflowCompleted(new WorkflowCompletedEvent outputs.Should().Contain(x => x.Contains("失败") && x.Contains("boom")); } + [Fact] + public async Task ConfigureAndCompletionEvents_ShouldPersistAndReplayAfterReactivate() + { + var sharedEventStore = new InMemoryEventStore(); + + var agent1 = CreateAgent(eventStore: sharedEventStore); + await agent1.ActivateAsync(); + await agent1.ConfigureWorkflowAsync(BuildValidWorkflowYaml("role_a", "RoleA"), "wf_replay"); + await agent1.HandleWorkflowCompleted(new WorkflowCompletedEvent + { + WorkflowName = "wf_replay", + Success = true, + Output = "ok", + }); + await agent1.HandleWorkflowCompleted(new WorkflowCompletedEvent + { + WorkflowName = "wf_replay", + Success = false, + Error = "err", + }); + await agent1.DeactivateAsync(); + + var persisted = await sharedEventStore.GetEventsAsync(agent1.Id); + persisted.Should().HaveCount(3); + persisted.Should().Contain(x => x.EventType.Contains(nameof(ConfigureWorkflowEvent), StringComparison.Ordinal)); + persisted.Count(x => x.EventType.Contains(nameof(WorkflowCompletedEvent), StringComparison.Ordinal)).Should().Be(2); + + var agent2 = CreateAgent(eventStore: sharedEventStore); + await agent2.ActivateAsync(); + + agent2.State.Compiled.Should().BeTrue(); + agent2.State.TotalExecutions.Should().Be(2); + agent2.State.SuccessfulExecutions.Should().Be(1); + agent2.State.FailedExecutions.Should().Be(1); + } + [Fact] public async Task ConfigureWorkflow_ShouldInstallAndConfigureModules() { diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 4c3cd8a99..01bc1a2eb 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -92,6 +92,11 @@ if rg -n "StateStore\.LoadAsync|StateStore\.SaveAsync" src/Aevatar.Foundation.Co exit 1 fi +if rg -n "public\s+IStateStore<" src/Aevatar.Foundation.Core/GAgentBase.TState.cs; then + echo "GAgentBase must not expose IStateStore property. Recovery/writes must go through EventStore semantics." + exit 1 +fi + set +e state_direct_mutation_report="$( rg --files -0 src -g '*.cs' -g '!*.g.cs' \ @@ -524,6 +529,59 @@ if [ -n "${implementation_ref_violations}" ]; then exit 1 fi +projection_provider_business_dependency_hits="$( + rg -n "Aevatar\.(Workflow|AI)\..*\.csproj" \ + src/Aevatar.CQRS.Projection.Providers.InMemory \ + src/Aevatar.CQRS.Projection.Providers.Elasticsearch \ + src/Aevatar.CQRS.Projection.Providers.Neo4j \ + -g '*.csproj' || true +)" + +if [ -n "${projection_provider_business_dependency_hits}" ]; then + echo "${projection_provider_business_dependency_hits}" + echo "Projection provider projects must remain business-agnostic. Workflow/AI project references are forbidden." + exit 1 +fi + +projection_provider_business_using_hits="$( + rg -n "using\s+Aevatar\.(Workflow|AI)\." \ + src/Aevatar.CQRS.Projection.Providers.InMemory \ + src/Aevatar.CQRS.Projection.Providers.Elasticsearch \ + src/Aevatar.CQRS.Projection.Providers.Neo4j \ + -g '*.cs' || true +)" + +if [ -n "${projection_provider_business_using_hits}" ]; then + echo "${projection_provider_business_using_hits}" + echo "Projection provider source files must not reference Workflow/AI namespaces." + exit 1 +fi + +projection_provider_store_files=( + "src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs" + "src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs" + "src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs" +) + +for provider_store_file in "${projection_provider_store_files[@]}"; do + if [ ! -f "${provider_store_file}" ]; then + echo "Missing provider store file: ${provider_store_file}" + exit 1 + fi + + if ! rg -F "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}" "${provider_store_file}" >/dev/null; then + echo "${provider_store_file}" + echo "Provider write path must emit structured success log with provider/readModelType/key/elapsedMs/result." + exit 1 + fi + + if ! rg -F "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}" "${provider_store_file}" >/dev/null; then + echo "${provider_store_file}" + echo "Provider write path must emit structured failure log with provider/readModelType/key/elapsedMs/result/errorType." + exit 1 + fi +done + command_side_readmodel_violations="$( rg -n "IProjectionReadModelStore<|ReadModelStore" \ src/workflow/Aevatar.Workflow.Application \ diff --git a/tools/ci/projection_provider_e2e_smoke.sh b/tools/ci/projection_provider_e2e_smoke.sh new file mode 100755 index 000000000..61ad0cdcb --- /dev/null +++ b/tools/ci/projection_provider_e2e_smoke.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +cd "${REPO_ROOT}" + +COMPOSE_FILE="docker-compose.projection-providers.yml" +ELASTICSEARCH_ENDPOINT="http://127.0.0.1:9200" +NEO4J_HOST="127.0.0.1" +NEO4J_PORT="7687" +NEO4J_URI="bolt://${NEO4J_HOST}:${NEO4J_PORT}" +NEO4J_USERNAME="neo4j" +NEO4J_PASSWORD="password" + +cleanup() { + docker compose -f "${COMPOSE_FILE}" down --volumes --remove-orphans >/dev/null 2>&1 || true +} +trap cleanup EXIT + +wait_elasticsearch() { + for _ in {1..90}; do + status="$(curl --max-time 2 -s "${ELASTICSEARCH_ENDPOINT}/_cluster/health" | rg -o "\"status\":\"[^\"]+\"" || true)" + if [[ "${status}" == "\"status\":\"green\"" || "${status}" == "\"status\":\"yellow\"" ]]; then + echo "Elasticsearch is ready: ${status}" + return 0 + fi + + echo "Waiting for Elasticsearch on ${ELASTICSEARCH_ENDPOINT}..." + sleep 2 + done + + echo "Elasticsearch failed to become ready." + return 1 +} + +wait_neo4j() { + for _ in {1..90}; do + if (echo >"/dev/tcp/${NEO4J_HOST}/${NEO4J_PORT}") >/dev/null 2>&1; then + echo "Neo4j bolt endpoint is reachable on ${NEO4J_HOST}:${NEO4J_PORT}." + return 0 + fi + + echo "Waiting for Neo4j bolt endpoint on ${NEO4J_HOST}:${NEO4J_PORT}..." + sleep 2 + done + + echo "Neo4j failed to become reachable." + return 1 +} + +echo "Starting Elasticsearch + Neo4j..." +docker compose -f "${COMPOSE_FILE}" up -d elasticsearch neo4j + +wait_elasticsearch +wait_neo4j + +echo "Running projection provider integration tests..." +AEVATAR_TEST_ELASTICSEARCH_ENDPOINT="${ELASTICSEARCH_ENDPOINT}" \ +AEVATAR_TEST_NEO4J_URI="${NEO4J_URI}" \ +AEVATAR_TEST_NEO4J_USERNAME="${NEO4J_USERNAME}" \ +AEVATAR_TEST_NEO4J_PASSWORD="${NEO4J_PASSWORD}" \ +dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj \ + --nologo \ + --filter "FullyQualifiedName~ProjectionProviderE2EIntegrationTests" + +echo "Projection provider e2e smoke test passed." From 45b6d9dc00f46fa89e91cfbe0c0302b0c756049e Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 00:11:16 +0800 Subject: [PATCH 07/46] Enhance Event Sourcing with Automatic Snapshots and Event Compaction - Introduced automatic snapshot functionality and event stream compaction in the event sourcing architecture, allowing for configurable snapshot intervals and retention policies. - Updated `IEventStore` to support deletion of historical events based on snapshot versions, improving storage efficiency. - Added `EventSourcingRuntimeOptions` to manage snapshot and compaction settings, enhancing flexibility for developers. - Implemented in-memory and file-based snapshot stores to support the new snapshot capabilities. - Updated documentation to reflect the new features and configuration options, aiding developer understanding and usage. --- docs/EVENT_SOURCING.md | 12 ++ ...ng-elasticsearch-readmodel-requirements.md | 6 +- .../Persistence/IEventStore.cs | 10 ++ .../EventSourcing/EventSourcingBehavior.cs | 45 ++++++- .../EventSourcingRuntimeOptions.cs | 28 ++++ .../GAgentBase.TState.cs | 31 ++++- .../AevatarActorRuntimeOptions.cs | 8 ++ .../ServiceCollectionExtensions.cs | 32 ++++- .../ServiceCollectionExtensions.cs | 4 + .../Grains/RuntimeActorGrain.cs | 6 + ...imeActorGrainEventSourcingSnapshotStore.cs | 61 +++++++++ .../Grains/RuntimeActorGrainState.cs | 3 + .../Grains/RuntimeActorGrainStateStore.cs | 2 + .../ServiceCollectionExtensions.cs | 10 +- .../FileEventSourcingSnapshotStore.cs | 100 ++++++++++++++ .../Persistence/FileEventStore.cs | 123 ++++++++++++++++-- .../InMemoryEventSourcingSnapshotStore.cs | 36 +++++ .../Persistence/InMemoryEventStore.cs | 65 +++++++-- src/Aevatar.Foundation.Runtime/README.md | 10 ++ .../ProjectionOwnershipAndSessionHubTests.cs | 25 +++- .../EventSourcingTests.cs | 41 +++++- .../FileEventStoreTests.cs | 55 ++++++++ .../InMemoryStoreTests.cs | 36 +++++ .../RuntimeEventStoreRegistrationTests.cs | 3 + 24 files changed, 712 insertions(+), 40 deletions(-) create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/EventSourcingRuntimeOptions.cs create mode 100644 src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainEventSourcingSnapshotStore.cs create mode 100644 src/Aevatar.Foundation.Runtime/Persistence/FileEventSourcingSnapshotStore.cs create mode 100644 src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventSourcingSnapshotStore.cs diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index 4ea9036fb..12dc69ae4 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -11,6 +11,7 @@ 3. 领域事件必须由开发者显式构建并持久化,不允许在线自动反推事件。 4. 有状态 Actor 激活必须 Replay;停用必须 flush pending events。 5. ES 行为构造走静态泛型路径,不走 Runtime 反射注入。 +6. 默认启用自动快照(可配置),并在快照成功后按版本裁剪历史事件流(可配置)。 ## 3. 当前代码事实(权威路径) - ES 行为契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` @@ -38,6 +39,7 @@ - `ConfirmEventsAsync` - `PersistSnapshotAsync` - 不再调用 `StateStore.SaveAsync` 写事实态。 +- 快照保存成功后,会调用 `IEventStore.DeleteEventsUpToAsync(...)` 自动清理历史事件(保留窗口可配置)。 ### 4.3 Fail-Fast 条件 - 未预设 `EventSourcing` 且容器中无 `IEventStore`:激活失败(`InvalidOperationException`)。 @@ -63,16 +65,25 @@ public async Task Handle(IncrementRequested evt) ## 6. DI 与容器约定 - `AddAevatarRuntime()` 默认注册 `IEventStore -> InMemoryEventStore`(开发/测试)。 +- `AddAevatarRuntime()` 默认注册 `IEventSourcingSnapshotStore -> InMemoryEventSourcingSnapshotStore`。 - 可通过 `AddFileEventStore(...)` 将 `IEventStore` 切换为本地持久化实现:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs`。 +- 调用 `AddFileEventStore(...)` 时,`IEventSourcingSnapshotStore` 会切换为 `FileEventSourcingSnapshotStore`,支持快照与事件裁剪后的持久化恢复。 - 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 - 如需自定义 ES 行为,可直接为 Agent 预设 `EventSourcing`,但必须保持相同语义契约。 - 如需解耦 Agent 里的 `TransitionState` 逻辑,可注册多个 `IStateEventApplier`,按 `Order` 升序匹配应用。 - Agent 侧推荐使用 `StateTransitionMatcher.Match(...).On(...).OrCurrent()`,避免重复 `Any + switch` 样板代码。 +- 可通过 `ActorRuntime:EventSourcing:*` 调整自动快照与裁剪策略: + - `EnableSnapshots`(默认 `true`) + - `SnapshotInterval`(默认 `200`) + - `EnableEventCompaction`(默认 `true`) + - `RetainedEventsAfterSnapshot`(默认 `0`) ## 7. 快照语义 1. 快照仅用于减少回放开销。 2. 快照写入失败不得影响已提交事件事实。 3. 恢复顺序:先快照,再从快照版本之后回放事件增量。 +4. 事件裁剪只在“快照写入成功”后触发,避免清理后无快照可恢复。 +5. 裁剪后事件流版本号必须保持单调递增,后续 append 继续基于最新版本并发控制。 ## 8. 明确禁止项 1. 把 `TState` 本体当事件写入 `EventStore`。 @@ -84,4 +95,5 @@ public async Task Handle(IncrementRequested evt) ## 9. 验证命令 - `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` - `dotnet test test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj --nologo` +- `dotnet test test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj --nologo` - `bash tools/ci/architecture_guards.sh` diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 50f119f2a..188f49713 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -37,6 +37,10 @@ 6. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 7. 运行期通过 `PersistDomainEventAsync` / `PersistDomainEventsAsync` 执行“持久化 + 顺序 apply”;Replay 主要用于激活恢复。 8. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 +9. 默认启用自动快照与事件流裁剪: + - 快照:`EventSourcingRuntimeOptions.SnapshotInterval` + - 裁剪:快照成功后调用 `IEventStore.DeleteEventsUpToAsync(...)` + - 保留窗口:`RetainedEventsAfterSnapshot` ### 3.2 Provider Runtime - 抽象:`src/Aevatar.CQRS.Projection.Abstractions` @@ -130,7 +134,7 @@ ### P4(待执行)性能与生产化增强 1. 为持久化 `IEventStore` 提供生产落地方案与压测基线(已落地本地持久化基线:`FileEventStore`,生产级后端仍待接入)。 2. 补齐 Elasticsearch/Neo4j 端到端集成脚本与回归套件(已落地基础 smoke + env-gated e2e,后续补 CI 常态化接入与更高负载回归)。 -3. 细化快照策略与回放窗口控制。 +3. 细化快照策略与回放窗口控制(已落地自动快照 + 裁剪基础能力,后续补压测驱动的阈值治理)。 ## 6. 验收标准(DoD) 1. 有状态 Actor 恢复路径全部来自 Replay,不存在 `StateStore.Load/Save` 事实回路。 diff --git a/src/Aevatar.Foundation.Abstractions/Persistence/IEventStore.cs b/src/Aevatar.Foundation.Abstractions/Persistence/IEventStore.cs index 0752d923b..497485911 100644 --- a/src/Aevatar.Foundation.Abstractions/Persistence/IEventStore.cs +++ b/src/Aevatar.Foundation.Abstractions/Persistence/IEventStore.cs @@ -28,4 +28,14 @@ Task> GetEventsAsync( /// Get current latest version number. Task GetVersionAsync(string agentId, CancellationToken ct = default); + + /// + /// Deletes historical events whose version is less than or equal to . + /// Used by snapshot-based compaction to control stream size growth. + /// Returns the number of deleted events. + /// + Task DeleteEventsUpToAsync( + string agentId, + long toVersion, + CancellationToken ct = default); } diff --git a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs index a350bc4cf..6e2f93b93 100644 --- a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs +++ b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs @@ -22,6 +22,8 @@ public class EventSourcingBehavior : IEventSourcingBehavior private readonly IEventStore _eventStore; private readonly IEventSourcingSnapshotStore? _snapshotStore; private readonly ISnapshotStrategy _snapshotStrategy; + private readonly bool _enableEventCompaction; + private readonly int _retainedEventsAfterSnapshot; private readonly ILogger> _logger; private readonly List _pending = []; private readonly string _agentId; @@ -32,12 +34,16 @@ public EventSourcingBehavior( string agentId, IEventSourcingSnapshotStore? snapshotStore = null, ISnapshotStrategy? snapshotStrategy = null, - ILogger>? logger = null) + ILogger>? logger = null, + bool enableEventCompaction = false, + int retainedEventsAfterSnapshot = 0) { _eventStore = eventStore; _agentId = agentId; _snapshotStore = snapshotStore; _snapshotStrategy = snapshotStrategy ?? NeverSnapshotStrategy.Instance; + _enableEventCompaction = enableEventCompaction; + _retainedEventsAfterSnapshot = Math.Max(0, retainedEventsAfterSnapshot); _logger = logger ?? NullLogger>.Instance; } @@ -117,6 +123,7 @@ await _snapshotStore.SaveAsync( _agentId, new EventSourcingSnapshot(currentState.Clone(), _currentVersion), ct); + await TryCompactEventsAsync(ct); } catch (Exception ex) { @@ -196,4 +203,40 @@ private static string JoinEventTypes(IEnumerable events) .ToArray(); return eventTypes.Length == 0 ? "" : string.Join(",", eventTypes); } + + private async Task TryCompactEventsAsync(CancellationToken ct) + { + if (!_enableEventCompaction) + return; + + var compactToVersion = _currentVersion - _retainedEventsAfterSnapshot; + if (compactToVersion <= 0) + return; + + try + { + var deleted = await _eventStore.DeleteEventsUpToAsync(_agentId, compactToVersion, ct); + if (deleted <= 0) + return; + + _logger.LogInformation( + "Event sourcing compaction completed. agentId={AgentId} compactToVersion={CompactToVersion} deletedEvents={DeletedEvents} retainedRecentEvents={RetainedRecentEvents} result={Result}", + _agentId, + compactToVersion, + deleted, + _retainedEventsAfterSnapshot, + "ok"); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Event sourcing compaction failed and will be ignored. agentId={AgentId} compactToVersion={CompactToVersion} retainedRecentEvents={RetainedRecentEvents} result={Result} errorType={ErrorType}", + _agentId, + compactToVersion, + _retainedEventsAfterSnapshot, + "ignored", + ex.GetType().Name); + } + } } diff --git a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingRuntimeOptions.cs b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingRuntimeOptions.cs new file mode 100644 index 000000000..5d49d9c5b --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingRuntimeOptions.cs @@ -0,0 +1,28 @@ +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Runtime defaults for automatic snapshot and event-stream compaction. +/// +public sealed class EventSourcingRuntimeOptions +{ + /// + /// Enables snapshot persistence for stateful agents when a snapshot store is registered. + /// + public bool EnableSnapshots { get; set; } = true; + + /// + /// Snapshot interval in committed event versions. + /// + public int SnapshotInterval { get; set; } = 200; + + /// + /// Enables deleting historical events after a snapshot is successfully saved. + /// + public bool EnableEventCompaction { get; set; } = true; + + /// + /// Number of latest events to retain after compaction. + /// 0 means delete all events up to snapshot version. + /// + public int RetainedEventsAfterSnapshot { get; set; } = 0; +} diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index c0fb52182..997f71de0 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -124,7 +124,22 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() if (Services?.GetService(typeof(IEventStore)) is IEventStore eventStore) { - EventSourcing = new AgentBackedEventSourcingBehavior(eventStore, Id, this); + var options = Services.GetService() ?? new EventSourcingRuntimeOptions(); + var snapshotStore = options.EnableSnapshots + ? Services.GetService>() + : null; + ISnapshotStrategy snapshotStrategy = options.EnableSnapshots && snapshotStore != null + ? new IntervalSnapshotStrategy(options.SnapshotInterval) + : NeverSnapshotStrategy.Instance; + + EventSourcing = new AgentBackedEventSourcingBehavior( + eventStore, + Id, + this, + snapshotStore, + snapshotStrategy, + options.EnableEventCompaction, + options.RetainedEventsAfterSnapshot); return EventSourcing; } @@ -159,8 +174,18 @@ private sealed class AgentBackedEventSourcingBehavior : EventSourcingBehavior owner) - : base(eventStore, agentId) + GAgentBase owner, + IEventSourcingSnapshotStore? snapshotStore, + ISnapshotStrategy snapshotStrategy, + bool enableEventCompaction, + int retainedEventsAfterSnapshot) + : base( + eventStore, + agentId, + snapshotStore, + snapshotStrategy, + enableEventCompaction: enableEventCompaction, + retainedEventsAfterSnapshot: retainedEventsAfterSnapshot) { _owner = owner; } diff --git a/src/Aevatar.Foundation.Runtime.Hosting/AevatarActorRuntimeOptions.cs b/src/Aevatar.Foundation.Runtime.Hosting/AevatarActorRuntimeOptions.cs index 6688582e4..5b134b5ae 100644 --- a/src/Aevatar.Foundation.Runtime.Hosting/AevatarActorRuntimeOptions.cs +++ b/src/Aevatar.Foundation.Runtime.Hosting/AevatarActorRuntimeOptions.cs @@ -34,4 +34,12 @@ public sealed class AevatarActorRuntimeOptions public string MassTransitKafkaTopicName { get; set; } = "aevatar-foundation-agent-events"; public string MassTransitKafkaConsumerGroup { get; set; } = "aevatar-foundation-kafka-streaming"; + + public bool EventSourcingEnableSnapshots { get; set; } = true; + + public int EventSourcingSnapshotInterval { get; set; } = 200; + + public bool EventSourcingEnableEventCompaction { get; set; } = true; + + public int EventSourcingRetainedEventsAfterSnapshot { get; set; } = 0; } diff --git a/src/Aevatar.Foundation.Runtime.Hosting/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Hosting/DependencyInjection/ServiceCollectionExtensions.cs index fb46c530e..6dc74289d 100644 --- a/src/Aevatar.Foundation.Runtime.Hosting/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime.Hosting/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ using Aevatar.Foundation.Runtime.DependencyInjection; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Implementations.Orleans.DependencyInjection; using Aevatar.Foundation.Runtime.Implementations.Orleans.Transport.MassTransit.DependencyInjection; using Aevatar.Foundation.Runtime.Streaming.Implementations.MassTransit; @@ -50,19 +51,31 @@ public static IServiceCollection AddAevatarActorRuntime( var configuredOrleansGarnetConnectionString = configuration[$"{AevatarActorRuntimeOptions.SectionName}:OrleansGarnetConnectionString"]; if (!string.IsNullOrWhiteSpace(configuredOrleansGarnetConnectionString)) options.OrleansGarnetConnectionString = configuredOrleansGarnetConnectionString; + var configuredEventSourcingEnableSnapshots = configuration[$"{AevatarActorRuntimeOptions.SectionName}:EventSourcing:EnableSnapshots"]; + if (bool.TryParse(configuredEventSourcingEnableSnapshots, out var eventSourcingEnableSnapshots)) + options.EventSourcingEnableSnapshots = eventSourcingEnableSnapshots; + var configuredEventSourcingSnapshotInterval = configuration[$"{AevatarActorRuntimeOptions.SectionName}:EventSourcing:SnapshotInterval"]; + if (int.TryParse(configuredEventSourcingSnapshotInterval, out var eventSourcingSnapshotInterval)) + options.EventSourcingSnapshotInterval = eventSourcingSnapshotInterval; + var configuredEventSourcingEnableCompaction = configuration[$"{AevatarActorRuntimeOptions.SectionName}:EventSourcing:EnableEventCompaction"]; + if (bool.TryParse(configuredEventSourcingEnableCompaction, out var eventSourcingEnableCompaction)) + options.EventSourcingEnableEventCompaction = eventSourcingEnableCompaction; + var configuredEventSourcingRetainedEvents = configuration[$"{AevatarActorRuntimeOptions.SectionName}:EventSourcing:RetainedEventsAfterSnapshot"]; + if (int.TryParse(configuredEventSourcingRetainedEvents, out var eventSourcingRetainedEvents)) + options.EventSourcingRetainedEventsAfterSnapshot = eventSourcingRetainedEvents; configure?.Invoke(options); services.Replace(ServiceDescriptor.Singleton(options)); if (string.Equals(options.Provider, AevatarActorRuntimeOptions.ProviderInMemory, StringComparison.OrdinalIgnoreCase)) { - services.AddAevatarRuntime(); + AddAevatarRuntimeWithEventSourcingOptions(services, options); return services; } if (string.Equals(options.Provider, AevatarActorRuntimeOptions.ProviderMassTransit, StringComparison.OrdinalIgnoreCase)) { - services.AddAevatarRuntime(); + AddAevatarRuntimeWithEventSourcingOptions(services, options); ConfigureMassTransitTransport(services, options); services.AddAevatarMassTransitStreamProvider(); return services; @@ -70,7 +83,7 @@ public static IServiceCollection AddAevatarActorRuntime( if (string.Equals(options.Provider, AevatarActorRuntimeOptions.ProviderOrleans, StringComparison.OrdinalIgnoreCase)) { - services.AddAevatarRuntime(); + AddAevatarRuntimeWithEventSourcingOptions(services, options); services.AddAevatarFoundationRuntimeOrleans(orleansOptions => { orleansOptions.StreamBackend = options.OrleansStreamBackend; @@ -98,6 +111,19 @@ public static IServiceCollection AddAevatarActorRuntime( $"Unsupported ActorRuntime provider '{options.Provider}'."); } + private static void AddAevatarRuntimeWithEventSourcingOptions( + IServiceCollection services, + AevatarActorRuntimeOptions options) + { + services.AddAevatarRuntime(configureEventSourcing: eventSourcingOptions => + { + eventSourcingOptions.EnableSnapshots = options.EventSourcingEnableSnapshots; + eventSourcingOptions.SnapshotInterval = options.EventSourcingSnapshotInterval; + eventSourcingOptions.EnableEventCompaction = options.EventSourcingEnableEventCompaction; + eventSourcingOptions.RetainedEventsAfterSnapshot = options.EventSourcingRetainedEventsAfterSnapshot; + }); + } + private static void ConfigureMassTransitTransport( IServiceCollection services, AevatarActorRuntimeOptions options) diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs index 07ea04bae..d216ef5a6 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Runtime.Implementations.Orleans.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming.DependencyInjection; +using Aevatar.Foundation.Core.EventSourcing; using Orleans.Hosting; using Orleans.Streams; @@ -22,9 +23,12 @@ public static IServiceCollection AddAevatarFoundationRuntimeOrleans( services.Replace(ServiceDescriptor.Singleton(options)); services.Replace(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); services.RemoveAll(typeof(IStateStore<>)); + services.RemoveAll(typeof(IEventSourcingSnapshotStore<>)); services.TryAddSingleton(); services.TryAddTransient(typeof(IStateStore<>), typeof(RuntimeActorGrainStateStore<>)); + services.TryAddTransient(typeof(IEventSourcingSnapshotStore<>), typeof(RuntimeActorGrainEventSourcingSnapshotStore<>)); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs index 0dbb40894..9f5c33ebb 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs @@ -16,6 +16,7 @@ public sealed class RuntimeActorGrain : Grain, IRuntimeActorGrain private IEnvelopePropagationPolicy _propagationPolicy = new DefaultEnvelopePropagationPolicy(new DefaultCorrelationLinkPolicy()); private Aevatar.Foundation.Abstractions.IStreamProvider _streams = null!; + private IRuntimeActorStateBindingAccessor? _stateBindingAccessor; private ILogger _logger = NullLogger.Instance; private IAsyncStream? _selfStream; private StreamSubscriptionHandle? _selfStreamHandle; @@ -31,6 +32,7 @@ public override async Task OnActivateAsync(CancellationToken cancellationToken) _deduplicator = ServiceProvider.GetService(); _propagationPolicy = ServiceProvider.GetService() ?? _propagationPolicy; _streams = ServiceProvider.GetRequiredService(); + _stateBindingAccessor = ServiceProvider.GetService(); var loggerFactory = ServiceProvider.GetService(); _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -51,6 +53,7 @@ public override async Task OnDeactivateAsync(DeactivationReason reason, Cancella if (_agent != null) { + using var stateBinding = _stateBindingAccessor?.Bind(_state); await _agent.DeactivateAsync(cancellationToken); _agent = null; } @@ -118,6 +121,7 @@ public async Task HandleEnvelopeAsync(byte[] envelopeBytes) return; } + using var stateBinding = _stateBindingAccessor?.Bind(_state); await _agent.HandleEventAsync(envelope); } @@ -198,6 +202,7 @@ public async Task PurgeAsync() _state.State.Children.Clear(); _state.State.AgentStateTypeName = null; _state.State.AgentStateSnapshot = null; + _state.State.AgentStateSnapshotVersion = 0; await _state.WriteStateAsync(); } @@ -212,6 +217,7 @@ private async Task InitializeAgentInternalAsync(string agentTypeName, Canc try { + using var stateBinding = _stateBindingAccessor?.Bind(_state); var agent = CreateAgentInstance(agentType); InjectDependencies(agent, this.GetPrimaryKeyString()); await agent.ActivateAsync(ct); diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainEventSourcingSnapshotStore.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainEventSourcingSnapshotStore.cs new file mode 100644 index 000000000..f610672e9 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainEventSourcingSnapshotStore.cs @@ -0,0 +1,61 @@ +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; +using Orleans.Runtime; + +namespace Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; + +/// +/// Uses RuntimeActorGrain persistent state as snapshot storage for event sourcing. +/// +internal sealed class RuntimeActorGrainEventSourcingSnapshotStore + : IEventSourcingSnapshotStore + where TState : class, IMessage, new() +{ + private static readonly string StateTypeName = typeof(TState).FullName ?? typeof(TState).Name; + + private readonly IPersistentState _runtimeState; + + public RuntimeActorGrainEventSourcingSnapshotStore(IPersistentState runtimeState) + { + _runtimeState = runtimeState; + } + + public RuntimeActorGrainEventSourcingSnapshotStore(IRuntimeActorStateBindingAccessor accessor) + { + ArgumentNullException.ThrowIfNull(accessor); + _runtimeState = accessor.Current + ?? throw new InvalidOperationException( + "Runtime actor state is not bound. " + + "Resolve IEventSourcingSnapshotStore only within RuntimeActorGrain binding context."); + } + + public Task?> LoadAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var snapshot = _runtimeState.State.AgentStateSnapshot; + if (snapshot == null || snapshot.Length == 0) + return Task.FromResult?>(null); + + if (!string.Equals(_runtimeState.State.AgentStateTypeName, StateTypeName, StringComparison.Ordinal)) + return Task.FromResult?>(null); + + var state = new TState(); + state.MergeFrom(snapshot); + return Task.FromResult?>( + new EventSourcingSnapshot(state, _runtimeState.State.AgentStateSnapshotVersion)); + } + + public Task SaveAsync(string agentId, EventSourcingSnapshot snapshot, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(snapshot); + ct.ThrowIfCancellationRequested(); + + _runtimeState.State.AgentStateTypeName = StateTypeName; + _runtimeState.State.AgentStateSnapshot = snapshot.State.ToByteArray(); + _runtimeState.State.AgentStateSnapshotVersion = snapshot.Version; + return _runtimeState.WriteStateAsync(); + } +} diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainState.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainState.cs index 88b9c4b93..ef76b231e 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainState.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainState.cs @@ -20,4 +20,7 @@ public sealed class RuntimeActorGrainState [Id(5)] public byte[]? AgentStateSnapshot { get; set; } + + [Id(6)] + public long AgentStateSnapshotVersion { get; set; } } diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainStateStore.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainStateStore.cs index 9f31ae8e6..3f7477e77 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainStateStore.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrainStateStore.cs @@ -53,6 +53,7 @@ public Task SaveAsync(string agentId, TState state, CancellationToken ct = defau _runtimeState.State.AgentStateTypeName = StateTypeName; _runtimeState.State.AgentStateSnapshot = state.ToByteArray(); + _runtimeState.State.AgentStateSnapshotVersion = 0; return _runtimeState.WriteStateAsync(); } @@ -63,6 +64,7 @@ public Task DeleteAsync(string agentId, CancellationToken ct = default) _runtimeState.State.AgentStateTypeName = null; _runtimeState.State.AgentStateSnapshot = null; + _runtimeState.State.AgentStateSnapshotVersion = 0; return _runtimeState.WriteStateAsync(); } } diff --git a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index e046b30ba..5d094fe44 100644 --- a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Propagation; using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Core.Propagation; using Aevatar.Foundation.Runtime.Persistence; using Aevatar.Foundation.Runtime.Routing; @@ -31,7 +32,8 @@ public static class ServiceCollectionExtensions /// Service collection for fluent chaining. public static IServiceCollection AddAevatarRuntime( this IServiceCollection services, - Action? configureStreams = null) + Action? configureStreams = null, + Action? configureEventSourcing = null) { // Streaming var streamOptions = new InMemoryStreamOptions(); @@ -57,7 +59,12 @@ public static IServiceCollection AddAevatarRuntime( sp.GetService>())); // Persistence + var eventSourcingOptions = new EventSourcingRuntimeOptions(); + configureEventSourcing?.Invoke(eventSourcingOptions); + services.Replace(ServiceDescriptor.Singleton(eventSourcingOptions)); + services.TryAddSingleton(typeof(IStateStore<>), typeof(InMemoryStateStore<>)); + services.TryAddSingleton(typeof(IEventSourcingSnapshotStore<>), typeof(InMemoryEventSourcingSnapshotStore<>)); services.TryAddSingleton(); services.TryAddSingleton(); @@ -91,6 +98,7 @@ public static IServiceCollection AddFileEventStore( configure?.Invoke(options); services.Replace(ServiceDescriptor.Singleton(options)); services.Replace(ServiceDescriptor.Singleton()); + services.Replace(ServiceDescriptor.Singleton(typeof(IEventSourcingSnapshotStore<>), typeof(FileEventSourcingSnapshotStore<>))); return services; } } diff --git a/src/Aevatar.Foundation.Runtime/Persistence/FileEventSourcingSnapshotStore.cs b/src/Aevatar.Foundation.Runtime/Persistence/FileEventSourcingSnapshotStore.cs new file mode 100644 index 000000000..9fc348b64 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Persistence/FileEventSourcingSnapshotStore.cs @@ -0,0 +1,100 @@ +using System.Collections.Concurrent; +using System.Text; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.Foundation.Runtime.Persistence; + +/// +/// File-backed snapshot store for event sourcing snapshots. +/// +public sealed class FileEventSourcingSnapshotStore : IEventSourcingSnapshotStore + where TState : class, IMessage, new() +{ + private readonly string _rootDirectory; + private readonly ConcurrentDictionary _agentLocks = new(StringComparer.Ordinal); + + public FileEventSourcingSnapshotStore(FileEventStoreOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.RootDirectory)) + throw new InvalidOperationException("File snapshot store requires a non-empty root directory."); + + _rootDirectory = Path.GetFullPath(options.RootDirectory); + Directory.CreateDirectory(_rootDirectory); + } + + public async Task?> LoadAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var path = GetSnapshotPath(agentId); + if (!File.Exists(path)) + return null; + + await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); + var version = reader.ReadInt64(); + var payloadLength = reader.ReadInt32(); + if (payloadLength <= 0) + throw new InvalidOperationException($"Corrupted snapshot for '{agentId}': invalid payload length {payloadLength}."); + + var payload = reader.ReadBytes(payloadLength); + if (payload.Length != payloadLength) + throw new InvalidOperationException($"Corrupted snapshot for '{agentId}': truncated payload."); + + var state = new TState(); + state.MergeFrom(payload); + return new EventSourcingSnapshot(state, version); + } + finally + { + gate.Release(); + } + } + + public async Task SaveAsync(string agentId, EventSourcingSnapshot snapshot, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(snapshot); + ct.ThrowIfCancellationRequested(); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var path = GetSnapshotPath(agentId); + var tempPath = path + ".tmp"; + await using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + var payload = snapshot.State.ToByteArray(); + writer.Write(snapshot.Version); + writer.Write(payload.Length); + writer.Write(payload); + } + + File.Move(tempPath, path, overwrite: true); + } + finally + { + gate.Release(); + } + } + + private string GetSnapshotPath(string agentId) + { + var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(agentId)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + return Path.Combine(_rootDirectory, encoded + ".snapshot"); + } +} diff --git a/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs index d6d25ad74..a3769a1d3 100644 --- a/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs +++ b/src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs @@ -13,6 +13,16 @@ namespace Aevatar.Foundation.Runtime.Persistence; /// public sealed class FileEventStore : IEventStore { + private const int StreamFormatMagic = 0x53464541; // AEFS + private const int StreamFormatVersion = 1; + + private sealed class EventStreamState + { + public long CurrentVersion { get; set; } + + public List Events { get; } = []; + } + private readonly string _rootDirectory; private readonly ConcurrentDictionary _agentLocks = new(StringComparer.Ordinal); private readonly ILogger _logger; @@ -53,17 +63,18 @@ public async Task AppendAsync( try { var stream = ReadStream(agentId, ct); - var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + var currentVersion = stream.CurrentVersion; if (currentVersion != expectedVersion) { throw new InvalidOperationException( $"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); } - stream.AddRange(pendingEvents); + stream.Events.AddRange(pendingEvents); + stream.CurrentVersion = pendingEvents[^1].Version; WriteStream(agentId, stream, ct); - var latestVersion = stream[^1].Version; + var latestVersion = stream.CurrentVersion; _logger.LogDebug( "File event-store append completed. agentId={AgentId} appended={Count} version={Version}", agentId, @@ -92,8 +103,8 @@ public async Task> GetEventsAsync( { var stream = ReadStream(agentId, ct); var filtered = fromVersion.HasValue - ? stream.Where(x => x.Version > fromVersion.Value) - : stream; + ? stream.Events.Where(x => x.Version > fromVersion.Value) + : stream.Events.AsEnumerable(); return filtered.Select(CloneEvent).ToList(); } finally @@ -113,7 +124,36 @@ public async Task GetVersionAsync(string agentId, CancellationToken ct = d try { var stream = ReadStream(agentId, ct); - return stream.Count == 0 ? 0 : stream[^1].Version; + return stream.CurrentVersion; + } + finally + { + gate.Release(); + } + } + + public async Task DeleteEventsUpToAsync( + string agentId, + long toVersion, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0) + return 0; + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + + try + { + var stream = ReadStream(agentId, ct); + var before = stream.Events.Count; + stream.Events.RemoveAll(x => x.Version <= toVersion); + var removed = before - stream.Events.Count; + if (removed > 0) + WriteStream(agentId, stream, ct); + return removed; } finally { @@ -121,15 +161,68 @@ public async Task GetVersionAsync(string agentId, CancellationToken ct = d } } - private List ReadStream(string agentId, CancellationToken ct) + private EventStreamState ReadStream(string agentId, CancellationToken ct) { var path = GetStreamPath(agentId); if (!File.Exists(path)) - return []; + return new EventStreamState(); - var result = new List(); + var result = new EventStreamState(); using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + if (stream.Length == 0) + return result; + + var firstToken = reader.ReadInt32(); + if (firstToken == StreamFormatMagic) + { + if (stream.Length < sizeof(int) + sizeof(int) + sizeof(long) + sizeof(int)) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': invalid header."); + } + + var formatVersion = reader.ReadInt32(); + if (formatVersion != StreamFormatVersion) + { + throw new InvalidOperationException( + $"Unsupported event stream format version {formatVersion} for agent '{agentId}'."); + } + + result.CurrentVersion = reader.ReadInt64(); + var count = reader.ReadInt32(); + if (count < 0) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': invalid event count {count}."); + } + + for (var i = 0; i < count; i++) + { + ct.ThrowIfCancellationRequested(); + var payloadLength = reader.ReadInt32(); + if (payloadLength <= 0) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': invalid payload length {payloadLength}."); + } + + var payload = reader.ReadBytes(payloadLength); + if (payload.Length != payloadLength) + { + throw new InvalidOperationException( + $"Corrupted event stream for agent '{agentId}': truncated payload."); + } + + result.Events.Add(StateEvent.Parser.ParseFrom(payload)); + } + + return result; + } + + // Legacy format fallback: [length][event][length][event]... + stream.Position = 0; + reader.BaseStream.Position = 0; while (stream.Position < stream.Length) { @@ -148,13 +241,14 @@ private List ReadStream(string agentId, CancellationToken ct) $"Corrupted event stream for agent '{agentId}': truncated payload."); } - result.Add(StateEvent.Parser.ParseFrom(payload)); + result.Events.Add(StateEvent.Parser.ParseFrom(payload)); } + result.CurrentVersion = result.Events.Count == 0 ? 0 : result.Events[^1].Version; return result; } - private void WriteStream(string agentId, IReadOnlyList stream, CancellationToken ct) + private void WriteStream(string agentId, EventStreamState stream, CancellationToken ct) { var path = GetStreamPath(agentId); var tempPath = path + ".tmp"; @@ -162,7 +256,12 @@ private void WriteStream(string agentId, IReadOnlyList stream, Cance using (var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None)) using (var writer = new BinaryWriter(fileStream, Encoding.UTF8, leaveOpen: false)) { - foreach (var evt in stream) + writer.Write(StreamFormatMagic); + writer.Write(StreamFormatVersion); + writer.Write(stream.CurrentVersion); + writer.Write(stream.Events.Count); + + foreach (var evt in stream.Events) { ct.ThrowIfCancellationRequested(); var payload = evt.ToByteArray(); diff --git a/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventSourcingSnapshotStore.cs b/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventSourcingSnapshotStore.cs new file mode 100644 index 000000000..594c88f0e --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventSourcingSnapshotStore.cs @@ -0,0 +1,36 @@ +using System.Collections.Concurrent; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; + +namespace Aevatar.Foundation.Runtime.Persistence; + +/// +/// In-memory snapshot store for event sourcing state snapshots. +/// +public sealed class InMemoryEventSourcingSnapshotStore : IEventSourcingSnapshotStore + where TState : class, IMessage, new() +{ + private readonly ConcurrentDictionary> _snapshots = new(StringComparer.Ordinal); + + public Task?> LoadAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + if (!_snapshots.TryGetValue(agentId, out var snapshot)) + return Task.FromResult?>(null); + + return Task.FromResult?>( + new EventSourcingSnapshot(snapshot.State.Clone(), snapshot.Version)); + } + + public Task SaveAsync(string agentId, EventSourcingSnapshot snapshot, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(snapshot); + ct.ThrowIfCancellationRequested(); + + _snapshots[agentId] = new EventSourcingSnapshot(snapshot.State.Clone(), snapshot.Version); + return Task.CompletedTask; + } +} diff --git a/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventStore.cs b/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventStore.cs index 6075092fa..684772b24 100644 --- a/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventStore.cs +++ b/src/Aevatar.Foundation.Runtime/Persistence/InMemoryEventStore.cs @@ -11,7 +11,14 @@ namespace Aevatar.Foundation.Runtime.Persistence; /// In-memory event store with append and version-based query support. public sealed class InMemoryEventStore : IEventStore { - private readonly ConcurrentDictionary> _store = new(); + private sealed class EventStreamState + { + public long CurrentVersion { get; set; } + + public List Events { get; } = []; + } + + private readonly ConcurrentDictionary _store = new(); private readonly object _lock = new(); /// Appends events with optimistic concurrency check on expectedVersion. @@ -22,30 +29,62 @@ public sealed class InMemoryEventStore : IEventStore /// Latest version after append. public Task AppendAsync(string agentId, IEnumerable events, long expectedVersion, CancellationToken ct = default) { + ct.ThrowIfCancellationRequested(); lock (_lock) { - var list = _store.GetOrAdd(agentId, _ => []); - var current = list.Count > 0 ? list[^1].Version : 0; + var stream = _store.GetOrAdd(agentId, _ => new EventStreamState()); + var current = stream.CurrentVersion; if (current != expectedVersion) throw new InvalidOperationException($"Optimistic concurrency conflict: expected {expectedVersion}, actual {current}"); var eventList = events.ToList(); - list.AddRange(eventList); - return Task.FromResult(eventList.Count > 0 ? eventList[^1].Version : current); + stream.Events.AddRange(eventList.Select(static x => x.Clone())); + if (eventList.Count > 0) + stream.CurrentVersion = eventList[^1].Version; + return Task.FromResult(stream.CurrentVersion); } } /// Gets events for an agent, optionally filtered by fromVersion. public Task> GetEventsAsync(string agentId, long? fromVersion = null, CancellationToken ct = default) { - if (!_store.TryGetValue(agentId, out var list)) - return Task.FromResult>([]); - IReadOnlyList result = fromVersion.HasValue - ? list.Where(e => e.Version > fromVersion.Value).ToList() - : list.ToList(); - return Task.FromResult(result); + ct.ThrowIfCancellationRequested(); + lock (_lock) + { + if (!_store.TryGetValue(agentId, out var stream)) + return Task.FromResult>([]); + + IReadOnlyList result = fromVersion.HasValue + ? stream.Events.Where(e => e.Version > fromVersion.Value).Select(static x => x.Clone()).ToList() + : stream.Events.Select(static x => x.Clone()).ToList(); + return Task.FromResult(result); + } } /// Gets the current version for the specified agent. - public Task GetVersionAsync(string agentId, CancellationToken ct = default) => - Task.FromResult(!_store.TryGetValue(agentId, out var list) || list.Count == 0 ? 0L : list[^1].Version); + public Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_lock) + { + return Task.FromResult(!_store.TryGetValue(agentId, out var stream) ? 0L : stream.CurrentVersion); + } + } + + public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0) + return Task.FromResult(0L); + + lock (_lock) + { + if (!_store.TryGetValue(agentId, out var stream)) + return Task.FromResult(0L); + + var before = stream.Events.Count; + stream.Events.RemoveAll(x => x.Version <= toVersion); + var removed = before - stream.Events.Count; + return Task.FromResult((long)removed); + } + } } diff --git a/src/Aevatar.Foundation.Runtime/README.md b/src/Aevatar.Foundation.Runtime/README.md index 265aa5687..7c745a482 100644 --- a/src/Aevatar.Foundation.Runtime/README.md +++ b/src/Aevatar.Foundation.Runtime/README.md @@ -62,9 +62,19 @@ Runtime 维护一棵 **Agent 树**(父/子关系)。每个事件带一个** 当前默认实现都是**内存**的,适合开发、演示和单机部署: - **事件存储**:Event Sourcing 事实源,默认 `InMemoryEventStore`,可替换为 `FileEventStore`。 +- **快照存储**:默认 `InMemoryEventSourcingSnapshotStore`;启用 `AddFileEventStore(...)` 后切换为 `FileEventSourcingSnapshotStore`。 - **Manifest**:Agent 的配置与已挂载的模块列表。 - **路由层级**:父子关系。 +Event Sourcing 默认启用自动快照与事件裁剪(快照成功后清理历史事件): + +- `EnableSnapshots`(默认 `true`) +- `SnapshotInterval`(默认 `200`) +- `EnableEventCompaction`(默认 `true`) +- `RetainedEventsAfterSnapshot`(默认 `0`) + +可通过 `ActorRuntime:EventSourcing:*` 配置覆盖。 + 生产环境可替换为数据库或其它持久化实现,接口由 Aevatar 抽象层定义。 --- diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index 25d80de7b..7c4bcde97 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -447,6 +447,7 @@ internal sealed class StringSessionEventCodec : IProjectionSessionEventCodec> _streams = new(StringComparer.Ordinal); + private readonly Dictionary _versions = new(StringComparer.Ordinal); private readonly object _sync = new(); public Task AppendAsync( @@ -462,9 +463,10 @@ public Task AppendAsync( { stream = []; _streams[agentId] = stream; + _versions[agentId] = 0; } - var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + var currentVersion = _versions.GetValueOrDefault(agentId); if (currentVersion != expectedVersion) { throw new InvalidOperationException( @@ -473,7 +475,8 @@ public Task AppendAsync( var appended = events.ToList(); stream.AddRange(appended.Select(x => x.Clone())); - var latest = stream.Count == 0 ? 0 : stream[^1].Version; + var latest = appended.Count == 0 ? currentVersion : appended[^1].Version; + _versions[agentId] = latest; return Task.FromResult(latest); } } @@ -501,10 +504,24 @@ public Task GetVersionAsync(string agentId, CancellationToken ct = default ct.ThrowIfCancellationRequested(); lock (_sync) { - if (!_streams.TryGetValue(agentId, out var stream) || stream.Count == 0) + return Task.FromResult(_versions.GetValueOrDefault(agentId)); + } + } + + public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0) + return Task.FromResult(0L); + + lock (_sync) + { + if (!_streams.TryGetValue(agentId, out var stream)) return Task.FromResult(0L); - return Task.FromResult(stream[^1].Version); + var before = stream.Count; + stream.RemoveAll(x => x.Version <= toVersion); + return Task.FromResult((long)(before - stream.Count)); } } } diff --git a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs index 004aec006..68e5222ff 100644 --- a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs @@ -159,6 +159,35 @@ public async Task PersistSnapshotAsync_WhenSnapshotSaveFails_ShouldNotThrowAndSh events.Count.ShouldBe(1); } + [Fact] + public async Task PersistSnapshotAsync_WhenCompactionEnabled_ShouldDeleteHistoricalEvents_AndKeepReplayWorking() + { + var store = new InMemoryEventStore(); + var snapshotStore = new InMemoryEventSourcingSnapshotStore(); + var behavior = new CounterEventSourcingBehavior( + store, + "agent-snapshot-compact", + snapshotStore: snapshotStore, + snapshotStrategy: new IntervalSnapshotStrategy(1), + enableEventCompaction: true, + retainedEventsAfterSnapshot: 0); + + behavior.RaiseEvent(new IncrementEvent { Amount = 4 }); + behavior.RaiseEvent(new IncrementEvent { Amount = 6 }); + await behavior.ConfirmEventsAsync(); + await behavior.PersistSnapshotAsync(new CounterState { Count = 10, Name = "snapshot" }); + + var version = await store.GetVersionAsync("agent-snapshot-compact"); + var events = await store.GetEventsAsync("agent-snapshot-compact"); + version.ShouldBe(2); + events.ShouldBeEmpty(); + + var replayed = await behavior.ReplayAsync("agent-snapshot-compact"); + replayed.ShouldNotBeNull(); + replayed!.Count.ShouldBe(10); + behavior.CurrentVersion.ShouldBe(2); + } + [Fact] public async Task ReplayAsync_WhenSnapshotExists_ShouldReplayOnlyDeltaEvents() { @@ -215,8 +244,16 @@ public CounterEventSourcingBehavior( IEventStore eventStore, string agentId, IEventSourcingSnapshotStore? snapshotStore = null, - ISnapshotStrategy? snapshotStrategy = null) - : base(eventStore, agentId, snapshotStore, snapshotStrategy) { } + ISnapshotStrategy? snapshotStrategy = null, + bool enableEventCompaction = false, + int retainedEventsAfterSnapshot = 0) + : base( + eventStore, + agentId, + snapshotStore, + snapshotStrategy, + enableEventCompaction: enableEventCompaction, + retainedEventsAfterSnapshot: retainedEventsAfterSnapshot) { } public override CounterState TransitionState(CounterState current, IMessage evt) => StateTransitionMatcher diff --git a/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs b/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs index 8e0882a9e..95c28cdd7 100644 --- a/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/FileEventStoreTests.cs @@ -122,6 +122,61 @@ await store.AppendAsync("agent-1", } } + [Fact] + public async Task DeleteEventsUpToAsync_ShouldCompactHistory_AndKeepLatestVersion() + { + var root = CreateTempRoot(); + try + { + var store = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + await store.AppendAsync("agent-1", + Enumerable.Range(1, 5).Select(i => new StateEvent + { + EventId = $"e{i}", + Timestamp = TimestampHelper.Now(), + Version = i, + EventType = "evt", + AgentId = "agent-1", + }), + expectedVersion: 0); + + var deleted = await store.DeleteEventsUpToAsync("agent-1", 4); + deleted.ShouldBe(4); + + var versionAfterCompact = await store.GetVersionAsync("agent-1"); + versionAfterCompact.ShouldBe(5); + + var remained = await store.GetEventsAsync("agent-1"); + remained.Count.ShouldBe(1); + remained[0].Version.ShouldBe(5); + + await store.AppendAsync("agent-1", + [ + new StateEvent + { + EventId = "e6", + Timestamp = TimestampHelper.Now(), + Version = 6, + EventType = "evt", + AgentId = "agent-1", + }, + ], + expectedVersion: 5); + + var store2 = new FileEventStore(new FileEventStoreOptions { RootDirectory = root }); + var version = await store2.GetVersionAsync("agent-1"); + var events = await store2.GetEventsAsync("agent-1"); + version.ShouldBe(6); + events.Count.ShouldBe(2); + events[0].Version.ShouldBe(5); + events[1].Version.ShouldBe(6); + } + finally + { + SafeDelete(root); + } + } + private static string CreateTempRoot() => Path.Combine(Path.GetTempPath(), "aevatar-eventstore-tests", Guid.NewGuid().ToString("N")); diff --git a/test/Aevatar.Foundation.Core.Tests/InMemoryStoreTests.cs b/test/Aevatar.Foundation.Core.Tests/InMemoryStoreTests.cs index 08c8ea2d9..e0fbfe3da 100644 --- a/test/Aevatar.Foundation.Core.Tests/InMemoryStoreTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/InMemoryStoreTests.cs @@ -119,4 +119,40 @@ public async Task GetEvents_FromVersion_FiltersCorrectly() fromV3[0].Version.ShouldBe(4); fromV3[1].Version.ShouldBe(5); } + + [Fact] + public async Task DeleteEventsUpToAsync_ShouldDeleteHistoryButKeepStreamVersion() + { + var store = new InMemoryEventStore(); + var events = Enumerable.Range(1, 5).Select(i => new StateEvent + { + EventId = $"e{i}", + Version = i, + AgentId = "a1", + }).ToList(); + + await store.AppendAsync("a1", events, 0); + + var deleted = await store.DeleteEventsUpToAsync("a1", 4); + deleted.ShouldBe(4); + + var version = await store.GetVersionAsync("a1"); + version.ShouldBe(5); + + var remained = await store.GetEventsAsync("a1"); + remained.Count.ShouldBe(1); + remained[0].Version.ShouldBe(5); + + await store.AppendAsync("a1", + [ + new StateEvent + { + EventId = "e6", + Version = 6, + AgentId = "a1", + }, + ], 5); + + (await store.GetVersionAsync("a1")).ShouldBe(6); + } } diff --git a/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs index e861eb91e..3114d754a 100644 --- a/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs @@ -1,4 +1,5 @@ using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; using Microsoft.Extensions.DependencyInjection; using Shouldly; @@ -18,8 +19,10 @@ public void AddFileEventStore_ShouldReplaceDefaultInMemoryEventStore() using var provider = services.BuildServiceProvider(); var eventStore = provider.GetRequiredService(); + var snapshotStore = provider.GetRequiredService>(); eventStore.ShouldBeOfType(); + snapshotStore.ShouldBeOfType>(); } finally { From 9b820f4a096612541943acbf6303f106462347db Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 00:36:59 +0800 Subject: [PATCH 08/46] Implement Event Store Compaction and Deactivation Hooks - Introduced `IEventStoreCompactionScheduler` to manage event compaction scheduling, allowing for deferred execution during idle actor lifecycles. - Added `IActorDeactivationHook` and `IActorDeactivationHookDispatcher` to facilitate the execution of non-critical tasks post-actor deactivation, enhancing the runtime's lifecycle management. - Implemented `EventStoreCompactionDeactivationHook` to trigger compaction on actor idle, improving event store efficiency. - Updated `EventSourcingBehavior` to utilize the new compaction scheduler, ensuring historical event cleanup is handled asynchronously. - Enhanced documentation to reflect the new compaction and deactivation hook features, aiding developer understanding and usage. - Added tests for the new compaction scheduler and deactivation hooks to ensure reliability and correctness of the implementation. --- docs/EVENT_SOURCING.md | 13 +- ...ng-elasticsearch-readmodel-requirements.md | 8 +- .../EventSourcing/EventSourcingBehavior.cs | 26 ++-- .../IEventStoreCompactionScheduler.cs | 19 +++ .../GAgentBase.TState.cs | 10 +- .../ServiceCollectionExtensions.cs | 4 + .../Grains/RuntimeActorGrain.cs | 13 ++ .../Actor/ActorDeactivationHookDispatcher.cs | 45 +++++++ .../EventStoreCompactionDeactivationHook.cs | 24 ++++ .../Actor/IActorDeactivationHook.cs | 10 ++ .../Actor/IActorDeactivationHookDispatcher.cs | 9 ++ .../Actor/LocalActor.cs | 14 +- .../Actor/LocalActorRuntime.cs | 5 +- .../ServiceCollectionExtensions.cs | 3 + .../DeferredEventStoreCompactionScheduler.cs | 84 ++++++++++++ .../ActorDeactivationHookDispatcherTests.cs | 67 ++++++++++ ...erredEventStoreCompactionSchedulerTests.cs | 120 ++++++++++++++++++ .../EventSourcingTests.cs | 21 ++- ...entStoreCompactionDeactivationHookTests.cs | 72 +++++++++++ 19 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs create mode 100644 src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs create mode 100644 src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs create mode 100644 src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs create mode 100644 src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs create mode 100644 src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs create mode 100644 test/Aevatar.Foundation.Core.Tests/ActorDeactivationHookDispatcherTests.cs create mode 100644 test/Aevatar.Foundation.Core.Tests/DeferredEventStoreCompactionSchedulerTests.cs create mode 100644 test/Aevatar.Foundation.Core.Tests/EventStoreCompactionDeactivationHookTests.cs diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index 12dc69ae4..aff2f84dd 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -16,10 +16,15 @@ ## 3. 当前代码事实(权威路径) - ES 行为契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` - ES 默认实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 事件裁剪调度抽象:`src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs` - 状态事件 applier 抽象:`src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs` - Typed applier 基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` - 状态事件匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 有状态生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- Runtime 停用钩子抽象:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs` +- Runtime 停用钩子分发器:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs` +- Runtime 停用钩子分发实现:`src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs` +- Runtime 默认裁剪钩子:`src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` - 本地持久化 EventStore:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` - Local Runtime 注入边界:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - Orleans Runtime 注入边界:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` @@ -39,7 +44,7 @@ - `ConfirmEventsAsync` - `PersistSnapshotAsync` - 不再调用 `StateStore.SaveAsync` 写事实态。 -- 快照保存成功后,会调用 `IEventStore.DeleteEventsUpToAsync(...)` 自动清理历史事件(保留窗口可配置)。 +- 快照保存成功后仅记录“待清理版本”;历史事件清理由 runtime `IActorDeactivationHookDispatcher` 分发所有 `IActorDeactivationHook`,其中默认裁剪钩子触发 `IEventStoreCompactionScheduler.RunOnIdleAsync(...)` 异步执行。 ### 4.3 Fail-Fast 条件 - 未预设 `EventSourcing` 且容器中无 `IEventStore`:激活失败(`InvalidOperationException`)。 @@ -66,6 +71,9 @@ public async Task Handle(IncrementRequested evt) ## 6. DI 与容器约定 - `AddAevatarRuntime()` 默认注册 `IEventStore -> InMemoryEventStore`(开发/测试)。 - `AddAevatarRuntime()` 默认注册 `IEventSourcingSnapshotStore -> InMemoryEventSourcingSnapshotStore`。 +- `AddAevatarRuntime()` 默认注册 `IEventStoreCompactionScheduler -> DeferredEventStoreCompactionScheduler`(记录裁剪意图,空闲期执行)。 +- `AddAevatarRuntime()` 默认注册 `IActorDeactivationHook -> EventStoreCompactionDeactivationHook`。 +- `AddAevatarRuntime()` 默认注册 `IActorDeactivationHookDispatcher -> ActorDeactivationHookDispatcher`(支持多 hook 顺序分发)。 - 可通过 `AddFileEventStore(...)` 将 `IEventStore` 切换为本地持久化实现:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs`。 - 调用 `AddFileEventStore(...)` 时,`IEventSourcingSnapshotStore` 会切换为 `FileEventSourcingSnapshotStore`,支持快照与事件裁剪后的持久化恢复。 - 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 @@ -83,7 +91,8 @@ public async Task Handle(IncrementRequested evt) 2. 快照写入失败不得影响已提交事件事实。 3. 恢复顺序:先快照,再从快照版本之后回放事件增量。 4. 事件裁剪只在“快照写入成功”后触发,避免清理后无快照可恢复。 -5. 裁剪后事件流版本号必须保持单调递增,后续 append 继续基于最新版本并发控制。 +5. 裁剪执行为异步延迟任务,默认在 Actor 空闲停用阶段触发,不阻塞命令写入主路径。 +6. 裁剪后事件流版本号必须保持单调递增,后续 append 继续基于最新版本并发控制。 ## 8. 明确禁止项 1. 把 `TState` 本体当事件写入 `EventStore`。 diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 188f49713..9218c2143 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -19,10 +19,16 @@ ### 3.1 Event Sourcing - 契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` - 实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 裁剪调度抽象:`src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs` +- 运行时裁剪调度实现:`src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs` - 状态转换扩展:`src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs` - Typed 状态转换基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` - 状态匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` - 生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` +- Runtime 停用钩子抽象:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs` +- Runtime 停用钩子分发器:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs` +- Runtime 停用钩子分发实现:`src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs` +- Runtime 默认裁剪钩子:`src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` - 本地持久化 EventStore 基线:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` - 运行时注入边界: - `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` @@ -39,7 +45,7 @@ 8. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 9. 默认启用自动快照与事件流裁剪: - 快照:`EventSourcingRuntimeOptions.SnapshotInterval` - - 裁剪:快照成功后调用 `IEventStore.DeleteEventsUpToAsync(...)` + - 裁剪:快照成功后通过 `IEventStoreCompactionScheduler.ScheduleAsync(...)` 记录待清理版本,在空闲期由 runtime `IActorDeactivationHookDispatcher` 分发 deactivation hooks,默认裁剪钩子触发 `RunOnIdleAsync(...)` 异步调用 `IEventStore.DeleteEventsUpToAsync(...)` - 保留窗口:`RetainedEventsAfterSnapshot` ### 3.2 Provider Runtime diff --git a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs index 6e2f93b93..1814279d2 100644 --- a/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs +++ b/src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs @@ -22,6 +22,7 @@ public class EventSourcingBehavior : IEventSourcingBehavior private readonly IEventStore _eventStore; private readonly IEventSourcingSnapshotStore? _snapshotStore; private readonly ISnapshotStrategy _snapshotStrategy; + private readonly IEventStoreCompactionScheduler? _compactionScheduler; private readonly bool _enableEventCompaction; private readonly int _retainedEventsAfterSnapshot; private readonly ILogger> _logger; @@ -36,12 +37,14 @@ public EventSourcingBehavior( ISnapshotStrategy? snapshotStrategy = null, ILogger>? logger = null, bool enableEventCompaction = false, - int retainedEventsAfterSnapshot = 0) + int retainedEventsAfterSnapshot = 0, + IEventStoreCompactionScheduler? compactionScheduler = null) { _eventStore = eventStore; _agentId = agentId; _snapshotStore = snapshotStore; _snapshotStrategy = snapshotStrategy ?? NeverSnapshotStrategy.Instance; + _compactionScheduler = compactionScheduler; _enableEventCompaction = enableEventCompaction; _retainedEventsAfterSnapshot = Math.Max(0, retainedEventsAfterSnapshot); _logger = logger ?? NullLogger>.Instance; @@ -123,7 +126,7 @@ await _snapshotStore.SaveAsync( _agentId, new EventSourcingSnapshot(currentState.Clone(), _currentVersion), ct); - await TryCompactEventsAsync(ct); + await TryScheduleCompactionAsync(ct); } catch (Exception ex) { @@ -204,34 +207,27 @@ private static string JoinEventTypes(IEnumerable events) return eventTypes.Length == 0 ? "" : string.Join(",", eventTypes); } - private async Task TryCompactEventsAsync(CancellationToken ct) + private async Task TryScheduleCompactionAsync(CancellationToken ct) { if (!_enableEventCompaction) return; + if (_compactionScheduler == null) + return; + var compactToVersion = _currentVersion - _retainedEventsAfterSnapshot; if (compactToVersion <= 0) return; try { - var deleted = await _eventStore.DeleteEventsUpToAsync(_agentId, compactToVersion, ct); - if (deleted <= 0) - return; - - _logger.LogInformation( - "Event sourcing compaction completed. agentId={AgentId} compactToVersion={CompactToVersion} deletedEvents={DeletedEvents} retainedRecentEvents={RetainedRecentEvents} result={Result}", - _agentId, - compactToVersion, - deleted, - _retainedEventsAfterSnapshot, - "ok"); + await _compactionScheduler.ScheduleAsync(_agentId, compactToVersion, ct); } catch (Exception ex) { _logger.LogWarning( ex, - "Event sourcing compaction failed and will be ignored. agentId={AgentId} compactToVersion={CompactToVersion} retainedRecentEvents={RetainedRecentEvents} result={Result} errorType={ErrorType}", + "Event sourcing compaction scheduling failed and will be ignored. agentId={AgentId} compactToVersion={CompactToVersion} retainedRecentEvents={RetainedRecentEvents} result={Result} errorType={ErrorType}", _agentId, compactToVersion, _retainedEventsAfterSnapshot, diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs b/src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs new file mode 100644 index 000000000..e3ea992ad --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs @@ -0,0 +1,19 @@ +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Runtime-managed scheduler for event-store compaction. +/// Event sourcing behavior only reports compaction intents; execution timing is controlled by runtime. +/// +public interface IEventStoreCompactionScheduler +{ + /// + /// Registers a compaction intent for one agent stream. + /// Implementations should coalesce repeated requests by keeping the maximum target version. + /// + Task ScheduleAsync(string agentId, long compactToVersion, CancellationToken ct = default); + + /// + /// Executes queued compaction for the specified agent in an idle lifecycle window. + /// + Task RunOnIdleAsync(string agentId, CancellationToken ct = default); +} diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index 997f71de0..f2790ac1c 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -128,6 +128,7 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() var snapshotStore = options.EnableSnapshots ? Services.GetService>() : null; + var compactionScheduler = Services.GetService(); ISnapshotStrategy snapshotStrategy = options.EnableSnapshots && snapshotStore != null ? new IntervalSnapshotStrategy(options.SnapshotInterval) : NeverSnapshotStrategy.Instance; @@ -139,7 +140,8 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() snapshotStore, snapshotStrategy, options.EnableEventCompaction, - options.RetainedEventsAfterSnapshot); + options.RetainedEventsAfterSnapshot, + compactionScheduler); return EventSourcing; } @@ -178,14 +180,16 @@ public AgentBackedEventSourcingBehavior( IEventSourcingSnapshotStore? snapshotStore, ISnapshotStrategy snapshotStrategy, bool enableEventCompaction, - int retainedEventsAfterSnapshot) + int retainedEventsAfterSnapshot, + IEventStoreCompactionScheduler? compactionScheduler) : base( eventStore, agentId, snapshotStore, snapshotStrategy, enableEventCompaction: enableEventCompaction, - retainedEventsAfterSnapshot: retainedEventsAfterSnapshot) + retainedEventsAfterSnapshot: retainedEventsAfterSnapshot, + compactionScheduler: compactionScheduler) { _owner = owner; } diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs index d216ef5a6..24cc696d9 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Aevatar.Foundation.Abstractions.TypeSystem; using Aevatar.Foundation.Core.TypeSystem; +using Aevatar.Foundation.Runtime.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming.DependencyInjection; @@ -30,6 +31,9 @@ public static IServiceCollection AddAevatarFoundationRuntimeOrleans( services.TryAddTransient(typeof(IStateStore<>), typeof(RuntimeActorGrainStateStore<>)); services.TryAddTransient(typeof(IEventSourcingSnapshotStore<>), typeof(RuntimeActorGrainEventSourcingSnapshotStore<>)); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs index 9f5c33ebb..b801a69aa 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Orleans.Runtime; using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.Foundation.Runtime.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using Orleans.Streams; @@ -17,6 +18,7 @@ public sealed class RuntimeActorGrain : Grain, IRuntimeActorGrain new DefaultEnvelopePropagationPolicy(new DefaultCorrelationLinkPolicy()); private Aevatar.Foundation.Abstractions.IStreamProvider _streams = null!; private IRuntimeActorStateBindingAccessor? _stateBindingAccessor; + private IActorDeactivationHookDispatcher? _deactivationHookDispatcher; private ILogger _logger = NullLogger.Instance; private IAsyncStream? _selfStream; private StreamSubscriptionHandle? _selfStreamHandle; @@ -33,6 +35,7 @@ public override async Task OnActivateAsync(CancellationToken cancellationToken) _propagationPolicy = ServiceProvider.GetService() ?? _propagationPolicy; _streams = ServiceProvider.GetRequiredService(); _stateBindingAccessor = ServiceProvider.GetService(); + _deactivationHookDispatcher = ServiceProvider.GetService(); var loggerFactory = ServiceProvider.GetService(); _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -57,6 +60,8 @@ public override async Task OnDeactivateAsync(DeactivationReason reason, Cancella await _agent.DeactivateAsync(cancellationToken); _agent = null; } + + TriggerDeactivationHook(); } public async Task InitializeAgentAsync(string agentTypeName) @@ -293,4 +298,12 @@ private Task OnSelfStreamEventAsync(EventEnvelope envelope, StreamSequenceToken? return HandleEnvelopeAsync(envelope.ToByteArray()); } + private void TriggerDeactivationHook() + { + if (_deactivationHookDispatcher == null) + return; + + _ = _deactivationHookDispatcher.DispatchAsync(this.GetPrimaryKeyString(), CancellationToken.None); + } + } diff --git a/src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs b/src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs new file mode 100644 index 000000000..a99a8ee6f --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Foundation.Runtime.Actors; + +/// +/// Executes all registered actor deactivation hooks. +/// Hook failures are isolated and logged without breaking deactivation path. +/// +public sealed class ActorDeactivationHookDispatcher : IActorDeactivationHookDispatcher +{ + private readonly IReadOnlyList _hooks; + private readonly ILogger _logger; + + public ActorDeactivationHookDispatcher( + IEnumerable hooks, + ILogger? logger = null) + { + _hooks = hooks.ToArray(); + _logger = logger ?? NullLogger.Instance; + } + + public async Task DispatchAsync(string actorId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(actorId)) + return; + + for (var i = 0; i < _hooks.Count; i++) + { + var hook = _hooks[i]; + try + { + await hook.OnDeactivatedAsync(actorId, ct); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Actor deactivation hook failed and was ignored. actorId={ActorId} hookType={HookType}", + actorId, + hook.GetType().FullName); + } + } + } +} diff --git a/src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs b/src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs new file mode 100644 index 000000000..120164d47 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs @@ -0,0 +1,24 @@ +using Aevatar.Foundation.Core.EventSourcing; + +namespace Aevatar.Foundation.Runtime.Actors; + +/// +/// Default deactivation hook: triggers deferred event-store compaction on actor idle. +/// +public sealed class EventStoreCompactionDeactivationHook : IActorDeactivationHook +{ + private readonly IEventStoreCompactionScheduler _compactionScheduler; + + public EventStoreCompactionDeactivationHook(IEventStoreCompactionScheduler compactionScheduler) + { + _compactionScheduler = compactionScheduler; + } + + public async Task OnDeactivatedAsync(string actorId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(actorId)) + return; + + await _compactionScheduler.RunOnIdleAsync(actorId, ct); + } +} diff --git a/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs b/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs new file mode 100644 index 000000000..cece1cd71 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs @@ -0,0 +1,10 @@ +namespace Aevatar.Foundation.Runtime.Actors; + +/// +/// Runtime lifecycle hook invoked after actor deactivation. +/// Used to execute non-critical async idle tasks. +/// +public interface IActorDeactivationHook +{ + Task OnDeactivatedAsync(string actorId, CancellationToken ct = default); +} diff --git a/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs b/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs new file mode 100644 index 000000000..6a6ae6115 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs @@ -0,0 +1,9 @@ +namespace Aevatar.Foundation.Runtime.Actors; + +/// +/// Dispatches deactivation lifecycle hooks for one actor. +/// +public interface IActorDeactivationHookDispatcher +{ + Task DispatchAsync(string actorId, CancellationToken ct = default); +} diff --git a/src/Aevatar.Foundation.Runtime/Actor/LocalActor.cs b/src/Aevatar.Foundation.Runtime/Actor/LocalActor.cs index 8232f3d95..541e148f2 100644 --- a/src/Aevatar.Foundation.Runtime/Actor/LocalActor.cs +++ b/src/Aevatar.Foundation.Runtime/Actor/LocalActor.cs @@ -15,6 +15,7 @@ public sealed class LocalActor : IActor private readonly EventRouter _router; private readonly IStreamProvider _streams; private readonly ILogger _logger; + private readonly IActorDeactivationHookDispatcher? _deactivationHookDispatcher; private IAsyncDisposable? _selfSubscription; public LocalActor( @@ -22,13 +23,15 @@ public LocalActor( string id, EventRouter router, IStreamProvider streams, - ILogger logger) + ILogger logger, + IActorDeactivationHookDispatcher? deactivationHookDispatcher = null) { Agent = agent; Id = id; _router = router; _streams = streams; _logger = logger; + _deactivationHookDispatcher = deactivationHookDispatcher; } public string Id { get; } @@ -75,6 +78,7 @@ public async Task DeactivateAsync(CancellationToken ct = default) { if (_selfSubscription != null) { await _selfSubscription.DisposeAsync(); _selfSubscription = null; } await Agent.DeactivateAsync(ct); + TriggerDeactivationHook(); } public Task HandleEventAsync(EventEnvelope envelope, CancellationToken ct = default) => @@ -146,4 +150,12 @@ private async Task EnqueueAsync(EventEnvelope envelope, bool propagateFailure = ]); } } + + private void TriggerDeactivationHook() + { + if (_deactivationHookDispatcher == null) + return; + + _ = _deactivationHookDispatcher.DispatchAsync(Id, CancellationToken.None); + } } diff --git a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs index 5369af602..e419ca934 100644 --- a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs +++ b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs @@ -23,6 +23,7 @@ public sealed class LocalActorRuntime : IActorRuntime private readonly IStreamProvider _streams; private readonly IStreamLifecycleManager _streamLifecycleManager; private readonly IServiceProvider _services; + private readonly IActorDeactivationHookDispatcher? _deactivationHookDispatcher; private readonly ILogger _logger; /// Creates local actor runtime. @@ -35,6 +36,7 @@ public LocalActorRuntime( _streams = streams; _services = services; _streamLifecycleManager = streamLifecycleManager; + _deactivationHookDispatcher = services.GetService(); _logger = logger ?? NullLogger.Instance; } @@ -56,7 +58,8 @@ public async Task CreateAsync(System.Type agentType, string? id = null, actorId, router, _streams, - logger); + logger, + _deactivationHookDispatcher); InjectDependencies(agent, publisher, actorId, logger); diff --git a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 5d094fe44..4f553a3e7 100644 --- a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -66,6 +66,9 @@ public static IServiceCollection AddAevatarRuntime( services.TryAddSingleton(typeof(IStateStore<>), typeof(InMemoryStateStore<>)); services.TryAddSingleton(typeof(IEventSourcingSnapshotStore<>), typeof(InMemoryEventSourcingSnapshotStore<>)); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); // Deduplication diff --git a/src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs b/src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs new file mode 100644 index 000000000..982933d22 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs @@ -0,0 +1,84 @@ +using System.Collections.Concurrent; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Foundation.Runtime.Persistence; + +/// +/// Runtime-level scheduler that defers event-store compaction until explicit idle trigger. +/// +public sealed class DeferredEventStoreCompactionScheduler : IEventStoreCompactionScheduler +{ + private readonly IEventStore _eventStore; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _pendingByAgent = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _agentLocks = new(StringComparer.Ordinal); + + public DeferredEventStoreCompactionScheduler( + IEventStore eventStore, + ILogger? logger = null) + { + _eventStore = eventStore; + _logger = logger ?? NullLogger.Instance; + } + + public Task ScheduleAsync(string agentId, long compactToVersion, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + if (compactToVersion <= 0) + return Task.CompletedTask; + + _pendingByAgent.AddOrUpdate( + agentId, + compactToVersion, + (_, current) => Math.Max(current, compactToVersion)); + return Task.CompletedTask; + } + + public async Task RunOnIdleAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var gate = _agentLocks.GetOrAdd(agentId, static _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(ct); + try + { + if (!_pendingByAgent.TryRemove(agentId, out var compactToVersion) || compactToVersion <= 0) + return; + + try + { + var deleted = await _eventStore.DeleteEventsUpToAsync(agentId, compactToVersion, ct); + _logger.LogInformation( + "Event store compaction executed on idle. agentId={AgentId} compactToVersion={CompactToVersion} deletedEvents={DeletedEvents} result={Result}", + agentId, + compactToVersion, + deleted, + "ok"); + } + catch (Exception ex) + { + // Requeue with latest target for best-effort retry in next idle window. + _pendingByAgent.AddOrUpdate( + agentId, + compactToVersion, + (_, current) => Math.Max(current, compactToVersion)); + _logger.LogWarning( + ex, + "Event store compaction on idle failed and was re-queued. agentId={AgentId} compactToVersion={CompactToVersion} result={Result} errorType={ErrorType}", + agentId, + compactToVersion, + "failed", + ex.GetType().Name); + } + } + finally + { + gate.Release(); + } + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/ActorDeactivationHookDispatcherTests.cs b/test/Aevatar.Foundation.Core.Tests/ActorDeactivationHookDispatcherTests.cs new file mode 100644 index 000000000..75d3c9b39 --- /dev/null +++ b/test/Aevatar.Foundation.Core.Tests/ActorDeactivationHookDispatcherTests.cs @@ -0,0 +1,67 @@ +using Aevatar.Foundation.Runtime.Actors; +using Shouldly; + +namespace Aevatar.Foundation.Core.Tests; + +public class ActorDeactivationHookDispatcherTests +{ + [Fact] + public async Task DispatchAsync_ShouldInvokeAllHooks() + { + var calls = new List(); + var dispatcher = new ActorDeactivationHookDispatcher( + [ + new CallbackHook(id => calls.Add($"h1:{id}")), + new CallbackHook(id => calls.Add($"h2:{id}")), + ]); + + await dispatcher.DispatchAsync("actor-1"); + + calls.ShouldBe(["h1:actor-1", "h2:actor-1"]); + } + + [Fact] + public async Task DispatchAsync_WhenOneHookThrows_ShouldContinue() + { + var calls = new List(); + var dispatcher = new ActorDeactivationHookDispatcher( + [ + new CallbackHook(_ => throw new InvalidOperationException("hook-failed")), + new CallbackHook(id => calls.Add($"ok:{id}")), + ]); + + await dispatcher.DispatchAsync("actor-2"); + + calls.ShouldBe(["ok:actor-2"]); + } + + [Fact] + public async Task DispatchAsync_WhenActorIdIsEmpty_ShouldSkip() + { + var called = false; + var dispatcher = new ActorDeactivationHookDispatcher( + [ + new CallbackHook(_ => called = true), + ]); + + await dispatcher.DispatchAsync(""); + + called.ShouldBeFalse(); + } + + private sealed class CallbackHook : IActorDeactivationHook + { + private readonly Action _callback; + + public CallbackHook(Action callback) + { + _callback = callback; + } + + public Task OnDeactivatedAsync(string actorId, CancellationToken ct = default) + { + _callback(actorId); + return Task.CompletedTask; + } + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/DeferredEventStoreCompactionSchedulerTests.cs b/test/Aevatar.Foundation.Core.Tests/DeferredEventStoreCompactionSchedulerTests.cs new file mode 100644 index 000000000..5ce4c2c49 --- /dev/null +++ b/test/Aevatar.Foundation.Core.Tests/DeferredEventStoreCompactionSchedulerTests.cs @@ -0,0 +1,120 @@ +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Runtime.Persistence; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Shouldly; + +namespace Aevatar.Foundation.Core.Tests; + +public class DeferredEventStoreCompactionSchedulerTests +{ + [Fact] + public async Task ScheduleAsync_ShouldOnlyRecordIntent_AndDeleteOnIdle() + { + var store = new InMemoryEventStore(); + await AppendEventsAsync(store, "agent-1", 3); + var scheduler = new DeferredEventStoreCompactionScheduler(store); + + await scheduler.ScheduleAsync("agent-1", 2); + + (await store.GetEventsAsync("agent-1")).Count.ShouldBe(3); + + await scheduler.RunOnIdleAsync("agent-1"); + + var remaining = await store.GetEventsAsync("agent-1"); + remaining.Count.ShouldBe(1); + remaining[0].Version.ShouldBe(3); + (await store.GetVersionAsync("agent-1")).ShouldBe(3); + } + + [Fact] + public async Task ScheduleAsync_ShouldCoalesceToMaxTargetVersion() + { + var store = new InMemoryEventStore(); + await AppendEventsAsync(store, "agent-2", 5); + var scheduler = new DeferredEventStoreCompactionScheduler(store); + + await scheduler.ScheduleAsync("agent-2", 2); + await scheduler.ScheduleAsync("agent-2", 4); + await scheduler.ScheduleAsync("agent-2", 3); + await scheduler.RunOnIdleAsync("agent-2"); + + var remaining = await store.GetEventsAsync("agent-2"); + remaining.Count.ShouldBe(1); + remaining[0].Version.ShouldBe(5); + } + + [Fact] + public async Task RunOnIdleAsync_WhenDeleteFails_ShouldRequeueForNextIdle() + { + var innerStore = new InMemoryEventStore(); + await AppendEventsAsync(innerStore, "agent-3", 3); + var flakyStore = new FlakyDeleteEventStore(innerStore); + var scheduler = new DeferredEventStoreCompactionScheduler(flakyStore); + + await scheduler.ScheduleAsync("agent-3", 2); + await scheduler.RunOnIdleAsync("agent-3"); + + (await innerStore.GetEventsAsync("agent-3")).Count.ShouldBe(3); + + await scheduler.RunOnIdleAsync("agent-3"); + + var remaining = await innerStore.GetEventsAsync("agent-3"); + remaining.Count.ShouldBe(1); + remaining[0].Version.ShouldBe(3); + flakyStore.DeleteCalls.ShouldBe(2); + } + + private static async Task AppendEventsAsync(IEventStore store, string agentId, int count) + { + var events = Enumerable.Range(1, count).Select(i => new StateEvent + { + EventId = $"{agentId}-e-{i}", + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Version = i, + EventType = typeof(Empty).FullName ?? nameof(Empty), + EventData = Any.Pack(new Empty()), + AgentId = agentId, + }); + + await store.AppendAsync(agentId, events, expectedVersion: 0); + } + + private sealed class FlakyDeleteEventStore : IEventStore + { + private readonly InMemoryEventStore _inner; + private int _deleteCalls; + + public FlakyDeleteEventStore(InMemoryEventStore inner) + { + _inner = inner; + } + + public int DeleteCalls => _deleteCalls; + + public Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + => _inner.AppendAsync(agentId, events, expectedVersion, ct); + + public Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + => _inner.GetEventsAsync(agentId, fromVersion, ct); + + public Task GetVersionAsync(string agentId, CancellationToken ct = default) + => _inner.GetVersionAsync(agentId, ct); + + public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) + { + var callNo = Interlocked.Increment(ref _deleteCalls); + if (callNo == 1) + throw new InvalidOperationException("delete-failure-once"); + + return _inner.DeleteEventsUpToAsync(agentId, toVersion, ct); + } + } +} diff --git a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs index 68e5222ff..9eb13b543 100644 --- a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs @@ -160,27 +160,34 @@ public async Task PersistSnapshotAsync_WhenSnapshotSaveFails_ShouldNotThrowAndSh } [Fact] - public async Task PersistSnapshotAsync_WhenCompactionEnabled_ShouldDeleteHistoricalEvents_AndKeepReplayWorking() + public async Task PersistSnapshotAsync_WhenCompactionEnabled_ShouldDeferDeletion_UntilDeferredCompactionRuns() { var store = new InMemoryEventStore(); var snapshotStore = new InMemoryEventSourcingSnapshotStore(); + var scheduler = new DeferredEventStoreCompactionScheduler(store); var behavior = new CounterEventSourcingBehavior( store, "agent-snapshot-compact", snapshotStore: snapshotStore, snapshotStrategy: new IntervalSnapshotStrategy(1), enableEventCompaction: true, - retainedEventsAfterSnapshot: 0); + retainedEventsAfterSnapshot: 0, + compactionScheduler: scheduler); behavior.RaiseEvent(new IncrementEvent { Amount = 4 }); behavior.RaiseEvent(new IncrementEvent { Amount = 6 }); await behavior.ConfirmEventsAsync(); await behavior.PersistSnapshotAsync(new CounterState { Count = 10, Name = "snapshot" }); - var version = await store.GetVersionAsync("agent-snapshot-compact"); var events = await store.GetEventsAsync("agent-snapshot-compact"); + events.Count.ShouldBe(2); + + await scheduler.RunOnIdleAsync("agent-snapshot-compact"); + + var version = await store.GetVersionAsync("agent-snapshot-compact"); + var compacted = await store.GetEventsAsync("agent-snapshot-compact"); version.ShouldBe(2); - events.ShouldBeEmpty(); + compacted.ShouldBeEmpty(); var replayed = await behavior.ReplayAsync("agent-snapshot-compact"); replayed.ShouldNotBeNull(); @@ -246,14 +253,16 @@ public CounterEventSourcingBehavior( IEventSourcingSnapshotStore? snapshotStore = null, ISnapshotStrategy? snapshotStrategy = null, bool enableEventCompaction = false, - int retainedEventsAfterSnapshot = 0) + int retainedEventsAfterSnapshot = 0, + IEventStoreCompactionScheduler? compactionScheduler = null) : base( eventStore, agentId, snapshotStore, snapshotStrategy, enableEventCompaction: enableEventCompaction, - retainedEventsAfterSnapshot: retainedEventsAfterSnapshot) { } + retainedEventsAfterSnapshot: retainedEventsAfterSnapshot, + compactionScheduler: compactionScheduler) { } public override CounterState TransitionState(CounterState current, IMessage evt) => StateTransitionMatcher diff --git a/test/Aevatar.Foundation.Core.Tests/EventStoreCompactionDeactivationHookTests.cs b/test/Aevatar.Foundation.Core.Tests/EventStoreCompactionDeactivationHookTests.cs new file mode 100644 index 000000000..a2728bf10 --- /dev/null +++ b/test/Aevatar.Foundation.Core.Tests/EventStoreCompactionDeactivationHookTests.cs @@ -0,0 +1,72 @@ +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.Foundation.Runtime.Actors; +using Shouldly; + +namespace Aevatar.Foundation.Core.Tests; + +public class EventStoreCompactionDeactivationHookTests +{ + [Fact] + public async Task OnDeactivatedAsync_ShouldInvokeSchedulerWithActorId() + { + var scheduler = new RecordingScheduler(); + var hook = new EventStoreCompactionDeactivationHook(scheduler); + + await hook.OnDeactivatedAsync("actor-1"); + + scheduler.Calls.ShouldBe(1); + scheduler.LastActorId.ShouldBe("actor-1"); + } + + [Fact] + public async Task OnDeactivatedAsync_WhenSchedulerThrows_ShouldBubble() + { + var scheduler = new ThrowingScheduler(); + var hook = new EventStoreCompactionDeactivationHook(scheduler); + + await Should.ThrowAsync(() => hook.OnDeactivatedAsync("actor-2")); + + scheduler.Calls.ShouldBe(1); + } + + [Fact] + public async Task OnDeactivatedAsync_WhenActorIdIsEmpty_ShouldSkip() + { + var scheduler = new RecordingScheduler(); + var hook = new EventStoreCompactionDeactivationHook(scheduler); + + await hook.OnDeactivatedAsync(" "); + + scheduler.Calls.ShouldBe(0); + } + + private sealed class RecordingScheduler : IEventStoreCompactionScheduler + { + public int Calls { get; private set; } + public string? LastActorId { get; private set; } + + public Task ScheduleAsync(string agentId, long compactToVersion, CancellationToken ct = default) + => Task.CompletedTask; + + public Task RunOnIdleAsync(string agentId, CancellationToken ct = default) + { + Calls++; + LastActorId = agentId; + return Task.CompletedTask; + } + } + + private sealed class ThrowingScheduler : IEventStoreCompactionScheduler + { + public int Calls { get; private set; } + + public Task ScheduleAsync(string agentId, long compactToVersion, CancellationToken ct = default) + => Task.CompletedTask; + + public Task RunOnIdleAsync(string agentId, CancellationToken ct = default) + { + Calls++; + throw new InvalidOperationException("scheduler-failed"); + } + } +} From 0984e469a0d01627a8a89c3f1da09c17f26d813c Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 01:05:40 +0800 Subject: [PATCH 09/46] Refactor Workflow Projection Registration and Enhance ReadModel Provider Integration - Updated the `WorkflowCapabilityServiceCollectionExtensions` to decouple ReadModel provider registration from the infrastructure layer, promoting a cleaner architecture. - Introduced `WorkflowReadModelStartupValidationHostedService` to validate ReadModel provider capabilities during host startup, ensuring proper configuration and preventing runtime errors. - Enhanced documentation to clarify the new provider registration process and startup validation features, aiding developer understanding. - Added tests to verify the functionality of the new validation service and ensure correct behavior under various configurations. - Updated CI guards to enforce architectural constraints regarding provider registrations, maintaining code quality and consistency. --- ...ng-elasticsearch-readmodel-requirements.md | 471 ++++++++++++------ .../Aevatar.Workflow.Infrastructure.csproj | 3 - ...owCapabilityServiceCollectionExtensions.cs | 31 -- .../Aevatar.Workflow.Infrastructure/README.md | 2 + .../Aevatar.Workflow.Projection.csproj | 1 + .../WorkflowExecutionProjectionOptions.cs | 5 + .../ServiceCollectionExtensions.cs | 2 + ...ReadModelStartupValidationHostedService.cs | 82 +++ .../Aevatar.Workflow.Projection/README.md | 5 +- ...Aevatar.Workflow.Extensions.Hosting.csproj | 4 + ...WorkflowCapabilityHostBuilderExtensions.cs | 1 + ...tionProviderServiceCollectionExtensions.cs | 58 +++ .../AIGAgentBaseToolRefreshTests.cs | 64 +++ ...lowExecutionProjectionRegistrationTests.cs | 53 ++ .../WorkflowHostingExtensionsCoverageTests.cs | 32 ++ tools/ci/architecture_guards.sh | 22 + 16 files changed, 640 insertions(+), 196 deletions(-) create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs create mode 100644 src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 9218c2143..73ce5907b 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -1,163 +1,314 @@ -# Generic Event Sourcing + Provider ReadModel 需求与重构计划(必要文档) +# Generic Event Sourcing + Provider ReadModel 全量重构执行文档(Detailed Refactor Blueprint) -## 1. 文档定位 +## 1. 文档元信息 - 状态:Active +- 版本:v3.1 - 日期:2026-02-23 -- 目的:作为 Event Sourcing 与 Provider-Based ReadModel 的唯一执行文档(需求 + 计划一体化)。 -- 范围:Foundation(ES)+ CQRS Projection(Provider Runtime)+ Workflow(接入层)。 -- 兼容策略:以清晰正确为第一目标,不保留历史兼容壳。 - -## 2. 架构硬约束 -1. 有状态 Actor 必须 Event Sourcing,`EventStore` 是事实源。 -2. `Command -> Domain Event -> Apply -> State`,开发者显式构建 event。 -3. ReadModel Provider 必须通用化,不绑定 Workflow 业务域。 -4. CQRS 与 AGUI 必须共用同一 Projection Pipeline,不得双轨。 -5. 中间层不得维护 `actor/run/session` 事实态进程内映射。 -6. Runtime 不得通过反射注入 ES(`MakeGenericType` / `GetProperty().SetValue`)。 - -## 3. 当前代码基线(已验证) -### 3.1 Event Sourcing -- 契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs` -- 实现:`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` -- 裁剪调度抽象:`src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs` -- 运行时裁剪调度实现:`src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs` -- 状态转换扩展:`src/Aevatar.Foundation.Core/EventSourcing/IStateEventApplier.cs` -- Typed 状态转换基类:`src/Aevatar.Foundation.Core/EventSourcing/StateEventApplierBase.cs` -- 状态匹配器:`src/Aevatar.Foundation.Core/EventSourcing/StateTransitionMatcher.cs` -- 生命周期:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs` -- Runtime 停用钩子抽象:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHook.cs` -- Runtime 停用钩子分发器:`src/Aevatar.Foundation.Runtime/Actor/IActorDeactivationHookDispatcher.cs` -- Runtime 停用钩子分发实现:`src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs` -- Runtime 默认裁剪钩子:`src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` -- 本地持久化 EventStore 基线:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` -- 运行时注入边界: - - `src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - - `src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` - -当前语义: -1. `ActivateAsync` 强制 Replay 恢复状态。 -2. `DeactivateAsync` 强制 `ConfirmEventsAsync + PersistSnapshotAsync`。 -3. `GAgentBase` 不再暴露 `StateStore` 事实通道。 -4. 未设置 `EventSourcing` 时,`GAgentBase` 在泛型上下文内用 `IEventStore` 静态构造 `AgentBackedEventSourcingBehavior`(继承 `EventSourcingBehavior`)。 -5. 缺失 `IEventStore` 时 fail-fast。 -6. `ConfirmDerivedEventsAsync` / `IDomainEventDeriver` / `EventSourcingAutoPersistenceOptions` 已从主链路移除。 -7. 运行期通过 `PersistDomainEventAsync` / `PersistDomainEventsAsync` 执行“持久化 + 顺序 apply”;Replay 主要用于激活恢复。 -8. `TransitionState` 可由 Agent override 或 `IStateEventApplier` 组合实现。 -9. 默认启用自动快照与事件流裁剪: - - 快照:`EventSourcingRuntimeOptions.SnapshotInterval` - - 裁剪:快照成功后通过 `IEventStoreCompactionScheduler.ScheduleAsync(...)` 记录待清理版本,在空闲期由 runtime `IActorDeactivationHookDispatcher` 分发 deactivation hooks,默认裁剪钩子触发 `RunOnIdleAsync(...)` 异步调用 `IEventStore.DeleteEventsUpToAsync(...)` - - 保留窗口:`RetainedEventsAfterSnapshot` - -### 3.2 Provider Runtime -- 抽象:`src/Aevatar.CQRS.Projection.Abstractions` -- 运行时:`src/Aevatar.CQRS.Projection.Runtime` -- Provider: - - InMemory:`src/Aevatar.CQRS.Projection.Providers.InMemory` - - Elasticsearch:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch` - - Neo4j:`src/Aevatar.CQRS.Projection.Providers.Neo4j` -- StateMirror:`src/Aevatar.CQRS.Projection.StateMirror` - -当前语义: -1. Provider 通过 `IProjectionReadModelStoreRegistration` 注册。 -2. Store 由 `ProviderRegistry + ProviderSelector + BindingResolver + StoreFactory` 统一创建。 -3. 多 Provider 并存时必须显式指定 provider;否则选择失败。 -4. 能力不匹配默认 fail-fast(`FailOnUnsupportedCapabilities=true`)。 -5. InMemory / Elasticsearch / Neo4j 写路径均输出统一结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 -6. Provider 端到端回归支持环境变量门控集成测试与一键 smoke 脚本: - - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs` - - `tools/ci/projection_provider_e2e_smoke.sh` - -### 3.3 Workflow 接入 -- 组合入口:`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` -- 投影 DI:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` - -当前语义: -1. Workflow 已接入 InMemory + Elasticsearch + Neo4j 三类 Provider 注册。 -2. `Projection:ReadModel:*` 全局选项会映射到 Workflow 投影选项。 -3. `ReadModelMode=StateOnly` 在 Workflow 组合阶段被拒绝(明确 fail-fast)。 - -## 4. 需求清单(必须满足) - -### R-ES-01 强制事件优先 -- 所有 `GAgentBase` 子类必须基于领域事件恢复状态,不得以快照替代事实。 - -### R-ES-02 显式事件构建 -- 命令处理逻辑必须显式 `RaiseEvent`,不得依赖自动事件推导。 - -### R-ES-03 可重放同态 -- 在线状态变更必须可通过 Replay 重建到同一结果。 - -### R-ES-04 静态泛型装配 -- ES 行为构造必须在泛型上下文完成,不得回退到 Runtime 反射注入。 - -### R-ES-05 状态转换可组合 -- `event -> state` 转换必须支持模块化拆分,避免在单个 Agent 中膨胀式 `switch`。 -- 支持 `IStateEventApplier` 组合式 apply,顺序由 `Order` 控制。 -- CI 必须禁止 `GAgentBase` 全继承链(含间接继承)子类直接修改 `State.xxx`,强制通过领域事件 + apply 路径变更状态。 - -### R-RM-01 Provider 解耦业务 -- Provider 项目不得引用 Workflow/AI 业务读模型类型。 - -### R-RM-02 能力协商 -- Provider 必须声明能力(索引类型、alias、schema 校验),ReadModel 需求必须在启动期校验。 - -### R-RM-03 路由确定性 -- 多 Provider 并存时必须可预测选择,不允许隐式随机或“最后注册覆盖”语义。 - -### R-RM-04 统一观测 -- Provider 写路径必须记录结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 - -### R-WF-01 Workflow 仅消费抽象 -- Workflow 层只依赖 Provider Runtime 抽象,不实现后端存储细节。 - -### R-WF-02 单链路投影 -- Workflow CQRS 与 AGUI 必须从同一订阅与分发链路进入,不得维护平行投影系统。 - -### R-GOV-01 门禁强制 -- 必须通过: - - `bash tools/ci/architecture_guards.sh` - - `bash tools/ci/projection_route_mapping_guard.sh` - - `bash tools/ci/test_stability_guards.sh` - -## 5. 重构计划(按优先级) - -### P1(已完成)ES 强制化主链路 -1. 移除自动反推事件接口与实现。 -2. `GAgentBase` 生命周期切换到 Replay + Confirm。 -3. Runtime 去除 ES 反射注入路径。 - -### P2(已完成)Provider Runtime 主干 -1. 建立 Provider 注册/选择/校验/创建主链路。 -2. 落地 InMemory/Elasticsearch/Neo4j 三类 Provider。 -3. Workflow 接入统一 Provider 选择逻辑。 - -### P3(进行中)一致性与可维护性收口 -1. 清理文档与代码中的历史双轨口径。 -2. 补齐跨模块契约测试:`Command -> Events -> Replay -> State`。 -3. 收敛 state transition 模型(Agent override + applier 组合)。 -4. 统一配置示例与错误合同说明(启动失败与能力不匹配)。 - -### P4(待执行)性能与生产化增强 -1. 为持久化 `IEventStore` 提供生产落地方案与压测基线(已落地本地持久化基线:`FileEventStore`,生产级后端仍待接入)。 -2. 补齐 Elasticsearch/Neo4j 端到端集成脚本与回归套件(已落地基础 smoke + env-gated e2e,后续补 CI 常态化接入与更高负载回归)。 -3. 细化快照策略与回放窗口控制(已落地自动快照 + 裁剪基础能力,后续补压测驱动的阈值治理)。 - -## 6. 验收标准(DoD) -1. 有状态 Actor 恢复路径全部来自 Replay,不存在 `StateStore.Load/Save` 事实回路。 -2. Runtime/Core 不存在 ES 反射注入路径。 -3. Provider 由统一 Runtime 选择并可校验能力。 -4. Workflow 仅作为 Provider Runtime 消费方,不绑定 ES/Neo4j 实现细节。 -5. 架构门禁、核心测试全绿。 - -## 7. 验证命令 -- `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` -- `dotnet test test/Aevatar.Foundation.Runtime.Hosting.Tests/Aevatar.Foundation.Runtime.Hosting.Tests.csproj --nologo` -- `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -- `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -- `bash tools/ci/architecture_guards.sh` -- `bash tools/ci/projection_provider_e2e_smoke.sh` - -## 8. 变更原则 -1. 删除优先于兼容。 -2. 文档必须与当前代码语义一致,不保留“未来可能”但无代码支撑的条目。 -3. 任何新扩展(Provider/ES)都必须接入现有主链路,不得开第二系统。 +- 适用范围:`src/`、`test/`、`tools/ci/`、`docs/architecture/` +- 文档定位:唯一重构执行蓝图(需求、现状、差距、任务包、验收、门禁) +- 执行原则:删除优先于兼容,不保留历史兼容壳 +- 最近门禁校验:`bash tools/ci/architecture_guards.sh`(2026-02-23,通过) + +## 2. 背景与关键决策(统一认知) + +### 2.1 EventStore 的语义边界 +1. EventStore 存储的是领域事件流(`StateEvent` 封装),不是状态快照流。 +2. 快照是恢复加速器,不是事实源。 +3. 有状态 Actor 的恢复语义必须是 `Replay(EventStore) + 可选Snapshot加速`。 +4. 事件处理(`TransitionState` / `IStateEventApplier`)与事件存储(`IEventStore`)是两个解耦关注点,可以并存,不冲突。 + +### 2.2 强制 Event Sourcing +1. 所有继承 `GAgentBase`(含间接继承)的有状态 Actor,必须走 `Command -> Domain Event -> Apply -> State`。 +2. 不允许“可选开启 EventStore”的业务语义分叉。 +3. 不允许自动生成状态快照事件替代领域事件。 + +### 2.3 快照与历史清理 +1. 快照由 `EventSourcingRuntimeOptions` + `ISnapshotStrategy` 控制。 +2. 历史事件清理由 `IEventStoreCompactionScheduler` 负责,采用“先登记、后异步执行”。 +3. 执行时机在 Actor runtime 生命周期空闲点(deactivation hook),不是命令热路径同步删除。 + +### 2.4 Projection Provider 的职责边界 +1. Provider(InMemory/Elasticsearch/Neo4j)只负责读模型存储能力,不绑定 Workflow/AI 业务语义。 +2. Workflow 必须消费 Projection Runtime 抽象,不应直接依赖 `Providers.*`。 +3. CQRS 与 AGUI 复用同一 Projection Pipeline 输入,不允许双轨实现。 + +## 3. 重构目标 +1. 建立唯一写侧事实源:EventStore(强制事件优先)。 +2. 建立可验证的状态演进链:显式领域事件 + 可重放同态。 +3. 建立 Provider Runtime 单主链路:注册、选择、能力校验、Store 创建、观测统一。 +4. 消除 Workflow 对具体 Provider 的基础设施耦合。 +5. 建立启动期 fail-fast 与 CI 架构门禁,防止回归。 + +## 4. 范围与非范围 + +### 4.1 范围 +- Foundation ES(Core + Runtime + Orleans runtime 接入) +- CQRS Projection Runtime(Abstractions/Core/Runtime + Providers) +- Workflow Projection 接入(Application/Projection/Infrastructure/Host) +- CI 架构门禁与测试合同 + +### 4.2 非范围 +- 不定义业务 DSL 设计细节。 +- 不展开 Elasticsearch/Neo4j 运维参数最佳实践。 +- 不保留历史兼容路径或双写迁移壳。 + +## 5. 架构硬约束(必须满足) +1. 有状态 Actor 必须 Event Sourcing,恢复事实源仅来自 EventStore replay。 +2. 命令侧必须显式产出领域事件,不允许自动反推事件。 +3. 禁止在有状态 Actor 中直接修改 `State.*`(含间接继承链)。 +4. Runtime 禁止反射注入 ES(`MakeGenericType` / `GetProperty().SetValue`)。 +5. Projection Provider 项目禁止依赖 Workflow/AI 业务项目。 +6. Workflow.Infrastructure 禁止直接依赖 `Aevatar.CQRS.Projection.Providers.*`。 +7. Projection 中间层禁止持久事实态进程内 ID 映射(actor/run/session/entity)。 +8. CQRS 与 AGUI 必须共享同一投影输入链路。 + +## 6. 当前基线(代码事实) + +### 6.1 Event Sourcing 主链路(已落地) +- 核心类: + - `src/Aevatar.Foundation.Core/GAgentBase.TState.cs` + - `src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` +- 已生效语义: + 1. `ActivateAsync` 强制 `ReplayAsync` 恢复状态。 + 2. `PersistDomainEventsAsync` 强制显式 `RaiseEvent -> ConfirmEventsAsync -> Apply`。 + 3. `DeactivateAsync` 执行 `ConfirmEventsAsync + PersistSnapshotAsync`。 + 4. `State` setter 受 `StateGuard.EnsureWritable()` 约束。 + 5. `EnsureNoStateSnapshotEvents()` 禁止把 `TState` 当事件写入 EventStore。 + +### 6.2 快照 + 异步清理(已落地) +- 关键抽象与实现: + - `src/Aevatar.Foundation.Core/EventSourcing/IEventStoreCompactionScheduler.cs` + - `src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs` + - `src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` + - `src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs` +- 当前行为: + 1. 快照成功后只调度清理意图。 + 2. 清理由 runtime deactivation hook 异步执行。 + 3. 删除调用是 `IEventStore.DeleteEventsUpToAsync(...)`,不阻塞命令处理主路径。 + +### 6.3 Provider Runtime(已落地) +- 运行时主干: + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs` +- Provider 项目: + - `src/Aevatar.CQRS.Projection.Providers.InMemory` + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j` +- 当前行为: + 1. 多 Provider 未显式指定时直接失败(确定性路由)。 + 2. 能力不匹配且开启 fail-fast 时抛异常。 + 3. Provider 写路径日志结构已统一。 + +### 6.4 Workflow 接入现状(已完成) +- 入口: + - `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` + - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` +- 当前行为: + 1. `Workflow.Infrastructure` 不再直接依赖或注册 `Providers.*`。 + 2. Provider 注册统一下沉到 Host/Extensions 层(`AddWorkflowProjectionReadModelProviders(...)`)。 + 3. `AddWorkflowCapabilityWithAIDefaults(...)` 统一装配 AI + Workflow + Provider 组合。 + +### 6.5 门禁现状(已落地) +- `tools/ci/architecture_guards.sh` 已覆盖: + 1. 反射 ES 注入禁用。 + 2. Stateful Actor 直写 `State.*` 禁用(含继承链扫描)。 + 3. Provider 业务依赖禁用。 + 4. 中间层 ID 映射事实态禁用。 + 5. `Workflow.Infrastructure` 禁止引用/using `Aevatar.CQRS.Projection.Providers.*`。 + +### 6.6 启动期能力预校验现状(已落地) +- 关键实现: + - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` + - `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` +- 当前行为: + 1. Host 启动阶段执行 read-model provider 选择 + 能力校验 dry-run。 + 2. provider 缺失或能力不匹配可在启动阶段 fail-fast。 + 3. 可通过 `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup` 控制启用(默认开启)。 + +## 7. 需求分解与状态矩阵 + +| ID | 需求 | 验收标准 | 当前状态 | 证据 | 差距 | +|---|---|---|---|---|---| +| R-ES-01 | 强制事件优先恢复 | 所有 `GAgentBase` 恢复来自 replay | Done | `GAgentBase.TState.cs` | 无 | +| R-ES-02 | 显式事件构建 | 无自动派生事件主链路 | Done | `PersistDomainEventsAsync` + 守卫脚本 | 无 | +| R-ES-03 | 回放同态 | 关键 actor 有统一合同测试 | Partial | `EventSourcingTests.cs` 等 | 缺全类别标准化合同矩阵 | +| R-ES-04 | 静态装配 ES 行为 | Runtime 无反射注入 | Done | `architecture_guards.sh` | 无 | +| R-ES-05 | 禁止直写 State | 含间接继承链全部受控 | Done | `StateGuard` + awk 扫描门禁 | 无 | +| R-ES-06 | 快照后异步清理 | 由 runtime 空闲机制执行删除 | Done | `DeferredEventStoreCompactionScheduler` + hook | 无 | +| R-RM-01 | Provider 业务解耦 | Provider 不引用 Workflow/AI | Done | 门禁 + Provider csproj | 无 | +| R-RM-02 | 路由确定性 | 多 provider 未指定时报错 | Done | `ProjectionReadModelProviderSelector` | 无 | +| R-RM-03 | 启动期全量能力校验 | Host 启动时全绑定 fail-fast | Done | `WorkflowReadModelStartupValidationHostedService` + 测试 | 无 | +| R-RM-04 | 统一观测 | provider 写成功/失败日志规范统一 | Done | provider store 三实现 | 无 | +| R-WF-01 | Workflow 仅依赖抽象 | Workflow.Infrastructure 不依赖 `Providers.*` | Done | Infrastructure csproj + DI 已去 Provider 引用 | 无 | +| R-WF-02 | 单投影主链路 | AGUI 与 CQRS 共 pipeline 输入 | Done | `WorkflowExecutionAGUIEventProjector` | 无 | +| R-GOV-01 | 架构规则可自动化验证 | 规则进入 CI 门禁 | Done | `architecture_guards.sh` 新增 Workflow->Providers 规则 | 无 | + +## 8. 差距详解 + +### 8.1 Gap-C(R-ES-03)Replay 合同覆盖不足 +- 现有测试覆盖核心路径,但缺“新增关键 actor 必须关联合同测试”的制度化约束。 + +### 8.2 Gap-P(生产化)EventStore 生产后端与压测基线未闭环 +- 当前 `IEventStore` 仍以 InMemory/File 为主。 +- 尚未建立容量压测与参数治理基线(快照间隔、保留事件数、压缩频率)。 + +## 9. 目标架构 + +### 9.1 分层与依赖方向 +1. Domain:事件定义、状态模型、状态转移契约。 +2. Application:编排端口与用例,不依赖 Provider 具体实现。 +3. Infrastructure:技术实现但仍依赖抽象;Workflow.Infrastructure 不拼 Provider。 +4. Host/Extensions:负责具体 Provider 组合与配置绑定。 + +### 9.2 Event Sourcing 目标链路 +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + E1["Command Handler"] --> E2["RaiseEvent / PersistDomainEventsAsync"] + E2 --> E3["IEventSourcingBehavior.ConfirmEventsAsync"] + E3 --> E4["IEventStore.AppendAsync"] + E4 --> E5["TransitionState / IStateEventApplier"] + E5 --> E6["In-Memory State"] + E6 --> E7["PersistSnapshotAsync"] + E7 --> E8["IEventStoreCompactionScheduler.ScheduleAsync"] + E8 --> E9["Runtime Deactivation Hook Dispatcher"] + E9 --> E10["EventStoreCompactionDeactivationHook"] + E10 --> E11["IEventStore.DeleteEventsUpToAsync"] +``` + +### 9.3 Projection Provider 目标链路 +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + P1["Projection Runtime"] --> P2["Provider Registry"] + P2 --> P3["Provider Selector"] + P3 --> P4["Capability Validator"] + P4 --> P5["Store Factory"] + P5 --> P6["IProjectionReadModelStore"] + P1 --> P7["Projector: ReadModel"] + P1 --> P8["Projector: AGUI"] +``` + +### 9.4 Workflow 目标边界 +1. `Aevatar.Workflow.Infrastructure` 仅装配 Workflow 抽象能力。 +2. 具体 Provider 注册放在 `Host` 或 `workflow/extensions/*` 组合包。 +3. `AddWorkflowCapability(...)` 不再直接调用 `AddElasticsearch.../AddNeo4j.../AddInMemory...`。 + +## 10. 重构工作包(WBS) + +### WP-1:Workflow 与 Provider 彻底解耦(优先级 P0,已完成) +- 目标:完成 R-WF-01。 +- 改动范围: + 1. 删除 `src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj` 对三个 Provider 项目的引用。 + 2. 重构 `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`,移除具体 Provider using 与注册调用。 + 3. 在 Host/Extensions 层新增组合入口(示例:`AddWorkflowProjectionProviders(...)`),统一承载 Provider 选择与配置。 + 4. 调整 `src/workflow/Aevatar.Workflow.Host.Api/Program.cs` 与 `src/Aevatar.Mainnet.Host.Api/Program.cs` 的组合顺序。 +- 产物: + 1. Workflow.Infrastructure 纯抽象消费。 + 2. Host 级 Provider 组合扩展。 + 3. 覆盖注册路径的单测/集测。 +- DoD: + 1. `rg -n "Aevatar\.CQRS\.Projection\.Providers\." src/workflow/Aevatar.Workflow.Infrastructure` 无命中。 + 2. 所有 Workflow Host 能正常启动并通过投影功能测试。 + +### WP-2:启动期全量能力预校验(优先级 P0,已完成) +- 目标:完成 R-RM-03。 +- 设计: + 1. 新增 startup 校验服务(`IHostedService` 或 startup task)。 + 2. 启动阶段遍历 read-model 绑定,调用 ProviderSelector + CapabilityValidator dry-run。 + 3. 任何绑定失败直接终止启动(fail-fast)。 +- 改动范围: + - `src/Aevatar.CQRS.Projection.Runtime/*` + - `src/workflow/Aevatar.Workflow.Projection/*` + - `src/workflow/Aevatar.Workflow.Host.Api/*`(注册校验服务) +- 产物: + 1. 启动期校验实现。 + 2. 启动失败测试:provider 未注册、能力不匹配、binding 非法。 + +### WP-3:Replay 同态合同测试矩阵(优先级 P1) +- 目标:完成 R-ES-03。 +- 设计: + 1. 抽象统一测试模板:`Command -> Events -> Replay -> StateEquals`。 + 2. 覆盖关键 actor 类别: + - `src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs` + - `src/Aevatar.AI.Core/RoleGAgent.cs` + - `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs` + 3. 新增守卫:关键目录新增 `GAgentBase` 子类必须在合同测试中被引用。 +- 产物: + 1. 合同测试基类与样例。 + 2. 关键 actor 覆盖报告。 + +### WP-4:治理门禁补齐(优先级 P1,已完成) +- 目标:完成 R-GOV-01 剩余项。 +- 改动范围: + - `tools/ci/architecture_guards.sh` +- 新增规则: + 1. 禁止 `src/workflow/Aevatar.Workflow.Infrastructure/*.csproj` 引用 `Aevatar.CQRS.Projection.Providers.*.csproj`。 + 2. 禁止 Workflow.Infrastructure 源码 `using Aevatar.CQRS.Projection.Providers.*`。 +- 产物: + 1. 守卫脚本更新。 + 2. 对应守卫测试或 CI 验证记录。 + +### WP-5:生产化增强(优先级 P2) +- 目标:压实容量与稳定性。 +- 内容: + 1. 引入生产级 `IEventStore` 后端(当前本地实现为 InMemory/File,生产需独立持久化方案)。 + 2. 建立快照与压缩参数压测基线:吞吐、恢复时延、磁盘增长曲线。 + 3. 将 provider e2e smoke 接入常态 CI(至少 nightly)。 +- 说明:此工作包不影响 P0/P1 的架构闭环,可并行推进。 + +## 11. 里程碑与依赖 + +| 里程碑 | 日期(计划) | 依赖 | 交付 | +|---|---|---|---| +| M1 | 2026-02-25 | 无 | WP-1 完成,Workflow 解耦完成 | +| M2 | 2026-02-27 | M1 | WP-2 完成,启动期 fail-fast 生效 | +| M3 | 2026-03-01 | M1 | WP-3 完成,合同测试矩阵落地 | +| M4 | 2026-03-02 | M1 | WP-4 完成,门禁补齐 | +| M5 | 2026-03-06 | M2+M3+M4 | WP-5 初版(压测+生产后端方案) | + +## 12. 验证矩阵(需求 -> 命令 -> 通过标准) + +| 需求 | 验证命令 | 通过标准 | +|---|---|---| +| R-ES-01/02/04/05/06 | `bash tools/ci/architecture_guards.sh` | 脚本通过,无 ES 反射注入/State 直写/违规路径 | +| R-RM-01/02/04 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` | Provider 选择与能力测试通过 | +| R-WF-02 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` | Workflow 投影与 AGUI 路径测试通过 | +| R-RM-03 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --filter "*ReadModel*" --nologo` | 启动期能力校验失败/成功场景通过 | +| R-ES-03 | `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo` + 合同测试项目 | 关键 actor replay 同态全部通过 | +| 全量回归 | `dotnet build aevatar.slnx --nologo` + `dotnet test aevatar.slnx --nologo` | 构建+测试全绿 | + +## 13. 完成定义(Final DoD) +1. 有状态 Actor 恢复事实源仅来自 EventStore replay。 +2. 命令路径上不存在自动派生事件机制。 +3. Workflow.Infrastructure 与 Provider 实现完全解耦。 +4. ReadModel 能力协商在启动期可全量 fail-fast。 +5. CQRS 与 AGUI 共用同一投影输入链路。 +6. CI 守卫覆盖上述约束并在主干持续执行。 + +## 14. 风险与应对 +1. 风险:Workflow 解耦后 Host 组合复杂度增加。 + - 应对:提供单一 Host 扩展入口,不允许业务重复拼装 Provider。 +2. 风险:启动期全量校验增加启动耗时。 + - 应对:采用 dry-run,不触发真实写入;提供严格模式与开发模式开关。 +3. 风险:合同测试扩大导致 CI 时间增长。 + - 应对:PR 快速集 + nightly 全量集分层执行。 +4. 风险:生产 EventStore 语义偏离现有实现。 + - 应对:统一 `IEventStore` 合同测试套件,后端接入前强制通过。 + +## 15. 执行清单(可勾选) +- [x] 完成 WP-1:Workflow 解耦与 Host 组合下沉 +- [x] 完成 WP-2:启动期全量能力校验 +- [ ] 完成 WP-3:Replay 合同测试矩阵 +- [x] 完成 WP-4:Workflow->Providers 门禁补齐 +- [ ] 完成 WP-5:生产化后端与压测闭环 + +## 16. 当前执行快照(2026-02-23) +- 已完成:R-ES-01、R-ES-02、R-ES-04、R-ES-05、R-ES-06、R-RM-01、R-RM-02、R-RM-03、R-RM-04、R-WF-01、R-WF-02、R-GOV-01 +- 部分完成:R-ES-03 +- 当前主阻塞:Replay 合同测试矩阵、生产 EventStore 后端与压测闭环 + +## 17. 变更纪律 +1. 删除优先,不做兼容壳。 +2. 代码先改,文档同步更新,不允许文档失真。 +3. 新增能力必须挂在主链路上,不允许旁路“第二系统”。 diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj index 60ffa6891..8c3b9131a 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj +++ b/src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj @@ -13,9 +13,6 @@ - - - diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index 88316bcfe..9ddd97d23 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -1,17 +1,11 @@ using Aevatar.Configuration; using Aevatar.CQRS.Projection.Abstractions; -using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; -using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; -using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; using Aevatar.Workflow.Application.DependencyInjection; using Aevatar.Workflow.Core; using Aevatar.Workflow.Presentation.AGUIAdapter; using Aevatar.Workflow.Presentation.AGUIAdapter.DependencyInjection; using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.DependencyInjection; -using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -29,31 +23,6 @@ public static IServiceCollection AddWorkflowCapability( configuration.GetSection("WorkflowExecutionProjection").Bind(options); ApplyGlobalReadModelOptions(configuration, options); }); - services.AddInMemoryReadModelStoreRegistration( - keySelector: report => report.RootActorId, - keyFormatter: key => key, - listSortSelector: report => report.StartedAt, - listTakeMax: 200); - services.AddElasticsearchReadModelStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); - return providerOptions; - }, - indexScope: "workflow-execution-reports", - keySelector: report => report.RootActorId, - keyFormatter: key => key); - services.AddNeo4jReadModelStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new Neo4jProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); - return providerOptions; - }, - scope: "workflow-execution-reports", - keySelector: report => report.RootActorId, - keyFormatter: key => key); services.AddWorkflowExecutionAGUIAdapter(); services.AddWorkflowExecutionProjectionProjector(); services.AddWorkflowApplication(); diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/README.md b/src/workflow/Aevatar.Workflow.Infrastructure/README.md index eeb503fa9..6b4ab24cf 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/README.md +++ b/src/workflow/Aevatar.Workflow.Infrastructure/README.md @@ -25,10 +25,12 @@ - 注册 workflow 文件源与启动加载 HostedService。 - `AddWorkflowCapability(IServiceCollection, IConfiguration)` - 能力一键组合(Application + Projection + AGUIAdapter + Infrastructure + workflow 文件源)。 + - 不负责具体 ReadModel Provider 注册(Provider 组合下沉到 Host/Extensions 层)。 - `AddWorkflowCapability(WebApplicationBuilder)` - Host 侧一行接入 Workflow 能力(服务注册 + 能力端点声明)。 - `Aevatar.Workflow.Extensions.Hosting.AddWorkflowCapabilityWithAIDefaults(WebApplicationBuilder)` - 在 Host 入口统一装配 Workflow capability + AI features + AI projection extension(推荐用于生产组合入口)。 + - 默认同时注册 Workflow 读模型 Provider(InMemory/Elasticsearch/Neo4j)。 - `MapWorkflowCapabilityEndpoints(...)` - 将 Workflow 能力 API 端点挂载到 Host(默认由 `UseAevatarDefaultHost()` 自动调用能力映射链路)。 diff --git a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj index 2136b2526..2042a1f12 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj +++ b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj @@ -19,5 +19,6 @@ + diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index fa5d4ccc8..2059b4aff 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -52,6 +52,11 @@ public bool EnableRunQueryEndpoints /// public bool FailOnUnsupportedCapabilities { get; set; } = true; + /// + /// Whether to pre-validate read-model provider selection and capabilities during host startup. + /// + public bool ValidateReadModelProviderOnStartup { get; set; } = true; + /// /// Optional read-model binding requirements (ReadModelName -> IndexKind). /// diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 4c1dcb375..319c62118 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Aevatar.CQRS.Projection.Core.Streaming; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using System.Reflection; namespace Aevatar.Workflow.Projection.DependencyInjection; @@ -58,6 +59,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(); services.TryAddSingleton>, ProjectionLifecycleService>>(); services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs new file mode 100644 index 000000000..72ad1e05f --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -0,0 +1,82 @@ +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.ReadModels; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedService +{ + private readonly IServiceProvider _serviceProvider; + private readonly WorkflowExecutionProjectionOptions _options; + private readonly IProjectionReadModelBindingResolver _bindingResolver; + private readonly IProjectionReadModelProviderRegistry _providerRegistry; + private readonly IProjectionReadModelProviderSelector _providerSelector; + private readonly ILogger _logger; + + public WorkflowReadModelStartupValidationHostedService( + IServiceProvider serviceProvider, + WorkflowExecutionProjectionOptions options, + IProjectionReadModelBindingResolver bindingResolver, + IProjectionReadModelProviderRegistry providerRegistry, + IProjectionReadModelProviderSelector providerSelector, + ILogger? logger = null) + { + _serviceProvider = serviceProvider; + _options = options; + _bindingResolver = bindingResolver; + _providerRegistry = providerRegistry; + _providerSelector = providerSelector; + _logger = logger ?? NullLogger.Instance; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_options.Enabled || !_options.ValidateReadModelProviderOnStartup) + return Task.CompletedTask; + + EnsureReadModelModeSupported(); + + var requirements = _bindingResolver.Resolve(_options.ReadModelBindings, typeof(WorkflowExecutionReport)); + var selectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = NormalizeProviderName(_options.ReadModelProvider), + FailOnUnsupportedCapabilities = _options.FailOnUnsupportedCapabilities, + }; + + var registrations = _providerRegistry.GetRegistrations(_serviceProvider); + var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + _logger.LogInformation( + "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", + typeof(WorkflowExecutionReport).FullName, + selected.ProviderName); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + private void EnsureReadModelModeSupported() + { + if (_options.ReadModelMode != ProjectionReadModelMode.StateOnly) + return; + + throw new InvalidOperationException( + "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + + "Use CustomReadModel or DefaultReadModel."); + } + + private static string NormalizeProviderName(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + return ProjectionReadModelProviderNames.InMemory; + + return providerName.Trim(); + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index faab7813c..dd81f8321 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -18,7 +18,7 @@ - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` -- Provider 能力校验:基于 `ProjectionReadModelCapabilityValidator` 在装配期校验 `ReadModelBindings` 与 Provider 能力匹配 +- Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionReadModelCapabilityValidator` 校验 本项目依赖: @@ -76,7 +76,7 @@ FAQ: - 在 DI 中注册 - 扩展 ReadModel Provider(推荐): - 实现 `IProjectionReadModelStoreRegistration` - - 在 Infrastructure 侧注册(例如 `AddInMemoryReadModelStoreRegistration` / `AddElasticsearchReadModelStoreRegistration`) + - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - 通过 `WorkflowExecutionProjection:ReadModelProvider` 或 `Projection:ReadModel:Provider` 选择 Provider - 直接替换 Store(仅测试/临时场景): - 调用 `AddWorkflowExecutionProjectionReadModelStore()` 直接覆盖 `IProjectionReadModelStore` @@ -86,6 +86,7 @@ FAQ: - `WorkflowExecutionProjection:ReadModelProvider`:`InMemory`(默认)/`Elasticsearch` - `WorkflowExecutionProjection:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) +- `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:是否在 Host 启动阶段预校验 Provider 选择与能力(默认 `true`) - `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(如 `WorkflowExecutionReport: Document`) - 推荐统一配置入口:`Projection:ReadModel:*`(由 Infrastructure 映射到 Workflow 投影选项) - `Projection:ReadModel:Provider`:全局默认 Provider(当前由 `WorkflowCapabilityServiceCollectionExtensions` 覆盖到模块选项) diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj index 44401e408..ba1f87406 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj @@ -7,8 +7,12 @@ Aevatar.Workflow.Extensions.Hosting + + + + diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowCapabilityHostBuilderExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowCapabilityHostBuilderExtensions.cs index 2e20af8bd..b6e3ee857 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowCapabilityHostBuilderExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowCapabilityHostBuilderExtensions.cs @@ -20,6 +20,7 @@ public static WebApplicationBuilder AddWorkflowCapabilityWithAIDefaults( options.EnableSkills = true; configureAIFeatures?.Invoke(options); }); + builder.Services.AddWorkflowProjectionReadModelProviders(builder.Configuration); builder.AddWorkflowCapability(); builder.Services.AddWorkflowAIProjectionExtensions(); diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs new file mode 100644 index 000000000..b9c78feba --- /dev/null +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -0,0 +1,58 @@ +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; +using Aevatar.Workflow.Projection.ReadModels; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Workflow.Extensions.Hosting; + +public static class WorkflowProjectionProviderServiceCollectionExtensions +{ + public static IServiceCollection AddWorkflowProjectionReadModelProviders( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + if (services.Any(x => x.ServiceType == typeof(WorkflowProjectionProviderRegistrationsMarker))) + return services; + + services.AddSingleton(); + + services.AddInMemoryReadModelStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.StartedAt, + listTakeMax: 200); + + services.AddElasticsearchReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); + return providerOptions; + }, + indexScope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + + services.AddNeo4jReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new Neo4jProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + return providerOptions; + }, + scope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + + return services; + } + + private sealed class WorkflowProjectionProviderRegistrationsMarker; +} diff --git a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs index 7f3cf477a..887b04588 100644 --- a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs +++ b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs @@ -4,6 +4,8 @@ using Aevatar.AI.Core; using Aevatar.AI.Core.Hooks; using Aevatar.AI.Abstractions.Middleware; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +19,7 @@ public async Task ConfigureAsync_WhenSourceToolsShrink_ShouldRemoveStaleTools() var source = new MutableToolSource("tool-a", "tool-b"); var services = new ServiceCollection(); services.AddSingleton(source); + services.AddSingleton(); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { @@ -38,6 +41,7 @@ public async Task ConfigureAsync_WhenSourceToolsChanged_ShouldKeepManualTools() var source = new MutableToolSource("source-old"); var services = new ServiceCollection(); services.AddSingleton(source); + services.AddSingleton(); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { @@ -145,4 +149,64 @@ public async IAsyncEnumerable ChatStreamAsync( yield break; } } + + private sealed class TestEventStore : IEventStore + { + private readonly Dictionary> _events = new(StringComparer.Ordinal); + + public Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream)) + { + stream = []; + _events[agentId] = stream; + } + + var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + if (currentVersion != expectedVersion) + throw new InvalidOperationException($"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); + + stream.AddRange(events.Select(x => x.Clone())); + return Task.FromResult(stream.Count == 0 ? 0 : stream[^1].Version); + } + + public Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream)) + return Task.FromResult>([]); + + IReadOnlyList result = fromVersion.HasValue + ? stream.Where(x => x.Version > fromVersion.Value).Select(x => x.Clone()).ToList() + : stream.Select(x => x.Clone()).ToList(); + return Task.FromResult(result); + } + + public Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream) || stream.Count == 0) + return Task.FromResult(0L); + return Task.FromResult(stream[^1].Version); + } + + public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0 || !_events.TryGetValue(agentId, out var stream)) + return Task.FromResult(0L); + + var before = stream.Count; + stream.RemoveAll(x => x.Version <= toVersion); + return Task.FromResult((long)(before - stream.Count)); + } + } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 5a8e11dd4..9ad85d5ca 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -18,11 +18,57 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace Aevatar.Workflow.Host.Api.Tests; public class WorkflowExecutionProjectionRegistrationTests { + [Fact] + public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationEnabledAndProviderMissing_ShouldFailFast() + { + var services = new ServiceCollection(); + services.AddWorkflowExecutionProjectionCQRS(); + + await using var provider = services.BuildServiceProvider(); + Func act = () => StartHostedServicesAsync(provider); + + await act.Should().ThrowAsync() + .WithMessage("*No provider registrations were found*"); + } + + [Fact] + public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationFindsUnsupportedCapabilities_ShouldFailFast() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + { + options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; + options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.FailOnUnsupportedCapabilities = true; + }); + + await using var provider = services.BuildServiceProvider(); + Func act = () => StartHostedServicesAsync(provider); + + await act.Should().ThrowAsync() + .Where(ex => ex.ReadModelType == typeof(WorkflowExecutionReport)); + } + + [Fact] + public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationConfiguredCorrectly_ShouldPass() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + services.AddWorkflowExecutionProjectionCQRS(); + + await using var provider = services.BuildServiceProvider(); + Func act = () => StartHostedServicesAsync(provider); + + await act.Should().NotThrowAsync(); + } + [Fact] public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefault() { @@ -398,4 +444,11 @@ protected override bool Reduce( return true; } } + + private static async Task StartHostedServicesAsync(ServiceProvider provider) + { + var hostedServices = provider.GetServices(); + foreach (var hostedService in hostedServices) + await hostedService.StartAsync(CancellationToken.None); + } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 2ddfd7f7f..4a6a6bfa3 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -11,6 +11,7 @@ using Aevatar.Workflow.Projection.ReadModels; using FluentAssertions; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -53,4 +54,35 @@ public async Task AddWorkflowCapabilityWithAIDefaults_ShouldRegisterWorkflowAndA toolSources.Should().NotContain(x => x is MCPAgentToolSource); toolSources.Should().NotContain(x => x is SkillsAgentToolSource); } + + [Fact] + public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProvidersInHostLayer() + { + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + EnvironmentName = Environments.Development, + }); + + builder.AddWorkflowCapabilityWithAIDefaults(); + + var providerRegistrations = builder.Services + .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .ToList(); + providerRegistrations.Should().HaveCount(3); + } + + [Fact] + public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempotent() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + + services.AddWorkflowProjectionReadModelProviders(configuration); + services.AddWorkflowProjectionReadModelProviders(configuration); + + var providerRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .ToList(); + providerRegistrations.Should().HaveCount(3); + } } diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 01bc1a2eb..bcbf67ba4 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -388,6 +388,13 @@ if ! rg -n "AddWorkflowCapabilityWithAIDefaults\(" src/workflow/Aevatar.Workflow exit 1 fi +if ! rg -n "AddWorkflowProjectionReadModelProviders\(" \ + src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowCapabilityHostBuilderExtensions.cs >/dev/null +then + echo "Workflow hosting extension must register read-model providers via AddWorkflowProjectionReadModelProviders()." + exit 1 +fi + if [ -f "src/workflow/extensions/Aevatar.Workflow.Extensions.Maker/MakerModuleFactory.cs" ]; then echo "Maker extension must use unified module pack model; MakerModuleFactory is forbidden." exit 1 @@ -427,6 +434,21 @@ then exit 1 fi +if rg -n "Aevatar\.CQRS\.Projection\.Providers\..*\.csproj" \ + src/workflow/Aevatar.Workflow.Infrastructure/Aevatar.Workflow.Infrastructure.csproj +then + echo "Workflow.Infrastructure must not reference projection provider implementation projects. Register providers in host/extensions layer." + exit 1 +fi + +if rg -n "using\s+Aevatar\.CQRS\.Projection\.Providers\." \ + src/workflow/Aevatar.Workflow.Infrastructure \ + -g '*.cs' +then + echo "Workflow.Infrastructure source must not reference projection provider namespaces. Register providers in host/extensions layer." + exit 1 +fi + if rg -n "TryGetContext\(" src; then echo "Projection context reverse lookup is forbidden. Use explicit projection lease/session handles." exit 1 From 413d369d7d58ad802f1ffb1ff4c290d5611b4de4 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 01:06:41 +0800 Subject: [PATCH 10/46] Enhance CI Workflow with Path Filtering and New Job Structure - Introduced path filtering in the CI workflow to conditionally trigger jobs based on changes in specific directories or files, improving efficiency. - Added new jobs for `split-test-guards`, `projection-provider-e2e`, and `kafka-transport-integration`, allowing for targeted testing and integration based on detected changes. - Implemented a `coverage-quality` job to enforce coverage thresholds and ensure code quality during CI runs. - Updated the CI configuration to include a comprehensive README, detailing the purpose and functionality of each script and job within the CI process, enhancing developer understanding and usability. --- .github/workflows/ci.yml | 192 ++++++++++++++++++++++++++++++++++----- tools/ci/README.md | 39 ++++++++ 2 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 tools/ci/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f56f53b64..902a28954 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,56 @@ on: - cron: "0 3 * * *" workflow_dispatch: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - fast-build-test: + changes: if: github.event_name != 'schedule' runs-on: ubuntu-latest + outputs: + core_code: ${{ steps.filter.outputs.core_code }} + projection_provider: ${{ steps.filter.outputs.projection_provider }} + kafka_runtime: ${{ steps.filter.outputs.kafka_runtime }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect Changed Paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + core_code: + - 'src/**' + - 'test/**' + - 'aevatar.slnx' + - 'aevatar.*.slnf' + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'global.json' + - 'tools/ci/**' + - '.github/workflows/ci.yml' + projection_provider: + - 'docker-compose.projection-providers.yml' + - 'src/Aevatar.CQRS.Projection.*/**' + - 'src/Aevatar.CQRS.Projection.Core/**' + - 'test/Aevatar.CQRS.Projection.Core.Tests/**' + - 'tools/ci/projection_provider_e2e_smoke.sh' + - '.github/workflows/ci.yml' + kafka_runtime: + - 'docker-compose.yml' + - 'src/Aevatar.Foundation.Runtime*/**' + - 'test/Aevatar.Foundation.Runtime.Hosting.Tests/**' + - '.github/workflows/ci.yml' + + fast-gates: + if: github.event_name != 'schedule' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.core_code == 'true') + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@v4 @@ -22,23 +68,14 @@ jobs: uses: actions/setup-dotnet@v4 with: global-json-file: global.json + cache: true - - name: Install ripgrep + - name: Ensure ripgrep run: | - sudo apt-get update - sudo apt-get install -y ripgrep - - - name: Restore - run: dotnet restore aevatar.slnx --nologo - - - name: Build - run: dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false - - - name: Coverage Quality Guard - env: - COVERAGE_LINE_THRESHOLD: "85" - COVERAGE_BRANCH_THRESHOLD: "72" - run: bash tools/ci/coverage_quality_guard.sh + if ! command -v rg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y ripgrep + fi - name: Architecture Guards (full-scan + projection route mapping) env: @@ -51,11 +88,96 @@ jobs: - name: Test Stability Guards run: bash tools/ci/test_stability_guards.sh - - name: Solution Split Test Guards - env: - SPLIT_TEST_NO_RESTORE: "1" - SPLIT_TEST_NO_BUILD: "1" - run: bash tools/ci/solution_split_test_guards.sh + split-test-guards: + if: github.event_name != 'schedule' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.core_code == 'true') + needs: + - changes + - fast-gates + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + solution_filter: + - aevatar.foundation.slnf + - aevatar.ai.slnf + - aevatar.cqrs.slnf + - aevatar.workflow.slnf + - aevatar.hosting.slnf + - aevatar.distributed.slnf + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + + - name: Run split test guard (${{ matrix.solution_filter }}) + run: | + dotnet test "${{ matrix.solution_filter }}" \ + --nologo \ + --tl:off \ + -m:1 \ + -p:UseSharedCompilation=false \ + -p:NuGetAudit=false + + projection-provider-e2e: + if: | + github.event_name != 'schedule' && ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || + needs.changes.outputs.projection_provider == 'true' + ) + needs: + - changes + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + + - name: Ensure ripgrep + run: | + if ! command -v rg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y ripgrep + fi + + - name: Projection Provider E2E Smoke Test + run: bash tools/ci/projection_provider_e2e_smoke.sh + + kafka-transport-integration: + if: | + github.event_name != 'schedule' && ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || + needs.changes.outputs.kafka_runtime == 'true' + ) + needs: + - changes + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + + - name: Restore + run: dotnet restore aevatar.slnx --nologo + + - name: Build + run: dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false - name: Start Kafka for Distributed Integration Test run: docker compose up -d kafka @@ -94,6 +216,34 @@ jobs: if: always() run: docker compose down --volumes --remove-orphans + coverage-quality: + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + cache: true + + - name: Restore + run: dotnet restore aevatar.slnx --nologo + + - name: Build + run: dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false + + - name: Coverage Quality Guard + env: + COVERAGE_LINE_THRESHOLD: "85" + COVERAGE_BRANCH_THRESHOLD: "72" + run: bash tools/ci/coverage_quality_guard.sh + distributed-3node-smoke: if: | github.event_name == 'schedule' || diff --git a/tools/ci/README.md b/tools/ci/README.md new file mode 100644 index 000000000..3c0877efd --- /dev/null +++ b/tools/ci/README.md @@ -0,0 +1,39 @@ +# CI Scripts Map + +This directory keeps CI gate scripts and smoke tests. + +## Quality Guards + +- `tools/ci/coverage_quality_guard.sh`: coverage collection and threshold gate. +- `tools/ci/architecture_guards.sh`: architecture/static guards (includes projection route mapping guard). +- `tools/ci/test_stability_guards.sh`: polling/unstable test pattern guard. +- `tools/ci/solution_split_guards.sh`: split build guard. +- `tools/ci/solution_split_test_guards.sh`: split test guard. +- `tools/ci/projection_route_mapping_guard.sh`: projection reducer routing static guard. + +## Integration/Smoke Scripts + +- `tools/ci/projection_provider_e2e_smoke.sh` + - Starts Elasticsearch + Neo4j from `docker-compose.projection-providers.yml`. + - Waits for readiness, runs `ProjectionProviderE2EIntegrationTests`, and cleans up containers. +- `tools/ci/orleans_garnet_persistence_smoke.sh`: Orleans + Garnet persistence smoke. + +## Workflow Mapping + +- `.github/workflows/ci.yml` + - Job `changes` + - Uses path filters to detect whether projection-provider or Kafka-runtime integration jobs must run. + - Job `fast-gates` + - Runs static architecture and test-stability guards. + - Job `split-test-guards` (matrix) + - Runs `dotnet test` for each split solution filter (`foundation/ai/cqrs/workflow/hosting/distributed`). + - Job `projection-provider-e2e` + - Runs `tools/ci/projection_provider_e2e_smoke.sh`. + - Triggered on projection-provider related changes, `main/dev` pushes, or manual dispatch. + - Job `kafka-transport-integration` + - Starts Kafka and runs the distributed runtime integration test. + - Triggered on runtime integration related changes, `main/dev` pushes, or manual dispatch. + - Job `coverage-quality` + - Runs restore/build + `tools/ci/coverage_quality_guard.sh`. + - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. + - Job `distributed-3node-smoke` -> `tools/ci/distributed_3node_smoke.sh` From 7a31e4aa2bf0e0c426791568d329a0e8770591bc Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 01:12:25 +0800 Subject: [PATCH 11/46] Add Prepare Runner Action and Update CI Workflows - Introduced a new GitHub Action, `Prepare Runner`, to streamline the setup of the .NET SDK, cache NuGet packages, and optionally install ripgrep. - Updated multiple CI workflow jobs to utilize the new `Prepare Runner` action, enhancing maintainability and reducing redundancy in the setup steps. - Added a new script, `restore_and_build.sh`, to centralize the restore and build process for the project, improving CI efficiency. - Updated documentation to reflect the new action and script, providing clarity on their usage within the CI process. --- .github/actions/prepare-runner/action.yml | 41 +++++++++++++ .github/workflows/ci.yml | 71 +++++++---------------- tools/ci/README.md | 3 + tools/ci/restore_and_build.sh | 10 ++++ 4 files changed, 74 insertions(+), 51 deletions(-) create mode 100644 .github/actions/prepare-runner/action.yml create mode 100755 tools/ci/restore_and_build.sh diff --git a/.github/actions/prepare-runner/action.yml b/.github/actions/prepare-runner/action.yml new file mode 100644 index 000000000..605151663 --- /dev/null +++ b/.github/actions/prepare-runner/action.yml @@ -0,0 +1,41 @@ +name: Prepare Runner +description: Setup .NET SDK, cache NuGet packages, and optionally install ripgrep. +inputs: + setup-dotnet: + description: Whether to install .NET SDK from global.json. + required: false + default: "true" + cache-nuget: + description: Whether to cache ~/.nuget/packages. + required: false + default: "true" + install-ripgrep: + description: Whether to install ripgrep when missing. + required: false + default: "false" +runs: + using: composite + steps: + - name: Setup .NET + if: ${{ inputs.setup-dotnet == 'true' }} + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Cache NuGet Packages + if: ${{ inputs.cache-nuget == 'true' }} + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'Directory.Build.props', 'Directory.Packages.props', '**/*.csproj', '**/*.props', '**/*.targets') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Ensure ripgrep + if: ${{ inputs.install-ripgrep == 'true' }} + shell: bash + run: | + if ! command -v rg >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y ripgrep + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 902a28954..c54709ec3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,18 +64,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Prepare Runner + uses: ./.github/actions/prepare-runner with: - global-json-file: global.json - cache: true - - - name: Ensure ripgrep - run: | - if ! command -v rg >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y ripgrep - fi + setup-dotnet: "false" + cache-nuget: "false" + install-ripgrep: "true" - name: Architecture Guards (full-scan + projection route mapping) env: @@ -108,11 +102,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - cache: true + - name: Prepare Runner + uses: ./.github/actions/prepare-runner - name: Run split test guard (${{ matrix.solution_filter }}) run: | @@ -137,18 +128,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 + - name: Prepare Runner + uses: ./.github/actions/prepare-runner with: - global-json-file: global.json - cache: true - - - name: Ensure ripgrep - run: | - if ! command -v rg >/dev/null 2>&1; then - sudo apt-get update - sudo apt-get install -y ripgrep - fi + install-ripgrep: "true" - name: Projection Provider E2E Smoke Test run: bash tools/ci/projection_provider_e2e_smoke.sh @@ -167,17 +150,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - cache: true - - - name: Restore - run: dotnet restore aevatar.slnx --nologo + - name: Prepare Runner + uses: ./.github/actions/prepare-runner - - name: Build - run: dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false + - name: Restore and Build + run: bash tools/ci/restore_and_build.sh - name: Start Kafka for Distributed Integration Test run: docker compose up -d kafka @@ -226,17 +203,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - cache: true + - name: Prepare Runner + uses: ./.github/actions/prepare-runner - - name: Restore - run: dotnet restore aevatar.slnx --nologo - - - name: Build - run: dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false + - name: Restore and Build + run: bash tools/ci/restore_and_build.sh - name: Coverage Quality Guard env: @@ -254,10 +225,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json + - name: Prepare Runner + uses: ./.github/actions/prepare-runner - name: 3-Node Distributed Smoke Test run: bash tools/ci/distributed_3node_smoke.sh diff --git a/tools/ci/README.md b/tools/ci/README.md index 3c0877efd..5a1bb3862 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -10,6 +10,7 @@ This directory keeps CI gate scripts and smoke tests. - `tools/ci/solution_split_guards.sh`: split build guard. - `tools/ci/solution_split_test_guards.sh`: split test guard. - `tools/ci/projection_route_mapping_guard.sh`: projection reducer routing static guard. +- `tools/ci/restore_and_build.sh`: shared restore/build entry used by CI jobs. ## Integration/Smoke Scripts @@ -21,6 +22,8 @@ This directory keeps CI gate scripts and smoke tests. ## Workflow Mapping - `.github/workflows/ci.yml` + - Shared runner preparation is centralized in local action: + - `.github/actions/prepare-runner/action.yml` (`setup-dotnet` + NuGet cache + optional `ripgrep` install) - Job `changes` - Uses path filters to detect whether projection-provider or Kafka-runtime integration jobs must run. - Job `fast-gates` diff --git a/tools/ci/restore_and_build.sh b/tools/ci/restore_and_build.sh new file mode 100755 index 000000000..2810749a4 --- /dev/null +++ b/tools/ci/restore_and_build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +cd "${REPO_ROOT}" + +dotnet restore aevatar.slnx --nologo +dotnet build aevatar.slnx --nologo --no-restore --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false From c433e1609e879da7722135cb797f675987193652 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 01:15:29 +0800 Subject: [PATCH 12/46] Enhance RoleGAgent with Event Sourcing and Replay Contract Tests - Updated `RoleGAgent` to utilize event sourcing for role configuration, ensuring state persistence through `HandleConfigureRoleAgent` method. - Implemented state transition logic in `RoleGAgent` to manage role state changes effectively. - Added `RoleGAgentReplayContractTests` to verify the correct behavior of role configuration persistence and replay functionality. - Introduced `InMemoryEventStoreForTests` to facilitate testing of event sourcing mechanisms. - Updated CI architecture guards to enforce replay contract testing requirements for stateful actors, ensuring comprehensive coverage and adherence to architectural standards. --- ...ng-elasticsearch-readmodel-requirements.md | 17 ++-- src/Aevatar.AI.Core/RoleGAgent.cs | 26 +++++- src/Aevatar.AI.Core/RoleGAgentFactory.cs | 14 ++- .../AIGAgentBaseToolRefreshTests.cs | 64 +------------- .../AIHooksAndRoleFactoryCoverageTests.cs | 8 +- .../RoleGAgentReplayContractTests.cs | 87 +++++++++++++++++++ .../TestDoubles/InMemoryEventStoreForTests.cs | 64 ++++++++++++++ tools/ci/architecture_guards.sh | 28 ++++++ 8 files changed, 226 insertions(+), 82 deletions(-) create mode 100644 test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs create mode 100644 test/Aevatar.AI.Tests/TestDoubles/InMemoryEventStoreForTests.cs diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 73ce5907b..8b829d80b 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -131,7 +131,7 @@ |---|---|---|---|---|---| | R-ES-01 | 强制事件优先恢复 | 所有 `GAgentBase` 恢复来自 replay | Done | `GAgentBase.TState.cs` | 无 | | R-ES-02 | 显式事件构建 | 无自动派生事件主链路 | Done | `PersistDomainEventsAsync` + 守卫脚本 | 无 | -| R-ES-03 | 回放同态 | 关键 actor 有统一合同测试 | Partial | `EventSourcingTests.cs` 等 | 缺全类别标准化合同矩阵 | +| R-ES-03 | 回放同态 | 关键 actor 有统一合同测试 | Done | `WorkflowGAgentCoverageTests` / `ProjectionOwnershipAndSessionHubTests` / `RoleGAgentReplayContractTests` + 守卫 | 无 | | R-ES-04 | 静态装配 ES 行为 | Runtime 无反射注入 | Done | `architecture_guards.sh` | 无 | | R-ES-05 | 禁止直写 State | 含间接继承链全部受控 | Done | `StateGuard` + awk 扫描门禁 | 无 | | R-ES-06 | 快照后异步清理 | 由 runtime 空闲机制执行删除 | Done | `DeferredEventStoreCompactionScheduler` + hook | 无 | @@ -145,10 +145,7 @@ ## 8. 差距详解 -### 8.1 Gap-C(R-ES-03)Replay 合同覆盖不足 -- 现有测试覆盖核心路径,但缺“新增关键 actor 必须关联合同测试”的制度化约束。 - -### 8.2 Gap-P(生产化)EventStore 生产后端与压测基线未闭环 +### 8.1 Gap-P(生产化)EventStore 生产后端与压测基线未闭环 - 当前 `IEventStore` 仍以 InMemory/File 为主。 - 尚未建立容量压测与参数治理基线(快照间隔、保留事件数、压缩频率)。 @@ -225,7 +222,7 @@ flowchart LR 1. 启动期校验实现。 2. 启动失败测试:provider 未注册、能力不匹配、binding 非法。 -### WP-3:Replay 同态合同测试矩阵(优先级 P1) +### WP-3:Replay 同态合同测试矩阵(优先级 P1,已完成) - 目标:完成 R-ES-03。 - 设计: 1. 抽象统一测试模板:`Command -> Events -> Replay -> StateEquals`。 @@ -299,14 +296,14 @@ flowchart LR ## 15. 执行清单(可勾选) - [x] 完成 WP-1:Workflow 解耦与 Host 组合下沉 - [x] 完成 WP-2:启动期全量能力校验 -- [ ] 完成 WP-3:Replay 合同测试矩阵 +- [x] 完成 WP-3:Replay 合同测试矩阵 - [x] 完成 WP-4:Workflow->Providers 门禁补齐 - [ ] 完成 WP-5:生产化后端与压测闭环 ## 16. 当前执行快照(2026-02-23) -- 已完成:R-ES-01、R-ES-02、R-ES-04、R-ES-05、R-ES-06、R-RM-01、R-RM-02、R-RM-03、R-RM-04、R-WF-01、R-WF-02、R-GOV-01 -- 部分完成:R-ES-03 -- 当前主阻塞:Replay 合同测试矩阵、生产 EventStore 后端与压测闭环 +- 已完成:R-ES-01、R-ES-02、R-ES-03、R-ES-04、R-ES-05、R-ES-06、R-RM-01、R-RM-02、R-RM-03、R-RM-04、R-WF-01、R-WF-02、R-GOV-01 +- 部分完成:无 +- 当前主阻塞:生产 EventStore 后端与压测闭环 ## 17. 变更纪律 1. 删除优先,不做兼容壳。 diff --git a/src/Aevatar.AI.Core/RoleGAgent.cs b/src/Aevatar.AI.Core/RoleGAgent.cs index 5149775d5..6bded471e 100644 --- a/src/Aevatar.AI.Core/RoleGAgent.cs +++ b/src/Aevatar.AI.Core/RoleGAgent.cs @@ -17,6 +17,8 @@ using Aevatar.Foundation.Abstractions.Attributes; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Google.Protobuf; using Microsoft.Extensions.Logging; namespace Aevatar.AI.Core; @@ -65,7 +67,7 @@ Task IRoleAgent.ConfigureAsync(RoleAgentConfig config, CancellationToken ct) => [EventHandler] public async Task HandleConfigureRoleAgent(ConfigureRoleAgentEvent evt) { - SetRoleName(evt.RoleName); + await PersistDomainEventAsync(evt); await ((IRoleAgent)this).ConfigureAsync(new RoleAgentConfig { ProviderName = string.IsNullOrWhiteSpace(evt.ProviderName) ? "deepseek" : evt.ProviderName, @@ -83,6 +85,19 @@ public async Task HandleConfigureRoleAgent(ConfigureRoleAgentEvent evt) public override Task GetDescriptionAsync() => Task.FromResult($"RoleGAgent[{RoleName}]:{Id}"); + protected override RoleGAgentState TransitionState(RoleGAgentState current, IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyConfigureRoleAgent) + .OrCurrent(); + + protected override Task OnStateChangedAsync(RoleGAgentState state, CancellationToken ct) + { + _ = ct; + RoleName = state.RoleName ?? string.Empty; + return Task.CompletedTask; + } + /// /// Handles ChatRequestEvent via streaming LLM call. /// Publishes AG-UI three-phase events and logs the interaction. @@ -128,4 +143,13 @@ await PublishAsync(new TextMessageEndEvent SessionId = request.SessionId, }, EventDirection.Up); } + + private static RoleGAgentState ApplyConfigureRoleAgent( + RoleGAgentState current, + ConfigureRoleAgentEvent evt) + { + var next = current.Clone(); + next.RoleName = evt.RoleName ?? string.Empty; + return next; + } } diff --git a/src/Aevatar.AI.Core/RoleGAgentFactory.cs b/src/Aevatar.AI.Core/RoleGAgentFactory.cs index ac85901d4..8e05ba9eb 100644 --- a/src/Aevatar.AI.Core/RoleGAgentFactory.cs +++ b/src/Aevatar.AI.Core/RoleGAgentFactory.cs @@ -46,16 +46,14 @@ public static Task ConfigureFromYaml(RoleGAgent agent, string yaml, IServiceProv /// 应用 RoleYamlConfig 到 RoleGAgent。 public static async Task ApplyConfig(RoleGAgent agent, RoleYamlConfig config, IServiceProvider services) { - // ─── 基础配置 ─── - if (!string.IsNullOrEmpty(config.Name)) - agent.SetRoleName(config.Name); - - await agent.ConfigureAsync(new AIAgentConfig + // ─── 基础配置(事件优先) ─── + await agent.HandleConfigureRoleAgent(new ConfigureRoleAgentEvent { - SystemPrompt = config.SystemPrompt ?? "", + RoleName = config.Name ?? string.Empty, + SystemPrompt = config.SystemPrompt ?? string.Empty, ProviderName = config.Provider ?? "deepseek", - Model = config.Model, - Temperature = config.Temperature, + Model = config.Model ?? string.Empty, + Temperature = config.Temperature ?? 0, }); // ─── EventModules 创建 ─── diff --git a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs index 887b04588..ae0e5cd8c 100644 --- a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs +++ b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs @@ -4,7 +4,6 @@ using Aevatar.AI.Core; using Aevatar.AI.Core.Hooks; using Aevatar.AI.Abstractions.Middleware; -using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.Persistence; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +18,7 @@ public async Task ConfigureAsync_WhenSourceToolsShrink_ShouldRemoveStaleTools() var source = new MutableToolSource("tool-a", "tool-b"); var services = new ServiceCollection(); services.AddSingleton(source); - services.AddSingleton(); + services.AddSingleton(); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { @@ -41,7 +40,7 @@ public async Task ConfigureAsync_WhenSourceToolsChanged_ShouldKeepManualTools() var source = new MutableToolSource("source-old"); var services = new ServiceCollection(); services.AddSingleton(source); - services.AddSingleton(); + services.AddSingleton(); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { @@ -150,63 +149,4 @@ public async IAsyncEnumerable ChatStreamAsync( } } - private sealed class TestEventStore : IEventStore - { - private readonly Dictionary> _events = new(StringComparer.Ordinal); - - public Task AppendAsync( - string agentId, - IEnumerable events, - long expectedVersion, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream)) - { - stream = []; - _events[agentId] = stream; - } - - var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; - if (currentVersion != expectedVersion) - throw new InvalidOperationException($"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); - - stream.AddRange(events.Select(x => x.Clone())); - return Task.FromResult(stream.Count == 0 ? 0 : stream[^1].Version); - } - - public Task> GetEventsAsync( - string agentId, - long? fromVersion = null, - CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream)) - return Task.FromResult>([]); - - IReadOnlyList result = fromVersion.HasValue - ? stream.Where(x => x.Version > fromVersion.Value).Select(x => x.Clone()).ToList() - : stream.Select(x => x.Clone()).ToList(); - return Task.FromResult(result); - } - - public Task GetVersionAsync(string agentId, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (!_events.TryGetValue(agentId, out var stream) || stream.Count == 0) - return Task.FromResult(0L); - return Task.FromResult(stream[^1].Version); - } - - public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - if (toVersion <= 0 || !_events.TryGetValue(agentId, out var stream)) - return Task.FromResult(0L); - - var before = stream.Count; - stream.RemoveAll(x => x.Version <= toVersion); - return Task.FromResult((long)(before - stream.Count)); - } - } } diff --git a/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs b/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs index 24721f6f1..18b0ce9d0 100644 --- a/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs +++ b/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs @@ -8,6 +8,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.EventModules; using Aevatar.Foundation.Abstractions.Hooks; +using Aevatar.Foundation.Abstractions.Persistence; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -95,6 +96,7 @@ public async Task RoleGAgentFactory_ShouldConfigureFromYamlAndWrapRoutableModule var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); await using var provider = services.BuildServiceProvider(); var agent = CreateRoleAgent(provider); @@ -124,6 +126,7 @@ public async Task RoleGAgentFactory_ShouldSupportDirectConfigWithoutExtensions() { var services = new ServiceCollection(); services.AddSingleton(); + services.AddSingleton(); await using var provider = services.BuildServiceProvider(); var cfg = new RoleYamlConfig @@ -217,5 +220,8 @@ private static RoleGAgent CreateRoleAgent(IServiceProvider provider) => provider.GetServices(), provider.GetServices(), provider.GetServices(), - provider.GetServices()); + provider.GetServices()) + { + Services = provider, + }; } diff --git a/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs new file mode 100644 index 000000000..4eb8796a2 --- /dev/null +++ b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs @@ -0,0 +1,87 @@ +using System.Reflection; +using Aevatar.AI.Abstractions; +using Aevatar.AI.Core; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.AI.Tests; + +public class RoleGAgentReplayContractTests +{ + [Fact] + public async Task ConfigureRoleEvent_ShouldPersistAndReplayRoleState() + { + var store = new InMemoryEventStoreForTests(); + var services = new ServiceCollection() + .AddSingleton(store) + .BuildServiceProvider(); + + var agent1 = CreateAgent(services, "role-replay-contract"); + await agent1.ActivateAsync(); + await agent1.HandleConfigureRoleAgent(new ConfigureRoleAgentEvent + { + RoleName = "researcher", + ProviderName = "mock", + Model = "m1", + SystemPrompt = "be helpful", + }); + await agent1.DeactivateAsync(); + + var persisted = await store.GetEventsAsync("role-replay-contract"); + persisted.Should().ContainSingle(x => x.EventType.Contains(nameof(ConfigureRoleAgentEvent), StringComparison.Ordinal)); + + var agent2 = CreateAgent(services, "role-replay-contract"); + await agent2.ActivateAsync(); + + agent2.State.RoleName.Should().Be(agent1.State.RoleName); + agent2.RoleName.Should().Be("researcher"); + } + + [Fact] + public async Task RoleGAgentFactory_ShouldUseEventSourcedConfigurePath() + { + var store = new InMemoryEventStoreForTests(); + var services = new ServiceCollection() + .AddSingleton(store) + .BuildServiceProvider(); + + var agent1 = CreateAgent(services, "role-factory-replay"); + await agent1.ActivateAsync(); + await RoleGAgentFactory.ApplyConfig(agent1, new RoleYamlConfig + { + Name = "assistant", + Provider = "mock", + SystemPrompt = "system", + }, services); + await agent1.DeactivateAsync(); + + var persisted = await store.GetEventsAsync("role-factory-replay"); + persisted.Should().ContainSingle(x => x.EventType.Contains(nameof(ConfigureRoleAgentEvent), StringComparison.Ordinal)); + + var agent2 = CreateAgent(services, "role-factory-replay"); + await agent2.ActivateAsync(); + agent2.State.RoleName.Should().Be("assistant"); + agent2.RoleName.Should().Be("assistant"); + } + + private static RoleGAgent CreateAgent(IServiceProvider services, string actorId) + { + var agent = new RoleGAgent + { + Services = services, + }; + AssignActorId(agent, actorId); + return agent; + } + + private static void AssignActorId(RoleGAgent agent, string actorId) + { + var setIdMethod = typeof(GAgentBase).GetMethod( + "SetId", + BindingFlags.Instance | BindingFlags.NonPublic); + setIdMethod.Should().NotBeNull(); + setIdMethod!.Invoke(agent, [actorId]); + } +} diff --git a/test/Aevatar.AI.Tests/TestDoubles/InMemoryEventStoreForTests.cs b/test/Aevatar.AI.Tests/TestDoubles/InMemoryEventStoreForTests.cs new file mode 100644 index 000000000..428a0324b --- /dev/null +++ b/test/Aevatar.AI.Tests/TestDoubles/InMemoryEventStoreForTests.cs @@ -0,0 +1,64 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; + +namespace Aevatar.AI.Tests; + +internal sealed class InMemoryEventStoreForTests : IEventStore +{ + private readonly Dictionary> _events = new(StringComparer.Ordinal); + + public Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream)) + { + stream = []; + _events[agentId] = stream; + } + + var currentVersion = stream.Count == 0 ? 0 : stream[^1].Version; + if (currentVersion != expectedVersion) + throw new InvalidOperationException($"Optimistic concurrency conflict: expected {expectedVersion}, actual {currentVersion}"); + + stream.AddRange(events.Select(x => x.Clone())); + return Task.FromResult(stream.Count == 0 ? 0 : stream[^1].Version); + } + + public Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream)) + return Task.FromResult>([]); + + IReadOnlyList result = fromVersion.HasValue + ? stream.Where(x => x.Version > fromVersion.Value).Select(x => x.Clone()).ToList() + : stream.Select(x => x.Clone()).ToList(); + return Task.FromResult(result); + } + + public Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_events.TryGetValue(agentId, out var stream) || stream.Count == 0) + return Task.FromResult(0L); + return Task.FromResult(stream[^1].Version); + } + + public Task DeleteEventsUpToAsync(string agentId, long toVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0 || !_events.TryGetValue(agentId, out var stream)) + return Task.FromResult(0L); + + var before = stream.Count; + stream.RemoveAll(x => x.Version <= toVersion); + return Task.FromResult((long)(before - stream.Count)); + } +} diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index bcbf67ba4..2a82ba9fa 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -315,6 +315,34 @@ if [ -n "${reducer_test_coverage_violations}" ]; then exit 1 fi +stateful_replay_contract_requirements=( + "WorkflowGAgent:test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs" + "ProjectionOwnershipCoordinatorGAgent:test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs" + "RoleGAgent:test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs" +) + +for requirement in "${stateful_replay_contract_requirements[@]}"; do + actor_name="${requirement%%:*}" + contract_file="${requirement#*:}" + + if [ ! -f "${contract_file}" ]; then + echo "Missing replay contract test file for ${actor_name}: ${contract_file}" + exit 1 + fi + + if ! rg -n "\\b${actor_name}\\b" "${contract_file}" >/dev/null; then + echo "${contract_file}" + echo "Replay contract test file must reference actor ${actor_name}." + exit 1 + fi + + if ! rg -n "Persist.*Event|Replay|Reactivate|ActivateAsync|DeactivateAsync" "${contract_file}" >/dev/null; then + echo "${contract_file}" + echo "Replay contract test file for ${actor_name} must assert persisted-event replay semantics." + exit 1 + fi +done + echo "Running projection route-mapping guard..." bash tools/ci/projection_route_mapping_guard.sh From 0bd186ea38fd4d37861d62b63c4fd048ba3021b2 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 01:29:59 +0800 Subject: [PATCH 13/46] Implement Garnet Event Store for Production Persistence - Introduced `GarnetEventStore` as a production-ready implementation of `IEventStore`, utilizing Redis for event persistence with optimistic concurrency support. - Updated dependency injection to automatically configure `GarnetEventStore` when the persistence backend is set to Garnet, ensuring seamless integration with Orleans runtime. - Enhanced documentation to include details on the new Garnet-backed event store and its configuration options. - Added integration tests for `GarnetEventStore` to verify event appending, retrieval, and optimistic concurrency handling. - Updated existing tests to ensure compatibility with the new persistence implementation, maintaining code quality and reliability. --- aevatar.slnx | 1 + docs/EVENT_SOURCING.md | 4 +- docs/FOUNDATION.md | 2 +- ...ng-elasticsearch-readmodel-requirements.md | 29 +- ...t-host-api-distributed-orleans-tm-kafka.md | 1 + ...ion.Runtime.Implementations.Orleans.csproj | 1 + .../ServiceCollectionExtensions.cs | 14 +- .../README.md | 2 + ....Persistence.Implementations.Garnet.csproj | 19 ++ .../ServiceCollectionExtensions.cs | 42 +++ .../GarnetEventStore.cs | 282 ++++++++++++++++++ .../GarnetEventStoreOptions.cs | 22 ++ .../README.md | 19 ++ src/Aevatar.Foundation.Runtime/README.md | 2 +- src/Aevatar.Mainnet.Host.Api/README.md | 2 + ...RuntimeServiceCollectionExtensionsTests.cs | 37 +++ .../GarnetEventStoreIntegrationTests.cs | 95 ++++++ ...RuntimeServiceCollectionExtensionsTests.cs | 21 ++ 18 files changed, 579 insertions(+), 16 deletions(-) create mode 100644 src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.csproj create mode 100644 src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs create mode 100644 src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStoreOptions.cs create mode 100644 src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/README.md create mode 100644 test/Aevatar.Foundation.Runtime.Hosting.Tests/GarnetEventStoreIntegrationTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index e8a10a101..6c1479a87 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -19,6 +19,7 @@ + diff --git a/docs/EVENT_SOURCING.md b/docs/EVENT_SOURCING.md index aff2f84dd..79cee26c2 100644 --- a/docs/EVENT_SOURCING.md +++ b/docs/EVENT_SOURCING.md @@ -26,6 +26,7 @@ - Runtime 停用钩子分发实现:`src/Aevatar.Foundation.Runtime/Actor/ActorDeactivationHookDispatcher.cs` - Runtime 默认裁剪钩子:`src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` - 本地持久化 EventStore:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs` +- 生产持久化 EventStore(Garnet):`src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs` - Local Runtime 注入边界:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs` - Orleans Runtime 注入边界:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs` - 防回退门禁:`tools/ci/architecture_guards.sh` @@ -76,7 +77,8 @@ public async Task Handle(IncrementRequested evt) - `AddAevatarRuntime()` 默认注册 `IActorDeactivationHookDispatcher -> ActorDeactivationHookDispatcher`(支持多 hook 顺序分发)。 - 可通过 `AddFileEventStore(...)` 将 `IEventStore` 切换为本地持久化实现:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs`。 - 调用 `AddFileEventStore(...)` 时,`IEventSourcingSnapshotStore` 会切换为 `FileEventSourcingSnapshotStore`,支持快照与事件裁剪后的持久化恢复。 -- 生产环境应替换为持久化实现(Redis/DB/日志存储等)。 +- 可通过 `AddGarnetEventStore(...)` 使用生产持久化实现:`src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/DependencyInjection/ServiceCollectionExtensions.cs`。 +- Orleans runtime 当 `PersistenceBackend=Garnet` 时,会自动装配 `IEventStore -> GarnetEventStore`(连接串复用 `GarnetConnectionString`),不再回退 `InMemoryEventStore`。 - 如需自定义 ES 行为,可直接为 Agent 预设 `EventSourcing`,但必须保持相同语义契约。 - 如需解耦 Agent 里的 `TransitionState` 逻辑,可注册多个 `IStateEventApplier`,按 `Order` 升序匹配应用。 - Agent 侧推荐使用 `StateTransitionMatcher.Match(...).On(...).OrCurrent()`,避免重复 `Any + switch` 样板代码。 diff --git a/docs/FOUNDATION.md b/docs/FOUNDATION.md index cedb46c80..c8e7ca85b 100644 --- a/docs/FOUNDATION.md +++ b/docs/FOUNDATION.md @@ -94,7 +94,7 @@ Agent 收到 `EventEnvelope` 后,会将两类处理器合并执行: 口径说明: - `InMemory*` 组件仅用于开发/测试环境,不作为生产容量治理对象。 -- 生产环境应替换为 Redis/持久化实现,并在生产实现上评估内存增长与容量风险。 +- 生产环境应使用持久化实现(仓库已提供 `Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet`),并在生产实现上评估内存增长与容量风险。 ### 分布式目标态(生产) diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 8b829d80b..a751555e1 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -2,7 +2,7 @@ ## 1. 文档元信息 - 状态:Active -- 版本:v3.1 +- 版本:v3.2 - 日期:2026-02-23 - 适用范围:`src/`、`test/`、`tools/ci/`、`docs/architecture/` - 文档定位:唯一重构执行蓝图(需求、现状、差距、任务包、验收、门禁) @@ -145,9 +145,14 @@ ## 8. 差距详解 -### 8.1 Gap-P(生产化)EventStore 生产后端与压测基线未闭环 -- 当前 `IEventStore` 仍以 InMemory/File 为主。 -- 尚未建立容量压测与参数治理基线(快照间隔、保留事件数、压缩频率)。 +### 8.1 Gap-P(生产化)压测基线与常态化 smoke 未闭环 +- 已完成: + 1. 生产级 `IEventStore` 后端已落地为 Garnet:`src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs`。 + 2. Orleans runtime 在 `PersistenceBackend=Garnet` 时自动绑定 `GarnetEventStore`,不再回退 `InMemoryEventStore`。 + 3. Garnet EventStore 集成测试已补齐(环境变量门控):`test/Aevatar.Foundation.Runtime.Hosting.Tests/GarnetEventStoreIntegrationTests.cs`。 +- 仍待完成: + 1. 快照与事件裁剪参数压测基线(吞吐、恢复时延、数据增长曲线)。 + 2. provider e2e smoke 的 nightly 常态化接入与告警治理。 ## 9. 目标架构 @@ -246,12 +251,12 @@ flowchart LR 1. 守卫脚本更新。 2. 对应守卫测试或 CI 验证记录。 -### WP-5:生产化增强(优先级 P2) +### WP-5:生产化增强(优先级 P2,部分完成) - 目标:压实容量与稳定性。 - 内容: - 1. 引入生产级 `IEventStore` 后端(当前本地实现为 InMemory/File,生产需独立持久化方案)。 - 2. 建立快照与压缩参数压测基线:吞吐、恢复时延、磁盘增长曲线。 - 3. 将 provider e2e smoke 接入常态 CI(至少 nightly)。 + 1. [x] 引入生产级 `IEventStore` 后端:Garnet(独立模块 + Orleans 自动装配)。 + 2. [ ] 建立快照与压缩参数压测基线:吞吐、恢复时延、磁盘增长曲线。 + 3. [ ] 将 provider e2e smoke 接入常态 CI(至少 nightly)。 - 说明:此工作包不影响 P0/P1 的架构闭环,可并行推进。 ## 11. 里程碑与依赖 @@ -262,7 +267,7 @@ flowchart LR | M2 | 2026-02-27 | M1 | WP-2 完成,启动期 fail-fast 生效 | | M3 | 2026-03-01 | M1 | WP-3 完成,合同测试矩阵落地 | | M4 | 2026-03-02 | M1 | WP-4 完成,门禁补齐 | -| M5 | 2026-03-06 | M2+M3+M4 | WP-5 初版(压测+生产后端方案) | +| M5 | 2026-03-06 | M2+M3+M4 | WP-5 余项闭环(压测+nightly smoke) | ## 12. 验证矩阵(需求 -> 命令 -> 通过标准) @@ -298,12 +303,12 @@ flowchart LR - [x] 完成 WP-2:启动期全量能力校验 - [x] 完成 WP-3:Replay 合同测试矩阵 - [x] 完成 WP-4:Workflow->Providers 门禁补齐 -- [ ] 完成 WP-5:生产化后端与压测闭环 +- [ ] 完成 WP-5:压测基线与 nightly smoke 闭环 ## 16. 当前执行快照(2026-02-23) - 已完成:R-ES-01、R-ES-02、R-ES-03、R-ES-04、R-ES-05、R-ES-06、R-RM-01、R-RM-02、R-RM-03、R-RM-04、R-WF-01、R-WF-02、R-GOV-01 -- 部分完成:无 -- 当前主阻塞:生产 EventStore 后端与压测闭环 +- 部分完成:WP-5-1(Garnet EventStore 生产后端 + Orleans 自动装配 + 集成测试) +- 当前主阻塞:压测基线与 nightly smoke 尚未接入 ## 17. 变更纪律 1. 删除优先,不做兼容壳。 diff --git a/docs/architecture/mainnet-host-api-distributed-orleans-tm-kafka.md b/docs/architecture/mainnet-host-api-distributed-orleans-tm-kafka.md index b9984e04c..f5b31b1d2 100644 --- a/docs/architecture/mainnet-host-api-distributed-orleans-tm-kafka.md +++ b/docs/architecture/mainnet-host-api-distributed-orleans-tm-kafka.md @@ -71,6 +71,7 @@ flowchart LR - Stream Forward/Topology 的权威状态仍在 Orleans Grain(`IStreamTopologyGrain`),非中间层进程内事实态。 - 该版本不改业务层编排逻辑,仅替换 runtime 与传输实现。 - Orleans grain state 与 Stream `PubSubStore` 持久化可按配置切换到 Garnet。 +- 当 `ActorRuntime:OrleansPersistenceBackend=Garnet` 时,`IEventStore` 也会自动切换到 `GarnetEventStore`,与 Actor 持久化后端保持一致。 - `Localhost` 模式使用 `UseLocalhostClustering`,适合本机多进程开发。 - `Development` 模式使用 `UseDevelopmentClustering + ConfigureEndpoints`,可通过主节点实现多机测试集群。 - 生产跨主机集群建议替换为持久化 Membership Provider。 diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Aevatar.Foundation.Runtime.Implementations.Orleans.csproj b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Aevatar.Foundation.Runtime.Implementations.Orleans.csproj index 8c15d154c..b85dc850f 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Aevatar.Foundation.Runtime.Implementations.Orleans.csproj +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Aevatar.Foundation.Runtime.Implementations.Orleans.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs index 24cc696d9..8520b4539 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Aevatar.Foundation.Abstractions.TypeSystem; using Aevatar.Foundation.Core.TypeSystem; using Aevatar.Foundation.Runtime.Actors; +using Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.DependencyInjection; using Aevatar.Foundation.Runtime.Implementations.Orleans.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming.DependencyInjection; @@ -30,7 +31,18 @@ public static IServiceCollection AddAevatarFoundationRuntimeOrleans( services.TryAddSingleton(); services.TryAddTransient(typeof(IStateStore<>), typeof(RuntimeActorGrainStateStore<>)); services.TryAddTransient(typeof(IEventSourcingSnapshotStore<>), typeof(RuntimeActorGrainEventSourcingSnapshotStore<>)); - services.TryAddSingleton(); + if (IsPersistenceBackend(options, AevatarOrleansRuntimeOptions.PersistenceBackendGarnet)) + { + services.AddGarnetEventStore(garnetOptions => + { + garnetOptions.ConnectionString = options.GarnetConnectionString; + }); + } + else + { + services.TryAddSingleton(); + } + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/README.md b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/README.md index 99461b928..26d04ec0c 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/README.md +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/README.md @@ -55,6 +55,8 @@ siloBuilder.AddAevatarFoundationRuntimeOrleans(options => }); ``` +当持久化后端选择 `Garnet` 时,Event Sourcing 的 `IEventStore` 也会自动切换为 `GarnetEventStore`(不再使用 `InMemoryEventStore`),确保重启后可依赖事件流恢复。 + ## MassTransitAdapter 启用方式 当需要 Orleans Stream 通过 MassTransit 传输时,显式启用 MassTransit 适配扩展,并选择 Kafka 作为传输实现: diff --git a/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.csproj b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.csproj new file mode 100644 index 000000000..24062221a --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet + Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet + + + + + + + + + + + + diff --git a/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..b9bee2ea0 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,42 @@ +using Aevatar.Foundation.Abstractions.Persistence; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; + +namespace Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Replaces with Garnet-backed persistence. + /// + public static IServiceCollection AddGarnetEventStore( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new GarnetEventStoreOptions(); + configure?.Invoke(options); + ValidateOptions(options); + + services.Replace(ServiceDescriptor.Singleton(options)); + services.TryAddSingleton(sp => + { + var garnetOptions = sp.GetRequiredService(); + var connectionOptions = ConfigurationOptions.Parse(garnetOptions.ConnectionString); + connectionOptions.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(connectionOptions); + }); + services.Replace(ServiceDescriptor.Singleton()); + return services; + } + + private static void ValidateOptions(GarnetEventStoreOptions options) + { + if (string.IsNullOrWhiteSpace(options.ConnectionString)) + throw new InvalidOperationException("GarnetEventStore requires a non-empty connection string."); + if (string.IsNullOrWhiteSpace(options.KeyPrefix)) + throw new InvalidOperationException("GarnetEventStore requires a non-empty key prefix."); + } +} diff --git a/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs new file mode 100644 index 000000000..187725a50 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs @@ -0,0 +1,282 @@ +using System.Text; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; +using Google.Protobuf; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using StackExchange.Redis; + +namespace Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet; + +/// +/// Garnet-backed event store with optimistic concurrency and stream compaction support. +/// +public sealed class GarnetEventStore : IEventStore +{ + private const string AppendScript = """ + local currentRaw = redis.call('GET', KEYS[1]) + local current = 0 + if currentRaw then + current = tonumber(currentRaw) + end + + local expected = tonumber(ARGV[1]) + if current ~= expected then + return {0, current} + end + + local count = tonumber(ARGV[2]) + if count <= 0 then + return {1, current} + end + + local latest = current + for i = 0, count - 1 do + local base = 3 + (i * 2) + local version = tonumber(ARGV[base]) + local payload = ARGV[base + 1] + local versionField = tostring(version) + + redis.call('ZADD', KEYS[2], version, versionField) + redis.call('HSET', KEYS[3], versionField, payload) + latest = version + end + + redis.call('SET', KEYS[1], tostring(latest)) + return {1, latest} + """; + + private const string DeleteScript = """ + local toVersion = tonumber(ARGV[1]) + if toVersion <= 0 then + return 0 + end + + local versions = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', toVersion) + local removed = #versions + if removed == 0 then + return 0 + end + + redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', toVersion) + for i = 1, removed do + redis.call('HDEL', KEYS[2], versions[i]) + end + + return removed + """; + + private readonly IDatabase _database; + private readonly string _keyPrefix; + private readonly ILogger _logger; + + public GarnetEventStore( + IConnectionMultiplexer connectionMultiplexer, + GarnetEventStoreOptions options, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(connectionMultiplexer); + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.ConnectionString)) + throw new InvalidOperationException("GarnetEventStore requires a non-empty connection string."); + if (string.IsNullOrWhiteSpace(options.KeyPrefix)) + throw new InvalidOperationException("GarnetEventStore requires a non-empty key prefix."); + + _database = connectionMultiplexer.GetDatabase(options.Database); + _keyPrefix = options.KeyPrefix.Trim(); + _logger = logger ?? NullLogger.Instance; + } + + public async Task AppendAsync( + string agentId, + IEnumerable events, + long expectedVersion, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ArgumentNullException.ThrowIfNull(events); + ct.ThrowIfCancellationRequested(); + + var pendingEvents = events.Select(static evt => evt.Clone()).ToList(); + if (pendingEvents.Count == 0) + return await GetVersionAsync(agentId, ct); + + ValidateEventVersions(pendingEvents, expectedVersion); + + var keys = BuildKeys(agentId); + var scriptArgs = BuildAppendScriptArgs(expectedVersion, pendingEvents); + var rawResult = await _database.ScriptEvaluateAsync( + AppendScript, + [keys.VersionKey, keys.EventIndexKey, keys.EventDataKey], + scriptArgs); + ct.ThrowIfCancellationRequested(); + + var result = (RedisResult[])rawResult!; + if (result.Length != 2) + throw new InvalidOperationException("Unexpected Garnet append script result."); + + var status = (long)result[0]; + var actualVersion = (long)result[1]; + if (status == 0) + { + throw new InvalidOperationException( + $"Optimistic concurrency conflict: expected {expectedVersion}, actual {actualVersion}"); + } + + if (status != 1) + throw new InvalidOperationException($"Unexpected Garnet append script status: {status}."); + + _logger.LogDebug( + "Garnet event-store append completed. agentId={AgentId} appended={Count} version={Version}", + agentId, + pendingEvents.Count, + actualVersion); + return actualVersion; + } + + public async Task> GetEventsAsync( + string agentId, + long? fromVersion = null, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var keys = BuildKeys(agentId); + var versions = await _database.SortedSetRangeByScoreAsync( + keys.EventIndexKey, + start: fromVersion ?? double.NegativeInfinity, + stop: double.PositiveInfinity, + exclude: fromVersion.HasValue ? Exclude.Start : Exclude.None, + order: Order.Ascending); + ct.ThrowIfCancellationRequested(); + if (versions.Length == 0) + return []; + + var payloads = await _database.HashGetAsync(keys.EventDataKey, versions); + ct.ThrowIfCancellationRequested(); + if (payloads.Length != versions.Length) + { + throw new InvalidOperationException( + $"Corrupted Garnet event stream for agent '{agentId}': version/payload length mismatch."); + } + + var events = new List(payloads.Length); + for (var i = 0; i < payloads.Length; i++) + { + var payload = payloads[i]; + if (payload.IsNull) + { + throw new InvalidOperationException( + $"Corrupted Garnet event stream for agent '{agentId}': missing payload at version '{versions[i]}'."); + } + + var bytes = (byte[]?)payload; + if (bytes == null || bytes.Length == 0) + { + throw new InvalidOperationException( + $"Corrupted Garnet event stream for agent '{agentId}': empty payload at version '{versions[i]}'."); + } + + events.Add(StateEvent.Parser.ParseFrom(bytes)); + } + + return events; + } + + public async Task GetVersionAsync(string agentId, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + + var keys = BuildKeys(agentId); + var rawVersion = await _database.StringGetAsync(keys.VersionKey); + ct.ThrowIfCancellationRequested(); + if (rawVersion.IsNullOrEmpty) + return 0; + + if (!long.TryParse(rawVersion.ToString(), out var version)) + throw new InvalidOperationException($"Corrupted Garnet stream version for agent '{agentId}'."); + + return version; + } + + public async Task DeleteEventsUpToAsync( + string agentId, + long toVersion, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(agentId); + ct.ThrowIfCancellationRequested(); + if (toVersion <= 0) + return 0; + + var keys = BuildKeys(agentId); + var rawDeleted = await _database.ScriptEvaluateAsync( + DeleteScript, + [keys.EventIndexKey, keys.EventDataKey], + [toVersion]); + ct.ThrowIfCancellationRequested(); + + var deleted = (long)rawDeleted; + _logger.LogDebug( + "Garnet event-store compaction completed. agentId={AgentId} compactToVersion={CompactToVersion} deletedEvents={DeletedEvents}", + agentId, + toVersion, + deleted); + return deleted; + } + + private StreamKeys BuildKeys(string agentId) + { + var encodedAgentId = EncodeAgentId(agentId); + return new StreamKeys( + VersionKey: $"{_keyPrefix}:{{{encodedAgentId}}}:version", + EventIndexKey: $"{_keyPrefix}:{{{encodedAgentId}}}:index", + EventDataKey: $"{_keyPrefix}:{{{encodedAgentId}}}:data"); + } + + private static string EncodeAgentId(string agentId) + { + return Convert.ToBase64String(Encoding.UTF8.GetBytes(agentId)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + private static RedisValue[] BuildAppendScriptArgs( + long expectedVersion, + IReadOnlyList pendingEvents) + { + var values = new RedisValue[2 + (pendingEvents.Count * 2)]; + values[0] = expectedVersion; + values[1] = pendingEvents.Count; + for (var i = 0; i < pendingEvents.Count; i++) + { + values[2 + (i * 2)] = pendingEvents[i].Version; + values[3 + (i * 2)] = pendingEvents[i].ToByteArray(); + } + + return values; + } + + private static void ValidateEventVersions( + IReadOnlyList pendingEvents, + long expectedVersion) + { + for (var i = 0; i < pendingEvents.Count; i++) + { + var expectedEventVersion = expectedVersion + i + 1; + if (pendingEvents[i].Version != expectedEventVersion) + { + throw new InvalidOperationException( + "StateEvent versions must be strictly contiguous and start from expectedVersion + 1."); + } + } + } + + private sealed record StreamKeys( + RedisKey VersionKey, + RedisKey EventIndexKey, + RedisKey EventDataKey); +} diff --git a/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStoreOptions.cs b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStoreOptions.cs new file mode 100644 index 000000000..76b4275d6 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStoreOptions.cs @@ -0,0 +1,22 @@ +namespace Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet; + +/// +/// Configuration for Garnet-backed event-store persistence. +/// +public sealed class GarnetEventStoreOptions +{ + /// + /// Garnet connection string (Redis protocol), for example: localhost:6379,abortConnect=false. + /// + public string ConnectionString { get; set; } = "localhost:6379,abortConnect=false"; + + /// + /// Logical key prefix for event streams. + /// + public string KeyPrefix { get; set; } = "aevatar:eventstore"; + + /// + /// Database index. -1 uses the default database from connection options. + /// + public int Database { get; set; } = -1; +} diff --git a/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/README.md b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/README.md new file mode 100644 index 000000000..d792d03a6 --- /dev/null +++ b/src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/README.md @@ -0,0 +1,19 @@ +# Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet + +该项目提供 `IEventStore` 的 Garnet 生产实现,不绑定 Workflow/AI 业务语义。 + +## 提供能力 + +- `GarnetEventStore`:基于 Redis 协议(Garnet)持久化 `StateEvent`。 +- `AddGarnetEventStore(...)`:DI 装配扩展,替换默认 `IEventStore`。 +- `GarnetEventStoreOptions`:连接串、Key 前缀、Database 配置。 + +## 关键语义 + +- 追加写入走 Lua 脚本,带 `expectedVersion` 乐观并发检查。 +- 读取按版本有序回放,支持 `fromVersion` 增量读取。 +- `DeleteEventsUpToAsync` 支持快照后的历史事件裁剪。 + +## 运行时装配 + +在 Orleans runtime 中,当 `PersistenceBackend=Garnet` 时会自动装配 `GarnetEventStore`(无需业务层额外绑定)。 diff --git a/src/Aevatar.Foundation.Runtime/README.md b/src/Aevatar.Foundation.Runtime/README.md index 7c745a482..56aa522cb 100644 --- a/src/Aevatar.Foundation.Runtime/README.md +++ b/src/Aevatar.Foundation.Runtime/README.md @@ -75,7 +75,7 @@ Event Sourcing 默认启用自动快照与事件裁剪(快照成功后清理 可通过 `ActorRuntime:EventSourcing:*` 配置覆盖。 -生产环境可替换为数据库或其它持久化实现,接口由 Aevatar 抽象层定义。 +生产环境可替换为数据库或其它持久化实现,接口由 Aevatar 抽象层定义。当前仓库提供了 Garnet 后端实现:`src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet`(`AddGarnetEventStore(...)`)。 --- diff --git a/src/Aevatar.Mainnet.Host.Api/README.md b/src/Aevatar.Mainnet.Host.Api/README.md index f2ed4e42c..241fcbcee 100644 --- a/src/Aevatar.Mainnet.Host.Api/README.md +++ b/src/Aevatar.Mainnet.Host.Api/README.md @@ -31,6 +31,8 @@ ASPNETCORE_ENVIRONMENT=Distributed dotnet run --project src/Aevatar.Mainnet.Host - `ActorRuntime:MassTransitTransportBackend=Kafka` - `Orleans:ClusteringMode=Localhost` +在上述配置下,Event Sourcing 的 `IEventStore` 会自动使用 `GarnetEventStore`(连接串复用 `ActorRuntime:OrleansGarnetConnectionString`)。 + `Orleans:ClusteringMode` 支持: - `Localhost`:本机多进程开发模式(默认)。 diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/AevatarActorRuntimeServiceCollectionExtensionsTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/AevatarActorRuntimeServiceCollectionExtensionsTests.cs index aef535923..1f6f1af88 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/AevatarActorRuntimeServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/AevatarActorRuntimeServiceCollectionExtensionsTests.cs @@ -7,6 +7,8 @@ using Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; using Aevatar.Foundation.Runtime.Implementations.Orleans.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; +using Aevatar.Foundation.Runtime.Persistence; +using Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet; using Aevatar.Foundation.Runtime.Streaming.Implementations.MassTransit; using Aevatar.Foundation.Runtime.Streaming; using FluentAssertions; @@ -242,6 +244,41 @@ public void AddAevatarActorRuntime_WhenProviderIsOrleans_ShouldReplaceOpenGeneri descriptor!.ImplementationType.Should().Be(typeof(RuntimeActorGrainStateStore<>)); } + [Fact] + public void AddAevatarActorRuntime_WhenOrleansPersistenceBackendIsGarnet_ShouldRegisterGarnetEventStore() + { + var services = new ServiceCollection(); + var configuration = BuildConfiguration(new Dictionary + { + [$"{AevatarActorRuntimeOptions.SectionName}:Provider"] = AevatarActorRuntimeOptions.ProviderOrleans, + [$"{AevatarActorRuntimeOptions.SectionName}:OrleansPersistenceBackend"] = AevatarActorRuntimeOptions.OrleansPersistenceBackendGarnet, + [$"{AevatarActorRuntimeOptions.SectionName}:OrleansGarnetConnectionString"] = "garnet.local:6379,abortConnect=false", + }); + + services.AddAevatarActorRuntime(configuration); + + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(IEventStore)); + descriptor.Should().NotBeNull(); + descriptor!.ImplementationType.Should().Be(typeof(GarnetEventStore)); + } + + [Fact] + public void AddAevatarActorRuntime_WhenOrleansPersistenceBackendIsInMemory_ShouldKeepInMemoryEventStore() + { + var services = new ServiceCollection(); + var configuration = BuildConfiguration(new Dictionary + { + [$"{AevatarActorRuntimeOptions.SectionName}:Provider"] = AevatarActorRuntimeOptions.ProviderOrleans, + [$"{AevatarActorRuntimeOptions.SectionName}:OrleansPersistenceBackend"] = AevatarActorRuntimeOptions.OrleansPersistenceBackendInMemory, + }); + + services.AddAevatarActorRuntime(configuration); + + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(IEventStore)); + descriptor.Should().NotBeNull(); + descriptor!.ImplementationType.Should().Be(typeof(InMemoryEventStore)); + } + [Fact] public void AddAevatarActorRuntime_WhenOrleansPersistenceBackendIsUnsupported_ShouldThrow() { diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/GarnetEventStoreIntegrationTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/GarnetEventStoreIntegrationTests.cs new file mode 100644 index 000000000..97398dc49 --- /dev/null +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/GarnetEventStoreIntegrationTests.cs @@ -0,0 +1,95 @@ +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet; +using Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet.DependencyInjection; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Foundation.Runtime.Hosting.Tests; + +public sealed class GarnetEventStoreIntegrationTests +{ + [GarnetIntegrationFact] + public async Task GarnetEventStore_ShouldAppendReplayAndCompactEvents() + { + var connectionString = RequireGarnetConnectionString(); + var keyPrefix = $"aevatar:test:eventstore:{Guid.NewGuid():N}"; + var agentId = $"agent-{Guid.NewGuid():N}"; + + using var provider = BuildProvider(connectionString, keyPrefix); + var store = provider.GetRequiredService(); + + var firstBatch = CreateEvents(agentId, startVersion: 1, count: 3); + (await store.AppendAsync(agentId, firstBatch, expectedVersion: 0)).Should().Be(3); + (await store.GetVersionAsync(agentId)).Should().Be(3); + + var all = await store.GetEventsAsync(agentId); + all.Select(x => x.Version).Should().Equal(1, 2, 3); + + var removed = await store.DeleteEventsUpToAsync(agentId, toVersion: 2); + removed.Should().Be(2); + (await store.GetVersionAsync(agentId)).Should().Be(3); + + var retained = await store.GetEventsAsync(agentId); + retained.Select(x => x.Version).Should().Equal(3); + + var secondBatch = CreateEvents(agentId, startVersion: 4, count: 2); + (await store.AppendAsync(agentId, secondBatch, expectedVersion: 3)).Should().Be(5); + + var afterAppend = await store.GetEventsAsync(agentId); + afterAppend.Select(x => x.Version).Should().Equal(3, 4, 5); + } + + [GarnetIntegrationFact] + public async Task GarnetEventStore_ShouldEnforceOptimisticConcurrency() + { + var connectionString = RequireGarnetConnectionString(); + var keyPrefix = $"aevatar:test:eventstore:{Guid.NewGuid():N}"; + var agentId = $"agent-{Guid.NewGuid():N}"; + + using var provider = BuildProvider(connectionString, keyPrefix); + var store = provider.GetRequiredService(); + + await store.AppendAsync(agentId, CreateEvents(agentId, startVersion: 1, count: 1), expectedVersion: 0); + + var conflicting = () => store.AppendAsync( + agentId, + CreateEvents(agentId, startVersion: 2, count: 1), + expectedVersion: 0); + await conflicting.Should().ThrowAsync() + .WithMessage("*Optimistic concurrency conflict*"); + } + + private static ServiceProvider BuildProvider(string connectionString, string keyPrefix) + { + var services = new ServiceCollection(); + services.AddGarnetEventStore(options => + { + options.ConnectionString = connectionString; + options.KeyPrefix = keyPrefix; + }); + return services.BuildServiceProvider(); + } + + private static StateEvent[] CreateEvents(string agentId, int startVersion, int count) + { + var events = new StateEvent[count]; + for (var i = 0; i < count; i++) + { + var version = startVersion + i; + events[i] = new StateEvent + { + EventId = Guid.NewGuid().ToString("N"), + AgentId = agentId, + EventType = "test", + Version = version, + }; + } + + return events; + } + + private static string RequireGarnetConnectionString() => + Environment.GetEnvironmentVariable("AEVATAR_TEST_GARNET_CONNECTION_STRING") + ?? throw new InvalidOperationException("Missing AEVATAR_TEST_GARNET_CONNECTION_STRING."); +} diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeServiceCollectionExtensionsTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeServiceCollectionExtensionsTests.cs index 77e128c50..fddf21034 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeServiceCollectionExtensionsTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansRuntimeServiceCollectionExtensionsTests.cs @@ -1,6 +1,8 @@ using Aevatar.Foundation.Runtime.Implementations.Orleans.DependencyInjection; using Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; +using Aevatar.Foundation.Runtime.Persistence; +using Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet; using Aevatar.Foundation.Abstractions.Persistence; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -57,6 +59,25 @@ public void AddAevatarFoundationRuntimeOrleans_ServiceCollection_WhenPersistence var options = provider.GetRequiredService(); options.PersistenceBackend.Should().Be(AevatarOrleansRuntimeOptions.PersistenceBackendGarnet); options.GarnetConnectionString.Should().Be("garnet.internal:6379,abortConnect=false"); + + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(IEventStore)); + descriptor.Should().NotBeNull(); + descriptor!.ImplementationType.Should().Be(typeof(GarnetEventStore)); + } + + [Fact] + public void AddAevatarFoundationRuntimeOrleans_ServiceCollection_WhenPersistenceBackendIsInMemory_ShouldKeepInMemoryEventStore() + { + var services = new ServiceCollection(); + + services.AddAevatarFoundationRuntimeOrleans(options => + { + options.PersistenceBackend = AevatarOrleansRuntimeOptions.PersistenceBackendInMemory; + }); + + var descriptor = services.LastOrDefault(x => x.ServiceType == typeof(IEventStore)); + descriptor.Should().NotBeNull(); + descriptor!.ImplementationType.Should().Be(typeof(InMemoryEventStore)); } [Fact] From d6d875efaa02146f81ff115e69c7d82ba2ad12be Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 01:42:10 +0800 Subject: [PATCH 14/46] Enhance CI Workflow with Orleans Garnet Persistence Integration Job - Added a new CI job `orleans-garnet-persistence-integration` to run the Orleans Garnet persistence smoke test, triggered by specific events such as pushes to `main` or `dev` branches and manual dispatch. - Updated the README documentation to include details about the new job and its purpose. - Refactored the `StatefulAgentSnapshot` test method to `StatefulAgentEventSourcedState` for clarity and improved state transition logic in the `RecordingGarnetStatefulAgent` class. --- .github/workflows/ci.yml | 20 +++++++++++++++++++ ...rleansGarnetPersistenceIntegrationTests.cs | 16 +++++++++++---- tools/ci/README.md | 3 +++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c54709ec3..aeddba3a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,26 @@ jobs: if: always() run: docker compose down --volumes --remove-orphans + orleans-garnet-persistence-integration: + if: | + github.event_name != 'schedule' && ( + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || + needs.changes.outputs.kafka_runtime == 'true' + ) + needs: + - changes + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Prepare Runner + uses: ./.github/actions/prepare-runner + + - name: Orleans Garnet Persistence Smoke Test + run: bash tools/ci/orleans_garnet_persistence_smoke.sh + coverage-quality: if: | github.event_name == 'workflow_dispatch' || diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs index 06211a6ae..64c5b3dc2 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs @@ -6,6 +6,7 @@ using Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using FluentAssertions; +using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -83,7 +84,7 @@ public async Task GrainState_ShouldPersistAcrossSiloRestart_WhenUsingGarnetStora } [GarnetIntegrationFact] - public async Task StatefulAgentSnapshot_ShouldPersistAcrossSiloRestart_WhenUsingGarnetStorage() + public async Task StatefulAgentEventSourcedState_ShouldPersistAcrossSiloRestart_WhenUsingGarnetStorage() { var garnetConnectionString = RequireGarnetConnectionString(); var actorId = $"stateful-actor-{Guid.NewGuid():N}"; @@ -214,10 +215,17 @@ public Task DeactivateAsync(CancellationToken ct = default) private sealed class RecordingGarnetStatefulAgent : GAgentBase { - protected override Task OnActivateAsync(CancellationToken ct) + protected override Task OnActivateAsync(CancellationToken ct) => + PersistDomainEventAsync(new StringValue { Value = "activated" }, ct); + + protected override Int32Value TransitionState(Int32Value current, IMessage evt) { - State.Value += 1; - return Task.CompletedTask; + if (evt is Any any && any.Is(StringValue.Descriptor)) + evt = any.Unpack(); + + return evt is StringValue { Value: "activated" } + ? new Int32Value { Value = current.Value + 1 } + : current; } public override Task GetDescriptionAsync() => diff --git a/tools/ci/README.md b/tools/ci/README.md index 5a1bb3862..c09e921be 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -36,6 +36,9 @@ This directory keeps CI gate scripts and smoke tests. - Job `kafka-transport-integration` - Starts Kafka and runs the distributed runtime integration test. - Triggered on runtime integration related changes, `main/dev` pushes, or manual dispatch. + - Job `orleans-garnet-persistence-integration` + - Runs `tools/ci/orleans_garnet_persistence_smoke.sh`. + - Triggered on runtime integration related changes, `main/dev` pushes, or manual dispatch. - Job `coverage-quality` - Runs restore/build + `tools/ci/coverage_quality_guard.sh`. - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. From 7223228fac9912e4674710a0e98c5ad68643a1ab Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 02:02:38 +0800 Subject: [PATCH 15/46] Update CI Workflow and Documentation for Split Test Guards - Modified the `split-test-guards` job in the CI workflow to trigger on pushes to `main` and `dev` branches, as well as on scheduled events and manual dispatch. - Updated the README documentation to clarify the new triggering conditions for the `split-test-guards` job, enhancing developer understanding of the CI process. --- .github/workflows/ci.yml | 8 ++++---- tools/ci/README.md | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeddba3a7..f74adbe03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,10 +83,10 @@ jobs: run: bash tools/ci/test_stability_guards.sh split-test-guards: - if: github.event_name != 'schedule' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.core_code == 'true') - needs: - - changes - - fast-gates + if: | + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) runs-on: ubuntu-latest timeout-minutes: 35 strategy: diff --git a/tools/ci/README.md b/tools/ci/README.md index c09e921be..54f1b37bf 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -30,6 +30,7 @@ This directory keeps CI gate scripts and smoke tests. - Runs static architecture and test-stability guards. - Job `split-test-guards` (matrix) - Runs `dotnet test` for each split solution filter (`foundation/ai/cqrs/workflow/hosting/distributed`). + - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. - Job `projection-provider-e2e` - Runs `tools/ci/projection_provider_e2e_smoke.sh`. - Triggered on projection-provider related changes, `main/dev` pushes, or manual dispatch. From 719f425782e2dc7cf0877d3b015a9cca9bac6bb0 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 04:45:43 +0800 Subject: [PATCH 16/46] Enhance Event Sourcing Architecture and CI Workflow - Introduced `IEventSourcingBehaviorFactory` to streamline the creation of event sourcing behaviors, improving dependency management and reducing reliance on service locators. - Updated `GAgentBase` to utilize the new factory for event sourcing behavior instantiation, enhancing code clarity and maintainability. - Added `DefaultEventSourcingBehaviorFactory` to encapsulate event sourcing behavior creation logic, ensuring consistent configuration across agents. - Implemented a new CI job `event-sourcing-regression` to run comprehensive tests for event sourcing, including core tests, Orleans integration, and architecture guards. - Created `event_sourcing_regression.sh` script to automate the regression testing process, ensuring robust validation of event sourcing functionality. - Updated CI documentation to reflect the new job and its purpose, enhancing developer understanding of the testing framework. --- .github/workflows/ci.yml | 24 ++++- .../event-sourcing-scorecard-2026-02-23.md | 100 ++++++++++++++++++ .../DefaultEventSourcingBehaviorFactory.cs | 89 ++++++++++++++++ .../IEventSourcingBehaviorFactory.cs | 17 +++ .../IEventSourcingFactoryBinding.cs | 12 +++ .../GAgentBase.TState.cs | 68 +++--------- .../ServiceCollectionExtensions.cs | 2 + .../Grains/RuntimeActorGrain.cs | 3 + .../Actor/LocalActorRuntime.cs | 3 + .../ServiceCollectionExtensions.cs | 1 + .../AIGAgentBaseToolRefreshTests.cs | 7 ++ .../AIHooksAndRoleFactoryCoverageTests.cs | 7 ++ .../RoleGAgentReplayContractTests.cs | 6 ++ .../ProjectionOwnershipAndSessionHubTests.cs | 23 ++-- .../EventSourcingTests.cs | 4 + .../RuntimeEventStoreRegistrationTests.cs | 2 + ...rleansGarnetPersistenceIntegrationTests.cs | 15 ++- .../WorkflowGAgentCoverageTests.cs | 5 + ...WorkflowExecutionProjectionServiceTests.cs | 3 + tools/ci/README.md | 7 +- tools/ci/architecture_guards.sh | 26 +++++ tools/ci/event_sourcing_regression.sh | 32 ++++++ 22 files changed, 383 insertions(+), 73 deletions(-) create mode 100644 docs/audit-scorecard/event-sourcing-scorecard-2026-02-23.md create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs create mode 100644 src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs create mode 100755 tools/ci/event_sourcing_regression.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f74adbe03..edbca546e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: core_code: ${{ steps.filter.outputs.core_code }} projection_provider: ${{ steps.filter.outputs.projection_provider }} kafka_runtime: ${{ steps.filter.outputs.kafka_runtime }} + event_sourcing: ${{ steps.filter.outputs.event_sourcing }} steps: - uses: actions/checkout@v4 with: @@ -55,6 +56,17 @@ jobs: - 'src/Aevatar.Foundation.Runtime*/**' - 'test/Aevatar.Foundation.Runtime.Hosting.Tests/**' - '.github/workflows/ci.yml' + event_sourcing: + - 'src/Aevatar.Foundation.Core/**' + - 'src/Aevatar.Foundation.Runtime/**' + - 'src/Aevatar.Foundation.Runtime.Implementations.Orleans/**' + - 'src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/**' + - 'test/Aevatar.Foundation.Core.Tests/**' + - 'test/Aevatar.Foundation.Runtime.Hosting.Tests/**' + - 'tools/ci/event_sourcing_regression.sh' + - 'tools/ci/orleans_garnet_persistence_smoke.sh' + - 'tools/ci/architecture_guards.sh' + - '.github/workflows/ci.yml' fast-gates: if: github.event_name != 'schedule' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.core_code == 'true') @@ -193,25 +205,27 @@ jobs: if: always() run: docker compose down --volumes --remove-orphans - orleans-garnet-persistence-integration: + event-sourcing-regression: if: | github.event_name != 'schedule' && ( github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) || - needs.changes.outputs.kafka_runtime == 'true' + needs.changes.outputs.event_sourcing == 'true' ) needs: - changes runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 - name: Prepare Runner uses: ./.github/actions/prepare-runner + with: + install-ripgrep: "true" - - name: Orleans Garnet Persistence Smoke Test - run: bash tools/ci/orleans_garnet_persistence_smoke.sh + - name: EventSourcing Regression + run: bash tools/ci/event_sourcing_regression.sh coverage-quality: if: | diff --git a/docs/audit-scorecard/event-sourcing-scorecard-2026-02-23.md b/docs/audit-scorecard/event-sourcing-scorecard-2026-02-23.md new file mode 100644 index 000000000..e48fc8aae --- /dev/null +++ b/docs/audit-scorecard/event-sourcing-scorecard-2026-02-23.md @@ -0,0 +1,100 @@ +# Aevatar EventSourcing 架构评分卡(2026-02-23,终版重评) + +## 1. 审计范围与方法 + +1. 审计对象:EventSourcing 主链路(Foundation Core + Runtime + Orleans + Garnet 持久化 + ES 专项 CI 门禁)。 +2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 +3. 证据来源:当前分支源码、CI 脚本、专项回归命令实跑结果。 + +## 2. 审计边界 + +1. 核心语义与契约: +`src/Aevatar.Foundation.Core/GAgentBase.TState.cs`、`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehavior.cs`、`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs`。 +2. 装配与依赖反转: +`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs`、`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs`、`src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs`、`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs`、`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs`。 +3. 治理与门禁: +`tools/ci/architecture_guards.sh`、`tools/ci/event_sourcing_regression.sh`、`.github/workflows/ci.yml`。 +4. 关键测试: +`test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs`、`test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs`、`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs`、`test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs`、`test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs`。 + +## 3. EventSourcing 架构主链 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Runtime InjectDependencies"] --> B["IEventSourcingFactoryBinding.BindEventSourcingFactory"] + B --> C["IEventSourcingBehaviorFactory.Create"] + C --> D["EventSourcingBehavior"] + D --> E["RaiseEvent / ConfirmEventsAsync"] + E --> F["IEventStore.AppendAsync"] + D --> G["ReplayAsync"] + G --> H["IEventStore.GetEventsAsync"] + D --> I["PersistSnapshotAsync"] + I --> J["IEventSourcingSnapshotStore"] + I --> K["IEventStoreCompactionScheduler.ScheduleAsync"] +``` + +## 4. 客观验证结果(终版复核) + +| 检查项 | 命令 | 结果 | +|---|---|---| +| ES 专项回归聚合 | `bash tools/ci/event_sourcing_regression.sh` | 通过(4/4 步全部通过) | +| ES 核心测试 | `dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj --nologo --filter "FullyQualifiedName~EventSourcing"` | 通过(19 passed / 0 failed) | +| Orleans+Garnet ES 集成 | `bash tools/ci/orleans_garnet_persistence_smoke.sh` | 通过(2 passed / 0 failed) | +| CQRS ownership 状态回放 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionOwnershipCoordinatorGAgentTests"` | 通过(5 passed / 0 failed) | +| Workflow 状态回放覆盖 | `dotnet test test/Aevatar.Integration.Tests/Aevatar.Integration.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowGAgentCoverageTests"` | 通过(10 passed / 0 failed) | +| AI stateful 回放覆盖 | `dotnet test test/Aevatar.AI.Tests/Aevatar.AI.Tests.csproj --nologo --filter "FullyQualifiedName~AIGAgentBaseToolRefreshTests|FullyQualifiedName~AIHooksAndRoleFactoryCoverageTests|FullyQualifiedName~RoleGAgentReplayContractTests"` | 通过(8 passed / 0 failed) | +| 架构门禁 | `bash tools/ci/architecture_guards.sh` | 通过(Architecture guards passed) | + +## 5. 整体评分(100 分制) + +**总分:100 / 100(A+)** + +| 维度 | 权重 | 得分 | 评分依据 | +|---|---:|---:|---| +| 分层与依赖反转 | 20 | 20 | `GAgentBase` 激活路径已移除 Service Locator 回退,改为 runtime 显式绑定工厂。 | +| CQRS 与统一投影链路 | 20 | 20 | 命令侧严格 `RaiseEvent -> ConfirmEventsAsync -> TransitionState`,禁止快照伪事件保持生效。 | +| Projection 编排与状态约束 | 20 | 20 | 回放事实源仍为 EventStore,matcher 约束 guard 已覆盖状态迁移一致性。 | +| 读写分离与会话语义 | 15 | 15 | `Activate=Replay`、`Deactivate=Confirm+Snapshot` 语义清晰稳定。 | +| 命名语义与冗余清理 | 10 | 10 | 工厂化与绑定接口命名准确,目录/命名空间保持一致。 | +| 可验证性(门禁/构建/测试) | 15 | 15 | ES 专项聚合回归 + 多子域状态回放测试 + 架构门禁形成闭环。 | + +## 6. 分模块评分 + +| 模块 | 得分 | 结论 | +|---|---:|---| +| ES 契约与核心行为(Core) | 100 | 事件提交/回放/快照/裁剪主链完整,工厂与绑定边界清晰。 | +| Local Runtime 装配 | 100 | 运行时显式绑定 ES 工厂,不再依赖激活阶段回退。 | +| Orleans Runtime 装配 | 100 | Orleans 注入路径与 Local 语义对齐。 | +| 持久化实现(InMemory/File/Garnet) | 100 | 事件存储与快照存储切换边界稳定,Garnet 集成验证通过。 | +| 门禁与测试体系(ES 维度) | 100 | 规则、脚本、CI job、跨域测试均已闭环。 | + +## 7. 关键证据(终版) + +1. 工厂契约:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs:8`。 +2. 显式绑定接口:`src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs:6`。 +3. `GAgentBase` 激活路径无 DI 回退:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs:122`、`src/Aevatar.Foundation.Core/GAgentBase.TState.cs:127`。 +4. `GAgentBase` 明确缺失即 fail-fast:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs:133`。 +5. `GAgentBase` 绑定方法只在注入阶段执行:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs:138`。 +6. Local runtime 显式调用绑定:`src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs:163`。 +7. Orleans runtime 显式调用绑定:`src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs:281`。 +8. 旧回退路径被 guard 禁止(含 `??=` 与旧 GetService 模式):`tools/ci/architecture_guards.sh:100`。 +9. 状态迁移 matcher 约束 guard:`tools/ci/architecture_guards.sh:266`。 +10. ES 专项回归入口:`tools/ci/event_sourcing_regression.sh:9`。 +11. CI 专项质量面板:`.github/workflows/ci.yml:208`。 +12. 关键子域回放覆盖:`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs:137`、`test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs:227`、`test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs:69`。 + +## 8. 主要扣分项 + +### P1 + +1. 无。 + +### P2 + +1. 无。 + +## 9. 后续建议(非扣分) + +1. 继续补充 `DefaultEventSourcingBehaviorFactory` 选项矩阵测试(快照/压缩参数组合)以提升回归可诊断性。 +2. 在 CI 报表层输出 ES 专项回归耗时与趋势,增强质量面板可观测性。 diff --git a/src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs b/src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs new file mode 100644 index 000000000..013502be7 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs @@ -0,0 +1,89 @@ +using Aevatar.Foundation.Abstractions.Persistence; +using Google.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Default event sourcing behavior factory bound to runtime persistence options. +/// +public sealed class DefaultEventSourcingBehaviorFactory + : IEventSourcingBehaviorFactory + where TState : class, IMessage, new() +{ + private readonly IEventStore _eventStore; + private readonly EventSourcingRuntimeOptions _options; + private readonly IEventSourcingSnapshotStore? _snapshotStore; + private readonly IEventStoreCompactionScheduler? _compactionScheduler; + private readonly ILogger>? _logger; + + public DefaultEventSourcingBehaviorFactory( + IEventStore eventStore, + EventSourcingRuntimeOptions? options = null, + IEventSourcingSnapshotStore? snapshotStore = null, + IEventStoreCompactionScheduler? compactionScheduler = null, + ILogger>? logger = null) + { + _eventStore = eventStore; + _options = options ?? new EventSourcingRuntimeOptions(); + _snapshotStore = snapshotStore; + _compactionScheduler = compactionScheduler; + _logger = logger; + } + + public IEventSourcingBehavior Create( + string agentId, + Func transitionState) + { + ArgumentNullException.ThrowIfNull(agentId); + ArgumentNullException.ThrowIfNull(transitionState); + + var snapshotsEnabled = _options.EnableSnapshots && _snapshotStore != null; + var snapshotStore = snapshotsEnabled ? _snapshotStore : null; + ISnapshotStrategy snapshotStrategy = snapshotsEnabled + ? new IntervalSnapshotStrategy(_options.SnapshotInterval) + : NeverSnapshotStrategy.Instance; + + return new DelegatingEventSourcingBehavior( + _eventStore, + agentId, + transitionState, + snapshotStore, + snapshotStrategy, + _logger, + _options.EnableEventCompaction, + _options.RetainedEventsAfterSnapshot, + _compactionScheduler); + } + + private sealed class DelegatingEventSourcingBehavior : EventSourcingBehavior + { + private readonly Func _transitionState; + + public DelegatingEventSourcingBehavior( + IEventStore eventStore, + string agentId, + Func transitionState, + IEventSourcingSnapshotStore? snapshotStore, + ISnapshotStrategy snapshotStrategy, + ILogger>? logger, + bool enableEventCompaction, + int retainedEventsAfterSnapshot, + IEventStoreCompactionScheduler? compactionScheduler) + : base( + eventStore, + agentId, + snapshotStore, + snapshotStrategy, + logger, + enableEventCompaction, + retainedEventsAfterSnapshot, + compactionScheduler) + { + _transitionState = transitionState; + } + + public override TState TransitionState(TState current, IMessage evt) => + _transitionState(current, evt); + } +} diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs new file mode 100644 index 000000000..a75baa947 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingBehaviorFactory.cs @@ -0,0 +1,17 @@ +using Google.Protobuf; + +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Factory for creating per-agent event sourcing behavior instances. +/// +public interface IEventSourcingBehaviorFactory + where TState : class, IMessage, new() +{ + /// + /// Creates event sourcing behavior for the specified agent and transition function. + /// + IEventSourcingBehavior Create( + string agentId, + Func transitionState); +} diff --git a/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs new file mode 100644 index 000000000..835c13b69 --- /dev/null +++ b/src/Aevatar.Foundation.Core/EventSourcing/IEventSourcingFactoryBinding.cs @@ -0,0 +1,12 @@ +namespace Aevatar.Foundation.Core.EventSourcing; + +/// +/// Runtime binding hook for stateful agents to receive their event sourcing factory explicitly. +/// +public interface IEventSourcingFactoryBinding +{ + /// + /// Binds the event sourcing behavior factory from runtime service provider. + /// + void BindEventSourcingFactory(IServiceProvider services); +} diff --git a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs index f2790ac1c..a74aa1e01 100644 --- a/src/Aevatar.Foundation.Core/GAgentBase.TState.cs +++ b/src/Aevatar.Foundation.Core/GAgentBase.TState.cs @@ -3,7 +3,6 @@ // State + mandatory EventSourcing + OnStateChanged Hook // ───────────────────────────────────────────────────────────── -using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Core.EventSourcing; using Google.Protobuf; using Microsoft.Extensions.DependencyInjection; @@ -14,7 +13,7 @@ namespace Aevatar.Foundation.Core; /// Stateful GAgent base class with Protobuf state and mandatory Event Sourcing lifecycle. /// /// Protobuf-generated state type. -public abstract class GAgentBase : GAgentBase, IAgent +public abstract class GAgentBase : GAgentBase, IAgent, IEventSourcingFactoryBinding where TState : class, IMessage, new() { private TState _state = new(); @@ -31,6 +30,9 @@ public TState State /// Event Sourcing behavior injected by runtime; required for state recovery and commit. public IEventSourcingBehavior? EventSourcing { get; set; } + /// Factory used to create per-agent event sourcing behavior when not explicitly injected. + public IEventSourcingBehaviorFactory? EventSourcingBehaviorFactory { get; set; } + /// Activates agent, replays events to restore state, then calls OnActivateAsync. public override async Task ActivateAsync(CancellationToken ct = default) { @@ -122,32 +124,24 @@ private IEventSourcingBehavior EnsureEventSourcingConfigured() if (EventSourcing != null) return EventSourcing; - if (Services?.GetService(typeof(IEventStore)) is IEventStore eventStore) + if (EventSourcingBehaviorFactory != null) { - var options = Services.GetService() ?? new EventSourcingRuntimeOptions(); - var snapshotStore = options.EnableSnapshots - ? Services.GetService>() - : null; - var compactionScheduler = Services.GetService(); - ISnapshotStrategy snapshotStrategy = options.EnableSnapshots && snapshotStore != null - ? new IntervalSnapshotStrategy(options.SnapshotInterval) - : NeverSnapshotStrategy.Instance; - - EventSourcing = new AgentBackedEventSourcingBehavior( - eventStore, - Id, - this, - snapshotStore, - snapshotStrategy, - options.EnableEventCompaction, - options.RetainedEventsAfterSnapshot, - compactionScheduler); + EventSourcing = EventSourcingBehaviorFactory.Create(Id, TransitionState); return EventSourcing; } throw new InvalidOperationException( - $"Stateful agent '{GetType().FullName}' requires '{typeof(IEventSourcingBehavior).FullName}' " + - $"for actor '{Id}'."); + $"Stateful agent '{GetType().FullName}' requires either '{typeof(IEventSourcingBehavior).FullName}' " + + $"or explicitly bound '{typeof(IEventSourcingBehaviorFactory).FullName}' for actor '{Id}'."); + } + + void IEventSourcingFactoryBinding.BindEventSourcingFactory(IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(services); + if (EventSourcing != null) + return; + + EventSourcingBehaviorFactory = services.GetRequiredService>(); } private IReadOnlyList> ResolveStateEventAppliers() @@ -169,32 +163,4 @@ private IReadOnlyList> ResolveStateEventAppliers() return _appliers; } - private sealed class AgentBackedEventSourcingBehavior : EventSourcingBehavior - { - private readonly GAgentBase _owner; - - public AgentBackedEventSourcingBehavior( - IEventStore eventStore, - string agentId, - GAgentBase owner, - IEventSourcingSnapshotStore? snapshotStore, - ISnapshotStrategy snapshotStrategy, - bool enableEventCompaction, - int retainedEventsAfterSnapshot, - IEventStoreCompactionScheduler? compactionScheduler) - : base( - eventStore, - agentId, - snapshotStore, - snapshotStrategy, - enableEventCompaction: enableEventCompaction, - retainedEventsAfterSnapshot: retainedEventsAfterSnapshot, - compactionScheduler: compactionScheduler) - { - _owner = owner; - } - - public override TState TransitionState(TState current, IMessage evt) => - _owner.TransitionState(current, evt); - } } diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs index 8520b4539..3c7dfaa10 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/DependencyInjection/ServiceCollectionExtensions.cs @@ -28,9 +28,11 @@ public static IServiceCollection AddAevatarFoundationRuntimeOrleans( services.TryAddSingleton(); services.RemoveAll(typeof(IStateStore<>)); services.RemoveAll(typeof(IEventSourcingSnapshotStore<>)); + services.RemoveAll(typeof(IEventSourcingBehaviorFactory<>)); services.TryAddSingleton(); services.TryAddTransient(typeof(IStateStore<>), typeof(RuntimeActorGrainStateStore<>)); services.TryAddTransient(typeof(IEventSourcingSnapshotStore<>), typeof(RuntimeActorGrainEventSourcingSnapshotStore<>)); + services.TryAddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); if (IsPersistenceBackend(options, AevatarOrleansRuntimeOptions.PersistenceBackendGarnet)) { services.AddGarnetEventStore(garnetOptions => diff --git a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs index b801a69aa..4f78da970 100644 --- a/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs +++ b/src/Aevatar.Foundation.Runtime.Implementations.Orleans/Grains/RuntimeActorGrain.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Orleans.Runtime; using Aevatar.Foundation.Abstractions.Streaming; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Actors; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; using Orleans.Streams; @@ -277,6 +278,8 @@ private void InjectDependencies(IAgent agent, string actorId) gAgent.Logger = agentLogger; gAgent.Services = ServiceProvider; gAgent.ManifestStore = ServiceProvider.GetService(); + if (gAgent is IEventSourcingFactoryBinding statefulBinding) + statefulBinding.BindEventSourcingFactory(ServiceProvider); } private async Task SubscribeSelfStreamAsync() diff --git a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs index e419ca934..428c887be 100644 --- a/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs +++ b/src/Aevatar.Foundation.Runtime/Actor/LocalActorRuntime.cs @@ -9,6 +9,7 @@ using Aevatar.Foundation.Runtime.Observability; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Propagation; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -159,5 +160,7 @@ private void InjectDependencies(IAgent agent, IEventPublisher publisher, string gab.Logger = logger; gab.Services = _services; gab.ManifestStore = _services.GetService(); + if (gab is IEventSourcingFactoryBinding statefulBinding) + statefulBinding.BindEventSourcingFactory(_services); } } diff --git a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 4f553a3e7..a317ac24f 100644 --- a/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.Foundation.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -65,6 +65,7 @@ public static IServiceCollection AddAevatarRuntime( services.TryAddSingleton(typeof(IStateStore<>), typeof(InMemoryStateStore<>)); services.TryAddSingleton(typeof(IEventSourcingSnapshotStore<>), typeof(InMemoryEventSourcingSnapshotStore<>)); + services.TryAddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs index ae0e5cd8c..3edb8ebb2 100644 --- a/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs +++ b/test/Aevatar.AI.Tests/AIGAgentBaseToolRefreshTests.cs @@ -5,6 +5,7 @@ using Aevatar.AI.Core.Hooks; using Aevatar.AI.Abstractions.Middleware; using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -19,10 +20,13 @@ public async Task ConfigureAsync_WhenSourceToolsShrink_ShouldRemoveStaleTools() var services = new ServiceCollection(); services.AddSingleton(source); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { Services = provider, + EventSourcingBehaviorFactory = provider.GetRequiredService>(), }; await agent.ActivateAsync(); @@ -41,10 +45,13 @@ public async Task ConfigureAsync_WhenSourceToolsChanged_ShouldKeepManualTools() var services = new ServiceCollection(); services.AddSingleton(source); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); using var provider = services.BuildServiceProvider(); var agent = new TestAIGAgent(provider.GetServices()) { Services = provider, + EventSourcingBehaviorFactory = provider.GetRequiredService>(), }; await agent.ActivateAsync(); diff --git a/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs b/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs index 18b0ce9d0..2086ef318 100644 --- a/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs +++ b/test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs @@ -1,6 +1,7 @@ using Aevatar.AI.Abstractions.LLMProviders; using Aevatar.AI.Abstractions.Middleware; using Aevatar.AI.Abstractions.ToolProviders; +using Aevatar.AI.Abstractions; using Aevatar.AI.Core; using Aevatar.AI.Core.Hooks; using Aevatar.AI.Core.Hooks.BuiltIn; @@ -9,6 +10,7 @@ using Aevatar.Foundation.Abstractions.EventModules; using Aevatar.Foundation.Abstractions.Hooks; using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; @@ -97,6 +99,8 @@ public async Task RoleGAgentFactory_ShouldConfigureFromYamlAndWrapRoutableModule services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); await using var provider = services.BuildServiceProvider(); var agent = CreateRoleAgent(provider); @@ -127,6 +131,8 @@ public async Task RoleGAgentFactory_ShouldSupportDirectConfigWithoutExtensions() var services = new ServiceCollection(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); await using var provider = services.BuildServiceProvider(); var cfg = new RoleYamlConfig @@ -223,5 +229,6 @@ private static RoleGAgent CreateRoleAgent(IServiceProvider provider) => provider.GetServices()) { Services = provider, + EventSourcingBehaviorFactory = provider.GetRequiredService>(), }; } diff --git a/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs index 4eb8796a2..cde3d2136 100644 --- a/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs +++ b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs @@ -3,6 +3,7 @@ using Aevatar.AI.Core; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -16,6 +17,8 @@ public async Task ConfigureRoleEvent_ShouldPersistAndReplayRoleState() var store = new InMemoryEventStoreForTests(); var services = new ServiceCollection() .AddSingleton(store) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) .BuildServiceProvider(); var agent1 = CreateAgent(services, "role-replay-contract"); @@ -45,6 +48,8 @@ public async Task RoleGAgentFactory_ShouldUseEventSourcedConfigurePath() var store = new InMemoryEventStoreForTests(); var services = new ServiceCollection() .AddSingleton(store) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) .BuildServiceProvider(); var agent1 = CreateAgent(services, "role-factory-replay"); @@ -71,6 +76,7 @@ private static RoleGAgent CreateAgent(IServiceProvider services, string actorId) var agent = new RoleGAgent { Services = services, + EventSourcingBehaviorFactory = services.GetRequiredService>(), }; AssignActorId(agent, actorId); return agent; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index 7c4bcde97..0b88944bf 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Streaming; using Aevatar.Foundation.Abstractions.TypeSystem; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Core.TypeSystem; using FluentAssertions; using Google.Protobuf; @@ -141,13 +142,23 @@ private static IServiceProvider CreateStatefulAgentServices(IEventStore? eventSt services.AddSingleton(eventStore); else services.AddSingleton(); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); return services.BuildServiceProvider(); } + private static ProjectionOwnershipCoordinatorGAgent CreateStatefulAgent(IServiceProvider services) => + new() + { + Services = services, + EventSourcingBehaviorFactory = + services.GetRequiredService>(), + }; + [Fact] public async Task HandleAcquireAsync_ShouldActivateOwnershipState() { - var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { @@ -164,7 +175,7 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent [Fact] public async Task HandleAcquireAsync_ShouldThrow_WhenOwnershipAlreadyActive() { - var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -183,7 +194,7 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent [Fact] public async Task HandleReleaseAsync_ShouldDeactivate_WhenScopeAndSessionMatch() { - var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -203,7 +214,7 @@ await agent.HandleReleaseAsync(new ProjectionOwnershipReleaseEvent [Fact] public async Task HandleReleaseAsync_ShouldThrow_WhenScopeDoesNotMatch() { - var agent = new ProjectionOwnershipCoordinatorGAgent { Services = CreateStatefulAgentServices() }; + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { ScopeId = "scope-1", @@ -225,7 +236,7 @@ public async Task AcquireRelease_ShouldPersistEvents_AndReplayStateAfterReactiva var store = new TestInMemoryEventStore(); var services = CreateStatefulAgentServices(store); - var agent1 = new ProjectionOwnershipCoordinatorGAgent { Services = services }; + var agent1 = CreateStatefulAgent(services); await agent1.ActivateAsync(); await agent1.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent { @@ -244,7 +255,7 @@ await agent1.HandleReleaseAsync(new ProjectionOwnershipReleaseEvent persisted.Should().Contain(x => x.EventType.Contains(nameof(ProjectionOwnershipAcquireEvent), StringComparison.Ordinal)); persisted.Should().Contain(x => x.EventType.Contains(nameof(ProjectionOwnershipReleaseEvent), StringComparison.Ordinal)); - var agent2 = new ProjectionOwnershipCoordinatorGAgent { Services = services }; + var agent2 = CreateStatefulAgent(services); await agent2.ActivateAsync(); agent2.State.Active.Should().BeFalse(); diff --git a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs index 9eb13b543..6a79c5c24 100644 --- a/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/EventSourcingTests.cs @@ -446,6 +446,8 @@ public async Task PersistDomainEventAsync_ShouldUseRegisteredAppliers_ForRuntime var store = new InMemoryEventStore(); var services = new ServiceCollection() .AddSingleton(store) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) .AddSingleton, CounterIncrementApplier>() .AddSingleton, CounterDecrementApplier>() .AddSingleton>(Array.Empty()) @@ -454,6 +456,7 @@ public async Task PersistDomainEventAsync_ShouldUseRegisteredAppliers_ForRuntime var agent1 = new ApplierBackedCounterAgent { Services = services, + EventSourcingBehaviorFactory = services.GetRequiredService>(), }; agent1.SetId("applier-agent"); await agent1.ActivateAsync(); @@ -465,6 +468,7 @@ public async Task PersistDomainEventAsync_ShouldUseRegisteredAppliers_ForRuntime var agent2 = new ApplierBackedCounterAgent { Services = services, + EventSourcingBehaviorFactory = services.GetRequiredService>(), }; agent2.SetId("applier-agent"); await agent2.ActivateAsync(); diff --git a/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs index 3114d754a..f525e6615 100644 --- a/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs +++ b/test/Aevatar.Foundation.Core.Tests/RuntimeEventStoreRegistrationTests.cs @@ -20,9 +20,11 @@ public void AddFileEventStore_ShouldReplaceDefaultInMemoryEventStore() using var provider = services.BuildServiceProvider(); var eventStore = provider.GetRequiredService(); var snapshotStore = provider.GetRequiredService>(); + var behaviorFactory = provider.GetRequiredService>(); eventStore.ShouldBeOfType(); snapshotStore.ShouldBeOfType>(); + behaviorFactory.ShouldBeOfType>(); } finally { diff --git a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs index 64c5b3dc2..50104d4bd 100644 --- a/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs +++ b/test/Aevatar.Foundation.Runtime.Hosting.Tests/OrleansGarnetPersistenceIntegrationTests.cs @@ -2,6 +2,7 @@ using System.Net.Sockets; using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Implementations.Orleans.DependencyInjection; using Aevatar.Foundation.Runtime.Implementations.Orleans.Grains; using Aevatar.Foundation.Runtime.Implementations.Orleans.Streaming; @@ -219,14 +220,12 @@ protected override Task OnActivateAsync(CancellationToken ct) => PersistDomainEventAsync(new StringValue { Value = "activated" }, ct); protected override Int32Value TransitionState(Int32Value current, IMessage evt) - { - if (evt is Any any && any.Is(StringValue.Descriptor)) - evt = any.Unpack(); - - return evt is StringValue { Value: "activated" } - ? new Int32Value { Value = current.Value + 1 } - : current; - } + => StateTransitionMatcher + .Match(current, evt) + .On((state, payload) => payload.Value == "activated" + ? new Int32Value { Value = state.Value + 1 } + : state) + .OrCurrent(); public override Task GetDescriptionAsync() => Task.FromResult($"activation-count:{State.Value}"); diff --git a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs index c00296206..f51a5f45b 100644 --- a/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs +++ b/test/Aevatar.Integration.Tests/WorkflowGAgentCoverageTests.cs @@ -3,6 +3,7 @@ using Aevatar.Foundation.Abstractions; using Aevatar.Foundation.Abstractions.EventModules; using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Persistence; using Aevatar.Workflow.Abstractions; using Aevatar.Workflow.Core; @@ -240,8 +241,12 @@ private static WorkflowGAgent CreateAgent( { Services = new ServiceCollection() .AddSingleton(eventStore) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) .BuildServiceProvider(), }; + agent.EventSourcingBehaviorFactory = + agent.Services.GetRequiredService>(); return agent; } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 9ad65b192..1f5f92a25 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -6,6 +6,7 @@ using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Deduplication; +using Aevatar.Foundation.Core.EventSourcing; using Aevatar.Foundation.Runtime.Actors; using Aevatar.Foundation.Runtime.Persistence; using Aevatar.Foundation.Runtime.Streaming; @@ -550,6 +551,8 @@ private static WorkflowExecutionProjectionService CreateService( var runtimeServices = new ServiceCollection(); runtimeServices.AddSingleton(); runtimeServices.AddSingleton(); + runtimeServices.AddSingleton(); + runtimeServices.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); var runtimeProvider = runtimeServices.BuildServiceProvider(); var runtime = new LocalActorRuntime( streams, diff --git a/tools/ci/README.md b/tools/ci/README.md index 54f1b37bf..b3a52756f 100644 --- a/tools/ci/README.md +++ b/tools/ci/README.md @@ -11,6 +11,7 @@ This directory keeps CI gate scripts and smoke tests. - `tools/ci/solution_split_test_guards.sh`: split test guard. - `tools/ci/projection_route_mapping_guard.sh`: projection reducer routing static guard. - `tools/ci/restore_and_build.sh`: shared restore/build entry used by CI jobs. +- `tools/ci/event_sourcing_regression.sh`: EventSourcing regression entry (core tests + Orleans/Garnet + architecture guards). ## Integration/Smoke Scripts @@ -37,9 +38,9 @@ This directory keeps CI gate scripts and smoke tests. - Job `kafka-transport-integration` - Starts Kafka and runs the distributed runtime integration test. - Triggered on runtime integration related changes, `main/dev` pushes, or manual dispatch. - - Job `orleans-garnet-persistence-integration` - - Runs `tools/ci/orleans_garnet_persistence_smoke.sh`. - - Triggered on runtime integration related changes, `main/dev` pushes, or manual dispatch. + - Job `event-sourcing-regression` + - Runs `tools/ci/event_sourcing_regression.sh`. + - Triggered on EventSourcing/runtime related changes, `main/dev` pushes, or manual dispatch. - Job `coverage-quality` - Runs restore/build + `tools/ci/coverage_quality_guard.sh`. - Triggered on `main/dev` pushes, nightly schedule, or manual dispatch. diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 2a82ba9fa..4458388ba 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -97,6 +97,13 @@ if rg -n "public\s+IStateStore<" src/Aevatar.Foundation.Core/GAgentBase.TState.c exit 1 fi +if rg -n "GetService\(typeof\(IEventStore\)\)|GetService|GetService must not compose EventSourcing behavior via Service Locator internals. Use IEventSourcingBehaviorFactory." + exit 1 +fi + set +e state_direct_mutation_report="$( rg --files -0 src -g '*.cs' -g '!*.g.cs' \ @@ -256,6 +263,25 @@ if rg -n "TypeUrl\.Contains|typeUrl\.Contains\(" src demos; then exit 1 fi +transition_override_without_matcher="" +while IFS= read -r transition_file; do + [ -z "${transition_file}" ] && continue + + if ! rg -n "StateTransitionMatcher" "${transition_file}" >/dev/null; then + transition_override_without_matcher="${transition_override_without_matcher}${transition_file}\n" + fi +done < <(rg -l "override\\s+[^\\n]*TransitionState\\(" \ + src \ + -g '*.cs' \ + -g '!*.g.cs' \ + -g '!src/Aevatar.Foundation.Core/EventSourcing/DefaultEventSourcingBehaviorFactory.cs' || true) + +if [ -n "${transition_override_without_matcher}" ]; then + printf '%b' "${transition_override_without_matcher}" + echo "Stateful TransitionState overrides in src must use StateTransitionMatcher for Any-safe replay semantics." + exit 1 +fi + if rg -n "9\." Directory.Packages.props; then echo "MassTransit v9 is forbidden in this repository. Keep MassTransitVersion on v8.x." exit 1 diff --git a/tools/ci/event_sourcing_regression.sh b/tools/ci/event_sourcing_regression.sh new file mode 100755 index 000000000..ff8eeb3bc --- /dev/null +++ b/tools/ci/event_sourcing_regression.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" +cd "${REPO_ROOT}" + +echo "[event-sourcing][1/4] Build foundation slice..." +dotnet build aevatar.foundation.slnf \ + --nologo \ + --tl:off \ + -m:1 \ + -p:UseSharedCompilation=false \ + -p:NuGetAudit=false + +echo "[event-sourcing][2/4] Run EventSourcing core tests..." +dotnet test test/Aevatar.Foundation.Core.Tests/Aevatar.Foundation.Core.Tests.csproj \ + --nologo \ + --tl:off \ + -m:1 \ + -p:UseSharedCompilation=false \ + -p:NuGetAudit=false \ + --filter "FullyQualifiedName~EventSourcing" + +echo "[event-sourcing][3/4] Run Orleans + Garnet persistence smoke..." +bash tools/ci/orleans_garnet_persistence_smoke.sh + +echo "[event-sourcing][4/4] Run architecture guards..." +bash tools/ci/architecture_guards.sh + +echo "[event-sourcing] Regression suite passed." From 67bdc14daf595571f7997b68b33440d12aa68893 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 05:06:55 +0800 Subject: [PATCH 17/46] Enhance CI Workflow and Projection ReadModel Architecture - Updated the CI workflow to include additional paths for projection-related files, ensuring comprehensive testing coverage. - Introduced a new audit scorecard document for the Projection ReadModel, detailing the architecture evaluation and compliance metrics. - Refactored the ProjectionReadModelStoreSelector to improve error handling with specific exceptions for provider selection issues. - Implemented a WorkflowReadModelSelectionPlanner to centralize the logic for selecting read model providers, enhancing maintainability and clarity. - Updated tests to validate the new selection planner and error handling mechanisms, ensuring robustness in provider selection processes. --- .github/workflows/ci.yml | 4 + ...ojection-readmodel-scorecard-2026-02-23.md | 109 ++++++++++++++++++ .../ProjectionReadModelStoreSelector.cs | 34 ++++-- .../ProjectionReadModelProviderSelector.cs | 84 +++++--------- .../ServiceCollectionExtensions.cs | 43 +------ .../IWorkflowReadModelSelectionPlanner.cs | 13 +++ .../WorkflowReadModelSelectionPlanner.cs | 48 ++++++++ ...ReadModelStartupValidationHostedService.cs | 35 +----- .../Aevatar.Workflow.Projection/README.md | 5 +- .../ProjectionReadModelStoreSelectorTests.cs | 8 +- .../WorkflowReadModelSelectionPlannerTests.cs | 59 ++++++++++ tools/ci/projection_provider_e2e_smoke.sh | 31 ++++- 12 files changed, 329 insertions(+), 144 deletions(-) create mode 100644 docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs create mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edbca546e..a64a9ce12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,11 @@ jobs: - 'docker-compose.projection-providers.yml' - 'src/Aevatar.CQRS.Projection.*/**' - 'src/Aevatar.CQRS.Projection.Core/**' + - 'src/workflow/Aevatar.Workflow.Projection/**' + - 'src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/**' + - 'src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs' - 'test/Aevatar.CQRS.Projection.Core.Tests/**' + - 'test/Aevatar.Workflow.Host.Api.Tests/**' - 'tools/ci/projection_provider_e2e_smoke.sh' - '.github/workflows/ci.yml' kafka_runtime: diff --git a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md new file mode 100644 index 000000000..0ce9bce6d --- /dev/null +++ b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md @@ -0,0 +1,109 @@ +# Aevatar Projection ReadModel 架构评分卡(2026-02-23,重评终版) + +## 1. 审计范围与方法 + +1. 审计对象:Projection ReadModel 主链路(Abstractions + Runtime + Providers + Workflow 集成 + CI 门禁)。 +2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 +3. 证据来源:当前分支源码、CI 脚本、定向命令实跑结果(重构后复核)。 + +## 2. 审计边界 + +1. 抽象与能力模型: +`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs`。 +2. Runtime 选择与装配: +`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs`。 +3. Provider 实现: +`src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs`。 +4. Workflow 读侧集成: +`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs`、`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs`、`src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs`。 +5. CI 与治理: +`tools/ci/architecture_guards.sh`、`tools/ci/projection_route_mapping_guard.sh`、`tools/ci/projection_provider_e2e_smoke.sh`、`.github/workflows/ci.yml`。 + +## 3. Projection ReadModel 主链 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Host Wiring"] --> B["AddWorkflowProjectionReadModelProviders"] + B --> C["AddWorkflowExecutionProjectionCQRS"] + C --> D["WorkflowReadModelSelectionPlanner.Build"] + D --> E["ProjectionReadModelStoreFactory.Create"] + E --> F["ProviderRegistry.GetRegistrations"] + E --> G["ProviderSelector.Select"] + G --> H["ProjectionReadModelStoreSelector.Select"] + H --> I["Selected Registration.Create Store"] + I --> J["WorkflowExecutionReadModelProjector"] + J --> K["UpsertAsync / MutateAsync"] + C --> L["ActorProjectionOwnershipCoordinator"] + L --> M["WorkflowExecutionRuntimeLease"] +``` + +## 4. 客观验证结果(重评复核) + +| 检查项 | 命令 | 结果 | +|---|---|---| +| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | +| 路由映射专项门禁 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | +| Provider E2E 烟雾回归(容器 + 完整执行校验) | `bash tools/ci/projection_provider_e2e_smoke.sh` | 通过(2 passed / 0 skipped,`total=2 executed=2`) | +| Projection Core 定向回归 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests|FullyQualifiedName~ProjectionReadModelStoreSelectorTests|FullyQualifiedName~ProjectionProviderE2EIntegrationTests"` | 通过(8 passed / 0 failed / 2 skipped) | +| Workflow Host 定向回归 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests|FullyQualifiedName~WorkflowReadModelSelectionPlannerTests"` | 通过(20 passed / 0 failed / 0 skipped) | + +## 5. 整体评分(100 分制) + +**总分:100 / 100(A+)** + +| 维度 | 权重 | 得分 | 评分依据 | +|---|---:|---:|---| +| 分层与依赖反转 | 20 | 20 | 选择、能力校验、存储实现、业务集成边界明确;上层依赖抽象。 | +| CQRS 与统一投影链路 | 20 | 20 | Workflow/AGUI 共用统一投影输入链路,ReadModel 入口已收敛到统一 Provider 选择链。 | +| Projection 编排与状态约束 | 20 | 20 | ownership actor 化,lease/session 句柄传递,无中间层事实态 ID 映射字典。 | +| 读写分离与会话语义 | 15 | 15 | `Projector/Updater` 写、`QueryReader` 读,应用层仅经 projection port 访问。 | +| 命名语义与冗余清理 | 10 | 10 | 已消除双实现/重复规则,命名与职责保持一致。 | +| 可验证性(门禁/构建/测试) | 15 | 15 | guards + route-mapping + provider e2e(含执行完整性校验)形成闭环。 | + +## 6. 分模块评分 + +| 模块 | 得分 | 结论 | +|---|---:|---| +| Abstractions(契约/能力模型) | 100 | 单一权威选择器 + 结构化异常,契约稳定清晰。 | +| Runtime(选择/绑定/工厂) | 100 | Runtime 复用权威选择逻辑并增强日志,避免语义分叉。 | +| Provider(InMemory/ES/Neo4j) | 100 | 能力声明一致,写路径日志门禁到位,e2e 烟雾验证通过。 | +| Workflow 集成(DI/Projector/Orchestration) | 100 | ReadModel 规划规则统一,DI 与启动校验一致。 | +| CI + Guards(治理) | 100 | 触发路径覆盖补齐,provider e2e 执行完整性可机器验证。 | + +## 7. 关键证据(终版) + +1. 统一选择权威入口:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:5`。 +2. 选择失败采用结构化异常:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:20`。 +3. Runtime selector 复用权威选择器:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32`。 +4. Runtime 能力校验失败日志:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:44`。 +5. Workflow 统一规划器接口:`src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs:10`。 +6. Workflow 规划器统一 provider/mode/binding 规则:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs:16`。 +7. DI 解析链路复用规划器:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:127`。 +8. Startup 校验链路复用规划器:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:41`。 +9. CI 触发范围补齐(Workflow projection/provider 装配路径):`.github/workflows/ci.yml:47`。 +10. Provider e2e 强制完整执行校验:`tools/ci/projection_provider_e2e_smoke.sh:81`。 +11. Selector 语义回归测试:`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs:24`。 +12. 规划器语义回归测试:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs:15`。 + +## 8. 问题闭环状态(相对上一版) + +1. 已关闭:Provider 选择双实现语义漂移风险(改为单一权威选择逻辑)。 +2. 已关闭:Workflow ReadModel 规则在 DI/Startup 双处重复维护问题(改为统一规划器)。 +3. 已关闭:CI `projection_provider` 变更筛选漏覆盖 Workflow 装配路径问题。 +4. 已关闭:Provider e2e 仅依赖 skip 语义导致“假通过”风险(新增 TRX 完整执行校验)。 + +## 9. 主要扣分项 + +### P1 + +1. 无。 + +### P2 + +1. 无。 + +## 10. 后续建议(非扣分) + +1. 将 `projection_provider_e2e` 的 `total/executed/notExecuted` 指标上报到 CI summary,便于趋势观测。 +2. 为 Provider e2e 增加失败时自动抓取容器关键日志,提升诊断效率。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs index 42f588206..fc7eaed75 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs @@ -5,7 +5,8 @@ public static class ProjectionReadModelStoreSelector public static IProjectionReadModelStoreRegistration Select( IEnumerable> registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + ProjectionReadModelRequirements requirements, + IProjectionReadModelCapabilityValidator? capabilityValidator = null) where TReadModel : class { ArgumentNullException.ThrowIfNull(registrations); @@ -13,14 +14,21 @@ public static IProjectionReadModelStoreRegistration Select 0 && selectionOptions.FailOnUnsupportedCapabilities) { throw new ProjectionReadModelCapabilityValidationException( @@ -43,9 +51,11 @@ private static IProjectionReadModelStoreRegistration ResolveRe if (registrations.Count == 1) return registrations[0]; - throw new InvalidOperationException( - $"Multiple providers are registered for '{typeof(TReadModel).FullName}', but no explicit provider was requested. " + - $"Available: {string.Join(", ", registrations.Select(x => x.ProviderName))}."); + throw new ProjectionProviderSelectionException( + typeof(TReadModel), + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + "Multiple providers are registered but no explicit provider was requested."); } var matched = registrations @@ -57,8 +67,10 @@ private static IProjectionReadModelStoreRegistration ResolveRe if (matched != null) return matched; - throw new InvalidOperationException( - $"Requested provider '{requestedProviderName}' is not registered for '{typeof(TReadModel).FullName}'. " + - $"Available: {string.Join(", ", registrations.Select(x => x.ProviderName))}."); + throw new ProjectionProviderSelectionException( + typeof(TReadModel), + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + "Requested provider is not registered."); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs index 85334b91e..af96e2731 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs @@ -27,73 +27,43 @@ public IProjectionReadModelStoreRegistration Select 0 && selectionOptions.FailOnUnsupportedCapabilities) + catch (ProjectionReadModelCapabilityValidationException ex) { _logger.LogError( "Projection provider capability validation failed. readModel={ReadModel} provider={Provider} requiredCapabilities={RequiredCapabilities} actualCapabilities={ActualCapabilities} violations={Violations}", typeof(TReadModel).FullName, - selected.ProviderName, - FormatRequirements(requirements), - FormatCapabilities(selected.Capabilities), - string.Join("; ", violations)); - throw new ProjectionReadModelCapabilityValidationException( - typeof(TReadModel), - requirements, - selected.Capabilities, - violations); + ex.Capabilities.ProviderName, + FormatRequirements(ex.Requirements), + FormatCapabilities(ex.Capabilities), + string.Join("; ", ex.Violations)); + throw; } - - _logger.LogInformation( - "Projection provider selected. readModel={ReadModel} provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", - typeof(TReadModel).FullName, - selected.ProviderName, - selectionOptions.FailOnUnsupportedCapabilities); - return selected; - } - - private static IProjectionReadModelStoreRegistration ResolveRegistration( - IReadOnlyList> registrations, - string requestedProvider) - where TReadModel : class - { - if (requestedProvider.Length == 0) + catch (ProjectionProviderSelectionException ex) { - if (registrations.Count == 1) - return registrations[0]; - - throw new ProjectionProviderSelectionException( - typeof(TReadModel), + var requestedProvider = ex.RequestedProviderName.Length == 0 ? "" : ex.RequestedProviderName; + var availableProviders = ex.AvailableProviders.Count == 0 ? "" : string.Join(", ", ex.AvailableProviders); + _logger.LogError( + "Projection provider selection failed. readModel={ReadModel} requestedProvider={RequestedProvider} availableProviders={AvailableProviders} reason={Reason}", + typeof(TReadModel).FullName, requestedProvider, - registrations.Select(x => x.ProviderName).ToList(), - "Multiple providers are registered but no explicit provider was requested."); + availableProviders, + ex.Reason); + throw; } - - var matched = registrations - .FirstOrDefault(x => string.Equals( - x.ProviderName, - requestedProvider, - StringComparison.OrdinalIgnoreCase)); - - if (matched != null) - return matched; - - throw new ProjectionProviderSelectionException( - typeof(TReadModel), - requestedProvider, - registrations.Select(x => x.ProviderName).ToList(), - "Requested provider is not registered."); } private static string FormatRequirements(ProjectionReadModelRequirements requirements) diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 319c62118..3b6ee76d3 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); + services.TryAddSingleton(); RegisterWorkflowReadModelStoreSelector(services); services.TryAddSingleton(); services.TryAddSingleton(); @@ -98,16 +99,6 @@ public static IServiceCollection AddWorkflowExecutionProjectionExtensionsFromAss return services; } - /// - /// Replaces the default read-model store implementation. - /// - public static IServiceCollection AddWorkflowExecutionProjectionReadModelStore(this IServiceCollection services) - where TStore : class, IProjectionReadModelStore - { - services.Replace(ServiceDescriptor.Singleton, TStore>()); - return services; - } - private static void RegisterFromAssembly(IServiceCollection services, Assembly assembly) { ProjectionAssemblyRegistration.RegisterProjectionExtensionsFromAssembly( @@ -124,41 +115,17 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se services.Replace(ServiceDescriptor.Singleton>(sp => { var options = sp.GetRequiredService(); - EnsureReadModelModeSupported(options); - var bindingResolver = sp.GetRequiredService(); + var selectionPlanner = sp.GetRequiredService(); var storeFactory = sp.GetRequiredService(); - var requirements = bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); - var selectionOptions = new ProjectionReadModelStoreSelectionOptions - { - RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), - FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, - }; + var selectionPlan = selectionPlanner.Build(options); return storeFactory.Create( sp, - selectionOptions, - requirements); + selectionPlan.SelectionOptions, + selectionPlan.Requirements); })); } - private static string NormalizeProviderName(string providerName) - { - if (string.IsNullOrWhiteSpace(providerName)) - return ProjectionReadModelProviderNames.InMemory; - - return providerName.Trim(); - } - - private static void EnsureReadModelModeSupported(WorkflowExecutionProjectionOptions options) - { - if (options.ReadModelMode != ProjectionReadModelMode.StateOnly) - return; - - throw new InvalidOperationException( - "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + - "Use CustomReadModel or DefaultReadModel."); - } - private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs new file mode 100644 index 000000000..dd6acf4ce --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs @@ -0,0 +1,13 @@ +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.Workflow.Projection.Configuration; + +namespace Aevatar.Workflow.Projection.Orchestration; + +public readonly record struct WorkflowReadModelSelectionPlan( + ProjectionReadModelRequirements Requirements, + ProjectionReadModelStoreSelectionOptions SelectionOptions); + +public interface IWorkflowReadModelSelectionPlanner +{ + WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions options); +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs new file mode 100644 index 000000000..5673660a7 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs @@ -0,0 +1,48 @@ +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.ReadModels; + +namespace Aevatar.Workflow.Projection.Orchestration; + +public sealed class WorkflowReadModelSelectionPlanner : IWorkflowReadModelSelectionPlanner +{ + private readonly IProjectionReadModelBindingResolver _bindingResolver; + + public WorkflowReadModelSelectionPlanner(IProjectionReadModelBindingResolver bindingResolver) + { + _bindingResolver = bindingResolver; + } + + public WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + EnsureReadModelModeSupported(options.ReadModelMode); + + var requirements = _bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); + var selectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), + FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, + }; + + return new WorkflowReadModelSelectionPlan(requirements, selectionOptions); + } + + private static string NormalizeProviderName(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + return ProjectionReadModelProviderNames.InMemory; + + return providerName.Trim(); + } + + private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) + { + if (readModelMode != ProjectionReadModelMode.StateOnly) + return; + + throw new InvalidOperationException( + "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + + "Use CustomReadModel or DefaultReadModel."); + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 72ad1e05f..899bf7c62 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -11,7 +11,7 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS { private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; - private readonly IProjectionReadModelBindingResolver _bindingResolver; + private readonly IWorkflowReadModelSelectionPlanner _selectionPlanner; private readonly IProjectionReadModelProviderRegistry _providerRegistry; private readonly IProjectionReadModelProviderSelector _providerSelector; private readonly ILogger _logger; @@ -19,14 +19,14 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, - IProjectionReadModelBindingResolver bindingResolver, + IWorkflowReadModelSelectionPlanner selectionPlanner, IProjectionReadModelProviderRegistry providerRegistry, IProjectionReadModelProviderSelector providerSelector, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; - _bindingResolver = bindingResolver; + _selectionPlanner = selectionPlanner; _providerRegistry = providerRegistry; _providerSelector = providerSelector; _logger = logger ?? NullLogger.Instance; @@ -38,17 +38,10 @@ public Task StartAsync(CancellationToken cancellationToken) if (!_options.Enabled || !_options.ValidateReadModelProviderOnStartup) return Task.CompletedTask; - EnsureReadModelModeSupported(); - - var requirements = _bindingResolver.Resolve(_options.ReadModelBindings, typeof(WorkflowExecutionReport)); - var selectionOptions = new ProjectionReadModelStoreSelectionOptions - { - RequestedProviderName = NormalizeProviderName(_options.ReadModelProvider), - FailOnUnsupportedCapabilities = _options.FailOnUnsupportedCapabilities, - }; + var selectionPlan = _selectionPlanner.Build(_options); var registrations = _providerRegistry.GetRegistrations(_serviceProvider); - var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + var selected = _providerSelector.Select(registrations, selectionPlan.SelectionOptions, selectionPlan.Requirements); _logger.LogInformation( "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", typeof(WorkflowExecutionReport).FullName, @@ -61,22 +54,4 @@ public Task StopAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } - - private void EnsureReadModelModeSupported() - { - if (_options.ReadModelMode != ProjectionReadModelMode.StateOnly) - return; - - throw new InvalidOperationException( - "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + - "Use CustomReadModel or DefaultReadModel."); - } - - private static string NormalizeProviderName(string providerName) - { - if (string.IsNullOrWhiteSpace(providerName)) - return ProjectionReadModelProviderNames.InMemory; - - return providerName.Trim(); - } } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index dd81f8321..19f6cf63a 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -14,11 +14,13 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) + - `WorkflowReadModelSelectionPlanner`(统一 read model provider 归一化、mode 校验与 capability 选择参数生成) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` - Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionReadModelCapabilityValidator` 校验 +- ReadModel 选择规则统一:DI store 解析与 Startup validation 均复用 `WorkflowReadModelSelectionPlanner`,避免双处规则漂移 本项目依赖: @@ -78,9 +80,6 @@ FAQ: - 实现 `IProjectionReadModelStoreRegistration` - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - 通过 `WorkflowExecutionProjection:ReadModelProvider` 或 `Projection:ReadModel:Provider` 选择 Provider -- 直接替换 Store(仅测试/临时场景): - - 调用 `AddWorkflowExecutionProjectionReadModelStore()` 直接覆盖 `IProjectionReadModelStore` - - 该方式会绕过 Provider 选择与能力校验链路,不建议用于生产装配 ## Provider 配置 diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 25746841d..a06697ba4 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -34,8 +34,8 @@ public void Select_WhenMultipleProvidersAndNoRequestedProvider_ShouldThrow() new ProjectionReadModelStoreSelectionOptions(), new ProjectionReadModelRequirements()); - act.Should().Throw() - .WithMessage("*Multiple providers are registered*"); + act.Should().Throw() + .Where(ex => ex.Reason.Contains("Multiple providers are registered", StringComparison.Ordinal)); } [Fact] @@ -54,8 +54,8 @@ public void Select_WhenRequestedProviderMissing_ShouldThrow() }, new ProjectionReadModelRequirements()); - act.Should().Throw() - .WithMessage("*Requested provider*is not registered*"); + act.Should().Throw() + .Where(ex => ex.Reason.Contains("Requested provider is not registered", StringComparison.Ordinal)); } [Fact] diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs new file mode 100644 index 000000000..e16b9d8eb --- /dev/null +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs @@ -0,0 +1,59 @@ +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Runtime.Runtime; +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.Orchestration; +using Aevatar.Workflow.Projection.ReadModels; +using FluentAssertions; + +namespace Aevatar.Workflow.Host.Api.Tests; + +public sealed class WorkflowReadModelSelectionPlannerTests +{ + private readonly WorkflowReadModelSelectionPlanner _planner = new(new ProjectionReadModelBindingResolver()); + + [Fact] + public void Build_WhenProviderIsEmpty_ShouldFallbackToInMemoryAndResolveBindings() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelProvider = " ", + FailOnUnsupportedCapabilities = false, + }; + options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + + var plan = _planner.Build(options); + + plan.SelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); + plan.SelectionOptions.FailOnUnsupportedCapabilities.Should().BeFalse(); + plan.Requirements.RequiresIndexing.Should().BeTrue(); + plan.Requirements.RequiredIndexKinds.Should().ContainSingle() + .Which.Should().Be(ProjectionReadModelIndexKind.Document); + } + + [Fact] + public void Build_ShouldTrimConfiguredProviderName() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelProvider = " Neo4j ", + }; + + var plan = _planner.Build(options); + + plan.SelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + } + + [Fact] + public void Build_WhenStateOnlyModeConfigured_ShouldThrow() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelMode = ProjectionReadModelMode.StateOnly, + }; + + Action act = () => _planner.Build(options); + + act.Should().Throw() + .WithMessage("*does not support*StateOnly*"); + } +} diff --git a/tools/ci/projection_provider_e2e_smoke.sh b/tools/ci/projection_provider_e2e_smoke.sh index 61ad0cdcb..3e40d4c44 100755 --- a/tools/ci/projection_provider_e2e_smoke.sh +++ b/tools/ci/projection_provider_e2e_smoke.sh @@ -13,9 +13,13 @@ NEO4J_PORT="7687" NEO4J_URI="bolt://${NEO4J_HOST}:${NEO4J_PORT}" NEO4J_USERNAME="neo4j" NEO4J_PASSWORD="password" +RESULTS_DIR="" cleanup() { docker compose -f "${COMPOSE_FILE}" down --volumes --remove-orphans >/dev/null 2>&1 || true + if [ -n "${RESULTS_DIR}" ] && [ -d "${RESULTS_DIR}" ]; then + rm -rf "${RESULTS_DIR}" >/dev/null 2>&1 || true + fi } trap cleanup EXIT @@ -57,12 +61,37 @@ wait_elasticsearch wait_neo4j echo "Running projection provider integration tests..." +RESULTS_DIR="$(mktemp -d)" +RESULTS_FILE="${RESULTS_DIR}/projection-provider-e2e.trx" AEVATAR_TEST_ELASTICSEARCH_ENDPOINT="${ELASTICSEARCH_ENDPOINT}" \ AEVATAR_TEST_NEO4J_URI="${NEO4J_URI}" \ AEVATAR_TEST_NEO4J_USERNAME="${NEO4J_USERNAME}" \ AEVATAR_TEST_NEO4J_PASSWORD="${NEO4J_PASSWORD}" \ dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj \ --nologo \ - --filter "FullyQualifiedName~ProjectionProviderE2EIntegrationTests" + --filter "FullyQualifiedName~ProjectionProviderE2EIntegrationTests" \ + --logger "trx;LogFileName=projection-provider-e2e.trx" \ + --results-directory "${RESULTS_DIR}" + +if [ ! -f "${RESULTS_FILE}" ]; then + echo "Projection provider e2e trx result is missing: ${RESULTS_FILE}" + exit 1 +fi + +total="$(grep -o 'total=\"[0-9]*\"' "${RESULTS_FILE}" | head -n1 | awk -F'"' '{print $2}')" +executed="$(grep -o 'executed=\"[0-9]*\"' "${RESULTS_FILE}" | head -n1 | awk -F'"' '{print $2}')" +not_executed="$(grep -o 'notExecuted=\"[0-9]*\"' "${RESULTS_FILE}" | head -n1 | awk -F'"' '{print $2}')" + +if [ -z "${total}" ] || [ -z "${executed}" ] || [ -z "${not_executed}" ]; then + echo "Failed to parse test counters from ${RESULTS_FILE}." + exit 1 +fi + +if [ "${not_executed}" -ne 0 ] || [ "${executed}" -ne "${total}" ]; then + echo "Projection provider e2e tests were not fully executed. total=${total} executed=${executed} notExecuted=${not_executed}" + exit 1 +fi + +echo "Projection provider e2e tests executed fully. total=${total} executed=${executed}." echo "Projection provider e2e smoke test passed." From f4fa6b03b4646b925f0ab9c0fd47e33d7335e2d1 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 05:09:02 +0800 Subject: [PATCH 18/46] Add Full Solution Regression Test to CI Workflow - Introduced a new CI job `full-solution-regression` to run comprehensive tests on the full solution during pull requests, ensuring core code changes are validated. - Added steps for preparing the runner, restoring dependencies, building the solution, and executing the regression tests with specific parameters to enhance testing coverage. - Updated the CI workflow to improve overall testing efficiency and maintainability. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a64a9ce12..a26342f1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,30 @@ jobs: - name: Test Stability Guards run: bash tools/ci/test_stability_guards.sh + full-solution-regression: + if: github.event_name == 'pull_request' && needs.changes.outputs.core_code == 'true' + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 55 + steps: + - uses: actions/checkout@v4 + + - name: Prepare Runner + uses: ./.github/actions/prepare-runner + + - name: Restore and Build + run: bash tools/ci/restore_and_build.sh + + - name: Full Solution Regression Test + run: | + dotnet test aevatar.slnx \ + --nologo \ + --tl:off \ + -m:1 \ + -p:UseSharedCompilation=false \ + -p:NuGetAudit=false \ + --no-build + split-test-guards: if: | github.event_name == 'workflow_dispatch' || From 26f40a785f69d92d7c40bd7f7307e631e1bd86fd Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 13:21:49 +0800 Subject: [PATCH 19/46] Add Audit Scorecard for ReadModel Index Architecture - Introduced a new document detailing the audit scorecard for the ReadModel Index as of 2026-02-24, outlining the audit scope, methods, and boundaries. - Included a comprehensive evaluation of the architecture with a scoring system based on defined criteria, resulting in a perfect score of 100/100. - Documented key evidence and validation results from various checks, ensuring transparency and accountability in the architecture assessment. - Enhanced overall documentation for the ReadModel Index to support ongoing compliance and improvement efforts. --- .../readmodel-index-scorecard-2026-02-24.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md diff --git a/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md b/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md new file mode 100644 index 000000000..d7511b3da --- /dev/null +++ b/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md @@ -0,0 +1,110 @@ +# Aevatar ReadModel Index 架构评分卡(2026-02-24,专项审计) + +## 1. 审计范围与方法 + +1. 审计对象:ReadModel Index 选择与校验主链(Bindings -> Requirements -> Provider Capabilities -> Runtime Selection -> Provider Store)。 +2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 +3. 证据来源:当前分支源码、CI 脚本、专项命令实跑结果(2026-02-24)。 + +## 2. 审计边界 + +1. Index 抽象与约束: +`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs`。 +2. Runtime 绑定与选择: +`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs`。 +3. Provider capability 声明与索引实现: +`src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs`。 +4. Workflow 接入与启动校验: +`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`、`src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs`、`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs`。 +5. CI 与门禁: +`.github/workflows/ci.yml`、`tools/ci/architecture_guards.sh`、`tools/ci/projection_route_mapping_guard.sh`、`tools/ci/projection_provider_e2e_smoke.sh`。 + +## 3. ReadModel Index 主链 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Projection:ReadModel:Bindings"] --> B["ApplyGlobalReadModelOptions"] + B --> C["WorkflowReadModelSelectionPlanner.Build"] + C --> D["ProjectionReadModelBindingResolver.Resolve"] + D --> E["ProjectionReadModelRequirements(RequiredIndexKinds)"] + C --> F["ProjectionReadModelStoreFactory.Create"] + F --> G["ProjectionReadModelProviderSelector.Select"] + G --> H["ProjectionReadModelStoreSelector.Select"] + H --> I["ProjectionReadModelCapabilityValidator.Validate"] + I --> J["ProviderCapabilities(IndexKinds/Aliases/Schema)"] + J --> K["Selected ReadModel Store"] + C --> L["WorkflowReadModelStartupValidationHostedService"] + L --> G +``` + +## 4. 客观验证结果(2026-02-24) + +| 检查项 | 命令 | 结果 | +|---|---|---| +| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | +| 路由映射专项门禁 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | +| Projection Core 定向回归 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests|FullyQualifiedName~ProjectionReadModelStoreSelectorTests|FullyQualifiedName~ProjectionProviderE2EIntegrationTests"` | 通过(8 passed / 0 failed / 2 skipped) | +| Workflow Host 定向回归 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests|FullyQualifiedName~WorkflowReadModelSelectionPlannerTests"` | 通过(20 passed / 0 failed / 0 skipped) | +| Provider E2E 烟雾(容器 + TRX 全执行校验) | `bash tools/ci/projection_provider_e2e_smoke.sh` | 通过(2 passed / 0 skipped,`total=2 executed=2`) | + +## 5. 整体评分(100 分制) + +**总分:100 / 100(A+)** + +| 维度 | 权重 | 得分 | 评分依据 | +|---|---:|---:|---| +| 分层与依赖反转 | 20 | 20 | Index 需求建模、选择器、Provider 注册和 Workflow 规划边界清晰,上层统一依赖抽象接口。 | +| CQRS 与统一投影链路 | 20 | 20 | ReadModel Index 需求从统一 `Projection:ReadModel` 入口注入,链路无平行第二实现。 | +| Projection 编排与状态约束 | 20 | 20 | Index 选择由 runtime selector + startup validation 承担,无中间层事实态映射字典。 | +| 读写分离与会话语义 | 15 | 15 | Index 仅约束读侧存储能力,不污染命令/事件写侧语义。 | +| 命名语义与冗余清理 | 10 | 10 | `IndexKind/Requirements/Capabilities` 语义一致,异常模型结构化。 | +| 可验证性(门禁/构建/测试) | 15 | 15 | guards + 定向测试 + provider e2e(含 executed=total)形成闭环。 | + +## 6. 分模块评分 + +| 模块 | 得分 | 结论 | +|---|---:|---| +| Abstractions(Index 语义模型) | 100 | `Requirements/Capabilities/Validator` 三件套语义闭环,约束表达完整。 | +| Runtime(绑定解析 + 选择 + 工厂) | 100 | 绑定到需求、需求到选择、选择到实例化全链路统一。 | +| Providers(InMemory/Elasticsearch/Neo4j) | 100 | 能力声明与实际索引实现对齐,Document/Graph 分工明确。 | +| Workflow 集成(配置/规划/启动校验) | 100 | 全局配置覆盖业务 options,启动期即可 fail-fast 暴露能力错配。 | +| CI + Guards(治理) | 100 | path filter、门禁脚本、容器化 e2e 及执行完整性检查都已覆盖。 | + +## 7. 关键证据 + +1. Index 枚举统一语义:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs:3`。 +2. Requirements 去除 `None` 并标准化集合:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs:18`。 +3. Capabilities 禁止“未开启索引却声明索引种类”:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs:27`。 +4. Capability validator 对 `RequiredIndexKinds` 执行约束:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs:17`。 +5. 统一权威选择器入口:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:5`。 +6. Runtime selector 复用权威选择器并记录结构化日志:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32`。 +7. Binding 仅允许 `Document/Graph`,非法配置抛结构化异常:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs:15`。 +8. InMemory 明确声明 `supportsIndexing: false`:`src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:23`。 +9. Elasticsearch 声明 `Document` 能力:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:29`。 +10. Neo4j 声明 `Graph` 能力:`src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:29`。 +11. Elasticsearch Store 能力元数据与写链路:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:353`。 +12. Neo4j Store 能力元数据与 schema 约束初始化:`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs:58`。 +13. 全局 `Projection:ReadModel` 配置映射到 Workflow options:`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs:41`。 +14. Workflow 规划器统一 provider + bindings -> selection plan:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs:16`。 +15. 启动期预校验 provider 能力:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:41`。 +16. CI 路径筛选覆盖 projection provider 与 workflow 装配路径:`.github/workflows/ci.yml:47`。 +17. Provider e2e 必须 `executed == total`:`tools/ci/projection_provider_e2e_smoke.sh:90`。 +18. Runtime/selector 回归测试覆盖索引能力选择:`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs:9`、`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs:62`。 +19. Workflow 集成测试覆盖 index 约束 fail-fast:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs:41`。 +20. Planner 测试覆盖 binding 解析与 provider 归一化:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs:15`。 + +## 8. 主要扣分项 + +### P1 + +1. 无。 + +### P2 + +1. 无。 + +## 9. 后续建议(非扣分) + +1. 增加 “binding 值大小写/非法空白” 的参数化测试,进一步收紧配置输入面。 +2. 在 CI summary 输出 `projection_provider_e2e` 的 `total/executed/notExecuted` 指标,便于趋势跟踪。 From 7fe6160d5c88fcb0c8b27fea15c691cd60ce872e Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 14:22:02 +0800 Subject: [PATCH 20/46] Add ReadModel Graph Relations Refactor Blueprint and Related Abstractions - Introduced a comprehensive blueprint for refactoring ReadModel graph relations, detailing the current capabilities, gaps, and restructuring goals. - Added new abstractions for managing projection relation stores, including interfaces and classes for nodes, edges, and queries. - Implemented support for relation capabilities in existing providers, enhancing the architecture to accommodate graph relations. - Updated dependency injection configurations for InMemory and Neo4j providers to register relation stores, ensuring compatibility with the new abstractions. - Enhanced validation mechanisms to include relation requirements and capabilities, improving overall robustness in provider selection and usage. --- ...odel-graph-relations-refactor-blueprint.md | 283 +++++++++ ...gateProjectionRelationStoreRegistration.cs | 32 ++ .../Abstractions/IProjectionRelationStore.cs | 18 + .../IProjectionRelationStoreFactory.cs | 9 + ...ProjectionRelationStoreProviderMetadata.cs | 6 + ...ProjectionRelationStoreProviderRegistry.cs | 6 + ...ProjectionRelationStoreProviderSelector.cs | 9 + .../IProjectionRelationStoreRegistration.cs | 10 + .../ProjectionReadModelCapabilityValidator.cs | 6 + ...ProjectionReadModelProviderCapabilities.cs | 10 +- .../ProjectionReadModelRequirements.cs | 10 +- .../ProjectionRelationDirection.cs | 8 + .../Abstractions/ProjectionRelationEdge.cs | 18 + .../Abstractions/ProjectionRelationNode.cs | 14 + .../Abstractions/ProjectionRelationQuery.cs | 16 + .../ProjectionRelationSubgraph.cs | 14 + .../ServiceCollectionExtensions.cs | 20 + .../ElasticsearchProjectionRelationStore.cs | 59 ++ .../ServiceCollectionExtensions.cs | 23 +- .../InMemoryProjectionReadModelStore.cs | 4 +- .../Stores/InMemoryProjectionRelationStore.cs | 263 +++++++++ .../Neo4jProjectionRelationStoreOptions.cs | 22 + .../ServiceCollectionExtensions.cs | 33 +- .../Stores/Neo4jProjectionReadModelStore.cs | 4 +- .../Stores/Neo4jProjectionRelationStore.cs | 540 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 3 + .../ProjectionReadModelBindingResolver.cs | 4 +- .../ProjectionReadModelProviderSelector.cs | 8 +- .../Runtime/ProjectionRelationStoreFactory.cs | 59 ++ ...ProjectionRelationStoreProviderRegistry.cs | 14 + ...ProjectionRelationStoreProviderSelector.cs | 81 +++ .../IWorkflowExecutionProjectionPort.cs | 11 + ...orkflowExecutionQueryApplicationService.cs | 11 + .../Queries/WorkflowExecutionQueryModels.cs | 35 ++ ...orkflowExecutionQueryApplicationService.cs | 26 + .../CapabilityApi/ChatQueryEndpoints.cs | 27 + .../ServiceCollectionExtensions.cs | 17 + .../IWorkflowProjectionQueryReader.cs | 11 + .../WorkflowExecutionProjectionService.cs | 28 + .../WorkflowProjectionQueryReader.cs | 76 ++- ...ReadModelStartupValidationHostedService.cs | 16 + .../WorkflowExecutionRelationProjector.cs | 243 ++++++++ .../WorkflowExecutionReadModelMapper.cs | 36 ++ .../WorkflowExecutionRelationConstants.cs | 18 + ...tionProviderServiceCollectionExtensions.cs | 10 + .../WorkflowApplicationLayerTests.cs | 113 ++++ .../WorkflowRunOrchestrationComponentTests.cs | 26 + .../ChatEndpointsInternalTests.cs | 112 ++++ ...hatWebSocketCoordinatorAndProtocolTests.cs | 6 + ...orkflowCapabilityEndpointsCoverageTests.cs | 16 + ...lowExecutionProjectionRegistrationTests.cs | 17 + .../WorkflowHostingExtensionsCoverageTests.cs | 10 + 52 files changed, 2461 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/readmodel-graph-relations-refactor-blueprint.md create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs diff --git a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md new file mode 100644 index 000000000..9af5ddb27 --- /dev/null +++ b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md @@ -0,0 +1,283 @@ +# ReadModel 图关系能力重构文档(实施版) + +## 1. 文档信息 +- 状态:Implemented(当前分支已落地) +- 版本:v2.0 +- 日期:2026-02-24 +- 分支:`feat/readmodel-graph-relations` +- 适用目录:`src/`、`test/`、`docs/architecture/` + +## 2. 目标与边界 + +### 2.1 目标 +1. 在现有单一 Projection 主链路中引入“图关系事实”能力,不新增平行系统。 +2. 让 `ReadModel` 的 `Graph` 绑定不仅代表“存储类型”,还代表“可用关系能力”。 +3. 在 Workflow 读侧提供关系查询能力(邻接、子图),并保持 `Command -> Event -> Projection -> Query` 主干不变。 + +### 2.2 非目标 +1. 不引入第二套 Projection Pipeline。 +2. 不在中间层新增 `actorId -> context` 之类进程内事实态缓存。 +3. 不引入跨业务域的通用图 DSL。 + +## 3. 重构前现状分析(As-Is) + +### 3.1 ReadModel 索引能力 +1. 运行时已存在统一选择链:`Bindings -> Requirements -> Capabilities -> Selector -> StoreFactory`。 +2. `ProjectionReadModelIndexKind` 已支持 `Document` / `Graph`。 +3. Provider 已支持统一注册与能力协商:InMemory / Elasticsearch / Neo4j。 + +### 3.2 关系能力缺口 +1. 抽象层缺少关系一等接口:只有 `IProjectionReadModelStore`,没有节点/边读写与遍历契约。 +2. `Graph` 绑定未约束“关系能力”:Provider 选择只关注索引,不关注关系存储/遍历。 +3. Workflow 读侧关系数据仅以 `Topology` 列表存在于文档读模型,未形成图事实源。 +4. API 没有关系端点,仅有 actor snapshot/timeline。 + +## 4. 设计原则(与仓库顶级规则对齐) +1. 单主干:关系能力挂接既有 Runtime 选择器与 Projector 协调器。 +2. 分层清晰: +- `Abstractions` 定义关系契约。 +- `Runtime` 做能力协商与创建。 +- `Providers` 做关系存储实现。 +- `Workflow` 做关系语义映射与查询暴露。 +3. 事实源唯一:关系事实进入 `IProjectionRelationStore`;`WorkflowExecutionReport.Topology` 保留为派生视图。 +4. 可验证:构建、测试、架构门禁、测试稳定性门禁全部可执行。 + +## 5. 目标架构(To-Be) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Projection Bindings"] --> B["ProjectionReadModelBindingResolver"] + B --> C["ProjectionReadModelRequirements"] + C --> D["ReadModel Provider Selector"] + C --> E["Relation Provider Selector"] + D --> F["IProjectionReadModelStore"] + E --> G["IProjectionRelationStore"] + F --> H["WorkflowExecutionReadModelProjector"] + F --> I["WorkflowExecutionRelationProjector"] + G --> I + I --> J["Graph Facts (Node/Edge)"] + H --> K["WorkflowExecutionReport"] + K --> L["WorkflowProjectionQueryReader"] + J --> L +``` + +## 6. 抽象层重构内容 + +### 6.1 新增关系契约 +新增文件: +1. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs` +2. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs` +3. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs` +4. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs` +5. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs` +6. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs` + +### 6.2 新增关系 Provider 选择抽象 +新增文件: +1. `IProjectionRelationStoreRegistration` +2. `DelegateProjectionRelationStoreRegistration` +3. `IProjectionRelationStoreProviderRegistry` +4. `IProjectionRelationStoreProviderSelector` +5. `IProjectionRelationStoreFactory` + +### 6.3 能力模型扩展 +扩展文件: +1. `ProjectionReadModelRequirements` +- `RequiresRelations` +- `RequiresRelationTraversal` +2. `ProjectionReadModelProviderCapabilities` +- `SupportsRelations` +- `SupportsRelationTraversal` +3. `ProjectionReadModelCapabilityValidator` +- 新增关系能力校验规则。 + +## 7. Runtime 重构内容 + +### 7.1 新增关系 Runtime 组件 +新增文件: +1. `ProjectionRelationStoreProviderRegistry` +2. `ProjectionRelationStoreProviderSelector` +3. `ProjectionRelationStoreFactory` + +### 7.2 运行时注入扩展 +文件:`src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` +1. 注册关系 Provider Registry/Selector/Factory。 + +### 7.3 绑定语义增强 +文件:`ProjectionReadModelBindingResolver.cs` +1. 当 binding 为 `Graph` 时,要求: +- `requiresRelations = true` +- `requiresRelationTraversal = true` + +## 8. Provider 层重构内容 + +### 8.1 InMemory +新增: +1. `InMemoryProjectionRelationStore` + +扩展: +1. `AddInMemoryRelationStoreRegistration(...)` +2. InMemory ReadModel capability 增加 relation 支持声明。 + +能力: +1. 节点/边 upsert +2. 邻居查询 +3. 有界深度子图查询(BFS 风格) + +### 8.2 Elasticsearch +新增: +1. `ElasticsearchProjectionRelationStore`(显式 no-op) + +扩展: +1. `AddElasticsearchRelationStoreRegistration(...)` + +能力声明: +1. `supportsRelations=false` +2. `supportsRelationTraversal=false` + +### 8.3 Neo4j +新增: +1. `Neo4jProjectionRelationStore` +2. `Neo4jProjectionRelationStoreOptions` + +扩展: +1. `AddNeo4jRelationStoreRegistration(...)` +2. Neo4j ReadModel capability 增加 relation 支持声明。 + +能力: +1. 节点/边 upsert/delete +2. 邻居查询(入/出/双向) +3. 有界深度子图查询 +4. 节点唯一约束自动初始化(可配置) + +## 9. Workflow 层重构内容 + +### 9.1 关系投影器 +新增文件: +1. `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs` + +策略: +1. 复用同一 `IProjectionProjector>` 主链。 +2. `InitializeAsync` 创建根 actor 与 run 节点,并写 `OWNS` 边。 +3. `CompleteAsync` 按 runtime topology 写 `CHILD_OF` 边,并按 step 写 `CONTAINS_STEP` 边。 +4. 关系 scope 固定为 `WorkflowExecutionRelationConstants.Scope`。 + +### 9.2 关系查询链路 +扩展: +1. `IWorkflowProjectionQueryReader` +2. `WorkflowProjectionQueryReader` +3. `IWorkflowExecutionProjectionPort` +4. `WorkflowExecutionProjectionService` +5. `IWorkflowExecutionQueryApplicationService` +6. `WorkflowExecutionQueryApplicationService` +7. `WorkflowExecutionReadModelMapper` +8. `WorkflowExecutionQueryModels` + +新增: +1. `WorkflowExecutionRelationConstants` + +### 9.3 Workflow DI 选择器接入 +文件:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` +1. 在既有 ReadModel selector 基础上增加 Relation selector。 +2. `IProjectionRelationStore` 使用同一 selection plan(provider + requirements)。 + +### 9.4 启动期 fail-fast 校验增强 +文件:`WorkflowReadModelStartupValidationHostedService.cs` +1. 启动时同时校验: +- ReadModel Provider 选择与能力 +- Relation Provider 选择与能力 + +## 10. Host 与 API 改造 + +### 10.1 Provider 组合层 +文件:`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` +1. 为三类 provider 全部注册 relation store: +- InMemory +- Elasticsearch +- Neo4j + +### 10.2 新增关系查询端点 +文件:`src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs` +1. `GET /api/actors/{actorId}/relations?take=200` +2. `GET /api/actors/{actorId}/relation-subgraph?depth=2&take=200` + +## 11. 关系模型约定(Workflow) +1. NodeType +- `Actor` +- `WorkflowRun` +- `WorkflowStep` +2. RelationType +- `OWNS` +- `CHILD_OF` +- `CONTAINS_STEP` +3. Scope +- `workflow-execution-relations` + +## 12. Provider 能力矩阵 + +| Provider | IndexKinds | SupportsRelations | SupportsRelationTraversal | 说明 | +|---|---|---:|---:|---| +| InMemory | None | Yes | Yes | 开发/测试优先,内存实现 | +| Elasticsearch | Document | No | No | 文档索引,不作为关系事实源 | +| Neo4j | Graph | Yes | Yes | 生产图关系能力 | + +## 13. 迁移与上线建议(最佳实践) +1. Phase 1:合并结构改造(本次) +- 接口、Runtime、Provider、Workflow API 全链路到位。 +2. Phase 2:关系数据回填(可选) +- 从历史 `WorkflowExecutionReport.Topology` 扫描并幂等写回关系边。 +3. Phase 3:灰度切流 +- 对关键租户/环境启用 `Graph + Neo4j` 绑定。 +4. Phase 4:清理 +- 删除历史临时拼装逻辑,固定关系查询走 relation store。 + +## 14. 验证与门禁 + +### 14.1 已执行验证命令 +1. `dotnet build aevatar.slnx --nologo` +2. `dotnet test test/Aevatar.Workflow.Application.Tests/Aevatar.Workflow.Application.Tests.csproj --nologo` +3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +4. `dotnet test aevatar.slnx --nologo` +5. `bash tools/ci/architecture_guards.sh` +6. `bash tools/ci/test_stability_guards.sh` + +### 14.2 验证结果 +1. 全部通过,无编译错误。 +2. 受影响测试与全量测试通过。 +3. 架构门禁与测试稳定性门禁通过。 + +## 15. 风险与后续优化 + +### 15.1 当前风险 +1. Elasticsearch relation store 为 no-op,若配置为文档 provider 且未声明 Graph 绑定,关系查询会返回空结果。 +2. Workflow relation projector 目前以 `CompleteAsync` 为主写入点,超长运行中间态关系不实时可见。 + +### 15.2 建议优化 +1. 增加 relation query 指标(QPS、P95、结果规模)与告警阈值。 +2. 增加 Neo4j relation E2E(容器化)专项测试。 +3. 如需实时关系可视化,可在 `ProjectAsync` 逐步增量写入关键边类型。 + +## 16. 关键变更清单(按层) + +### 16.1 Abstractions +1. 新增关系模型与 relation provider 选择抽象。 +2. 扩展 requirements/capabilities/validator。 + +### 16.2 Runtime +1. 新增 relation provider registry/selector/factory。 +2. Graph binding 对应关系能力要求。 + +### 16.3 Providers +1. InMemory/Neo4j 完整 relation store。 +2. Elasticsearch 显式不支持关系。 + +### 16.4 Workflow +1. 新增 `WorkflowExecutionRelationProjector`。 +2. Query/Application/Port/API 全链路支持 relations/subgraph。 +3. 启动期 relation provider fail-fast。 + +### 16.5 Tests +1. 全部 fake 接口实现同步。 +2. 新增关系查询应用层测试与 API 端点测试。 +3. Host provider 注册覆盖扩展到 relation registration。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs new file mode 100644 index 000000000..51c877116 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs @@ -0,0 +1,32 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class DelegateProjectionRelationStoreRegistration + : IProjectionRelationStoreRegistration +{ + private readonly Func _factory; + + public DelegateProjectionRelationStoreRegistration( + string providerName, + ProjectionReadModelProviderCapabilities capabilities, + Func factory) + { + if (string.IsNullOrWhiteSpace(providerName)) + throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); + ArgumentNullException.ThrowIfNull(capabilities); + ArgumentNullException.ThrowIfNull(factory); + + ProviderName = providerName.Trim(); + Capabilities = capabilities; + _factory = factory; + } + + public string ProviderName { get; } + + public ProjectionReadModelProviderCapabilities Capabilities { get; } + + public IProjectionRelationStore Create(IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + return _factory(serviceProvider); + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs new file mode 100644 index 000000000..d792b7969 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs @@ -0,0 +1,18 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStore +{ + Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default); + + Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default); + + Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); + + Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default); + + Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs new file mode 100644 index 000000000..d07980700 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreFactory +{ + IProjectionRelationStore Create( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs new file mode 100644 index 000000000..c8fc3fcce --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreProviderMetadata +{ + ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs new file mode 100644 index 000000000..146fb30d3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreProviderRegistry +{ + IReadOnlyList GetRegistrations(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs new file mode 100644 index 000000000..a4a755fb0 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreProviderSelector +{ + IProjectionRelationStoreRegistration Select( + IReadOnlyList registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs new file mode 100644 index 000000000..f350069af --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreRegistration +{ + string ProviderName { get; } + + ProjectionReadModelProviderCapabilities Capabilities { get; } + + IProjectionRelationStore Create(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs index bb6bb87ef..4ee3c55ee 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs @@ -34,6 +34,12 @@ public static IReadOnlyList Validate( if (requirements.RequiresSchemaValidation && !capabilities.SupportsSchemaValidation) violations.Add("requires schema validation, but provider does not support schema validation"); + if (requirements.RequiresRelations && !capabilities.SupportsRelations) + violations.Add("requires relation storage, but provider does not support relations"); + + if (requirements.RequiresRelationTraversal && !capabilities.SupportsRelationTraversal) + violations.Add("requires relation traversal, but provider does not support relation traversal"); + return violations; } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs index bb5d50d18..fd7721284 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs @@ -10,7 +10,9 @@ public ProjectionReadModelProviderCapabilities( bool supportsIndexing, IEnumerable? indexKinds = null, bool supportsAliases = false, - bool supportsSchemaValidation = false) + bool supportsSchemaValidation = false, + bool supportsRelations = false, + bool supportsRelationTraversal = false) { if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); @@ -19,6 +21,8 @@ public ProjectionReadModelProviderCapabilities( SupportsIndexing = supportsIndexing; SupportsAliases = supportsAliases; SupportsSchemaValidation = supportsSchemaValidation; + SupportsRelations = supportsRelations; + SupportsRelationTraversal = supportsRelationTraversal; var normalizedIndexKinds = (indexKinds ?? []) .Where(x => x != ProjectionReadModelIndexKind.None) @@ -43,4 +47,8 @@ public ProjectionReadModelProviderCapabilities( public bool SupportsAliases { get; } public bool SupportsSchemaValidation { get; } + + public bool SupportsRelations { get; } + + public bool SupportsRelationTraversal { get; } } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs index f17764aba..7b5d7a75b 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs @@ -9,11 +9,15 @@ public ProjectionReadModelRequirements( bool requiresIndexing = false, IEnumerable? requiredIndexKinds = null, bool requiresAliases = false, - bool requiresSchemaValidation = false) + bool requiresSchemaValidation = false, + bool requiresRelations = false, + bool requiresRelationTraversal = false) { RequiresIndexing = requiresIndexing; RequiresAliases = requiresAliases; RequiresSchemaValidation = requiresSchemaValidation; + RequiresRelations = requiresRelations; + RequiresRelationTraversal = requiresRelationTraversal; var normalizedIndexKinds = (requiredIndexKinds ?? []) .Where(x => x != ProjectionReadModelIndexKind.None) @@ -31,4 +35,8 @@ public ProjectionReadModelRequirements( public bool RequiresAliases { get; } public bool RequiresSchemaValidation { get; } + + public bool RequiresRelations { get; } + + public bool RequiresRelationTraversal { get; } } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs new file mode 100644 index 000000000..681c0ff70 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public enum ProjectionRelationDirection +{ + Outbound = 0, + Inbound = 1, + Both = 2, +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs new file mode 100644 index 000000000..9272a0cf7 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs @@ -0,0 +1,18 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionRelationEdge +{ + public string Scope { get; set; } = ""; + + public string EdgeId { get; set; } = ""; + + public string FromNodeId { get; set; } = ""; + + public string ToNodeId { get; set; } = ""; + + public string RelationType { get; set; } = ""; + + public Dictionary Properties { get; set; } = new(StringComparer.Ordinal); + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs new file mode 100644 index 000000000..f8978e67b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionRelationNode +{ + public string Scope { get; set; } = ""; + + public string NodeId { get; set; } = ""; + + public string NodeType { get; set; } = ""; + + public Dictionary Properties { get; set; } = new(StringComparer.Ordinal); + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs new file mode 100644 index 000000000..a1f20d155 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs @@ -0,0 +1,16 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionRelationQuery +{ + public string Scope { get; set; } = ""; + + public string RootNodeId { get; set; } = ""; + + public ProjectionRelationDirection Direction { get; set; } = ProjectionRelationDirection.Both; + + public IReadOnlyList RelationTypes { get; set; } = []; + + public int Depth { get; set; } = 1; + + public int Take { get; set; } = 200; +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs new file mode 100644 index 000000000..f879d3d47 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public sealed class ProjectionRelationSubgraph +{ + public ProjectionRelationSubgraph() + { + Nodes = []; + Edges = []; + } + + public IReadOnlyList Nodes { get; set; } + + public IReadOnlyList Edges { get; set; } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 496fa1b4f..755e0fc63 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -39,4 +39,24 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration( + new DelegateProjectionRelationStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Document], + supportsAliases: true, + supportsSchemaValidation: true, + supportsRelations: false, + supportsRelationTraversal: false), + _ => new ElasticsearchProjectionRelationStore(providerName))); + + return services; + } } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs new file mode 100644 index 000000000..7da58fb70 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs @@ -0,0 +1,59 @@ +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +public sealed class ElasticsearchProjectionRelationStore + : IProjectionRelationStore, + IProjectionRelationStoreProviderMetadata +{ + public ElasticsearchProjectionRelationStore( + string providerName = ProjectionReadModelProviderNames.Elasticsearch) + { + ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Document], + supportsAliases: true, + supportsSchemaValidation: true, + supportsRelations: false, + supportsRelationTraversal: false); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(node); + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(edge); + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + + public Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new ProjectionRelationSubgraph()); + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 3aa3ea803..452cec1c7 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -20,7 +20,11 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration>( new DelegateProjectionReadModelStoreRegistration( providerName, - new ProjectionReadModelProviderCapabilities(providerName, supportsIndexing: false), + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: false, + supportsRelations: true, + supportsRelationTraversal: true), provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -31,4 +35,21 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration( + new DelegateProjectionRelationStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: false, + supportsRelations: true, + supportsRelationTraversal: true), + _ => new InMemoryProjectionRelationStore(providerName))); + + return services; + } } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index 9f33f36cb..cb7d26722 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -34,7 +34,9 @@ public InMemoryProjectionReadModelStore( _logger = logger ?? NullLogger>.Instance; ProviderCapabilities = new ProjectionReadModelProviderCapabilities( providerName, - supportsIndexing: false); + supportsIndexing: false, + supportsRelations: true, + supportsRelationTraversal: true); } public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs new file mode 100644 index 000000000..a011b30e2 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs @@ -0,0 +1,263 @@ +using System.Text.Json; + +namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; + +public sealed class InMemoryProjectionRelationStore + : IProjectionRelationStore, + IProjectionRelationStoreProviderMetadata +{ + private readonly object _gate = new(); + private readonly Dictionary _nodes = new(StringComparer.Ordinal); + private readonly Dictionary _edges = new(StringComparer.Ordinal); + private readonly JsonSerializerOptions _jsonOptions = new(); + + public InMemoryProjectionRelationStore( + string providerName = ProjectionReadModelProviderNames.InMemory) + { + ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: false, + supportsRelations: true, + supportsRelationTraversal: true); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(node); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(node.Scope); + var nodeId = NormalizeToken(node.NodeId); + if (scope.Length == 0 || nodeId.Length == 0) + throw new InvalidOperationException("Relation node requires non-empty scope and nodeId."); + + var key = BuildNodeKey(scope, nodeId); + var clone = CloneNode(node, scope, nodeId); + lock (_gate) + _nodes[key] = clone; + return Task.CompletedTask; + } + + public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(edge); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(edge.Scope); + var edgeId = NormalizeToken(edge.EdgeId); + var fromNodeId = NormalizeToken(edge.FromNodeId); + var toNodeId = NormalizeToken(edge.ToNodeId); + var relationType = NormalizeToken(edge.RelationType); + if (scope.Length == 0 || edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0 || relationType.Length == 0) + throw new InvalidOperationException("Relation edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); + + var key = BuildEdgeKey(scope, edgeId); + var clone = CloneEdge(edge, scope, edgeId, fromNodeId, toNodeId, relationType); + lock (_gate) + _edges[key] = clone; + return Task.CompletedTask; + } + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var edgeValue = NormalizeToken(edgeId); + if (scopeValue.Length == 0 || edgeValue.Length == 0) + return Task.CompletedTask; + + lock (_gate) + _edges.Remove(BuildEdgeKey(scopeValue, edgeValue)); + return Task.CompletedTask; + } + + public Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(query.Scope); + var rootNodeId = NormalizeToken(query.RootNodeId); + if (scope.Length == 0 || rootNodeId.Length == 0) + return Task.FromResult>([]); + + var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var take = Math.Clamp(query.Take, 1, 5000); + List edges; + lock (_gate) + { + edges = _edges.Values + .Where(x => string.Equals(x.Scope, scope, StringComparison.Ordinal)) + .Where(x => relationTypes.Count == 0 || relationTypes.Contains(x.RelationType)) + .Where(x => MatchesDirection(x, rootNodeId, query.Direction)) + .OrderByDescending(x => x.UpdatedAt) + .Take(take) + .Select(CloneEdge) + .ToList(); + } + + return Task.FromResult>(edges); + } + + public Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(query.Scope); + var rootNodeId = NormalizeToken(query.RootNodeId); + if (scope.Length == 0 || rootNodeId.Length == 0) + return Task.FromResult(new ProjectionRelationSubgraph()); + + var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var depth = Math.Clamp(query.Depth, 1, 8); + var take = Math.Clamp(query.Take, 1, 5000); + + var visitedNodeIds = new HashSet(StringComparer.Ordinal) { rootNodeId }; + var frontier = new HashSet(StringComparer.Ordinal) { rootNodeId }; + var collectedEdges = new Dictionary(StringComparer.Ordinal); + + for (var currentDepth = 0; currentDepth < depth; currentDepth++) + { + if (frontier.Count == 0 || collectedEdges.Count >= take) + break; + + var nextFrontier = new HashSet(StringComparer.Ordinal); + foreach (var nodeId in frontier) + { + ct.ThrowIfCancellationRequested(); + IReadOnlyList neighbors; + lock (_gate) + { + neighbors = _edges.Values + .Where(x => string.Equals(x.Scope, scope, StringComparison.Ordinal)) + .Where(x => relationTypes.Count == 0 || relationTypes.Contains(x.RelationType)) + .Where(x => MatchesDirection(x, nodeId, query.Direction)) + .OrderByDescending(x => x.UpdatedAt) + .ToList(); + } + + foreach (var edge in neighbors) + { + if (collectedEdges.Count >= take) + break; + + if (!collectedEdges.ContainsKey(edge.EdgeId)) + collectedEdges[edge.EdgeId] = CloneEdge(edge); + + var counterpartNodeId = ResolveCounterpartNodeId(edge, nodeId); + if (counterpartNodeId.Length == 0) + continue; + + if (visitedNodeIds.Add(counterpartNodeId)) + nextFrontier.Add(counterpartNodeId); + } + } + + frontier = nextFrontier; + } + + List nodes; + lock (_gate) + { + nodes = visitedNodeIds + .Select(x => + { + var key = BuildNodeKey(scope, x); + if (_nodes.TryGetValue(key, out var existing)) + return CloneNode(existing); + + return new ProjectionRelationNode + { + Scope = scope, + NodeId = x, + NodeType = "Unknown", + Properties = new Dictionary(StringComparer.Ordinal), + UpdatedAt = DateTimeOffset.UtcNow, + }; + }) + .ToList(); + } + + var graph = new ProjectionRelationSubgraph + { + Nodes = nodes, + Edges = collectedEdges.Values.ToList(), + }; + return Task.FromResult(graph); + } + + private bool MatchesDirection( + ProjectionRelationEdge edge, + string rootNodeId, + ProjectionRelationDirection direction) + { + return direction switch + { + ProjectionRelationDirection.Outbound => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal), + ProjectionRelationDirection.Inbound => string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), + _ => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal) || + string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), + }; + } + + private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, string nodeId) + { + if (string.Equals(edge.FromNodeId, nodeId, StringComparison.Ordinal)) + return edge.ToNodeId; + if (string.Equals(edge.ToNodeId, nodeId, StringComparison.Ordinal)) + return edge.FromNodeId; + return ""; + } + + private static HashSet NormalizeRelationTypes(IReadOnlyList relationTypes) + { + return relationTypes + .Select(NormalizeToken) + .Where(x => x.Length > 0) + .ToHashSet(StringComparer.Ordinal); + } + + private string BuildNodeKey(string scope, string nodeId) => $"{scope}:{nodeId}"; + + private string BuildEdgeKey(string scope, string edgeId) => $"{scope}:{edgeId}"; + + private ProjectionRelationNode CloneNode(ProjectionRelationNode source) => + CloneNode(source, source.Scope, source.NodeId); + + private ProjectionRelationNode CloneNode(ProjectionRelationNode source, string scope, string nodeId) + { + var payload = JsonSerializer.Serialize(source, _jsonOptions); + var clone = JsonSerializer.Deserialize(payload, _jsonOptions) + ?? throw new InvalidOperationException("Failed to clone relation node."); + clone.Scope = scope; + clone.NodeId = nodeId; + return clone; + } + + private ProjectionRelationEdge CloneEdge(ProjectionRelationEdge source) => + CloneEdge(source, source.Scope, source.EdgeId, source.FromNodeId, source.ToNodeId, source.RelationType); + + private ProjectionRelationEdge CloneEdge( + ProjectionRelationEdge source, + string scope, + string edgeId, + string fromNodeId, + string toNodeId, + string relationType) + { + var payload = JsonSerializer.Serialize(source, _jsonOptions); + var clone = JsonSerializer.Deserialize(payload, _jsonOptions) + ?? throw new InvalidOperationException("Failed to clone relation edge."); + clone.Scope = scope; + clone.EdgeId = edgeId; + clone.FromNodeId = fromNodeId; + clone.ToNodeId = toNodeId; + clone.RelationType = relationType; + return clone; + } + + private static string NormalizeToken(string token) => token?.Trim() ?? ""; +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs new file mode 100644 index 000000000..2e40e40a9 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs @@ -0,0 +1,22 @@ +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; + +public sealed class Neo4jProjectionRelationStoreOptions +{ + public string Uri { get; set; } = "bolt://localhost:7687"; + + public string Username { get; set; } = "neo4j"; + + public string Password { get; set; } = ""; + + public string Database { get; set; } = ""; + + public int RequestTimeoutMs { get; set; } = 5000; + + public bool AutoCreateConstraints { get; set; } = true; + + public string NodeLabel { get; set; } = "ProjectionRelationNode"; + + public string EdgeType { get; set; } = "PROJECTION_REL"; + + public int MaxTraversalDepth { get; set; } = 4; +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index c8ea3ce45..5b026c871 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -28,7 +28,9 @@ public static IServiceCollection AddNeo4jReadModelStoreRegistration new Neo4jProjectionReadModelStore( optionsFactory(provider), scope, @@ -39,4 +41,33 @@ public static IServiceCollection AddNeo4jReadModelStoreRegistration optionsFactory, + string scope, + string providerName = ProjectionReadModelProviderNames.Neo4j) + { + ArgumentNullException.ThrowIfNull(optionsFactory); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + services.AddSingleton( + new DelegateProjectionRelationStoreRegistration( + providerName, + new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph], + supportsAliases: false, + supportsSchemaValidation: true, + supportsRelations: true, + supportsRelationTraversal: true), + provider => new Neo4jProjectionRelationStore( + optionsFactory(provider), + scope, + providerName, + provider.GetService>()))); + + return services; + } } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index 3e1fe3138..d52d5264a 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -60,7 +60,9 @@ public Neo4jProjectionReadModelStore( supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Graph], supportsAliases: false, - supportsSchemaValidation: true); + supportsSchemaValidation: true, + supportsRelations: true, + supportsRelationTraversal: true); } public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs new file mode 100644 index 000000000..eea9e5a65 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs @@ -0,0 +1,540 @@ +using System.Text.Json; +using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Neo4j.Driver; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +public sealed class Neo4jProjectionRelationStore + : IProjectionRelationStore, + IProjectionRelationStoreProviderMetadata, + IAsyncDisposable +{ + private readonly IDriver _driver; + private readonly string _scope; + private readonly string _database; + private readonly string _nodeLabel; + private readonly string _edgeType; + private readonly bool _autoCreateConstraints; + private readonly int _maxTraversalDepth; + private readonly ILogger _logger; + private readonly SemaphoreSlim _schemaLock = new(1, 1); + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + private bool _schemaInitialized; + + public Neo4jProjectionRelationStore( + Neo4jProjectionRelationStoreOptions options, + string scope, + string providerName = ProjectionReadModelProviderNames.Neo4j, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentException.ThrowIfNullOrWhiteSpace(scope); + + _scope = scope.Trim(); + _database = options.Database?.Trim() ?? ""; + _nodeLabel = NormalizeLabel(options.NodeLabel, "ProjectionRelationNode"); + _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); + _autoCreateConstraints = options.AutoCreateConstraints; + _maxTraversalDepth = Math.Clamp(options.MaxTraversalDepth, 1, 8); + _logger = logger ?? NullLogger.Instance; + + var auth = string.IsNullOrWhiteSpace(options.Username) + ? AuthTokens.None + : AuthTokens.Basic(options.Username.Trim(), options.Password ?? ""); + _driver = GraphDatabase.Driver(options.Uri, auth, config => + config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); + + ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + providerName, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph], + supportsAliases: false, + supportsSchemaValidation: true, + supportsRelations: true, + supportsRelationTraversal: true); + } + + public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + + public async Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(node); + ct.ThrowIfCancellationRequested(); + + var scope = NormalizeToken(node.Scope); + var nodeId = NormalizeToken(node.NodeId); + if (scope.Length == 0 || nodeId.Length == 0) + throw new InvalidOperationException("Relation node requires non-empty scope and nodeId."); + + var nodeType = NormalizeToken(node.NodeType); + if (nodeType.Length == 0) + nodeType = "Unknown"; + var updatedAtEpochMs = NormalizeTimestamp(node.UpdatedAt); + var propertiesJson = SerializeProperties(node.Properties); + var cypher = $"MERGE (n:{_nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + + "SET n.nodeType = $nodeType, n.propertiesJson = $propertiesJson, n.updatedAtEpochMs = $updatedAtEpochMs"; + var parameters = new Dictionary + { + ["scope"] = scope, + ["nodeId"] = nodeId, + ["nodeType"] = nodeType, + ["propertiesJson"] = propertiesJson, + ["updatedAtEpochMs"] = updatedAtEpochMs, + }; + + await EnsureSchemaAsync(ct); + await ExecuteWriteAsync(cypher, parameters, ct); + } + + public async Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(edge); + ct.ThrowIfCancellationRequested(); + + var scope = NormalizeToken(edge.Scope); + var edgeId = NormalizeToken(edge.EdgeId); + var fromNodeId = NormalizeToken(edge.FromNodeId); + var toNodeId = NormalizeToken(edge.ToNodeId); + var relationType = NormalizeToken(edge.RelationType); + if (scope.Length == 0 || edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0 || relationType.Length == 0) + { + throw new InvalidOperationException("Relation edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); + } + + var updatedAtEpochMs = NormalizeTimestamp(edge.UpdatedAt); + var propertiesJson = SerializeProperties(edge.Properties); + var cypher = $"MERGE (from:{_nodeLabel} {{scope: $scope, nodeId: $fromNodeId}}) " + + "ON CREATE SET from.nodeType = 'Unknown', from.propertiesJson = '{}', from.updatedAtEpochMs = $updatedAtEpochMs " + + $"MERGE (to:{_nodeLabel} {{scope: $scope, nodeId: $toNodeId}}) " + + "ON CREATE SET to.nodeType = 'Unknown', to.propertiesJson = '{}', to.updatedAtEpochMs = $updatedAtEpochMs " + + $"MERGE (from)-[r:{_edgeType} {{scope: $scope, edgeId: $edgeId}}]->(to) " + + "SET r.relationType = $relationType, r.propertiesJson = $propertiesJson, r.updatedAtEpochMs = $updatedAtEpochMs"; + var parameters = new Dictionary + { + ["scope"] = scope, + ["edgeId"] = edgeId, + ["fromNodeId"] = fromNodeId, + ["toNodeId"] = toNodeId, + ["relationType"] = relationType, + ["propertiesJson"] = propertiesJson, + ["updatedAtEpochMs"] = updatedAtEpochMs, + }; + + await EnsureSchemaAsync(ct); + await ExecuteWriteAsync(cypher, parameters, ct); + } + + public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var edgeIdValue = NormalizeToken(edgeId); + if (scopeValue.Length == 0 || edgeIdValue.Length == 0) + return; + + await EnsureSchemaAsync(ct); + var cypher = $"MATCH ()-[r:{_edgeType} {{scope: $scope, edgeId: $edgeId}}]->() DELETE r"; + var parameters = new Dictionary + { + ["scope"] = scopeValue, + ["edgeId"] = edgeIdValue, + }; + await ExecuteWriteAsync(cypher, parameters, ct); + } + + public async Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(query.Scope); + var rootNodeId = NormalizeToken(query.RootNodeId); + if (scope.Length == 0 || rootNodeId.Length == 0) + return []; + + await EnsureSchemaAsync(ct); + var boundedTake = Math.Clamp(query.Take, 1, 5000); + var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var cypher = BuildNeighborCypher(query.Direction, boundedTake); + var parameters = new Dictionary + { + ["scope"] = scope, + ["rootNodeId"] = rootNodeId, + ["relationTypes"] = relationTypes, + ["take"] = boundedTake, + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var edges = new List(rows.Count); + foreach (var row in rows) + { + var edge = BuildEdgeFromRow(scope, row); + if (edge != null) + edges.Add(edge); + } + + return edges; + } + + public async Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(query); + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(query.Scope); + var rootNodeId = NormalizeToken(query.RootNodeId); + if (scope.Length == 0 || rootNodeId.Length == 0) + return new ProjectionRelationSubgraph(); + + var depth = Math.Clamp(query.Depth, 1, _maxTraversalDepth); + var take = Math.Clamp(query.Take, 1, 5000); + var visitedNodeIds = new HashSet(StringComparer.Ordinal) { rootNodeId }; + var frontier = new HashSet(StringComparer.Ordinal) { rootNodeId }; + var collectedEdges = new Dictionary(StringComparer.Ordinal); + + for (var currentDepth = 0; currentDepth < depth; currentDepth++) + { + if (frontier.Count == 0 || collectedEdges.Count >= take) + break; + + var nextFrontier = new HashSet(StringComparer.Ordinal); + foreach (var nodeId in frontier) + { + ct.ThrowIfCancellationRequested(); + var neighbors = await GetNeighborsAsync( + new ProjectionRelationQuery + { + Scope = scope, + RootNodeId = nodeId, + Direction = query.Direction, + RelationTypes = query.RelationTypes, + Depth = 1, + Take = take - collectedEdges.Count, + }, + ct); + + foreach (var edge in neighbors) + { + if (collectedEdges.Count >= take) + break; + + if (!collectedEdges.ContainsKey(edge.EdgeId)) + collectedEdges[edge.EdgeId] = edge; + + var counterpartNodeId = ResolveCounterpartNodeId(edge, nodeId); + if (counterpartNodeId.Length == 0) + continue; + + if (visitedNodeIds.Add(counterpartNodeId)) + nextFrontier.Add(counterpartNodeId); + } + } + + frontier = nextFrontier; + } + + var nodes = await GetNodesByIdsAsync(scope, visitedNodeIds, ct); + if (!nodes.Any(x => string.Equals(x.NodeId, rootNodeId, StringComparison.Ordinal))) + { + nodes.Add(new ProjectionRelationNode + { + Scope = scope, + NodeId = rootNodeId, + NodeType = "Unknown", + Properties = new Dictionary(StringComparer.Ordinal), + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + + return new ProjectionRelationSubgraph + { + Nodes = nodes, + Edges = collectedEdges.Values.ToList(), + }; + } + + public async ValueTask DisposeAsync() + { + _schemaLock.Dispose(); + await _driver.DisposeAsync(); + } + + private async Task> GetNodesByIdsAsync( + string scope, + IReadOnlySet nodeIds, + CancellationToken ct) + { + if (nodeIds.Count == 0) + return []; + + await EnsureSchemaAsync(ct); + var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + + "WHERE n.nodeId IN $nodeIds " + + "RETURN n.nodeId AS nodeId, " + + "coalesce(n.nodeType, '') AS nodeType, " + + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs"; + var parameters = new Dictionary + { + ["scope"] = scope, + ["nodeIds"] = nodeIds.ToArray(), + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var nodes = new List(rows.Count); + foreach (var row in rows) + { + if (!row.TryGetValue("nodeId", out var nodeIdValue)) + continue; + var nodeId = NormalizeToken(nodeIdValue.As()); + if (nodeId.Length == 0) + continue; + + var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) + ? NormalizeToken(nodeTypeValue.As()) + : "Unknown"; + if (nodeType.Length == 0) + nodeType = "Unknown"; + + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + nodes.Add(new ProjectionRelationNode + { + Scope = scope, + NodeId = nodeId, + NodeType = nodeType, + Properties = DeserializeProperties(propertiesJson), + UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), + }); + } + + return nodes; + } + + private ProjectionRelationEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) + { + if (!row.TryGetValue("edgeId", out var edgeIdValue)) + return null; + if (!row.TryGetValue("fromNodeId", out var fromNodeIdValue)) + return null; + if (!row.TryGetValue("toNodeId", out var toNodeIdValue)) + return null; + + var edgeId = NormalizeToken(edgeIdValue.As()); + var fromNodeId = NormalizeToken(fromNodeIdValue.As()); + var toNodeId = NormalizeToken(toNodeIdValue.As()); + if (edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) + return null; + + var relationType = row.TryGetValue("relationType", out var relationTypeValue) + ? NormalizeToken(relationTypeValue.As()) + : "Unknown"; + if (relationType.Length == 0) + relationType = "Unknown"; + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + return new ProjectionRelationEdge + { + Scope = scope, + EdgeId = edgeId, + FromNodeId = fromNodeId, + ToNodeId = toNodeId, + RelationType = relationType, + Properties = DeserializeProperties(propertiesJson), + UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), + }; + } + + private string BuildNeighborCypher(ProjectionRelationDirection direction, int take) + { + var filter = "WHERE size($relationTypes) = 0 OR r.relationType IN $relationTypes "; + var projection = "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + return direction switch + { + ProjectionRelationDirection.Outbound => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]->(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + ProjectionRelationDirection.Inbound => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + _ => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + }; + } + + private async Task EnsureSchemaAsync(CancellationToken ct) + { + if (!_autoCreateConstraints || _schemaInitialized) + return; + + await _schemaLock.WaitAsync(ct); + try + { + if (_schemaInitialized) + return; + + var nodeConstraintName = NormalizeConstraintName($"projection_relation_node_scope_id_{_nodeLabel}"); + var cypher = $"CREATE CONSTRAINT {nodeConstraintName} IF NOT EXISTS " + + $"FOR (n:{_nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; + await ExecuteWriteAsync(cypher, new Dictionary(), ct); + _schemaInitialized = true; + } + finally + { + _schemaLock.Release(); + } + } + + private async Task ExecuteWriteAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Write); + var cursor = await session.RunAsync(cypher, parameters); + await cursor.ConsumeAsync(); + ct.ThrowIfCancellationRequested(); + } + + private async Task>> ExecuteReadAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Read); + var cursor = await session.RunAsync(cypher, parameters); + var rows = await cursor.ToListAsync(record => + (IReadOnlyDictionary)record.Values.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal)); + ct.ThrowIfCancellationRequested(); + return rows; + } + + private IAsyncSession CreateSession(AccessMode accessMode) + { + return _driver.AsyncSession(options => + { + options.WithDefaultAccessMode(accessMode); + if (_database.Length > 0) + options.WithDatabase(_database); + }); + } + + private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, string nodeId) + { + if (string.Equals(edge.FromNodeId, nodeId, StringComparison.Ordinal)) + return edge.ToNodeId; + if (string.Equals(edge.ToNodeId, nodeId, StringComparison.Ordinal)) + return edge.FromNodeId; + return ""; + } + + private static string[] NormalizeRelationTypes(IReadOnlyList relationTypes) + { + return relationTypes + .Select(NormalizeToken) + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private string SerializeProperties(IReadOnlyDictionary properties) + { + if (properties.Count == 0) + return "{}"; + return JsonSerializer.Serialize(properties, _jsonOptions); + } + + private Dictionary DeserializeProperties(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return new Dictionary(StringComparer.Ordinal); + try + { + var parsed = JsonSerializer.Deserialize>(payload, _jsonOptions); + return parsed == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to deserialize relation properties payload. provider={Provider} scope={Scope}", + ProviderCapabilities.ProviderName, + _scope); + return new Dictionary(StringComparer.Ordinal); + } + } + + private static long NormalizeTimestamp(DateTimeOffset timestamp) + { + if (timestamp == default) + return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return timestamp.ToUnixTimeMilliseconds(); + } + + private static DateTimeOffset FromUnixTimeMilliseconds(long value) + { + var safeValue = Math.Max(0, value); + return DateTimeOffset.FromUnixTimeMilliseconds(safeValue); + } + + private static string NormalizeToken(string token) => token?.Trim() ?? ""; + + private static string NormalizeLabel(string rawLabel, string fallback) + { + var label = (rawLabel ?? "").Trim(); + if (label.Length == 0) + label = fallback; + + var chars = label + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + normalized = fallback; + if (char.IsDigit(normalized[0])) + normalized = $"N_{normalized}"; + return normalized; + } + + private static string NormalizeConstraintName(string rawName) + { + var chars = rawName + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + return "projection_relation_constraint"; + if (char.IsDigit(normalized[0])) + normalized = $"c_{normalized}"; + return normalized; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 0b3dfc039..658e1ec74 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,6 +13,9 @@ public static IServiceCollection AddProjectionReadModelRuntime(this IServiceColl services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs index 08f811cd4..0fdb4cfc6 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs @@ -24,7 +24,9 @@ public ProjectionReadModelRequirements Resolve( return new ProjectionReadModelRequirements( requiresIndexing: true, - requiredIndexKinds: [indexKind]); + requiredIndexKinds: [indexKind], + requiresRelations: indexKind == ProjectionReadModelIndexKind.Graph, + requiresRelationTraversal: indexKind == ProjectionReadModelIndexKind.Graph); } private static bool TryGetBinding( diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs index af96e2731..f8b96cd29 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs @@ -71,7 +71,9 @@ private static string FormatRequirements(ProjectionReadModelRequirements require return $"requiresIndexing={requirements.RequiresIndexing};" + $"requiredIndexKinds=[{string.Join(",", requirements.RequiredIndexKinds)}];" + $"requiresAliases={requirements.RequiresAliases};" + - $"requiresSchemaValidation={requirements.RequiresSchemaValidation}"; + $"requiresSchemaValidation={requirements.RequiresSchemaValidation};" + + $"requiresRelations={requirements.RequiresRelations};" + + $"requiresRelationTraversal={requirements.RequiresRelationTraversal}"; } private static string FormatCapabilities(ProjectionReadModelProviderCapabilities capabilities) @@ -79,6 +81,8 @@ private static string FormatCapabilities(ProjectionReadModelProviderCapabilities return $"supportsIndexing={capabilities.SupportsIndexing};" + $"indexKinds=[{string.Join(",", capabilities.IndexKinds)}];" + $"supportsAliases={capabilities.SupportsAliases};" + - $"supportsSchemaValidation={capabilities.SupportsSchemaValidation}"; + $"supportsSchemaValidation={capabilities.SupportsSchemaValidation};" + + $"supportsRelations={capabilities.SupportsRelations};" + + $"supportsRelationTraversal={capabilities.SupportsRelationTraversal}"; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs new file mode 100644 index 000000000..32e2156aa --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionRelationStoreFactory : IProjectionRelationStoreFactory +{ + private readonly IProjectionRelationStoreProviderRegistry _providerRegistry; + private readonly IProjectionRelationStoreProviderSelector _providerSelector; + private readonly ILogger _logger; + + public ProjectionRelationStoreFactory( + IProjectionRelationStoreProviderRegistry providerRegistry, + IProjectionRelationStoreProviderSelector providerSelector, + ILogger? logger = null) + { + _providerRegistry = providerRegistry; + _providerSelector = providerSelector; + _logger = logger ?? NullLogger.Instance; + } + + public IProjectionRelationStore Create( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var registrations = _providerRegistry.GetRegistrations(serviceProvider); + var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + + var startedAt = DateTimeOffset.UtcNow; + try + { + var store = selected.Create(serviceProvider); + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogInformation( + "Projection relation store created. provider={Provider} elapsedMs={ElapsedMs} result={Result}", + selected.ProviderName, + elapsedMs, + "ok"); + return store; + } + catch (Exception ex) + { + var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; + _logger.LogError( + ex, + "Projection relation store creation failed. provider={Provider} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + selected.ProviderName, + elapsedMs, + "failed", + ex.GetType().Name); + throw; + } + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs new file mode 100644 index 000000000..c22b7eab2 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionRelationStoreProviderRegistry : IProjectionRelationStoreProviderRegistry +{ + public IReadOnlyList GetRegistrations(IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + return serviceProvider + .GetServices() + .ToList(); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs new file mode 100644 index 000000000..ba8a5542c --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionRelationStoreProviderSelector + : IProjectionRelationStoreProviderSelector +{ + private readonly IProjectionReadModelCapabilityValidator _capabilityValidator; + private readonly ILogger _logger; + + public ProjectionRelationStoreProviderSelector( + IProjectionReadModelCapabilityValidator capabilityValidator, + ILogger? logger = null) + { + _capabilityValidator = capabilityValidator; + _logger = logger ?? NullLogger.Instance; + } + + public IProjectionRelationStoreRegistration Select( + IReadOnlyList registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; + if (registrations.Count == 0) + { + throw new ProjectionProviderSelectionException( + typeof(ProjectionRelationNode), + requestedProviderName, + [], + "No relation store provider registrations were found."); + } + + IProjectionRelationStoreRegistration selected; + if (requestedProviderName.Length == 0) + { + if (registrations.Count != 1) + { + throw new ProjectionProviderSelectionException( + typeof(ProjectionRelationNode), + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + "Multiple relation store providers are registered but no explicit provider was requested."); + } + + selected = registrations[0]; + } + else + { + selected = registrations.FirstOrDefault(x => + string.Equals(x.ProviderName, requestedProviderName, StringComparison.OrdinalIgnoreCase)) + ?? throw new ProjectionProviderSelectionException( + typeof(ProjectionRelationNode), + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + "Requested relation store provider is not registered."); + } + + var violations = _capabilityValidator.Validate(requirements, selected.Capabilities); + if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) + { + throw new ProjectionReadModelCapabilityValidationException( + typeof(ProjectionRelationNode), + requirements, + selected.Capabilities, + violations); + } + + _logger.LogInformation( + "Projection relation provider selected. provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", + selected.ProviderName, + selectionOptions.FailOnUnsupportedCapabilities); + + return selected; + } +} diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs index 353ec7896..e76d9668d 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs @@ -42,4 +42,15 @@ Task> ListActorTimelineAsync( string actorId, int take = 200, CancellationToken ct = default); + + Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default); + + Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs index 273920e93..a50f6f8ac 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs @@ -11,4 +11,15 @@ public interface IWorkflowExecutionQueryApplicationService Task GetActorSnapshotAsync(string actorId, CancellationToken ct = default); Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default); + + Task> ListActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default); + + Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs index f04aa39ec..4e9591f55 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs @@ -34,6 +34,41 @@ public sealed class WorkflowActorTimelineItem public Dictionary Data { get; set; } = []; } +public sealed class WorkflowActorRelationNode +{ + public string NodeId { get; set; } = string.Empty; + + public string NodeType { get; set; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; set; } + + public Dictionary Properties { get; set; } = []; +} + +public sealed class WorkflowActorRelationItem +{ + public string EdgeId { get; set; } = string.Empty; + + public string FromNodeId { get; set; } = string.Empty; + + public string ToNodeId { get; set; } = string.Empty; + + public string RelationType { get; set; } = string.Empty; + + public DateTimeOffset UpdatedAt { get; set; } + + public Dictionary Properties { get; set; } = []; +} + +public sealed class WorkflowActorRelationSubgraph +{ + public string RootNodeId { get; set; } = string.Empty; + + public List Nodes { get; set; } = []; + + public List Edges { get; set; } = []; +} + public sealed record WorkflowTopologyEdge(string Parent, string Child); public enum WorkflowRunProjectionScope diff --git a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs index 93965941e..f89111b8b 100644 --- a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs @@ -54,4 +54,30 @@ public async Task> ListActorTimelineAsy return await _projectionPort.ListActorTimelineAsync(actorId, take, ct); } + + public async Task> ListActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) + return []; + + return await _projectionPort.GetActorRelationsAsync(actorId, take, ct); + } + + public async Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) + return new WorkflowActorRelationSubgraph + { + RootNodeId = actorId ?? string.Empty, + }; + + return await _projectionPort.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + } } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs index c897a409a..c1a85fafd 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs @@ -21,6 +21,12 @@ public static void Map(RouteGroupBuilder group) group.MapGet("/actors/{actorId}/timeline", ListActorTimeline) .Produces(StatusCodes.Status200OK); + + group.MapGet("/actors/{actorId}/relations", ListActorRelations) + .Produces(StatusCodes.Status200OK); + + group.MapGet("/actors/{actorId}/relation-subgraph", GetActorRelationSubgraph) + .Produces(StatusCodes.Status200OK); } internal static async Task ListAgents( @@ -53,4 +59,25 @@ internal static async Task ListActorTimeline( return Results.Ok(timeline); } + internal static async Task ListActorRelations( + string actorId, + IWorkflowExecutionQueryApplicationService queryService, + int take = 200, + CancellationToken ct = default) + { + var relations = await queryService.ListActorRelationsAsync(actorId, take, ct); + return Results.Ok(relations); + } + + internal static async Task GetActorRelationSubgraph( + string actorId, + IWorkflowExecutionQueryApplicationService queryService, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + var subgraph = await queryService.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + return Results.Ok(subgraph); + } + } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 3b6ee76d3..3d0c34187 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.AddProjectionReadModelRuntime(); services.TryAddSingleton(); RegisterWorkflowReadModelStoreSelector(services); + RegisterWorkflowRelationStoreSelector(services); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -126,6 +127,22 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se })); } + private static void RegisterWorkflowRelationStoreSelector(IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton(sp => + { + var options = sp.GetRequiredService(); + var selectionPlanner = sp.GetRequiredService(); + var relationStoreFactory = sp.GetRequiredService(); + var selectionPlan = selectionPlanner.Build(options); + + return relationStoreFactory.Create( + sp, + selectionPlan.SelectionOptions, + selectionPlan.Requirements); + })); + } + private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs index e33f26a17..0d7547ad6 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs @@ -16,4 +16,15 @@ Task> ListActorTimelineAsync( string actorId, int take = 200, CancellationToken ct = default); + + Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default); + + Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs index a8ce90d0c..2293dc7a7 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs @@ -135,6 +135,34 @@ public async Task> ListActorTimelineAsy return await _queryReader.ListActorTimelineAsync(actorId, take, ct); } + public async Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) + return []; + + return await _queryReader.GetActorRelationsAsync(actorId, take, ct); + } + + public async Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) + { + return new WorkflowActorRelationSubgraph + { + RootNodeId = actorId ?? string.Empty, + }; + } + + return await _queryReader.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + } + private static WorkflowExecutionRuntimeLease ResolveRuntimeLease(IWorkflowExecutionProjectionLease lease) => lease as WorkflowExecutionRuntimeLease ?? throw new InvalidOperationException("Unsupported workflow projection lease implementation."); diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index 308373aeb..e16537641 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -6,14 +6,17 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionQueryReader : IWorkflowProjectionQueryReader { private readonly IProjectionReadModelStore _store; + private readonly IProjectionRelationStore _relationStore; private readonly WorkflowExecutionReadModelMapper _mapper; public WorkflowProjectionQueryReader( IProjectionReadModelStore store, - WorkflowExecutionReadModelMapper mapper) + WorkflowExecutionReadModelMapper mapper, + IProjectionRelationStore? relationStore = null) { _store = store; _mapper = mapper; + _relationStore = relationStore ?? NoopProjectionRelationStore.Instance; } public async Task GetActorSnapshotAsync( @@ -51,4 +54,75 @@ public async Task> ListActorTimelineAsy .Select(_mapper.ToActorTimelineItem) .ToList(); } + + public async Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + var actorIdValue = actorId?.Trim() ?? ""; + if (actorIdValue.Length == 0) + return []; + + var boundedTake = Math.Clamp(take, 1, 1000); + var edges = await _relationStore.GetNeighborsAsync( + new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = actorIdValue, + Direction = ProjectionRelationDirection.Both, + Take = boundedTake, + }, + ct); + return edges.Select(_mapper.ToActorRelationItem).ToList(); + } + + public async Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + var actorIdValue = actorId?.Trim() ?? ""; + if (actorIdValue.Length == 0) + return new WorkflowActorRelationSubgraph(); + + var boundedDepth = Math.Clamp(depth, 1, 8); + var boundedTake = Math.Clamp(take, 1, 2000); + var subgraph = await _relationStore.GetSubgraphAsync( + new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = actorIdValue, + Direction = ProjectionRelationDirection.Both, + Depth = boundedDepth, + Take = boundedTake, + }, + ct); + return _mapper.ToActorRelationSubgraph(actorIdValue, subgraph); + } + + private sealed class NoopProjectionRelationStore : IProjectionRelationStore + { + public static NoopProjectionRelationStore Instance { get; } = new(); + + public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) => + Task.CompletedTask; + + public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) => + Task.CompletedTask; + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) => + Task.CompletedTask; + + public Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) => + Task.FromResult(new ProjectionRelationSubgraph()); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 899bf7c62..6f080c448 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -14,6 +14,8 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS private readonly IWorkflowReadModelSelectionPlanner _selectionPlanner; private readonly IProjectionReadModelProviderRegistry _providerRegistry; private readonly IProjectionReadModelProviderSelector _providerSelector; + private readonly IProjectionRelationStoreProviderRegistry _relationProviderRegistry; + private readonly IProjectionRelationStoreProviderSelector _relationProviderSelector; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( @@ -22,6 +24,8 @@ public WorkflowReadModelStartupValidationHostedService( IWorkflowReadModelSelectionPlanner selectionPlanner, IProjectionReadModelProviderRegistry providerRegistry, IProjectionReadModelProviderSelector providerSelector, + IProjectionRelationStoreProviderRegistry relationProviderRegistry, + IProjectionRelationStoreProviderSelector relationProviderSelector, ILogger? logger = null) { _serviceProvider = serviceProvider; @@ -29,6 +33,8 @@ public WorkflowReadModelStartupValidationHostedService( _selectionPlanner = selectionPlanner; _providerRegistry = providerRegistry; _providerSelector = providerSelector; + _relationProviderRegistry = relationProviderRegistry; + _relationProviderSelector = relationProviderSelector; _logger = logger ?? NullLogger.Instance; } @@ -46,6 +52,16 @@ public Task StartAsync(CancellationToken cancellationToken) "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", typeof(WorkflowExecutionReport).FullName, selected.ProviderName); + + var relationRegistrations = _relationProviderRegistry.GetRegistrations(_serviceProvider); + var selectedRelationProvider = _relationProviderSelector.Select( + relationRegistrations, + selectionPlan.SelectionOptions, + selectionPlan.Requirements); + _logger.LogInformation( + "Workflow relation provider startup validation passed. relationType={RelationType} provider={Provider}", + typeof(ProjectionRelationNode).FullName, + selectedRelationProvider.ProviderName); return Task.CompletedTask; } diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs new file mode 100644 index 000000000..08b53dc90 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs @@ -0,0 +1,243 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Aevatar.Workflow.Projection.Projectors; + +public sealed class WorkflowExecutionRelationProjector + : IProjectionProjector> +{ + private const string UnknownToken = "unknown"; + private readonly IProjectionRelationStore _relationStore; + private readonly IProjectionReadModelStore _readModelStore; + + public WorkflowExecutionRelationProjector( + IProjectionRelationStore relationStore, + IProjectionReadModelStore readModelStore) + { + _relationStore = relationStore; + _readModelStore = readModelStore; + } + + public async ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, CancellationToken ct = default) + { + var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); + var now = context.StartedAt; + await _relationStore.UpsertNodeAsync( + BuildActorNode(context.RootActorId, context.WorkflowName, now), + ct); + await _relationStore.UpsertNodeAsync( + BuildRunNode(runNodeId, context.RootActorId, context.WorkflowName, context.CommandId, context.Input, now), + ct); + await _relationStore.UpsertEdgeAsync( + BuildEdge( + context.RootActorId, + runNodeId, + WorkflowExecutionRelationConstants.RelationOwns, + now), + ct); + } + + public ValueTask ProjectAsync( + WorkflowExecutionProjectionContext context, + EventEnvelope envelope, + CancellationToken ct = default) + { + _ = context; + _ = envelope; + _ = ct; + return ValueTask.CompletedTask; + } + + public async ValueTask CompleteAsync( + WorkflowExecutionProjectionContext context, + IReadOnlyList topology, + CancellationToken ct = default) + { + var report = await _readModelStore.GetAsync(context.RootActorId, ct); + var completedAt = report?.EndedAt ?? DateTimeOffset.UtcNow; + var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); + + await _relationStore.UpsertNodeAsync( + BuildActorNode(context.RootActorId, context.WorkflowName, completedAt), + ct); + await _relationStore.UpsertNodeAsync( + BuildRunNode( + runNodeId, + context.RootActorId, + context.WorkflowName, + context.CommandId, + context.Input, + completedAt), + ct); + await _relationStore.UpsertEdgeAsync( + BuildEdge( + context.RootActorId, + runNodeId, + WorkflowExecutionRelationConstants.RelationOwns, + completedAt), + ct); + + foreach (var edge in topology) + { + var parentNodeId = NormalizeToken(edge.Parent); + var childNodeId = NormalizeToken(edge.Child); + if (parentNodeId.Length == 0 || childNodeId.Length == 0) + continue; + + await _relationStore.UpsertNodeAsync( + BuildActorNode(parentNodeId, context.WorkflowName, completedAt), + ct); + await _relationStore.UpsertNodeAsync( + BuildActorNode(childNodeId, context.WorkflowName, completedAt), + ct); + await _relationStore.UpsertEdgeAsync( + BuildEdge( + parentNodeId, + childNodeId, + WorkflowExecutionRelationConstants.RelationChildOf, + completedAt), + ct); + } + + if (report == null || report.Steps.Count == 0) + return; + + foreach (var step in report.Steps) + { + var stepId = NormalizeToken(step.StepId); + if (stepId.Length == 0) + continue; + + var stepNodeId = BuildStepNodeId(context.RootActorId, stepId); + var stepUpdatedAt = step.CompletedAt ?? step.RequestedAt ?? completedAt; + await _relationStore.UpsertNodeAsync( + BuildStepNode( + stepNodeId, + context.RootActorId, + step, + stepUpdatedAt), + ct); + await _relationStore.UpsertEdgeAsync( + BuildEdge( + runNodeId, + stepNodeId, + WorkflowExecutionRelationConstants.RelationContainsStep, + stepUpdatedAt), + ct); + } + } + + private static ProjectionRelationNode BuildActorNode( + string actorId, + string workflowName, + DateTimeOffset updatedAt) + { + return new ProjectionRelationNode + { + Scope = WorkflowExecutionRelationConstants.Scope, + NodeId = NormalizeToken(actorId), + NodeType = WorkflowExecutionRelationConstants.ActorNodeType, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = workflowName ?? "", + }, + UpdatedAt = updatedAt, + }; + } + + private static ProjectionRelationNode BuildRunNode( + string runNodeId, + string rootActorId, + string workflowName, + string commandId, + string input, + DateTimeOffset updatedAt) + { + return new ProjectionRelationNode + { + Scope = WorkflowExecutionRelationConstants.Scope, + NodeId = runNodeId, + NodeType = WorkflowExecutionRelationConstants.RunNodeType, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["rootActorId"] = NormalizeToken(rootActorId), + ["workflowName"] = workflowName ?? "", + ["commandId"] = NormalizeToken(commandId), + ["input"] = input ?? "", + }, + UpdatedAt = updatedAt, + }; + } + + private static ProjectionRelationNode BuildStepNode( + string stepNodeId, + string rootActorId, + WorkflowExecutionStepTrace step, + DateTimeOffset updatedAt) + { + return new ProjectionRelationNode + { + Scope = WorkflowExecutionRelationConstants.Scope, + NodeId = stepNodeId, + NodeType = WorkflowExecutionRelationConstants.StepNodeType, + Properties = new Dictionary(StringComparer.Ordinal) + { + ["rootActorId"] = NormalizeToken(rootActorId), + ["stepId"] = NormalizeToken(step.StepId), + ["stepType"] = step.StepType ?? "", + ["targetRole"] = step.TargetRole ?? "", + ["workerId"] = step.WorkerId ?? "", + ["success"] = step.Success?.ToString() ?? "", + }, + UpdatedAt = updatedAt, + }; + } + + private static ProjectionRelationEdge BuildEdge( + string fromNodeId, + string toNodeId, + string relationType, + DateTimeOffset updatedAt) + { + var normalizedFromNodeId = NormalizeToken(fromNodeId); + var normalizedToNodeId = NormalizeToken(toNodeId); + var normalizedRelationType = NormalizeToken(relationType); + return new ProjectionRelationEdge + { + Scope = WorkflowExecutionRelationConstants.Scope, + EdgeId = BuildEdgeId(normalizedRelationType, normalizedFromNodeId, normalizedToNodeId), + FromNodeId = normalizedFromNodeId, + ToNodeId = normalizedToNodeId, + RelationType = normalizedRelationType, + UpdatedAt = updatedAt, + Properties = new Dictionary(StringComparer.Ordinal), + }; + } + + private static string BuildRunNodeId(string rootActorId, string commandId) + { + var normalizedRootActorId = NormalizeToken(rootActorId); + var normalizedCommandId = NormalizeToken(commandId); + return $"run:{normalizedRootActorId}:{normalizedCommandId}"; + } + + private static string BuildStepNodeId(string rootActorId, string stepId) + { + var normalizedRootActorId = NormalizeToken(rootActorId); + var normalizedStepId = NormalizeToken(stepId); + return $"step:{normalizedRootActorId}:{normalizedStepId}"; + } + + private static string BuildEdgeId(string relationType, string fromNodeId, string toNodeId) + { + var payload = $"{relationType}|{fromNodeId}|{toNodeId}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return $"{relationType}:{Convert.ToHexString(hash.AsSpan(0, 8))}"; + } + + private static string NormalizeToken(string token) + { + var normalized = token?.Trim() ?? ""; + return normalized.Length == 0 ? UnknownToken : normalized; + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs index 3f0cc9fb0..8d21181e0 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs @@ -38,4 +38,40 @@ public WorkflowActorTimelineItem ToActorTimelineItem(WorkflowExecutionTimelineEv Data = new Dictionary(source.Data, StringComparer.Ordinal), }; } + + public WorkflowActorRelationNode ToActorRelationNode(ProjectionRelationNode source) + { + return new WorkflowActorRelationNode + { + NodeId = source.NodeId, + NodeType = source.NodeType, + UpdatedAt = source.UpdatedAt, + Properties = new Dictionary(source.Properties, StringComparer.Ordinal), + }; + } + + public WorkflowActorRelationItem ToActorRelationItem(ProjectionRelationEdge source) + { + return new WorkflowActorRelationItem + { + EdgeId = source.EdgeId, + FromNodeId = source.FromNodeId, + ToNodeId = source.ToNodeId, + RelationType = source.RelationType, + UpdatedAt = source.UpdatedAt, + Properties = new Dictionary(source.Properties, StringComparer.Ordinal), + }; + } + + public WorkflowActorRelationSubgraph ToActorRelationSubgraph( + string rootNodeId, + ProjectionRelationSubgraph source) + { + return new WorkflowActorRelationSubgraph + { + RootNodeId = rootNodeId, + Nodes = source.Nodes.Select(ToActorRelationNode).ToList(), + Edges = source.Edges.Select(ToActorRelationItem).ToList(), + }; + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs new file mode 100644 index 000000000..be34a7cf7 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs @@ -0,0 +1,18 @@ +namespace Aevatar.Workflow.Projection.ReadModels; + +public static class WorkflowExecutionRelationConstants +{ + public const string Scope = "workflow-execution-relations"; + + public const string ActorNodeType = "Actor"; + + public const string RunNodeType = "WorkflowRun"; + + public const string StepNodeType = "WorkflowStep"; + + public const string RelationOwns = "OWNS"; + + public const string RelationContainsStep = "CONTAINS_STEP"; + + public const string RelationChildOf = "CHILD_OF"; +} diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index b9c78feba..d34135d77 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( keyFormatter: key => key, listSortSelector: report => report.StartedAt, listTakeMax: 200); + services.AddInMemoryRelationStoreRegistration(); services.AddElasticsearchReadModelStoreRegistration( optionsFactory: _ => @@ -39,6 +40,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( indexScope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); + services.AddElasticsearchRelationStoreRegistration(); services.AddNeo4jReadModelStoreRegistration( optionsFactory: _ => @@ -50,6 +52,14 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( scope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); + services.AddNeo4jRelationStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new Neo4jProjectionRelationStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + return providerOptions; + }, + scope: WorkflowExecutionRelationConstants.Scope); return services; } diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs index 404262be4..580184f85 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs @@ -292,6 +292,85 @@ public async Task GetActorSnapshotAsync_ShouldReturnProjectionPortResult() detail.LastCommandId.Should().Be("cmd-1"); detail.TotalSteps.Should().Be(3); } + + [Fact] + public async Task ListActorRelationsAsync_ShouldReturnProjectionPortResult() + { + var relation = new WorkflowActorRelationItem + { + EdgeId = "edge-1", + FromNodeId = "actor-1", + ToNodeId = "actor-2", + RelationType = "CHILD_OF", + UpdatedAt = DateTimeOffset.UtcNow, + }; + var projection = new FakeProjectionService + { + EnableActorQueryEndpointsValue = true, + RelationsByActorId = new Dictionary>(StringComparer.Ordinal) + { + ["actor-1"] = [relation], + }, + }; + var queryService = new WorkflowExecutionQueryApplicationService( + new WorkflowDefinitionRegistry(), + projection); + + var items = await queryService.ListActorRelationsAsync("actor-1", ct: CancellationToken.None); + + items.Should().ContainSingle(); + items[0].EdgeId.Should().Be("edge-1"); + items[0].RelationType.Should().Be("CHILD_OF"); + } + + [Fact] + public async Task GetActorRelationSubgraphAsync_ShouldReturnProjectionPortResult() + { + var subgraph = new WorkflowActorRelationSubgraph + { + RootNodeId = "actor-1", + Nodes = + [ + new WorkflowActorRelationNode + { + NodeId = "actor-1", + NodeType = "Actor", + }, + new WorkflowActorRelationNode + { + NodeId = "actor-2", + NodeType = "Actor", + }, + ], + Edges = + [ + new WorkflowActorRelationItem + { + EdgeId = "edge-1", + FromNodeId = "actor-1", + ToNodeId = "actor-2", + RelationType = "CHILD_OF", + }, + ], + }; + var projection = new FakeProjectionService + { + EnableActorQueryEndpointsValue = true, + SubgraphByActorId = new Dictionary(StringComparer.Ordinal) + { + ["actor-1"] = subgraph, + }, + }; + var queryService = new WorkflowExecutionQueryApplicationService( + new WorkflowDefinitionRegistry(), + projection); + + var item = await queryService.GetActorRelationSubgraphAsync("actor-1", ct: CancellationToken.None); + + item.RootNodeId.Should().Be("actor-1"); + item.Nodes.Should().HaveCount(2); + item.Edges.Should().ContainSingle(x => x.EdgeId == "edge-1"); + } } public class ActorRuntimeWorkflowExecutionTopologyResolverTests @@ -327,6 +406,8 @@ internal sealed class FakeProjectionService : IWorkflowExecutionProjectionPort public IWorkflowExecutionProjectionLease? LastLease { get; private set; } public Dictionary SnapshotByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary> TimelineByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); public IReadOnlyList SnapshotList { get; set; } = []; public bool EnableActorQueryEndpoints => EnableActorQueryEndpointsValue; @@ -389,6 +470,38 @@ public Task> ListActorTimelineAsync( return Task.FromResult>(timeline.Take(Math.Max(1, take)).ToList()); } + public Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + _ = ct; + if (!RelationsByActorId.TryGetValue(actorId, out var relations)) + relations = []; + + return Task.FromResult>(relations.Take(Math.Max(1, take)).ToList()); + } + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + _ = depth; + _ = take; + _ = ct; + if (!SubgraphByActorId.TryGetValue(actorId, out var subgraph)) + { + subgraph = new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }; + } + + return Task.FromResult(subgraph); + } + private sealed record FakeProjectionLease(string ActorId, string CommandId) : IWorkflowExecutionProjectionLease; } diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs index 239a49279..b2b8c0963 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs @@ -357,6 +357,32 @@ public Task> ListActorTimelineAsync(str ct.ThrowIfCancellationRequested(); return Task.FromResult>([]); } + + public Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + _ = actorId; + _ = take; + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + _ = depth; + _ = take; + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }); + } } private sealed class ComponentEnvelopeFactory : ICommandEnvelopeFactory diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs index 068249ead..0f4196afa 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs @@ -39,6 +39,8 @@ public void MapWorkflowCapabilityEndpoints_ShouldRegisterCoreRoutes() routePatterns.Should().Contain("/api/ws/chat"); routePatterns.Should().Contain("/api/actors/{actorId}"); routePatterns.Should().Contain("/api/actors/{actorId}/timeline"); + routePatterns.Should().Contain("/api/actors/{actorId}/relations"); + routePatterns.Should().Contain("/api/actors/{actorId}/relation-subgraph"); } [Fact] @@ -268,6 +270,84 @@ public async Task ListActorTimeline_ShouldReturnTimelineItems() doc.RootElement[0].GetProperty("stage").GetString().Should().Be("workflow.start"); } + [Fact] + public async Task ListActorRelations_ShouldReturnRelationItems() + { + var queryService = new FakeQueryService + { + ActorQueryEnabledValue = true, + RelationsByActorId = new Dictionary>(StringComparer.Ordinal) + { + ["actor-1"] = + [ + new WorkflowActorRelationItem + { + EdgeId = "edge-1", + FromNodeId = "actor-1", + ToNodeId = "actor-2", + RelationType = "CHILD_OF", + }, + ], + }, + }; + + var result = await ChatQueryEndpoints.ListActorRelations("actor-1", queryService, 50, CancellationToken.None); + var (statusCode, body) = await ExecuteResultAsync(result); + using var doc = JsonDocument.Parse(body); + + statusCode.Should().Be(StatusCodes.Status200OK); + doc.RootElement.GetArrayLength().Should().Be(1); + doc.RootElement[0].GetProperty("edgeId").GetString().Should().Be("edge-1"); + } + + [Fact] + public async Task GetActorRelationSubgraph_ShouldReturnSubgraph() + { + var queryService = new FakeQueryService + { + ActorQueryEnabledValue = true, + SubgraphByActorId = new Dictionary(StringComparer.Ordinal) + { + ["actor-1"] = new WorkflowActorRelationSubgraph + { + RootNodeId = "actor-1", + Nodes = + [ + new WorkflowActorRelationNode + { + NodeId = "actor-1", + NodeType = "Actor", + }, + new WorkflowActorRelationNode + { + NodeId = "actor-2", + NodeType = "Actor", + }, + ], + Edges = + [ + new WorkflowActorRelationItem + { + EdgeId = "edge-1", + FromNodeId = "actor-1", + ToNodeId = "actor-2", + RelationType = "CHILD_OF", + }, + ], + }, + }, + }; + + var result = await ChatQueryEndpoints.GetActorRelationSubgraph("actor-1", queryService, 2, 50, CancellationToken.None); + var (statusCode, body) = await ExecuteResultAsync(result); + using var doc = JsonDocument.Parse(body); + + statusCode.Should().Be(StatusCodes.Status200OK); + doc.RootElement.GetProperty("rootNodeId").GetString().Should().Be("actor-1"); + doc.RootElement.GetProperty("nodes").GetArrayLength().Should().Be(2); + doc.RootElement.GetProperty("edges").GetArrayLength().Should().Be(1); + } + private static DefaultHttpContext CreateHttpContext() { return new DefaultHttpContext @@ -332,6 +412,8 @@ private sealed class FakeQueryService : public IReadOnlyList Workflows { get; set; } = []; public Dictionary SnapshotByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary> TimelineByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); public bool ActorQueryEnabled => ActorQueryEnabledValue; @@ -353,6 +435,36 @@ public Task> ListActorTimelineAsync(str return Task.FromResult>(items.Take(Math.Max(1, take)).ToList()); } + + public Task> ListActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + { + if (!RelationsByActorId.TryGetValue(actorId, out var items)) + items = []; + + return Task.FromResult>(items.Take(Math.Max(1, take)).ToList()); + } + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + _ = depth; + _ = take; + if (!SubgraphByActorId.TryGetValue(actorId, out var item)) + { + item = new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }; + } + + return Task.FromResult(item); + } } private static CommandExecutionResult ToCoreResult( diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs index 7697aa284..aa08858fd 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs @@ -189,6 +189,12 @@ private sealed class FakeQueryService : IWorkflowExecutionQueryApplicationServic return Task.FromResult(Snapshot); } public Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); + public Task> ListActorRelationsAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); + public Task GetActorRelationSubgraphAsync(string actorId, int depth = 2, int take = 200, CancellationToken ct = default) => + Task.FromResult(new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }); } private sealed class FakeWebSocket : WebSocket diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs index fd66fb26d..91bd4236d 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs @@ -244,6 +244,22 @@ public Task> ListAgentsAsync(CancellationTok public Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); + + public Task> ListActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) => + Task.FromResult(new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }); } private sealed class FakeWebSocketFeature : IHttpWebSocketFeature diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 9ad85d5ca..0ba3c4b76 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -78,8 +78,10 @@ public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefaul using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); store.Should().BeOfType>(); + relationStore.Should().BeOfType(); var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.ProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); } @@ -95,8 +97,10 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfigured_Shoul using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); store.Should().BeOfType>(); + relationStore.Should().BeOfType(); var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); @@ -113,8 +117,10 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNeo4jConfigured_ShouldR await using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); store.Should().BeOfType>(); + relationStore.Should().BeOfType(); var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Graph); @@ -398,6 +404,7 @@ private static void RegisterElasticsearchProvider(IServiceCollection services) indexScope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); + services.AddElasticsearchRelationStoreRegistration(); } private static void RegisterInMemoryProvider(IServiceCollection services) @@ -406,6 +413,7 @@ private static void RegisterInMemoryProvider(IServiceCollection services) keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.StartedAt); + services.AddInMemoryRelationStoreRegistration(); } private static void RegisterNeo4jProvider(IServiceCollection services) @@ -421,6 +429,15 @@ private static void RegisterNeo4jProvider(IServiceCollection services) scope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); + services.AddNeo4jRelationStoreRegistration( + optionsFactory: _ => new Neo4jProjectionRelationStoreOptions + { + Uri = "bolt://localhost:7687", + Username = "neo4j", + Password = "test", + AutoCreateConstraints = false, + }, + scope: WorkflowExecutionRelationConstants.Scope); } public sealed class CustomChatRequestReducer : WorkflowExecutionEventReducerBase diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 4a6a6bfa3..9784e8d86 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -69,6 +69,11 @@ public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProviders .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) .ToList(); providerRegistrations.Should().HaveCount(3); + + var relationRegistrations = builder.Services + .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .ToList(); + relationRegistrations.Should().HaveCount(3); } [Fact] @@ -84,5 +89,10 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) .ToList(); providerRegistrations.Should().HaveCount(3); + + var relationRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .ToList(); + relationRegistrations.Should().HaveCount(3); } } From cdebcbc2d0904e79b53afea5daf4d332c1e6b2b7 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 15:40:11 +0800 Subject: [PATCH 21/46] Refactor ReadModel Graph Relations and Enhance Projection Architecture - Completed Phase 2 of the ReadModel graph relations refactor, implementing breaking changes to improve the architecture. - Introduced `IProjectionStoreStartupValidator` for enhanced validation of read model and relation providers during startup. - Split `IWorkflowExecutionProjectionPort` into `IWorkflowExecutionProjectionLifecyclePort` and `IWorkflowExecutionProjectionQueryPort`, clarifying responsibilities and improving maintainability. - Updated dependency injection to register new lifecycle and query services, ensuring proper integration with the refactored architecture. - Enhanced documentation to reflect changes in the projection architecture and the new validation mechanisms, providing clearer guidance for future development. --- docs/CQRS_ARCHITECTURE.md | 5 +- docs/FOUNDATION.md | 3 +- ...odel-graph-relations-refactor-blueprint.md | 84 ++++++-- .../IProjectionPortActivationService.cs | 14 ++ .../IProjectionPortLiveSinkForwarder.cs | 13 ++ .../IProjectionPortReleaseService.cs | 11 + .../IProjectionPortSinkSubscriptionManager.cs | 18 ++ .../IProjectionStoreStartupValidator.cs | 15 ++ .../ProjectionReadModelRuntimeOptions.cs | 2 + .../README.md | 1 + .../ProjectionLifecyclePortServiceBase.cs | 102 +++++++++ .../ProjectionQueryPortServiceBase.cs | 96 +++++++++ src/Aevatar.CQRS.Projection.Core/README.md | 2 + .../ServiceCollectionExtensions.cs | 1 + .../ProjectionStoreStartupValidator.cs | 48 +++++ ...orkflowExecutionProjectionLifecyclePort.cs | 29 +++ ... IWorkflowExecutionProjectionQueryPort.cs} | 26 +-- ...orkflowExecutionQueryApplicationService.cs | 4 +- .../Aevatar.Workflow.Application/README.md | 2 +- .../Runs/WorkflowRunContextFactory.cs | 4 +- .../Runs/WorkflowRunResourceFinalizer.cs | 4 +- ...owCapabilityServiceCollectionExtensions.cs | 2 + .../WorkflowExecutionProjectionOptions.cs | 10 + .../ServiceCollectionExtensions.cs | 15 +- .../IWorkflowProjectionActivationService.cs | 11 +- .../IWorkflowProjectionLiveSinkForwarder.cs | 9 +- .../IWorkflowProjectionReleaseService.cs | 8 +- ...rkflowProjectionSinkSubscriptionManager.cs | 13 +- .../IWorkflowReadModelSelectionPlanner.cs | 6 +- ...flowExecutionProjectionLifecycleService.cs | 62 ++++++ ...WorkflowExecutionProjectionQueryService.cs | 89 ++++++++ .../WorkflowExecutionProjectionService.cs | 169 --------------- .../WorkflowProjectionQueryReader.cs | 28 +-- .../WorkflowReadModelSelectionPlanner.cs | 37 +++- ...ReadModelStartupValidationHostedService.cs | 54 +++-- .../WorkflowExecutionRelationProjector.cs | 198 ++++++++++++++---- .../Aevatar.Workflow.Projection/README.md | 15 +- src/workflow/README.md | 8 +- .../WorkflowApplicationLayerTests.cs | 4 +- .../WorkflowRunOrchestrationComponentTests.cs | 2 +- ...lowExecutionProjectionRegistrationTests.cs | 29 ++- ...WorkflowExecutionProjectionServiceTests.cs | 116 ++++++++-- ...WorkflowExecutionRelationProjectorTests.cs | 142 +++++++++++++ ...owProjectionOrchestrationComponentTests.cs | 5 +- .../WorkflowReadModelSelectionPlannerTests.cs | 29 ++- tools/ci/architecture_guards.sh | 27 ++- 46 files changed, 1171 insertions(+), 401 deletions(-) create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecyclePortServiceBase.cs create mode 100644 src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs create mode 100644 src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs rename src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/{IWorkflowExecutionProjectionPort.cs => IWorkflowExecutionProjectionQueryPort.cs} (54%) create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs create mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs diff --git a/docs/CQRS_ARCHITECTURE.md b/docs/CQRS_ARCHITECTURE.md index 3c8a4b54d..4bce64725 100644 --- a/docs/CQRS_ARCHITECTURE.md +++ b/docs/CQRS_ARCHITECTURE.md @@ -55,7 +55,10 @@ flowchart LR `WorkflowRunCompletionPolicy`(终态) + `WorkflowRunResourceFinalizer`(清理)。 2. Projection 端口实现已拆分为: - `WorkflowExecutionProjectionService`(facade) + + `WorkflowExecutionProjectionLifecycleService`(生命周期端口) + + `WorkflowExecutionProjectionQueryService`(查询端口) + + `ProjectionLifecyclePortServiceBase<>`(通用基类) + + `ProjectionQueryPortServiceBase<>`(通用基类) + `WorkflowProjectionActivationService`(激活) + `WorkflowProjectionReleaseService`(释放) + `WorkflowProjectionLeaseManager`(ownership) + diff --git a/docs/FOUNDATION.md b/docs/FOUNDATION.md index c8e7ca85b..f53f859c3 100644 --- a/docs/FOUNDATION.md +++ b/docs/FOUNDATION.md @@ -131,7 +131,8 @@ Agent 收到 `EventEnvelope` 后,会将两类处理器合并执行: - `Aevatar.Foundation.Projection`:提供读模型最小公共字段(`RootActorId/CommandId/StateVersion/LastEventId`)与通用能力接口(Timeline / RoleReplies) - `Aevatar.AI.Projection`:提供 AI 通用事件 reducer(`TextMessage*` / `Tool*`)和 `IProjectionEventApplier<,,>` 扩展模式 - **WorkflowExecution 业务扩展** 在 `Aevatar.Workflow.Projection`: - - `WorkflowExecutionProjectionService` 作为应用端口 facade(仅流程编排) + - `WorkflowExecutionProjectionLifecycleService`(生命周期端口)与 `WorkflowExecutionProjectionQueryService`(查询端口) + - 两者复用 `Aevatar.CQRS.Projection.Core` 的通用基类:`ProjectionLifecyclePortServiceBase<>` / `ProjectionQueryPortServiceBase<>` - `WorkflowProjectionActivationService` 负责 projection 启动与上下文激活 - `WorkflowProjectionReleaseService` 负责 idle 检测与 stop/release - `WorkflowProjectionLeaseManager` 负责 ownership acquire/release diff --git a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md index 9af5ddb27..471528212 100644 --- a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md +++ b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md @@ -1,8 +1,8 @@ # ReadModel 图关系能力重构文档(实施版) ## 1. 文档信息 -- 状态:Implemented(当前分支已落地) -- 版本:v2.0 +- 状态:Phase2 Refactor Implemented(Breaking) +- 版本:v3.0 - 日期:2026-02-24 - 分支:`feat/readmodel-graph-relations` - 适用目录:`src/`、`test/`、`docs/architecture/` @@ -62,6 +62,11 @@ flowchart LR J --> L ``` +### 5.1 说明 +1. 上图描述的是当前已落地主链路(Phase1 + Phase2)。 +2. Phase2 已完成:Projection 端口拆分为 Lifecycle/Query,ReadModel/Relation provider 配置与校验解耦。 +3. 运行时启动校验抽象已下沉到 CQRS Runtime:`IProjectionStoreStartupValidator`。 + ## 6. 抽象层重构内容 ### 6.1 新增关系契约 @@ -167,12 +172,14 @@ flowchart LR 扩展: 1. `IWorkflowProjectionQueryReader` 2. `WorkflowProjectionQueryReader` -3. `IWorkflowExecutionProjectionPort` -4. `WorkflowExecutionProjectionService` -5. `IWorkflowExecutionQueryApplicationService` -6. `WorkflowExecutionQueryApplicationService` -7. `WorkflowExecutionReadModelMapper` -8. `WorkflowExecutionQueryModels` +3. `IWorkflowExecutionProjectionLifecyclePort` +4. `IWorkflowExecutionProjectionQueryPort` +5. `WorkflowExecutionProjectionLifecycleService` +6. `WorkflowExecutionProjectionQueryService` +7. `IWorkflowExecutionQueryApplicationService` +8. `WorkflowExecutionQueryApplicationService` +9. `WorkflowExecutionReadModelMapper` +10. `WorkflowExecutionQueryModels` 新增: 1. `WorkflowExecutionRelationConstants` @@ -180,7 +187,7 @@ flowchart LR ### 9.3 Workflow DI 选择器接入 文件:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` 1. 在既有 ReadModel selector 基础上增加 Relation selector。 -2. `IProjectionRelationStore` 使用同一 selection plan(provider + requirements)。 +2. `IProjectionRelationStore` 使用独立 relation selection(`RelationSelectionOptions + RelationRequirements`)。 ### 9.4 启动期 fail-fast 校验增强 文件:`WorkflowReadModelStartupValidationHostedService.cs` @@ -223,14 +230,19 @@ flowchart LR | Neo4j | Graph | Yes | Yes | 生产图关系能力 | ## 13. 迁移与上线建议(最佳实践) -1. Phase 1:合并结构改造(本次) +1. Phase 1:合并结构改造(已完成) - 接口、Runtime、Provider、Workflow API 全链路到位。 -2. Phase 2:关系数据回填(可选) +2. Phase 2:架构收敛(已完成) +- 拆分 `ProjectionPort` 读写职责,删除混合接口。 +- 将启动校验下沉到 Runtime 通用抽象。 +- 分离 ReadModel 与 Relation 的 provider 选择配置。 +- 删除 QueryReader relation fallback,错误配置 fail-fast。 +3. Phase 3:关系数据回填(可选) - 从历史 `WorkflowExecutionReport.Topology` 扫描并幂等写回关系边。 -3. Phase 3:灰度切流 +4. Phase 4:灰度切流 - 对关键租户/环境启用 `Graph + Neo4j` 绑定。 -4. Phase 4:清理 -- 删除历史临时拼装逻辑,固定关系查询走 relation store。 +5. Phase 5:运行优化 +- 根据业务规模补充 relation 查询指标、告警与容量基线。 ## 14. 验证与门禁 @@ -250,13 +262,16 @@ flowchart LR ## 15. 风险与后续优化 ### 15.1 当前风险 -1. Elasticsearch relation store 为 no-op,若配置为文档 provider 且未声明 Graph 绑定,关系查询会返回空结果。 -2. Workflow relation projector 目前以 `CompleteAsync` 为主写入点,超长运行中间态关系不实时可见。 +1. Elasticsearch relation store 为 no-op,若将其配置为 `RelationProvider` 将在选择阶段 fail-fast(需显式配置支持关系能力的 provider)。 +2. `CHILD_OF` 关系在 `CompleteAsync` 阶段写入;超长运行期间拓扑中间态不可见。 +3. `StepCompletedEvent` 缺少 `step_type/target_role`,关系投影需要读取已存在 step 节点做字段合并,对 relation store 的节点读取语义有依赖。 +4. ReadModel/Relation 双 provider 部署下,文档视图与关系视图在异步投影时可能出现短暂最终一致性窗口。 ### 15.2 建议优化 1. 增加 relation query 指标(QPS、P95、结果规模)与告警阈值。 2. 增加 Neo4j relation E2E(容器化)专项测试。 3. 如需实时关系可视化,可在 `ProjectAsync` 逐步增量写入关键边类型。 +4. 在配置中心增加 `ReadModelProvider/RelationProvider` 组合审计,防止发布时误配。 ## 16. 关键变更清单(按层) @@ -274,10 +289,43 @@ flowchart LR ### 16.4 Workflow 1. 新增 `WorkflowExecutionRelationProjector`。 -2. Query/Application/Port/API 全链路支持 relations/subgraph。 -3. 启动期 relation provider fail-fast。 +2. 应用端口拆分为 `LifecyclePort + QueryPort`,Application 层按职责依赖端口。 +3. Query/Application/API 全链路支持 relations/subgraph。 +4. 启动期 relation provider fail-fast。 ### 16.5 Tests 1. 全部 fake 接口实现同步。 2. 新增关系查询应用层测试与 API 端点测试。 3. Host provider 注册覆盖扩展到 relation registration。 + +## 17. Phase2 整改完成记录(2026-02-24) + +### 17.1 已完成整改项 +1. 端口拆分完成: +- `IWorkflowExecutionProjectionLifecyclePort`(Ensure/Attach/Detach/Release) +- `IWorkflowExecutionProjectionQueryPort`(Snapshot/Timeline/Relations/Subgraph) +2. Provider 解耦完成: +- 新增 `RelationProvider` 独立配置,支持与 `ReadModelProvider` 不同。 +- 选择计划拆分为 `ReadModelSelectionOptions/Requirements` 与 `RelationSelectionOptions/Requirements`。 +3. Runtime 抽象下沉完成: +- 新增 `IProjectionStoreStartupValidator`,启动校验通过 Runtime 抽象执行。 +- 新增通用端口基类:`ProjectionLifecyclePortServiceBase<>`、`ProjectionQueryPortServiceBase<>`,Workflow 端只保留领域类型绑定。 +4. 失败语义收敛完成: +- 删除 QueryReader relation fallback。 +- 关系 provider 不满足能力时在选择阶段 fail-fast。 +5. 投影耦合收敛完成: +- `WorkflowExecutionRelationProjector` 不再依赖 ReadModelStore。 +- step 关系字段通过 relation store 读写合并,避免覆盖已写入属性。 +6. 治理门禁同步完成: +- `architecture_guards.sh` 已升级为校验 Lifecycle/Query 双端口约束。 + +### 17.2 DoD 验收结果 +1. `Workflow.Application` 已不依赖混合 ProjectionPort:通过。 +2. 生产可验证 `ReadModelProvider != RelationProvider`:通过(新增 Elasticsearch + InMemory 组合测试)。 +3. 错误 relation 配置 fail-fast:通过(新增 Elasticsearch 未配置 RelationProvider 失败测试)。 +4. 架构与稳定性门禁:通过。 +5. `dotnet build/test` 全量验证:通过。 + +### 17.3 兼容性声明 +1. 本次重构为不兼容变更,已删除 `IWorkflowExecutionProjectionPort`。 +2. 旧接口调用方必须迁移到 Lifecycle/Query 双端口。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs new file mode 100644 index 000000000..407ade9c0 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +/// +/// Generic activation contract for projection runtime lease acquisition. +/// +public interface IProjectionPortActivationService +{ + Task EnsureAsync( + string rootEntityId, + string projectionName, + string input, + string commandId, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs new file mode 100644 index 000000000..14129a60b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +/// +/// Generic forwarder contract for projection runtime events to external sinks. +/// +public interface IProjectionPortLiveSinkForwarder +{ + ValueTask ForwardAsync( + TLease lease, + TSink sink, + TEvent evt, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs new file mode 100644 index 000000000..689c66054 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs @@ -0,0 +1,11 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +/// +/// Generic release contract for projection runtime lease lifecycle. +/// +public interface IProjectionPortReleaseService +{ + Task ReleaseIfIdleAsync( + TLease lease, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs new file mode 100644 index 000000000..3332fcbc5 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs @@ -0,0 +1,18 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +/// +/// Generic sink subscription manager for projection live-stream delivery. +/// +public interface IProjectionPortSinkSubscriptionManager +{ + Task AttachOrReplaceAsync( + TLease lease, + TSink sink, + Func handler, + CancellationToken ct = default); + + Task DetachAsync( + TLease lease, + TSink sink, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs new file mode 100644 index 000000000..d66074f97 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionStoreStartupValidator +{ + IProjectionReadModelStoreRegistration ValidateReadModelProvider( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class; + + IProjectionRelationStoreRegistration ValidateRelationProvider( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs index de1ca1ac1..7132b612d 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs @@ -11,6 +11,8 @@ public ProjectionReadModelRuntimeOptions() public string Provider { get; set; } = ProjectionReadModelProviderNames.InMemory; + public string RelationProvider { get; set; } = ""; + public bool FailOnUnsupportedCapabilities { get; set; } = true; public Dictionary Bindings { get; } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Abstractions/README.md index 6b3efd136..8c3abc60c 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Abstractions/README.md @@ -5,6 +5,7 @@ ## 包含内容 - 生命周期与编排:`IProjectionLifecycleService<,>`、`IProjectionCoordinator<,>`、`IProjectionDispatcher<>`、`IProjectionSubscriptionRegistry<>` +- 端口抽象:`IProjectionPortActivationService<>`、`IProjectionPortReleaseService<>`、`IProjectionPortSinkSubscriptionManager<,,>`、`IProjectionPortLiveSinkForwarder<,,>` - 扩展抽象:`IProjectionProjector<,>`、`IProjectionEventReducer<,>` - 失败回传:`IProjectionDispatchFailureReporter<>` - 读模型存储:`IProjectionReadModelStore<,>` diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecyclePortServiceBase.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecyclePortServiceBase.cs new file mode 100644 index 000000000..4858a27a8 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecyclePortServiceBase.cs @@ -0,0 +1,102 @@ +namespace Aevatar.CQRS.Projection.Core.Orchestration; + +/// +/// Generic lifecycle port base that centralizes projection enable-gate and lease/sink orchestration. +/// +public abstract class ProjectionLifecyclePortServiceBase + where TLeaseContract : class + where TRuntimeLease : class, TLeaseContract + where TSink : class +{ + private readonly Func _projectionEnabledAccessor; + private readonly IProjectionPortActivationService _activationService; + private readonly IProjectionPortReleaseService _releaseService; + private readonly IProjectionPortSinkSubscriptionManager _sinkSubscriptionManager; + private readonly IProjectionPortLiveSinkForwarder _liveSinkForwarder; + + protected ProjectionLifecyclePortServiceBase( + Func projectionEnabledAccessor, + IProjectionPortActivationService activationService, + IProjectionPortReleaseService releaseService, + IProjectionPortSinkSubscriptionManager sinkSubscriptionManager, + IProjectionPortLiveSinkForwarder liveSinkForwarder) + { + _projectionEnabledAccessor = projectionEnabledAccessor ?? throw new ArgumentNullException(nameof(projectionEnabledAccessor)); + _activationService = activationService ?? throw new ArgumentNullException(nameof(activationService)); + _releaseService = releaseService ?? throw new ArgumentNullException(nameof(releaseService)); + _sinkSubscriptionManager = sinkSubscriptionManager ?? throw new ArgumentNullException(nameof(sinkSubscriptionManager)); + _liveSinkForwarder = liveSinkForwarder ?? throw new ArgumentNullException(nameof(liveSinkForwarder)); + } + + protected bool ProjectionEnabledCore => _projectionEnabledAccessor(); + + protected async Task EnsureProjectionAsync( + string rootEntityId, + string projectionName, + string input, + string commandId, + CancellationToken ct = default) + { + if (!ProjectionEnabledCore || string.IsNullOrWhiteSpace(rootEntityId)) + return null; + + return await _activationService.EnsureAsync( + rootEntityId, + projectionName, + input, + commandId, + ct); + } + + protected async Task AttachSinkAsync( + TLeaseContract lease, + TSink sink, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(lease); + ArgumentNullException.ThrowIfNull(sink); + ct.ThrowIfCancellationRequested(); + + if (!ProjectionEnabledCore) + return; + + var runtimeLease = ResolveRuntimeLease(lease); + await _sinkSubscriptionManager.AttachOrReplaceAsync( + runtimeLease, + sink, + evt => _liveSinkForwarder.ForwardAsync(runtimeLease, sink, evt, CancellationToken.None), + ct); + } + + protected async Task DetachSinkAsync( + TLeaseContract lease, + TSink sink, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(lease); + ArgumentNullException.ThrowIfNull(sink); + ct.ThrowIfCancellationRequested(); + + if (!ProjectionEnabledCore) + return; + + var runtimeLease = ResolveRuntimeLease(lease); + await _sinkSubscriptionManager.DetachAsync(runtimeLease, sink, ct); + } + + protected async Task ReleaseProjectionAsync( + TLeaseContract lease, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(lease); + ct.ThrowIfCancellationRequested(); + + if (!ProjectionEnabledCore) + return; + + var runtimeLease = ResolveRuntimeLease(lease); + await _releaseService.ReleaseIfIdleAsync(runtimeLease, ct); + } + + protected abstract TRuntimeLease ResolveRuntimeLease(TLeaseContract lease); +} diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs new file mode 100644 index 000000000..73506a514 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs @@ -0,0 +1,96 @@ +namespace Aevatar.CQRS.Projection.Core.Orchestration; + +/// +/// Generic query port base that centralizes query enable-gate behavior. +/// +public abstract class ProjectionQueryPortServiceBase +{ + private readonly Func _queryEnabledAccessor; + + protected ProjectionQueryPortServiceBase(Func queryEnabledAccessor) + { + _queryEnabledAccessor = queryEnabledAccessor ?? throw new ArgumentNullException(nameof(queryEnabledAccessor)); + } + + protected bool QueryEnabledCore => _queryEnabledAccessor(); + + protected async Task GetSnapshotAsync( + string entityId, + CancellationToken ct = default) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) + return default; + + return await ReadSnapshotCoreAsync(entityId, ct); + } + + protected async Task> ListSnapshotsAsync( + int take = 200, + CancellationToken ct = default) + { + if (!QueryEnabledCore) + return []; + + return await ReadSnapshotsCoreAsync(take, ct); + } + + protected async Task> ListTimelineAsync( + string entityId, + int take = 200, + CancellationToken ct = default) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) + return []; + + return await ReadTimelineCoreAsync(entityId, take, ct); + } + + protected async Task> GetRelationsAsync( + string entityId, + int take = 200, + CancellationToken ct = default) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) + return []; + + return await ReadRelationsCoreAsync(entityId, take, ct); + } + + protected async Task GetRelationSubgraphAsync( + string entityId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) + return CreateEmptyRelationSubgraph(entityId); + + return await ReadRelationSubgraphCoreAsync(entityId, depth, take, ct); + } + + protected abstract Task ReadSnapshotCoreAsync( + string entityId, + CancellationToken ct); + + protected abstract Task> ReadSnapshotsCoreAsync( + int take, + CancellationToken ct); + + protected abstract Task> ReadTimelineCoreAsync( + string entityId, + int take, + CancellationToken ct); + + protected abstract Task> ReadRelationsCoreAsync( + string entityId, + int take, + CancellationToken ct); + + protected abstract Task ReadRelationSubgraphCoreAsync( + string entityId, + int depth, + int take, + CancellationToken ct); + + protected virtual TRelationSubgraph CreateEmptyRelationSubgraph(string entityId) => default!; +} diff --git a/src/Aevatar.CQRS.Projection.Core/README.md b/src/Aevatar.CQRS.Projection.Core/README.md index 449a92cf1..fa0b94a4f 100644 --- a/src/Aevatar.CQRS.Projection.Core/README.md +++ b/src/Aevatar.CQRS.Projection.Core/README.md @@ -12,6 +12,8 @@ - `ProjectionDispatcher` - `ProjectionSubscriptionRegistry` - `ProjectionLifecycleService` + - `ProjectionLifecyclePortServiceBase` + - `ProjectionQueryPortServiceBase` - `ActorStreamSubscriptionHub` - `ProjectionAssemblyRegistration` - `SystemProjectionClock` diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 658e1ec74..0d1c3f1fd 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -16,6 +16,7 @@ public static IServiceCollection AddProjectionReadModelRuntime(this IServiceColl services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs new file mode 100644 index 000000000..cc57725c5 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs @@ -0,0 +1,48 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionStoreStartupValidator : IProjectionStoreStartupValidator +{ + private readonly IProjectionReadModelProviderRegistry _readModelProviderRegistry; + private readonly IProjectionReadModelProviderSelector _readModelProviderSelector; + private readonly IProjectionRelationStoreProviderRegistry _relationProviderRegistry; + private readonly IProjectionRelationStoreProviderSelector _relationProviderSelector; + + public ProjectionStoreStartupValidator( + IProjectionReadModelProviderRegistry readModelProviderRegistry, + IProjectionReadModelProviderSelector readModelProviderSelector, + IProjectionRelationStoreProviderRegistry relationProviderRegistry, + IProjectionRelationStoreProviderSelector relationProviderSelector) + { + _readModelProviderRegistry = readModelProviderRegistry; + _readModelProviderSelector = readModelProviderSelector; + _relationProviderRegistry = relationProviderRegistry; + _relationProviderSelector = relationProviderSelector; + } + + public IProjectionReadModelStoreRegistration ValidateReadModelProvider( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var registrations = _readModelProviderRegistry.GetRegistrations(serviceProvider); + return _readModelProviderSelector.Select(registrations, selectionOptions, requirements); + } + + public IProjectionRelationStoreRegistration ValidateRelationProvider( + IServiceProvider serviceProvider, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + ArgumentNullException.ThrowIfNull(requirements); + + var registrations = _relationProviderRegistry.GetRegistrations(serviceProvider); + return _relationProviderSelector.Select(registrations, selectionOptions, requirements); + } +} diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs new file mode 100644 index 000000000..5a0cecc75 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs @@ -0,0 +1,29 @@ +using Aevatar.Workflow.Application.Abstractions.Runs; + +namespace Aevatar.Workflow.Application.Abstractions.Projections; + +public interface IWorkflowExecutionProjectionLifecyclePort +{ + bool ProjectionEnabled { get; } + + Task EnsureActorProjectionAsync( + string rootActorId, + string workflowName, + string input, + string commandId, + CancellationToken ct = default); + + Task AttachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default); + + Task DetachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default); + + Task ReleaseActorProjectionAsync( + IWorkflowExecutionProjectionLease lease, + CancellationToken ct = default); +} diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs similarity index 54% rename from src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs rename to src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs index e76d9668d..451207ba1 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs @@ -1,35 +1,11 @@ using Aevatar.Workflow.Application.Abstractions.Queries; -using Aevatar.Workflow.Application.Abstractions.Runs; namespace Aevatar.Workflow.Application.Abstractions.Projections; -public interface IWorkflowExecutionProjectionPort +public interface IWorkflowExecutionProjectionQueryPort { - bool ProjectionEnabled { get; } - bool EnableActorQueryEndpoints { get; } - Task EnsureActorProjectionAsync( - string rootActorId, - string workflowName, - string input, - string commandId, - CancellationToken ct = default); - - Task AttachLiveSinkAsync( - IWorkflowExecutionProjectionLease lease, - IWorkflowRunEventSink sink, - CancellationToken ct = default); - - Task DetachLiveSinkAsync( - IWorkflowExecutionProjectionLease lease, - IWorkflowRunEventSink sink, - CancellationToken ct = default); - - Task ReleaseActorProjectionAsync( - IWorkflowExecutionProjectionLease lease, - CancellationToken ct = default); - Task GetActorSnapshotAsync( string actorId, CancellationToken ct = default); diff --git a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs index f89111b8b..e4164373b 100644 --- a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs @@ -7,11 +7,11 @@ namespace Aevatar.Workflow.Application.Queries; public sealed class WorkflowExecutionQueryApplicationService : IWorkflowExecutionQueryApplicationService { private readonly IWorkflowDefinitionRegistry _workflowRegistry; - private readonly IWorkflowExecutionProjectionPort _projectionPort; + private readonly IWorkflowExecutionProjectionQueryPort _projectionPort; public WorkflowExecutionQueryApplicationService( IWorkflowDefinitionRegistry workflowRegistry, - IWorkflowExecutionProjectionPort projectionPort) + IWorkflowExecutionProjectionQueryPort projectionPort) { _workflowRegistry = workflowRegistry; _projectionPort = projectionPort; diff --git a/src/workflow/Aevatar.Workflow.Application/README.md b/src/workflow/Aevatar.Workflow.Application/README.md index fa2344505..ce9ec45e0 100644 --- a/src/workflow/Aevatar.Workflow.Application/README.md +++ b/src/workflow/Aevatar.Workflow.Application/README.md @@ -22,7 +22,7 @@ - `WorkflowRunOutputStreamer` - 读取 run 事件并映射 `WorkflowOutputFrame`。 - `WorkflowExecutionQueryApplicationService` - - `agents/workflows/runs` 查询门面(经 `IWorkflowExecutionProjectionPort` 读取读侧模型)。 + - `agents/workflows/runs` 查询门面(经 `IWorkflowExecutionProjectionQueryPort` 读取读侧模型)。 - `ListAgentsAsync` 仅返回 `WorkflowGAgent`,不扫描暴露非 Workflow actor。 - `WorkflowDefinitionRegistry` - 维护 workflow 名称到 YAML 的内存注册表。 diff --git a/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs b/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs index 05015cef9..f162709b6 100644 --- a/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs +++ b/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunContextFactory.cs @@ -7,12 +7,12 @@ namespace Aevatar.Workflow.Application.Runs; public sealed class WorkflowRunContextFactory : IWorkflowRunContextFactory { private readonly IWorkflowRunActorResolver _actorResolver; - private readonly IWorkflowExecutionProjectionPort _projectionPort; + private readonly IWorkflowExecutionProjectionLifecyclePort _projectionPort; private readonly ICommandContextPolicy _commandContextPolicy; public WorkflowRunContextFactory( IWorkflowRunActorResolver actorResolver, - IWorkflowExecutionProjectionPort projectionPort, + IWorkflowExecutionProjectionLifecyclePort projectionPort, ICommandContextPolicy commandContextPolicy) { _actorResolver = actorResolver; diff --git a/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunResourceFinalizer.cs b/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunResourceFinalizer.cs index 84c9376e4..e5240d06f 100644 --- a/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunResourceFinalizer.cs +++ b/src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunResourceFinalizer.cs @@ -4,9 +4,9 @@ namespace Aevatar.Workflow.Application.Runs; public sealed class WorkflowRunResourceFinalizer : IWorkflowRunResourceFinalizer { - private readonly IWorkflowExecutionProjectionPort _projectionPort; + private readonly IWorkflowExecutionProjectionLifecyclePort _projectionPort; - public WorkflowRunResourceFinalizer(IWorkflowExecutionProjectionPort projectionPort) + public WorkflowRunResourceFinalizer(IWorkflowExecutionProjectionLifecyclePort projectionPort) { _projectionPort = projectionPort; } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index 9ddd97d23..55fd2a1fb 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -47,6 +47,8 @@ private static void ApplyGlobalReadModelOptions( if (!string.IsNullOrWhiteSpace(readModelOptions.Provider)) options.ReadModelProvider = readModelOptions.Provider.Trim(); + if (!string.IsNullOrWhiteSpace(readModelOptions.RelationProvider)) + options.RelationProvider = readModelOptions.RelationProvider.Trim(); options.ReadModelMode = readModelOptions.Mode; options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index 2059b4aff..42c6ed708 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -47,6 +47,11 @@ public bool EnableRunQueryEndpoints /// public string ReadModelProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; + /// + /// Relation store provider name. Empty means fallback to . + /// + public string RelationProvider { get; set; } = ""; + /// /// Whether unsupported provider capabilities should fail fast during startup registration. /// @@ -57,6 +62,11 @@ public bool EnableRunQueryEndpoints /// public bool ValidateReadModelProviderOnStartup { get; set; } = true; + /// + /// Whether to pre-validate relation provider selection and capabilities during host startup. + /// + public bool ValidateRelationProviderOnStartup { get; set; } = true; + /// /// Optional read-model binding requirements (ReadModelName -> IndexKind). /// diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 3d0c34187..cc032d862 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -60,7 +60,12 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton>, ProjectionLifecycleService>>(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + sp.GetRequiredService()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } @@ -122,8 +127,8 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se return storeFactory.Create( sp, - selectionPlan.SelectionOptions, - selectionPlan.Requirements); + selectionPlan.ReadModelSelectionOptions, + selectionPlan.ReadModelRequirements); })); } @@ -138,8 +143,8 @@ private static void RegisterWorkflowRelationStoreSelector(IServiceCollection ser return relationStoreFactory.Create( sp, - selectionPlan.SelectionOptions, - selectionPlan.Requirements); + selectionPlan.RelationSelectionOptions, + selectionPlan.RelationRequirements); })); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs index d8a5f0216..eb487dd04 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs @@ -1,11 +1,6 @@ +using Aevatar.CQRS.Projection.Abstractions; + namespace Aevatar.Workflow.Projection.Orchestration; public interface IWorkflowProjectionActivationService -{ - Task EnsureAsync( - string rootActorId, - string workflowName, - string input, - string commandId, - CancellationToken ct = default); -} + : IProjectionPortActivationService; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs index dbb1b4349..2b8b9c228 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs @@ -1,12 +1,7 @@ using Aevatar.Workflow.Application.Abstractions.Runs; +using Aevatar.CQRS.Projection.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; public interface IWorkflowProjectionLiveSinkForwarder -{ - ValueTask ForwardAsync( - WorkflowExecutionRuntimeLease runtimeLease, - IWorkflowRunEventSink sink, - WorkflowRunEvent evt, - CancellationToken ct = default); -} + : IProjectionPortLiveSinkForwarder; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs index ec4a49f36..df7a43274 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs @@ -1,8 +1,6 @@ +using Aevatar.CQRS.Projection.Abstractions; + namespace Aevatar.Workflow.Projection.Orchestration; public interface IWorkflowProjectionReleaseService -{ - Task ReleaseIfIdleAsync( - WorkflowExecutionRuntimeLease runtimeLease, - CancellationToken ct = default); -} + : IProjectionPortReleaseService; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs index 4a9610ab3..d0e5192b5 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs @@ -1,19 +1,10 @@ using Aevatar.Workflow.Application.Abstractions.Runs; +using Aevatar.CQRS.Projection.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; public interface IWorkflowProjectionSinkSubscriptionManager + : IProjectionPortSinkSubscriptionManager { - Task AttachOrReplaceAsync( - WorkflowExecutionRuntimeLease lease, - IWorkflowRunEventSink sink, - Func handler, - CancellationToken ct = default); - - Task DetachAsync( - WorkflowExecutionRuntimeLease lease, - IWorkflowRunEventSink sink, - CancellationToken ct = default); - int GetSubscriptionCount(WorkflowExecutionRuntimeLease lease); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs index dd6acf4ce..0a4545779 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs @@ -4,8 +4,10 @@ namespace Aevatar.Workflow.Projection.Orchestration; public readonly record struct WorkflowReadModelSelectionPlan( - ProjectionReadModelRequirements Requirements, - ProjectionReadModelStoreSelectionOptions SelectionOptions); + ProjectionReadModelRequirements ReadModelRequirements, + ProjectionReadModelStoreSelectionOptions ReadModelSelectionOptions, + ProjectionReadModelRequirements RelationRequirements, + ProjectionReadModelStoreSelectionOptions RelationSelectionOptions); public interface IWorkflowReadModelSelectionPlanner { diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs new file mode 100644 index 000000000..de3afb5e9 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs @@ -0,0 +1,62 @@ +using Aevatar.Workflow.Application.Abstractions.Projections; +using Aevatar.Workflow.Application.Abstractions.Runs; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.Workflow.Projection.Configuration; + +namespace Aevatar.Workflow.Projection.Orchestration; + +public sealed class WorkflowExecutionProjectionLifecycleService + : ProjectionLifecyclePortServiceBase, + IWorkflowExecutionProjectionLifecyclePort +{ + public WorkflowExecutionProjectionLifecycleService( + WorkflowExecutionProjectionOptions options, + IWorkflowProjectionActivationService activationService, + IWorkflowProjectionReleaseService releaseService, + IWorkflowProjectionSinkSubscriptionManager sinkSubscriptionManager, + IWorkflowProjectionLiveSinkForwarder liveSinkForwarder) + : base( + () => options.Enabled, + activationService, + releaseService, + sinkSubscriptionManager, + liveSinkForwarder) + { + } + + public bool ProjectionEnabled => ProjectionEnabledCore; + + public Task EnsureActorProjectionAsync( + string rootActorId, + string workflowName, + string input, + string commandId, + CancellationToken ct = default) => + EnsureProjectionAsync( + rootActorId, + workflowName, + input, + commandId, + ct); + + public Task AttachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default) => + AttachSinkAsync(lease, sink, ct); + + public Task DetachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default) => + DetachSinkAsync(lease, sink, ct); + + public Task ReleaseActorProjectionAsync( + IWorkflowExecutionProjectionLease lease, + CancellationToken ct = default) => + ReleaseProjectionAsync(lease, ct); + + protected override WorkflowExecutionRuntimeLease ResolveRuntimeLease(IWorkflowExecutionProjectionLease lease) => + lease as WorkflowExecutionRuntimeLease + ?? throw new InvalidOperationException("Unsupported workflow projection lease implementation."); +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs new file mode 100644 index 000000000..742b2cae3 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs @@ -0,0 +1,89 @@ +using Aevatar.Workflow.Application.Abstractions.Projections; +using Aevatar.Workflow.Application.Abstractions.Queries; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.Workflow.Projection.Configuration; + +namespace Aevatar.Workflow.Projection.Orchestration; + +public sealed class WorkflowExecutionProjectionQueryService + : ProjectionQueryPortServiceBase, + IWorkflowExecutionProjectionQueryPort +{ + private readonly IWorkflowProjectionQueryReader _queryReader; + + public WorkflowExecutionProjectionQueryService( + WorkflowExecutionProjectionOptions options, + IWorkflowProjectionQueryReader queryReader) + : base(() => options.Enabled && options.EnableActorQueryEndpoints) + { + _queryReader = queryReader; + } + + public bool EnableActorQueryEndpoints => QueryEnabledCore; + + public Task GetActorSnapshotAsync( + string actorId, + CancellationToken ct = default) => + GetSnapshotAsync(actorId, ct); + + public Task> ListActorSnapshotsAsync( + int take = 200, + CancellationToken ct = default) => + ListSnapshotsAsync(take, ct); + + public Task> ListActorTimelineAsync( + string actorId, + int take = 200, + CancellationToken ct = default) => + ListTimelineAsync(actorId, take, ct); + + public Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) => + GetRelationsAsync(actorId, take, ct); + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) => + GetRelationSubgraphAsync(actorId, depth, take, ct); + + protected override Task ReadSnapshotCoreAsync( + string entityId, + CancellationToken ct) + => _queryReader.GetActorSnapshotAsync(entityId, ct); + + protected override Task> ReadSnapshotsCoreAsync( + int take, + CancellationToken ct) + => _queryReader.ListActorSnapshotsAsync(take, ct); + + protected override Task> ReadTimelineCoreAsync( + string entityId, + int take, + CancellationToken ct) + => _queryReader.ListActorTimelineAsync(entityId, take, ct); + + protected override Task> ReadRelationsCoreAsync( + string entityId, + int take, + CancellationToken ct) + => _queryReader.GetActorRelationsAsync(entityId, take, ct); + + protected override Task ReadRelationSubgraphCoreAsync( + string entityId, + int depth, + int take, + CancellationToken ct) + => _queryReader.GetActorRelationSubgraphAsync(entityId, depth, take, ct); + + protected override WorkflowActorRelationSubgraph CreateEmptyRelationSubgraph(string entityId) + { + return new WorkflowActorRelationSubgraph + { + RootNodeId = entityId ?? string.Empty, + }; + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs deleted file mode 100644 index 2293dc7a7..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs +++ /dev/null @@ -1,169 +0,0 @@ -using Aevatar.Workflow.Application.Abstractions.Projections; -using Aevatar.Workflow.Application.Abstractions.Queries; -using Aevatar.Workflow.Application.Abstractions.Runs; -using Aevatar.Workflow.Projection.Configuration; - -namespace Aevatar.Workflow.Projection.Orchestration; - -/// -/// Application-facing facade for workflow run projection lifecycle and read-model queries. -/// -public sealed class WorkflowExecutionProjectionService : IWorkflowExecutionProjectionPort -{ - private readonly WorkflowExecutionProjectionOptions _options; - private readonly IWorkflowProjectionQueryReader _queryReader; - private readonly IWorkflowProjectionActivationService _activationService; - private readonly IWorkflowProjectionReleaseService _releaseService; - private readonly IWorkflowProjectionSinkSubscriptionManager _sinkSubscriptionManager; - private readonly IWorkflowProjectionLiveSinkForwarder _liveSinkForwarder; - - public WorkflowExecutionProjectionService( - WorkflowExecutionProjectionOptions options, - IWorkflowProjectionQueryReader queryReader, - IWorkflowProjectionActivationService activationService, - IWorkflowProjectionReleaseService releaseService, - IWorkflowProjectionSinkSubscriptionManager sinkSubscriptionManager, - IWorkflowProjectionLiveSinkForwarder liveSinkForwarder) - { - _options = options; - _queryReader = queryReader; - _activationService = activationService; - _releaseService = releaseService; - _sinkSubscriptionManager = sinkSubscriptionManager; - _liveSinkForwarder = liveSinkForwarder; - } - - public bool ProjectionEnabled => _options.Enabled; - - public bool EnableActorQueryEndpoints => _options.Enabled && _options.EnableActorQueryEndpoints; - - public async Task EnsureActorProjectionAsync( - string rootActorId, - string workflowName, - string input, - string commandId, - CancellationToken ct = default) - { - if (!ProjectionEnabled || string.IsNullOrWhiteSpace(rootActorId)) - return null; - - return await _activationService.EnsureAsync( - rootActorId, - workflowName, - input, - commandId, - ct); - } - - public async Task AttachLiveSinkAsync( - IWorkflowExecutionProjectionLease lease, - IWorkflowRunEventSink sink, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(lease); - ArgumentNullException.ThrowIfNull(sink); - ct.ThrowIfCancellationRequested(); - - if (!ProjectionEnabled) - return; - - var runtimeLease = ResolveRuntimeLease(lease); - await _sinkSubscriptionManager.AttachOrReplaceAsync( - runtimeLease, - sink, - evt => _liveSinkForwarder.ForwardAsync(runtimeLease, sink, evt, CancellationToken.None), - ct); - } - - public async Task DetachLiveSinkAsync( - IWorkflowExecutionProjectionLease lease, - IWorkflowRunEventSink sink, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(lease); - ArgumentNullException.ThrowIfNull(sink); - ct.ThrowIfCancellationRequested(); - - if (!ProjectionEnabled) - return; - - var runtimeLease = ResolveRuntimeLease(lease); - await _sinkSubscriptionManager.DetachAsync(runtimeLease, sink, ct); - } - - public async Task ReleaseActorProjectionAsync( - IWorkflowExecutionProjectionLease lease, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(lease); - ct.ThrowIfCancellationRequested(); - if (!ProjectionEnabled) - return; - - var runtimeLease = ResolveRuntimeLease(lease); - await _releaseService.ReleaseIfIdleAsync(runtimeLease, ct); - } - - public async Task GetActorSnapshotAsync( - string actorId, - CancellationToken ct = default) - { - if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) - return null; - - return await _queryReader.GetActorSnapshotAsync(actorId, ct); - } - - public async Task> ListActorSnapshotsAsync( - int take = 200, - CancellationToken ct = default) - { - if (!EnableActorQueryEndpoints) - return []; - - return await _queryReader.ListActorSnapshotsAsync(take, ct); - } - - public async Task> ListActorTimelineAsync( - string actorId, - int take = 200, - CancellationToken ct = default) - { - if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) - return []; - - return await _queryReader.ListActorTimelineAsync(actorId, take, ct); - } - - public async Task> GetActorRelationsAsync( - string actorId, - int take = 200, - CancellationToken ct = default) - { - if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) - return []; - - return await _queryReader.GetActorRelationsAsync(actorId, take, ct); - } - - public async Task GetActorRelationSubgraphAsync( - string actorId, - int depth = 2, - int take = 200, - CancellationToken ct = default) - { - if (!EnableActorQueryEndpoints || string.IsNullOrWhiteSpace(actorId)) - { - return new WorkflowActorRelationSubgraph - { - RootNodeId = actorId ?? string.Empty, - }; - } - - return await _queryReader.GetActorRelationSubgraphAsync(actorId, depth, take, ct); - } - - private static WorkflowExecutionRuntimeLease ResolveRuntimeLease(IWorkflowExecutionProjectionLease lease) => - lease as WorkflowExecutionRuntimeLease - ?? throw new InvalidOperationException("Unsupported workflow projection lease implementation."); -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index e16537641..1e3b988be 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -12,11 +12,11 @@ public sealed class WorkflowProjectionQueryReader : IWorkflowProjectionQueryRead public WorkflowProjectionQueryReader( IProjectionReadModelStore store, WorkflowExecutionReadModelMapper mapper, - IProjectionRelationStore? relationStore = null) + IProjectionRelationStore relationStore) { _store = store; _mapper = mapper; - _relationStore = relationStore ?? NoopProjectionRelationStore.Instance; + _relationStore = relationStore; } public async Task GetActorSnapshotAsync( @@ -101,28 +101,4 @@ public async Task GetActorRelationSubgraphAsync( ct); return _mapper.ToActorRelationSubgraph(actorIdValue, subgraph); } - - private sealed class NoopProjectionRelationStore : IProjectionRelationStore - { - public static NoopProjectionRelationStore Instance { get; } = new(); - - public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) => - Task.CompletedTask; - - public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) => - Task.CompletedTask; - - public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) => - Task.CompletedTask; - - public Task> GetNeighborsAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) => - Task.FromResult>([]); - - public Task GetSubgraphAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) => - Task.FromResult(new ProjectionRelationSubgraph()); - } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs index 5673660a7..b809039f8 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs @@ -18,20 +18,47 @@ public WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions o ArgumentNullException.ThrowIfNull(options); EnsureReadModelModeSupported(options.ReadModelMode); - var requirements = _bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); - var selectionOptions = new ProjectionReadModelStoreSelectionOptions + var readModelRequirements = _bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); + var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions { RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; + var relationRequirements = BuildRelationRequirements(readModelRequirements); + var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = NormalizeProviderName( + options.RelationProvider, + options.ReadModelProvider), + FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, + }; - return new WorkflowReadModelSelectionPlan(requirements, selectionOptions); + return new WorkflowReadModelSelectionPlan( + readModelRequirements, + readModelSelectionOptions, + relationRequirements, + relationSelectionOptions); } - private static string NormalizeProviderName(string providerName) + private static ProjectionReadModelRequirements BuildRelationRequirements( + ProjectionReadModelRequirements readModelRequirements) + { + // Workflow relation endpoints depend on relation storage + traversal as first-class capability. + return new ProjectionReadModelRequirements( + requiresRelations: true, + requiresRelationTraversal: true, + requiresAliases: readModelRequirements.RequiresAliases, + requiresSchemaValidation: readModelRequirements.RequiresSchemaValidation); + } + + private static string NormalizeProviderName(string providerName, string fallbackProviderName = "") { if (string.IsNullOrWhiteSpace(providerName)) - return ProjectionReadModelProviderNames.InMemory; + { + if (string.IsNullOrWhiteSpace(fallbackProviderName)) + return ProjectionReadModelProviderNames.InMemory; + return fallbackProviderName.Trim(); + } return providerName.Trim(); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 6f080c448..c5c4e9bc2 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -12,56 +12,54 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; private readonly IWorkflowReadModelSelectionPlanner _selectionPlanner; - private readonly IProjectionReadModelProviderRegistry _providerRegistry; - private readonly IProjectionReadModelProviderSelector _providerSelector; - private readonly IProjectionRelationStoreProviderRegistry _relationProviderRegistry; - private readonly IProjectionRelationStoreProviderSelector _relationProviderSelector; + private readonly IProjectionStoreStartupValidator _startupValidator; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, IWorkflowReadModelSelectionPlanner selectionPlanner, - IProjectionReadModelProviderRegistry providerRegistry, - IProjectionReadModelProviderSelector providerSelector, - IProjectionRelationStoreProviderRegistry relationProviderRegistry, - IProjectionRelationStoreProviderSelector relationProviderSelector, + IProjectionStoreStartupValidator startupValidator, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; _selectionPlanner = selectionPlanner; - _providerRegistry = providerRegistry; - _providerSelector = providerSelector; - _relationProviderRegistry = relationProviderRegistry; - _relationProviderSelector = relationProviderSelector; + _startupValidator = startupValidator; _logger = logger ?? NullLogger.Instance; } public Task StartAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (!_options.Enabled || !_options.ValidateReadModelProviderOnStartup) + if (!_options.Enabled) return Task.CompletedTask; var selectionPlan = _selectionPlanner.Build(_options); - var registrations = _providerRegistry.GetRegistrations(_serviceProvider); - var selected = _providerSelector.Select(registrations, selectionPlan.SelectionOptions, selectionPlan.Requirements); - _logger.LogInformation( - "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", - typeof(WorkflowExecutionReport).FullName, - selected.ProviderName); + if (_options.ValidateReadModelProviderOnStartup) + { + var selectedReadModelProvider = _startupValidator.ValidateReadModelProvider( + _serviceProvider, + selectionPlan.ReadModelSelectionOptions, + selectionPlan.ReadModelRequirements); + _logger.LogInformation( + "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", + typeof(WorkflowExecutionReport).FullName, + selectedReadModelProvider.ProviderName); + } - var relationRegistrations = _relationProviderRegistry.GetRegistrations(_serviceProvider); - var selectedRelationProvider = _relationProviderSelector.Select( - relationRegistrations, - selectionPlan.SelectionOptions, - selectionPlan.Requirements); - _logger.LogInformation( - "Workflow relation provider startup validation passed. relationType={RelationType} provider={Provider}", - typeof(ProjectionRelationNode).FullName, - selectedRelationProvider.ProviderName); + if (_options.ValidateRelationProviderOnStartup) + { + var selectedRelationProvider = _startupValidator.ValidateRelationProvider( + _serviceProvider, + selectionPlan.RelationSelectionOptions, + selectionPlan.RelationRequirements); + _logger.LogInformation( + "Workflow relation provider startup validation passed. relationType={RelationType} provider={Provider}", + typeof(ProjectionRelationNode).FullName, + selectedRelationProvider.ProviderName); + } return Task.CompletedTask; } diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs index 08b53dc90..c37d875b3 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs @@ -8,14 +8,10 @@ public sealed class WorkflowExecutionRelationProjector { private const string UnknownToken = "unknown"; private readonly IProjectionRelationStore _relationStore; - private readonly IProjectionReadModelStore _readModelStore; - public WorkflowExecutionRelationProjector( - IProjectionRelationStore relationStore, - IProjectionReadModelStore readModelStore) + public WorkflowExecutionRelationProjector(IProjectionRelationStore relationStore) { _relationStore = relationStore; - _readModelStore = readModelStore; } public async ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, CancellationToken ct = default) @@ -37,15 +33,48 @@ await _relationStore.UpsertEdgeAsync( ct); } - public ValueTask ProjectAsync( + public async ValueTask ProjectAsync( WorkflowExecutionProjectionContext context, EventEnvelope envelope, CancellationToken ct = default) { - _ = context; - _ = envelope; - _ = ct; - return ValueTask.CompletedTask; + var payload = envelope.Payload; + if (payload == null) + return; + + var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); + var now = ResolveEventTimestamp(envelope); + + if (payload.Is(StepRequestEvent.Descriptor)) + { + var evt = payload.Unpack(); + await UpsertStepRelationAsync( + context, + runNodeId, + evt.StepId, + evt.StepType, + evt.TargetRole, + workerId: "", + success: null, + now, + ct); + return; + } + + if (payload.Is(StepCompletedEvent.Descriptor)) + { + var evt = payload.Unpack(); + await UpsertStepRelationAsync( + context, + runNodeId, + evt.StepId, + stepType: "", + targetRole: "", + evt.WorkerId, + evt.Success, + now, + ct); + } } public async ValueTask CompleteAsync( @@ -53,8 +82,7 @@ public async ValueTask CompleteAsync( IReadOnlyList topology, CancellationToken ct = default) { - var report = await _readModelStore.GetAsync(context.RootActorId, ct); - var completedAt = report?.EndedAt ?? DateTimeOffset.UtcNow; + var completedAt = DateTimeOffset.UtcNow; var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); await _relationStore.UpsertNodeAsync( @@ -79,10 +107,12 @@ await _relationStore.UpsertEdgeAsync( foreach (var edge in topology) { - var parentNodeId = NormalizeToken(edge.Parent); - var childNodeId = NormalizeToken(edge.Child); - if (parentNodeId.Length == 0 || childNodeId.Length == 0) + var rawParentNodeId = edge.Parent?.Trim() ?? ""; + var rawChildNodeId = edge.Child?.Trim() ?? ""; + if (rawParentNodeId.Length == 0 || rawChildNodeId.Length == 0) continue; + var parentNodeId = NormalizeToken(rawParentNodeId); + var childNodeId = NormalizeToken(rawChildNodeId); await _relationStore.UpsertNodeAsync( BuildActorNode(parentNodeId, context.WorkflowName, completedAt), @@ -98,33 +128,101 @@ await _relationStore.UpsertEdgeAsync( completedAt), ct); } + } - if (report == null || report.Steps.Count == 0) + private async Task UpsertStepRelationAsync( + WorkflowExecutionProjectionContext context, + string runNodeId, + string stepId, + string stepType, + string targetRole, + string workerId, + bool? success, + DateTimeOffset updatedAt, + CancellationToken ct) + { + var rawStepId = stepId?.Trim() ?? ""; + if (rawStepId.Length == 0) return; - foreach (var step in report.Steps) + var normalizedStepId = NormalizeToken(rawStepId); + var stepNodeId = BuildStepNodeId(context.RootActorId, normalizedStepId); + var stepTypeValue = stepType?.Trim() ?? ""; + var targetRoleValue = targetRole?.Trim() ?? ""; + var workerIdValue = workerId?.Trim() ?? ""; + var successValue = success; + if (stepTypeValue.Length == 0 || + targetRoleValue.Length == 0 || + workerIdValue.Length == 0 || + !successValue.HasValue) { - var stepId = NormalizeToken(step.StepId); - if (stepId.Length == 0) - continue; + var existingNode = await TryGetNodeAsync(stepNodeId, ct); + if (existingNode != null) + { + if (stepTypeValue.Length == 0 && + existingNode.Properties.TryGetValue("stepType", out var existingStepType)) + { + stepTypeValue = existingStepType; + } - var stepNodeId = BuildStepNodeId(context.RootActorId, stepId); - var stepUpdatedAt = step.CompletedAt ?? step.RequestedAt ?? completedAt; - await _relationStore.UpsertNodeAsync( - BuildStepNode( - stepNodeId, - context.RootActorId, - step, - stepUpdatedAt), - ct); - await _relationStore.UpsertEdgeAsync( - BuildEdge( - runNodeId, - stepNodeId, - WorkflowExecutionRelationConstants.RelationContainsStep, - stepUpdatedAt), - ct); + if (targetRoleValue.Length == 0 && + existingNode.Properties.TryGetValue("targetRole", out var existingTargetRole)) + { + targetRoleValue = existingTargetRole; + } + + if (workerIdValue.Length == 0 && + existingNode.Properties.TryGetValue("workerId", out var existingWorkerId)) + { + workerIdValue = existingWorkerId; + } + + if (!successValue.HasValue && + existingNode.Properties.TryGetValue("success", out var existingSuccess) && + bool.TryParse(existingSuccess, out var parsedSuccess)) + { + successValue = parsedSuccess; + } + } } + + await _relationStore.UpsertNodeAsync( + BuildStepNode( + stepNodeId, + context.RootActorId, + normalizedStepId, + stepTypeValue, + targetRoleValue, + workerIdValue, + successValue, + updatedAt), + ct); + await _relationStore.UpsertEdgeAsync( + BuildEdge( + runNodeId, + stepNodeId, + WorkflowExecutionRelationConstants.RelationContainsStep, + updatedAt), + ct); + } + + private async Task TryGetNodeAsync( + string nodeId, + CancellationToken ct) + { + var subgraph = await _relationStore.GetSubgraphAsync( + new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = nodeId, + Direction = ProjectionRelationDirection.Both, + Depth = 1, + Take = 1, + }, + ct); + return subgraph.Nodes.FirstOrDefault(x => + string.Equals(x.NodeId, nodeId, StringComparison.Ordinal) && + x.Properties.Count > 0); } private static ProjectionRelationNode BuildActorNode( @@ -172,7 +270,11 @@ private static ProjectionRelationNode BuildRunNode( private static ProjectionRelationNode BuildStepNode( string stepNodeId, string rootActorId, - WorkflowExecutionStepTrace step, + string stepId, + string stepType, + string targetRole, + string workerId, + bool? success, DateTimeOffset updatedAt) { return new ProjectionRelationNode @@ -183,11 +285,11 @@ private static ProjectionRelationNode BuildStepNode( Properties = new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = NormalizeToken(rootActorId), - ["stepId"] = NormalizeToken(step.StepId), - ["stepType"] = step.StepType ?? "", - ["targetRole"] = step.TargetRole ?? "", - ["workerId"] = step.WorkerId ?? "", - ["success"] = step.Success?.ToString() ?? "", + ["stepId"] = NormalizeToken(stepId), + ["stepType"] = stepType ?? "", + ["targetRole"] = targetRole ?? "", + ["workerId"] = workerId ?? "", + ["success"] = success?.ToString() ?? "", }, UpdatedAt = updatedAt, }; @@ -240,4 +342,16 @@ private static string NormalizeToken(string token) var normalized = token?.Trim() ?? ""; return normalized.Length == 0 ? UnknownToken : normalized; } + + private static DateTimeOffset ResolveEventTimestamp(EventEnvelope envelope) + { + var ts = envelope.Timestamp; + if (ts == null) + return DateTimeOffset.UtcNow; + + var dt = ts.ToDateTime(); + if (dt.Kind != DateTimeKind.Utc) + dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); + return new DateTimeOffset(dt); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 19f6cf63a..edd3e81c0 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -4,7 +4,11 @@ ## 职责边界 -- 应用层投影端口实现:`IWorkflowExecutionProjectionPort`(实现类 `WorkflowExecutionProjectionService`) +- 应用层投影端口实现: + - `IWorkflowExecutionProjectionLifecyclePort`(`Ensure/Attach/Detach/Release`) + - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/Relations/Subgraph`) + - 默认实现分别为 `WorkflowExecutionProjectionLifecycleService` 与 `WorkflowExecutionProjectionQueryService` + - 两个实现分别继承 `ProjectionLifecyclePortServiceBase` / `ProjectionQueryPortServiceBase`,通用端口编排已下沉到 `Aevatar.CQRS.Projection.Core` - 编排组件拆分(避免单类过重): - `WorkflowProjectionActivationService`(projection 启动与上下文激活) - `WorkflowProjectionReleaseService`(idle 检测与停止/释放) @@ -14,7 +18,7 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) - - `WorkflowReadModelSelectionPlanner`(统一 read model provider 归一化、mode 校验与 capability 选择参数生成) + - `WorkflowReadModelSelectionPlanner`(统一 read model/relation provider 归一化、mode 校验与 capability 选择参数生成) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) @@ -31,7 +35,7 @@ ## 统一运行链路 -1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionService` 转发到 `WorkflowProjectionActivationService`,先通过 `WorkflowProjectionLeaseManager`(底层复用 `Aevatar.CQRS.Projection.Core` ownership coordinator)申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 +1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionLifecycleService` 转发到 `WorkflowProjectionActivationService`,先通过 `WorkflowProjectionLeaseManager`(底层复用 `Aevatar.CQRS.Projection.Core` ownership coordinator)申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 2. 每条 `EventEnvelope` 进入统一 coordinator,一对多调用已注册 projector 3. `WorkflowExecutionReadModelProjector` 驱动 reducers 生成并更新 read model 4. AI 通用事件通过 `Aevatar.Workflow.Extensions.AIProjection` 扩展接入,扩展内部复用 `Aevatar.AI.Projection` 的默认 applier + reducer,将事件写入 `WorkflowExecutionReport` 的 AI 能力字段,业务层无需重复维护映射代码 @@ -83,12 +87,15 @@ FAQ: ## Provider 配置 -- `WorkflowExecutionProjection:ReadModelProvider`:`InMemory`(默认)/`Elasticsearch` +- `WorkflowExecutionProjection:ReadModelProvider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` +- `WorkflowExecutionProjection:RelationProvider`:关系存储 provider;留空时回退到 `ReadModelProvider` - `WorkflowExecutionProjection:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) - `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:是否在 Host 启动阶段预校验 Provider 选择与能力(默认 `true`) +- `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:是否在 Host 启动阶段预校验 Relation Provider(默认 `true`) - `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(如 `WorkflowExecutionReport: Document`) - 推荐统一配置入口:`Projection:ReadModel:*`(由 Infrastructure 映射到 Workflow 投影选项) - `Projection:ReadModel:Provider`:全局默认 Provider(当前由 `WorkflowCapabilityServiceCollectionExtensions` 覆盖到模块选项) +- `Projection:ReadModel:RelationProvider`:全局默认 Relation Provider(覆盖到模块选项) - `Projection:ReadModel:FailOnUnsupportedCapabilities`:全局 fail-fast 策略 - `Projection:ReadModel:Bindings:*`:全局 ReadModel -> IndexKind 约束 - `Projection:ReadModel:Providers:Elasticsearch:Endpoints`:Elasticsearch endpoint 列表 diff --git a/src/workflow/README.md b/src/workflow/README.md index c29aa760c..a0876bd62 100644 --- a/src/workflow/README.md +++ b/src/workflow/README.md @@ -74,7 +74,7 @@ sequenceDiagram participant CtxFactory as "WorkflowRunContextFactory" participant Engine as "WorkflowRunExecutionEngine" participant Resolver as "WorkflowRunActorResolver" - participant Port as "IWorkflowExecutionProjectionPort" + participant Port as "IWorkflowExecutionProjectionLifecyclePort" participant WFAgent as "WorkflowGAgent" participant Sink as "WorkflowRunEventChannel" @@ -116,7 +116,7 @@ flowchart LR BUS["ProjectionSessionEventHub\nworkflow-run:{actorId}:{commandId}"] CH["WorkflowRunEventChannel"] - QP["WorkflowExecutionProjectionService(IWorkflowExecutionProjectionPort)"] + QP["Projection Ports(Lifecycle/Query)"] ACT["WorkflowProjectionActivationService"] REL["WorkflowProjectionReleaseService"] SUB["WorkflowProjectionSinkSubscriptionManager"] @@ -215,8 +215,8 @@ sequenceDiagram - 传入 `actorId` 的 run 请求不允许切换 workflow;workflow 变更必须创建新 actor。 - `WorkflowGAgent` 子 Actor ID 使用 `"{parentActorId}:{roleId}"` 命名空间,避免跨 workflow 根 Actor 冲突。 - Actor 事件域不承载 CQRS 命令语义:不在 `EventEnvelope` metadata 与 `StartWorkflowEvent` 中传递 `commandId`。 -- `WorkflowExecutionProjectionService` 以 `ActorId` 为共享投影上下文键,同一 Actor 多次触发共享读模型与事件流。 -- `WorkflowExecutionProjectionService` 仅作为 facade,对外暴露统一端口;具体激活/释放/查询/sink 推送分别委托到 `Activation/Release/QueryReader/LiveSinkForwarder` 组件。 +- `WorkflowExecutionProjectionLifecycleService` 以 `ActorId` 为共享投影上下文键,同一 Actor 多次触发共享投影会话与事件流。 +- `WorkflowExecutionProjectionLifecycleService` 与 `WorkflowExecutionProjectionQueryService` 职责分离:生命周期与查询走独立端口,避免读写边界侵蚀。 - Application/Projection 编排类受 CI 体量守卫约束(非空行数与依赖数上限),避免职责反弹。 - Projection 启动并发(`Ensure/Release`)由 `projection:{rootActorId}` 协调 Actor 串行裁决,不依赖进程内 `SemaphoreSlim`。 - `AttachLiveSink/DetachLiveSink` 通过 `workflow-run:{actorId}:{commandId}` 事件流订阅/退订,不在 `WorkflowExecutionProjectionContext` 维护 sink 事实态。 diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs index 580184f85..2c4a37ea7 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs @@ -396,7 +396,9 @@ public async Task ResolveAsync_ShouldOnlyReturnReachableEdgesFromRoot() } } -internal sealed class FakeProjectionService : IWorkflowExecutionProjectionPort +internal sealed class FakeProjectionService : + IWorkflowExecutionProjectionLifecyclePort, + IWorkflowExecutionProjectionQueryPort { public bool ProjectionEnabled { get; set; } = true; public bool EnableActorQueryEndpointsValue { get; set; } = true; diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs index b2b8c0963..4d84cad87 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs @@ -277,7 +277,7 @@ public CommandContext Create( } } - private sealed class CapturingProjectionPort : IWorkflowExecutionProjectionPort + private sealed class CapturingProjectionPort : IWorkflowExecutionProjectionLifecyclePort { public bool ProjectionEnabled { get; set; } = true; public bool EnableActorQueryEndpoints => false; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 0ba3c4b76..807621b32 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -87,7 +87,7 @@ public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefaul } [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfigured_ShouldResolveElasticsearchStore() + public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfiguredWithoutRelationProvider_ShouldFailFastOnRelationStore() { var services = new ServiceCollection(); RegisterInMemoryProvider(services); @@ -97,13 +97,34 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfigured_Shoul using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); - var relationStore = provider.GetRequiredService(); - store.Should().BeOfType>(); - relationStore.Should().BeOfType(); var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); + + Action act = () => provider.GetRequiredService(); + act.Should().Throw() + .Where(ex => ex.ReadModelType == typeof(ProjectionRelationNode)); + } + + [Fact] + public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchReadModelWithInMemoryRelationConfigured_ShouldResolveSplitProviders() + { + var services = new ServiceCollection(); + RegisterInMemoryProvider(services); + RegisterElasticsearchProvider(services); + services.AddWorkflowExecutionProjectionCQRS(options => + { + options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch; + options.RelationProvider = ProjectionReadModelProviderNames.InMemory; + }); + + using var provider = services.BuildServiceProvider(); + var store = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); + + store.Should().BeOfType>(); + relationStore.Should().BeOfType(); } [Fact] diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 1f5f92a25..25972be12 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -13,6 +13,7 @@ using Aevatar.Foundation.Abstractions.TypeSystem; using Aevatar.Foundation.Core.TypeSystem; using Aevatar.Workflow.Application.Abstractions.Projections; +using Aevatar.Workflow.Application.Abstractions.Queries; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Core; using Aevatar.Workflow.Projection; @@ -505,7 +506,7 @@ await act.Should().ThrowAsync() await sink.DisposeAsync(); } - private static WorkflowExecutionProjectionService CreateService( + private static ProjectionPortsHarness CreateService( WorkflowExecutionProjectionOptions options, out InMemoryStreamProvider streams, out ObservableWorkflowExecutionReadModelStore store, @@ -519,7 +520,7 @@ private static WorkflowExecutionProjectionService CreateService( clock); } - private static WorkflowExecutionProjectionService CreateService( + private static ProjectionPortsHarness CreateService( WorkflowExecutionProjectionOptions options, out InMemoryStreamProvider streams, out ObservableWorkflowExecutionReadModelStore store, @@ -574,7 +575,10 @@ private static WorkflowExecutionProjectionService CreateService( var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventStreamHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventStreamHub, resolvedClock); var readModelUpdater = new WorkflowProjectionReadModelUpdater(store, resolvedClock); - var queryReader = new WorkflowProjectionQueryReader(store, mapper); + var queryReader = new WorkflowProjectionQueryReader( + store, + mapper, + new InMemoryProjectionRelationStore()); var activationService = new WorkflowProjectionActivationService( lifecycle, resolvedClock, @@ -588,16 +592,19 @@ private static WorkflowExecutionProjectionService CreateService( leaseManager); var liveSinkForwarder = new WorkflowProjectionLiveSinkForwarder(sinkFailurePolicy); - return new WorkflowExecutionProjectionService( + var lifecyclePort = new WorkflowExecutionProjectionLifecycleService( options, - queryReader, activationService, releaseService, sinkManager, liveSinkForwarder); + var queryPort = new WorkflowExecutionProjectionQueryService( + options, + queryReader); + return new ProjectionPortsHarness(lifecyclePort, queryPort); } - private static WorkflowExecutionProjectionService CreateServiceForStartFailure( + private static ProjectionPortsHarness CreateServiceForStartFailure( IProjectionOwnershipCoordinator ownershipCoordinator, IProjectionLifecycleService> lifecycle) { @@ -609,7 +616,10 @@ private static WorkflowExecutionProjectionService CreateServiceForStartFailure( var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventHub, clock); var readModelUpdater = new WorkflowProjectionReadModelUpdater(store, clock); - var queryReader = new WorkflowProjectionQueryReader(store, mapper); + var queryReader = new WorkflowProjectionQueryReader( + store, + mapper, + new InMemoryProjectionRelationStore()); var activationService = new WorkflowProjectionActivationService( lifecycle, clock, @@ -623,17 +633,21 @@ private static WorkflowExecutionProjectionService CreateServiceForStartFailure( leaseManager); var liveSinkForwarder = new WorkflowProjectionLiveSinkForwarder(sinkFailurePolicy); - return new WorkflowExecutionProjectionService( - new WorkflowExecutionProjectionOptions - { - Enabled = true, - EnableActorQueryEndpoints = true, - }, - queryReader, + var options = new WorkflowExecutionProjectionOptions + { + Enabled = true, + EnableActorQueryEndpoints = true, + }; + var lifecyclePort = new WorkflowExecutionProjectionLifecycleService( + options, activationService, releaseService, sinkManager, liveSinkForwarder); + var queryPort = new WorkflowExecutionProjectionQueryService( + options, + queryReader); + return new ProjectionPortsHarness(lifecyclePort, queryPort); } private static IReadOnlyList> BuildReducers() => @@ -710,6 +724,80 @@ public MutableProjectionClock(DateTimeOffset utcNow) public DateTimeOffset UtcNow { get; set; } } + private sealed class ProjectionPortsHarness + : IWorkflowExecutionProjectionLifecyclePort, + IWorkflowExecutionProjectionQueryPort + { + private readonly IWorkflowExecutionProjectionLifecyclePort _lifecyclePort; + private readonly IWorkflowExecutionProjectionQueryPort _queryPort; + + public ProjectionPortsHarness( + IWorkflowExecutionProjectionLifecyclePort lifecyclePort, + IWorkflowExecutionProjectionQueryPort queryPort) + { + _lifecyclePort = lifecyclePort; + _queryPort = queryPort; + } + + public bool ProjectionEnabled => _lifecyclePort.ProjectionEnabled; + + public bool EnableActorQueryEndpoints => _queryPort.EnableActorQueryEndpoints; + + public Task EnsureActorProjectionAsync( + string rootActorId, + string workflowName, + string input, + string commandId, + CancellationToken ct = default) + => _lifecyclePort.EnsureActorProjectionAsync(rootActorId, workflowName, input, commandId, ct); + + public Task AttachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default) + => _lifecyclePort.AttachLiveSinkAsync(lease, sink, ct); + + public Task DetachLiveSinkAsync( + IWorkflowExecutionProjectionLease lease, + IWorkflowRunEventSink sink, + CancellationToken ct = default) + => _lifecyclePort.DetachLiveSinkAsync(lease, sink, ct); + + public Task ReleaseActorProjectionAsync( + IWorkflowExecutionProjectionLease lease, + CancellationToken ct = default) + => _lifecyclePort.ReleaseActorProjectionAsync(lease, ct); + + public Task GetActorSnapshotAsync( + string actorId, + CancellationToken ct = default) + => _queryPort.GetActorSnapshotAsync(actorId, ct); + + public Task> ListActorSnapshotsAsync( + int take = 200, + CancellationToken ct = default) + => _queryPort.ListActorSnapshotsAsync(take, ct); + + public Task> ListActorTimelineAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + => _queryPort.ListActorTimelineAsync(actorId, take, ct); + + public Task> GetActorRelationsAsync( + string actorId, + int take = 200, + CancellationToken ct = default) + => _queryPort.GetActorRelationsAsync(actorId, take, ct); + + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + CancellationToken ct = default) + => _queryPort.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + } + private sealed class ObservableWorkflowExecutionReadModelStore : IProjectionReadModelStore { diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs new file mode 100644 index 000000000..554942d1b --- /dev/null +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs @@ -0,0 +1,142 @@ +using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Aevatar.Workflow.Core; +using Aevatar.Workflow.Projection; +using Aevatar.Workflow.Projection.Projectors; +using Aevatar.Workflow.Projection.ReadModels; +using FluentAssertions; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Workflow.Host.Api.Tests; + +public sealed class WorkflowExecutionRelationProjectorTests +{ + [Fact] + public async Task ProjectAsync_WhenStepIdIsBlank_ShouldSkipContainsStepEdge() + { + var relationStore = new InMemoryProjectionRelationStore(); + var projector = new WorkflowExecutionRelationProjector(relationStore); + var context = CreateContext(); + + await projector.InitializeAsync(context); + await projector.ProjectAsync(context, Wrap(new StepRequestEvent + { + StepId = " ", + StepType = "llm_call", + TargetRole = "assistant", + })); + + var runEdges = await relationStore.GetNeighborsAsync(new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = BuildRunNodeId(context), + Direction = ProjectionRelationDirection.Outbound, + Take = 50, + }); + + runEdges.Should().NotContain(x => + string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationContainsStep, StringComparison.Ordinal)); + } + + [Fact] + public async Task ProjectAsync_ShouldUpsertStepNodeFromRequestAndCompletionEvents() + { + var relationStore = new InMemoryProjectionRelationStore(); + var projector = new WorkflowExecutionRelationProjector(relationStore); + var context = CreateContext(); + var requestTime = new DateTime(2026, 2, 24, 8, 0, 0, DateTimeKind.Utc); + var completedTime = requestTime.AddSeconds(5); + + await projector.InitializeAsync(context); + await projector.ProjectAsync(context, Wrap(new StepRequestEvent + { + StepId = "step-1", + StepType = "llm_call", + TargetRole = "assistant", + }, requestTime)); + await projector.ProjectAsync(context, Wrap(new StepCompletedEvent + { + StepId = "step-1", + WorkerId = "assistant-1", + Success = true, + Output = "done", + }, completedTime)); + + var subgraph = await relationStore.GetSubgraphAsync(new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = BuildRunNodeId(context), + Direction = ProjectionRelationDirection.Outbound, + Depth = 2, + Take = 50, + }); + + subgraph.Edges.Should().ContainSingle(x => + string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationContainsStep, StringComparison.Ordinal)); + var stepNode = subgraph.Nodes.Single(x => + string.Equals(x.NodeType, WorkflowExecutionRelationConstants.StepNodeType, StringComparison.Ordinal)); + stepNode.Properties["stepType"].Should().Be("llm_call"); + stepNode.Properties["targetRole"].Should().Be("assistant"); + stepNode.Properties["workerId"].Should().Be("assistant-1"); + stepNode.Properties["success"].Should().Be("True"); + stepNode.UpdatedAt.Should().Be(new DateTimeOffset(completedTime)); + } + + [Fact] + public async Task CompleteAsync_WhenTopologyContainsBlankNodes_ShouldSkipUnknownRelations() + { + var relationStore = new InMemoryProjectionRelationStore(); + var projector = new WorkflowExecutionRelationProjector(relationStore); + var context = CreateContext(); + + await projector.InitializeAsync(context); + await projector.CompleteAsync( + context, + [ + new WorkflowExecutionTopologyEdge("root-1", "worker-1"), + new WorkflowExecutionTopologyEdge(" ", "worker-2"), + new WorkflowExecutionTopologyEdge("root-2", " "), + ]); + + var subgraph = await relationStore.GetSubgraphAsync(new ProjectionRelationQuery + { + Scope = WorkflowExecutionRelationConstants.Scope, + RootNodeId = "root-1", + Direction = ProjectionRelationDirection.Both, + Depth = 2, + Take = 100, + }); + + subgraph.Nodes.Should().NotContain(x => string.Equals(x.NodeId, "unknown", StringComparison.Ordinal)); + subgraph.Edges.Should().Contain(x => + string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationChildOf, StringComparison.Ordinal) && + string.Equals(x.FromNodeId, "root-1", StringComparison.Ordinal) && + string.Equals(x.ToNodeId, "worker-1", StringComparison.Ordinal)); + subgraph.Edges.Should().NotContain(x => + string.Equals(x.FromNodeId, "unknown", StringComparison.Ordinal) || + string.Equals(x.ToNodeId, "unknown", StringComparison.Ordinal)); + } + + private static WorkflowExecutionProjectionContext CreateContext() => new() + { + ProjectionId = "projection-relation", + CommandId = "cmd-1", + RootActorId = "root", + WorkflowName = "direct", + StartedAt = new DateTimeOffset(2026, 2, 24, 7, 0, 0, TimeSpan.Zero), + Input = "hello", + }; + + private static EventEnvelope Wrap(IMessage evt, DateTime? utcTimestamp = null) => new() + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime((utcTimestamp ?? DateTime.UtcNow).ToUniversalTime()), + Payload = Any.Pack(evt), + PublisherId = "root", + Direction = EventDirection.Down, + }; + + private static string BuildRunNodeId(WorkflowExecutionProjectionContext context) => + $"run:{context.RootActorId}:{context.CommandId}"; +} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index dc94ce5f6..f977e9f02 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -159,7 +159,10 @@ await store.UpsertAsync(new WorkflowExecutionReport RoleReplyCount = 1, }, }); - var reader = new WorkflowProjectionQueryReader(store, new WorkflowExecutionReadModelMapper()); + var reader = new WorkflowProjectionQueryReader( + store, + new WorkflowExecutionReadModelMapper(), + new InMemoryProjectionRelationStore()); var snapshot = await reader.GetActorSnapshotAsync("actor-3"); var timeline = await reader.ListActorTimelineAsync("actor-3", take: 2); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs index e16b9d8eb..fc40d3f8c 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs @@ -23,11 +23,14 @@ public void Build_WhenProviderIsEmpty_ShouldFallbackToInMemoryAndResolveBindings var plan = _planner.Build(options); - plan.SelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); - plan.SelectionOptions.FailOnUnsupportedCapabilities.Should().BeFalse(); - plan.Requirements.RequiresIndexing.Should().BeTrue(); - plan.Requirements.RequiredIndexKinds.Should().ContainSingle() + plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); + plan.ReadModelSelectionOptions.FailOnUnsupportedCapabilities.Should().BeFalse(); + plan.ReadModelRequirements.RequiresIndexing.Should().BeTrue(); + plan.ReadModelRequirements.RequiredIndexKinds.Should().ContainSingle() .Which.Should().Be(ProjectionReadModelIndexKind.Document); + plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); + plan.RelationRequirements.RequiresRelations.Should().BeTrue(); + plan.RelationRequirements.RequiresRelationTraversal.Should().BeTrue(); } [Fact] @@ -40,7 +43,23 @@ public void Build_ShouldTrimConfiguredProviderName() var plan = _planner.Build(options); - plan.SelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + } + + [Fact] + public void Build_WhenRelationProviderConfigured_ShouldUseRelationProvider() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch, + RelationProvider = " InMemory ", + }; + + var plan = _planner.Build(options); + + plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); + plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); } [Fact] diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 4458388ba..985a20930 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -508,8 +508,8 @@ if rg -n "TryGetContext\(" src; then exit 1 fi -if rg -n "SemaphoreSlim" src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs; then - echo "WorkflowExecutionProjectionService must not use process-local SemaphoreSlim for projection start arbitration." +if rg -n "SemaphoreSlim" src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs; then + echo "WorkflowExecutionProjectionLifecycleService must not use process-local SemaphoreSlim for projection start arbitration." exit 1 fi @@ -518,17 +518,30 @@ if rg -n "Dictionary<|ConcurrentDictionary<" src/Aevatar.CQRS.Projection.Core/Or exit 1 fi +lifecycle_port="src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs" +query_port="src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs" + +if [ ! -f "${lifecycle_port}" ] || [ ! -f "${query_port}" ]; then + echo "Workflow projection ports must be split into lifecycle/query contracts." + exit 1 +fi + if rg -n "Task\s+AttachLiveSinkAsync\(\s*string\s+actorId|Task\s+DetachLiveSinkAsync\(\s*string\s+actorId|Task\s+ReleaseActorProjectionAsync\(\s*string\s+actorId" \ - src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs + "${lifecycle_port}" then - echo "Workflow projection port must use lease/session handles instead of actorId context lookup." + echo "Workflow projection lifecycle port must use lease/session handles instead of actorId context lookup." + exit 1 +fi + +if ! rg -n "IWorkflowExecutionProjectionLease" "${lifecycle_port}" >/dev/null; then + echo "Workflow projection lifecycle port must depend on IWorkflowExecutionProjectionLease." exit 1 fi -if ! rg -n "IWorkflowExecutionProjectionLease" \ - src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs >/dev/null +if rg -n "EnsureActorProjectionAsync|AttachLiveSinkAsync|DetachLiveSinkAsync|ReleaseActorProjectionAsync" \ + "${query_port}" then - echo "Workflow projection port must depend on IWorkflowExecutionProjectionLease." + echo "Workflow projection query port must not include lifecycle operations." exit 1 fi From dceaaa3c3e15550cb82d894e653b68d1e08a7060 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 17:08:25 +0800 Subject: [PATCH 22/46] Refactor Elasticsearch Provider and Enhance ReadModel Architecture - Updated Elasticsearch provider capabilities to align with actual implementations, removing unsupported alias/schema validation declarations. - Introduced optimistic concurrency control (OCC) in `MutateAsync` to handle conflicts with retry logic. - Implemented a new `MissingIndexBehavior` option to manage index absence scenarios, defaulting to fail-fast. - Enforced binding key requirements to use `Type.FullName`, eliminating ambiguity in read model bindings. - Adjusted provider registration to be conditional based on configuration, improving clarity and reducing potential errors. - Enhanced documentation to reflect these architectural changes and provide clearer guidance for implementation. --- docs/CQRS_ARCHITECTURE.md | 2 + ...review-remediation-blueprint-2026-02-24.md | 255 ++++++++++++++++ ...odel-graph-relations-refactor-blueprint.md | 8 + .../ProjectionReadModelCapabilityValidator.cs | 12 +- .../ElasticsearchMissingIndexBehavior.cs | 7 + ...icsearchProjectionReadModelStoreOptions.cs | 4 + .../ServiceCollectionExtensions.cs | 8 +- .../README.md | 5 +- .../ElasticsearchProjectionReadModelStore.cs | 284 +++++++++++++++--- .../ElasticsearchProjectionRelationStore.cs | 4 +- .../ProjectionReadModelBindingResolver.cs | 15 +- src/Aevatar.Foundation.Projection/README.md | 2 +- .../ReadModels/AevatarReadModelBase.cs | 4 +- .../Queries/WorkflowExecutionQueryModels.cs | 2 + ...owCapabilityServiceCollectionExtensions.cs | 33 +- .../WorkflowExecutionReportWriter.cs | 2 + .../WorkflowExecutionProjectionOptions.cs | 2 +- .../WorkflowProjectionReadModelUpdater.cs | 11 +- .../WorkflowReadModelSelectionPlanner.cs | 21 +- .../WorkflowExecutionReadModelProjector.cs | 9 +- .../Aevatar.Workflow.Projection/README.md | 5 +- .../ReadModels/WorkflowExecutionReadModel.cs | 2 + .../WorkflowExecutionReadModelMapper.cs | 2 +- .../WorkflowExecutionProjectionMutations.cs | 3 +- ...tionProviderServiceCollectionExtensions.cs | 159 +++++++--- ...chProjectionReadModelStoreBehaviorTests.cs | 191 ++++++++++++ .../ProjectionReadModelRuntimeTests.cs | 20 +- .../ProjectionReadModelStoreSelectorTests.cs | 26 ++ ...lowExecutionProjectionRegistrationTests.cs | 6 +- ...WorkflowExecutionProjectionServiceTests.cs | 4 + ...orkflowExecutionReadModelProjectorTests.cs | 2 + .../WorkflowHostingExtensionsCoverageTests.cs | 51 +++- .../WorkflowReadModelSelectionPlannerTests.cs | 49 ++- 33 files changed, 1066 insertions(+), 144 deletions(-) create mode 100644 docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs diff --git a/docs/CQRS_ARCHITECTURE.md b/docs/CQRS_ARCHITECTURE.md index 4bce64725..5360bb8fb 100644 --- a/docs/CQRS_ARCHITECTURE.md +++ b/docs/CQRS_ARCHITECTURE.md @@ -45,6 +45,8 @@ flowchart LR 3. 未命中 reducer 的事件必须为 no-op。 4. Workflow 投影生命周期通过 lease/session 句柄管理,不允许 `actorId -> context` 反查。 5. 同一 `EventEnvelope` 分发到多个 projector 时采用“一对多全分支尝试”语义:单个 projector 失败不阻断其他 projector 执行,最终以聚合异常统一回传。 +6. `Projection:ReadModel:Bindings` 的 key 必须使用 read model `Type.FullName`,禁止短类名绑定。 +7. Host 组合层按配置仅注册所需 provider 组合,不允许无条件并列注册 InMemory/Elasticsearch/Neo4j。 ## 5.1 编排减重落地(2026-02-22) diff --git a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md new file mode 100644 index 000000000..a78a33455 --- /dev/null +++ b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md @@ -0,0 +1,255 @@ +# Projection Provider 评审问题重构蓝图(2026-02-24) + +- 状态:Implemented +- 变更类型:Breaking Refactor(不考虑兼容性) +- 输入来源:本轮 code review 7 项问题(3 Blocking / 2 Major / 2 Minor) + +## 1. 执行目标 + +1. 让 Provider 能力声明与真实实现严格一致,避免误导选择器与启动校验。 +2. 为 Elasticsearch ReadModel 写入引入可验证的并发安全(OCC),消除 stale write 覆盖。 +3. 清理 Host 组合层多 Provider 默认并存导致的选择歧义。 +4. 对索引缺失、绑定冲突、排序稳定性、能力匹配语义做 fail-fast 与确定性收敛。 +5. 通过测试与 CI 门禁固化上述规则,避免回归。 + +## 2. 问题清单(Review 输入映射) + +| ID | Severity | 证据位置 | 问题摘要 | +|---|---|---|---| +| B1 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:353` | Elasticsearch 声明 `supportsAliases=true`、`supportsSchemaValidation=true`,但无对应实现。 | +| B2 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:76` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:174` | `MutateAsync` 读改写 + 普通 PUT,缺少 OCC,重放/重试/并发存在覆盖风险。 | +| B3 | Blocking | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:49` | 同一 ReadModel 同时注册多个 Provider,未显式指定时选择歧义。 | +| M1 | Major | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:105` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:127` | `AutoCreateIndex=false` 且索引缺失时被当成无数据,掩盖配置错误。 | +| M2 | Major | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs:43` | 绑定解析 `Type.Name` 优先于 `FullName`,同名类型误绑定风险。 | +| N1 | Minor | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:241` | `ListSortField` 为空时不排序,返回顺序不稳定。 | +| N2 | Minor | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs:24` | `RequiredIndexKinds` 使用 `Overlaps`(任一匹配),语义偏宽松。 | + +## 3. 目标架构决策(To-Be) + +### 3.1 B1 能力声明真实性(Capability Truthfulness) + +1. 立即收敛为“声明即实现”:Elasticsearch ReadModel/Relation provider 的 `supportsAliases` 与 `supportsSchemaValidation` 统一改为 `false`。 +2. 不在本轮引入“空能力声明 + 未来补实现”的过渡状态。 +3. README 与运行日志输出同步能力矩阵,避免二义性。 + +设计原则: + +- 能力字段仅表达已落地、可被测试验证的行为。 +- Provider 选择与启动校验不允许依赖“计划能力”。 + +### 3.2 B2 写入并发安全(OCC) + +1. Elasticsearch Provider 引入 `seq_no + primary_term` 的乐观并发控制。 +2. `MutateAsync` 改为:`GET(_seq_no/_primary_term/_source) -> mutate -> PUT(if_seq_no/if_primary_term)`,冲突时有限重试。 +3. 冲突超过重试上限后抛出明确并发异常(包含 `index/key/retries`),不静默覆盖。 +4. `UpsertAsync` 保持直接写入,但对“更新路径”也支持可选 OCC 参数扩展点。 + +设计原则: + +- 读改写必须具备并发冲突检测能力。 +- 重放/重试场景下禁止“最后写入者覆盖”。 + +### 3.3 B3 Provider 组合层去歧义 + +1. Host 组合层不再无条件注册 InMemory/Elasticsearch/Neo4j 三套 ReadModel Provider。 +2. 改为“按配置按需注册”:仅注册 `ReadModelProvider` 与 `RelationProvider` 实际需要的 provider。 +3. 若配置为空或未知 provider,启动阶段直接 fail-fast 并给出配置路径提示(`Projection:ReadModel:Provider` / `Projection:ReadModel:RelationProvider`)。 +4. 删除隐式默认推断,避免环境切换时行为漂移。 + +设计原则: + +- 组合层负责消除二义性,不把歧义下放到运行时选择器。 +- 配置错误要在启动期暴露,不延迟到首个请求。 + +### 3.4 M1 索引缺失策略显式化 + +1. 新增 `MissingIndexBehavior`(建议枚举):`Throw` / `WarnAndReturnEmpty`。 +2. 默认 `Throw`(breaking):`AutoCreateIndex=false` 且索引不存在时,`Get/List` 抛错。 +3. `WarnAndReturnEmpty` 仅作为开发调试模式,不作为生产默认。 +4. 日志与指标输出:`provider/index/operation/behavior`。 + +### 3.5 M2 绑定解析确定性 + +1. 绑定键只接受 `Type.FullName`(breaking)。不再使用 `Type.Name` 回退。 +2. 解析失败时抛出结构化异常,明确给出期望键名示例。 +3. 启动校验增加“绑定键格式检查”,禁止短名键混入。 + +### 3.6 N1 List 顺序稳定性 + +1. `ListAsync` 始终带排序条件。 +2. 当 `ListSortField` 为空时,默认按 `CreatedAt desc -> _id desc` 排序,优先按创建时间倒序并保证稳定输出。 +3. 在 README 中明确排序语义与默认行为。 + +### 3.7 N2 RequiredIndexKinds 语义收紧 + +1. 能力校验由“任一命中(Overlaps)”改为“全部包含(AllContained)”。 +2. 未来如需“任一命中”语义,必须通过显式匹配模式字段表达,不能默认放宽。 + +## 4. 详细改造清单(按代码层次) + +### 4.1 Abstractions 层 + +目标文件: + +- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs` +- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs`(如需引入匹配模式) + +改造项: + +1. `RequiredIndexKinds` 校验从 `Overlaps` 切换为 `All(...)`。 +2. 如保留双语义,新增显式 `IndexKindMatchMode`,默认 `All`。 + +### 4.2 Runtime 层 + +目标文件: + +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs` +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs` + +改造项: + +1. Binding resolver 仅解析 `FullName` 键。 +2. 异常消息增强:输出 read model type + 期望配置键 + 实际键列表(截断)。 +3. 选择器错误日志保持结构化,补充“配置缺失/未知 provider”的专有 reason。 + +### 4.3 Elasticsearch Provider 层 + +目标文件: + +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs` +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs` +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs` +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md` + +改造项: + +1. 能力声明修正:`supportsAliases=false`、`supportsSchemaValidation=false`。 +2. OCC 落地: + - 读取文档时抓取 `_seq_no`、`_primary_term`。 + - 更新请求带 `if_seq_no`、`if_primary_term`。 + - 冲突重试(可配置上限)。 +3. 索引缺失行为策略化:新增 `MissingIndexBehavior` 与默认 `Throw`。 +4. `ListAsync` 默认排序兜底为 `CreatedAt desc -> _id desc`。 +5. 文档更新:能力矩阵、排序语义、索引缺失策略、并发冲突行为。 + +### 4.4 Workflow Host 组合层 + +目标文件: + +- `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` +- `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` +- `src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs` +- `src/workflow/Aevatar.Workflow.Projection/README.md` + +改造项: + +1. Provider 注册改为按需注册,不再全量注册三套 provider。 +2. 配置缺失与未知 provider 启动即失败。 +3. 移除隐式 provider 默认回退行为(保持配置显式化)。 +4. 文档示例统一改为 `FullName` 绑定键。 + +## 5. 测试计划(必须新增) + +### 5.1 单元测试 + +目标项目:`test/Aevatar.CQRS.Projection.Core.Tests` + +新增/改造用例: + +1. 能力声明一致性:Elasticsearch capability 不再声明 alias/schema 支持。 +2. `RequiredIndexKinds` 全包含语义测试(正例/反例)。 +3. Binding resolver 仅 FullName:短名键失败、FullName 成功。 + +### 5.2 组件/集成测试 + +目标项目: + +- `test/Aevatar.CQRS.Projection.Core.Tests` +- `test/Aevatar.Workflow.Host.Api.Tests` + +新增/改造用例: + +1. Elasticsearch OCC 并发冲突测试:并发 mutate 不发生静默覆盖,冲突可观测。 +2. `AutoCreateIndex=false` 且索引缺失:默认抛错;`WarnAndReturnEmpty` 下返回空并记录警告。 +3. Host 按需注册:仅配置单 provider 时无歧义;空配置启动失败;未知 provider 启动失败。 +4. `ListAsync` 默认排序稳定性测试(同一数据集多次读取顺序一致)。 + +### 5.3 回归验证命令 + +1. `dotnet build aevatar.slnx --nologo` +2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` +3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +4. `dotnet test aevatar.slnx --nologo` +5. `bash tools/ci/architecture_guards.sh` +6. `bash tools/ci/test_stability_guards.sh` + +## 6. 实施阶段(WBS) + +### Phase 0(Blocking,必须先完成) + +1. 修正 Elasticsearch capability 声明(B1)。 +2. 引入 OCC 并发控制与冲突异常(B2)。 +3. Host 组合层改为按需注册 + 显式配置 fail-fast(B3)。 + +交付标准: + +1. 3 个 blocking 问题对应测试全部通过。 +2. 无配置歧义路径可进入运行期。 + +### Phase 1(Major) + +1. 索引缺失策略改为显式配置且默认抛错(M1)。 +2. Binding resolver 改为 FullName-only 并补齐启动校验(M2)。 + +交付标准: + +1. 配置错误在启动阶段暴露。 +2. 读模型绑定无短名误绑定路径。 + +### Phase 2(Minor + 文档收口) + +1. List 默认排序兜底(N1)。 +2. IndexKind 全包含语义(N2)。 +3. README/架构文档与配置示例统一更新。 + +交付标准: + +1. 行为确定性增强且文档可执行。 +2. 全量构建/测试/门禁通过。 + +## 7. 验收标准(Definition of Done) + +1. 所有 review 项对应的代码路径均有测试覆盖。 +2. Provider 能力声明与实现一致,不存在“声明支持但无实现”的字段。 +3. Elasticsearch 读改写路径具备 OCC,冲突有确定性行为(重试或失败)。 +4. Workflow Host Provider 组合无歧义,配置缺失/错误启动即失败。 +5. Binding 只接受 FullName,消除跨命名空间同名冲突风险。 +6. List 默认顺序稳定。 +7. CI 门禁与全量测试通过,文档与实现一致。 + +## 8. 风险与控制 + +1. 风险:去除短名绑定与隐式 provider 默认值会导致旧配置启动失败。 +2. 控制:错误信息必须指向具体配置路径,并在 README 给出新配置示例。 +3. 风险:OCC 重试增加写延迟。 +4. 控制:重试次数与超时可配置,冲突率纳入日志/指标观察。 + +## 9. 文档同步清单 + +需要同步更新: + +1. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md` +2. `src/workflow/Aevatar.Workflow.Projection/README.md` +3. `docs/CQRS_ARCHITECTURE.md`(Provider 选择与 binding 规则) +4. `docs/architecture/readmodel-graph-relations-refactor-blueprint.md`(补充本次 hardening 决策链接) + +## 10. 本轮实施结果(2026-02-24) + +1. 已完成 B1/B2/B3、M1/M2、N1/N2 全部代码改造与测试补齐。 +2. 验证结果: + - `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo`:通过。 + - `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo`:通过。 + - `dotnet test aevatar.slnx --nologo`:通过。 + - `bash tools/ci/architecture_guards.sh`:通过。 + - `bash tools/ci/test_stability_guards.sh`:通过。 diff --git a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md index 471528212..67fae2883 100644 --- a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md +++ b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md @@ -329,3 +329,11 @@ flowchart LR ### 17.3 兼容性声明 1. 本次重构为不兼容变更,已删除 `IWorkflowExecutionProjectionPort`。 2. 旧接口调用方必须迁移到 Lifecycle/Query 双端口。 + +## 18. Provider 硬化补充(2026-02-24) +1. Elasticsearch 能力声明已收敛为真实实现:不再声明 alias/schema validation 能力。 +2. Elasticsearch `MutateAsync` 已引入 `seq_no/primary_term` OCC,冲突路径具备重试与失败语义。 +3. `AutoCreateIndex=false` 且索引缺失默认 fail-fast,支持 `MissingIndexBehavior=WarnAndReturnEmpty` 调试模式。 +4. ReadModel binding 已收敛为 `Type.FullName` 键,短名键直接抛配置异常。 +5. Workflow Host provider 注册改为按配置按需注册,消除多 provider 未指定歧义。 +6. 详见:`docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md`。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs index 4ee3c55ee..c03febeca 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs @@ -21,10 +21,16 @@ public static IReadOnlyList Validate( violations.Add( $"requires index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}], but provider indexing is disabled"); } - else if (!requirements.RequiredIndexKinds.Overlaps(capabilities.IndexKinds)) + else { - violations.Add( - $"required index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}] are not supported by provider kinds [{string.Join(", ", capabilities.IndexKinds)}]"); + var missingKinds = requirements.RequiredIndexKinds + .Where(kind => !capabilities.IndexKinds.Contains(kind)) + .ToList(); + if (missingKinds.Count > 0) + { + violations.Add( + $"required index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}] are not fully supported by provider kinds [{string.Join(", ", capabilities.IndexKinds)}]; missing kinds [{string.Join(", ", missingKinds)}]"); + } } } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs new file mode 100644 index 000000000..bf7a50c48 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; + +public enum ElasticsearchMissingIndexBehavior +{ + Throw = 0, + WarnAndReturnEmpty = 1, +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs index 7628858fa..21121ae17 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs @@ -12,6 +12,10 @@ public sealed class ElasticsearchProjectionReadModelStoreOptions public bool AutoCreateIndex { get; set; } = true; + public ElasticsearchMissingIndexBehavior MissingIndexBehavior { get; set; } = ElasticsearchMissingIndexBehavior.Throw; + + public int MutateMaxRetryCount { get; set; } = 3; + public string Username { get; set; } = ""; public string Password { get; set; } = ""; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 755e0fc63..5fe6cc4bf 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -27,8 +27,8 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration new ElasticsearchProjectionReadModelStore( optionsFactory(provider), indexScope, @@ -51,8 +51,8 @@ public static IServiceCollection AddElasticsearchRelationStoreRegistration( providerName, supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document], - supportsAliases: true, - supportsSchemaValidation: true, + supportsAliases: false, + supportsSchemaValidation: false, supportsRelations: false, supportsRelationTraversal: false), _ => new ElasticsearchProjectionRelationStore(providerName))); diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 12d6d1cff..aac8f9bc4 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -4,8 +4,11 @@ - 不依赖任何业务域 read model。 - 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 -- 能力声明:`Document` 索引、alias、schema validation。 +- 能力声明:`Document` 索引(不声明 alias/schema validation 能力)。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 +- `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 +- `AutoCreateIndex=false` 时可通过 `MissingIndexBehavior` 控制索引缺失行为(默认抛错)。 +- `ListSortField` 为空时默认按 `CreatedAt desc -> _id desc` 排序,优先按创建时间倒序并保证稳定性。 ## DI 注册 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index 77efa698f..94216dd28 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -14,6 +14,9 @@ public sealed class ElasticsearchProjectionReadModelStore IDisposable where TReadModel : class { + private const string DefaultListPrimarySortField = "CreatedAt"; + private const string DefaultListTiebreakSortField = "_id"; + private readonly HttpClient _httpClient; private readonly Func _keySelector; private readonly Func _keyFormatter; @@ -21,6 +24,8 @@ public sealed class ElasticsearchProjectionReadModelStore private readonly int _listTakeMax; private readonly bool _autoCreateIndex; private readonly string _listSortField; + private readonly ElasticsearchMissingIndexBehavior _missingIndexBehavior; + private readonly int _mutateMaxRetryCount; private readonly ILogger> _logger; private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -35,17 +40,18 @@ public ElasticsearchProjectionReadModelStore( Func keySelector, Func? keyFormatter = null, string providerName = ProjectionReadModelProviderNames.Elasticsearch, - ILogger>? logger = null) + ILogger>? logger = null, + HttpMessageHandler? httpMessageHandler = null) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(keySelector); var endpoint = ResolvePrimaryEndpoint(options.Endpoints); - _httpClient = new HttpClient - { - BaseAddress = endpoint, - Timeout = TimeSpan.FromMilliseconds(Math.Max(500, options.RequestTimeoutMs)), - }; + _httpClient = httpMessageHandler == null + ? new HttpClient() + : new HttpClient(httpMessageHandler, disposeHandler: true); + _httpClient.BaseAddress = endpoint; + _httpClient.Timeout = TimeSpan.FromMilliseconds(Math.Max(500, options.RequestTimeoutMs)); if (!string.IsNullOrWhiteSpace(options.Username)) { @@ -61,6 +67,8 @@ public ElasticsearchProjectionReadModelStore( _indexName = BuildIndexName(options.IndexPrefix, normalizedScope); _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; _autoCreateIndex = options.AutoCreateIndex; + _missingIndexBehavior = options.MissingIndexBehavior; + _mutateMaxRetryCount = Math.Clamp(options.MutateMaxRetryCount, 0, 20); _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortField = options.ListSortField?.Trim() ?? ""; @@ -79,27 +87,62 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT ct.ThrowIfCancellationRequested(); var keyValue = FormatKey(key); + if (keyValue.Length == 0) + throw new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' resolved an empty key for Elasticsearch mutation."); + var startedAt = DateTimeOffset.UtcNow; - var existing = await GetAsync(key, ct); - if (existing == null) + for (var attempt = 0; attempt <= _mutateMaxRetryCount; attempt++) { - var notFound = new InvalidOperationException( - $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); - LogWriteFailure(keyValue, startedAt, notFound); - throw notFound; - } + var snapshot = await GetDocumentSnapshotAsync(keyValue, ct); + if (snapshot == null) + { + var notFound = new InvalidOperationException( + $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); + LogWriteFailure(keyValue, startedAt, notFound); + throw notFound; + } - try - { - mutate(existing); - } - catch (Exception ex) - { - LogWriteFailure(keyValue, startedAt, ex); - throw; - } + try + { + mutate(snapshot.ReadModel); + } + catch (Exception ex) + { + LogWriteFailure(keyValue, startedAt, ex); + throw; + } - await UpsertCoreAsync(existing, allowCreateIndex: true, ct); + try + { + await UpsertCoreAsync( + snapshot.ReadModel, + allowCreateIndex: true, + ct, + ifSeqNo: snapshot.SeqNo, + ifPrimaryTerm: snapshot.PrimaryTerm); + return; + } + catch (ElasticsearchOptimisticConcurrencyException ex) when (attempt < _mutateMaxRetryCount) + { + _logger.LogWarning( + ex, + "Projection read-model optimistic concurrency conflict. provider={Provider} readModelType={ReadModelType} key={Key} attempt={Attempt}/{MaxAttempts}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + keyValue, + attempt + 1, + _mutateMaxRetryCount + 1); + } + catch (ElasticsearchOptimisticConcurrencyException ex) + { + var conflict = new InvalidOperationException( + $"Elasticsearch optimistic concurrency update failed for read-model '{typeof(TReadModel).FullName}' with key '{keyValue}' after {_mutateMaxRetryCount + 1} attempt(s).", + ex); + LogWriteFailure(keyValue, startedAt, conflict); + throw conflict; + } + } } public async Task GetAsync(TKey key, CancellationToken ct = default) @@ -113,11 +156,16 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT using var response = await _httpClient.GetAsync($"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}", ct); if (response.StatusCode == HttpStatusCode.NotFound) + { + var payload = await response.Content.ReadAsStringAsync(ct); + if (TryHandleMissingIndexForRead("get", payload)) + return null; return null; + } await EnsureSuccessAsync(response, "get", ct); - var payload = await response.Content.ReadAsStringAsync(ct); - using var jsonDoc = JsonDocument.Parse(payload); + var successfulPayload = await response.Content.ReadAsStringAsync(ct); + using var jsonDoc = JsonDocument.Parse(successfulPayload); if (!jsonDoc.RootElement.TryGetProperty("_source", out var sourceNode)) return null; @@ -136,11 +184,16 @@ public async Task> ListAsync(int take = 50, Cancellati }; using var response = await _httpClient.SendAsync(request, ct); if (response.StatusCode == HttpStatusCode.NotFound) + { + var payload = await response.Content.ReadAsStringAsync(ct); + if (TryHandleMissingIndexForRead("list", payload)) + return []; return []; + } await EnsureSuccessAsync(response, "list", ct); - var payload = await response.Content.ReadAsStringAsync(ct); - using var jsonDoc = JsonDocument.Parse(payload); + var successfulPayload = await response.Content.ReadAsStringAsync(ct); + using var jsonDoc = JsonDocument.Parse(successfulPayload); if (!jsonDoc.RootElement.TryGetProperty("hits", out var hitsNode) || !hitsNode.TryGetProperty("hits", out var hitItems)) return []; @@ -159,7 +212,48 @@ public async Task> ListAsync(int take = 50, Cancellati return items; } - private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, CancellationToken ct) + private async Task GetDocumentSnapshotAsync(string keyValue, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + await EnsureIndexAsync(ct); + + using var response = await _httpClient.GetAsync($"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}", ct); + if (response.StatusCode == HttpStatusCode.NotFound) + { + var payload = await response.Content.ReadAsStringAsync(ct); + if (IsIndexNotFoundPayload(payload)) + throw BuildMissingIndexException("mutate", payload); + return null; + } + + await EnsureSuccessAsync(response, "mutate-get", ct); + var successfulPayload = await response.Content.ReadAsStringAsync(ct); + using var jsonDoc = JsonDocument.Parse(successfulPayload); + if (!jsonDoc.RootElement.TryGetProperty("_source", out var sourceNode)) + return null; + + var readModel = DeserializeOrNull(sourceNode.GetRawText()); + if (readModel == null) + return null; + + if (!jsonDoc.RootElement.TryGetProperty("_seq_no", out var seqNoNode) || + !seqNoNode.TryGetInt64(out var seqNo) || + !jsonDoc.RootElement.TryGetProperty("_primary_term", out var primaryTermNode) || + !primaryTermNode.TryGetInt64(out var primaryTerm)) + { + throw new InvalidOperationException( + $"Elasticsearch mutate-get response missing optimistic concurrency metadata for index '{_indexName}' key '{keyValue}'."); + } + + return new ElasticsearchDocumentSnapshot(readModel, seqNo, primaryTerm); + } + + private async Task UpsertCoreAsync( + TReadModel readModel, + bool allowCreateIndex, + CancellationToken ct, + long? ifSeqNo = null, + long? ifPrimaryTerm = null) { ArgumentNullException.ThrowIfNull(readModel); ct.ThrowIfCancellationRequested(); @@ -171,11 +265,19 @@ private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, var startedAt = DateTimeOffset.UtcNow; try { - using var request = new HttpRequestMessage(HttpMethod.Put, $"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}") + var requestPath = BuildDocumentRequestPath(keyValue, ifSeqNo, ifPrimaryTerm); + using var request = new HttpRequestMessage(HttpMethod.Put, requestPath) { Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; using var response = await _httpClient.SendAsync(request, ct); + if (response.StatusCode == HttpStatusCode.Conflict) + { + var conflictPayload = await response.Content.ReadAsStringAsync(ct); + throw new ElasticsearchOptimisticConcurrencyException( + $"Elasticsearch optimistic concurrency conflict for index '{_indexName}' key '{keyValue}'. body={TruncatePayload(conflictPayload)}"); + } + await EnsureSuccessAsync(response, "upsert", ct); var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; @@ -189,11 +291,55 @@ private async Task UpsertCoreAsync(TReadModel readModel, bool allowCreateIndex, } catch (Exception ex) { - LogWriteFailure(keyValue, startedAt, ex); + if (ex is not ElasticsearchOptimisticConcurrencyException) + LogWriteFailure(keyValue, startedAt, ex); throw; } } + private string BuildDocumentRequestPath(string keyValue, long? ifSeqNo, long? ifPrimaryTerm) + { + var requestPath = $"{_indexName}/_doc/{Uri.EscapeDataString(keyValue)}"; + if (!ifSeqNo.HasValue && !ifPrimaryTerm.HasValue) + return requestPath; + + if (!ifSeqNo.HasValue || !ifPrimaryTerm.HasValue) + throw new InvalidOperationException("Elasticsearch optimistic concurrency update requires both seq_no and primary_term."); + + return requestPath + $"?if_seq_no={ifSeqNo.Value}&if_primary_term={ifPrimaryTerm.Value}"; + } + + private bool TryHandleMissingIndexForRead(string operation, string payload) + { + if (!IsIndexNotFoundPayload(payload)) + return false; + + if (_autoCreateIndex || _missingIndexBehavior == ElasticsearchMissingIndexBehavior.Throw) + throw BuildMissingIndexException(operation, payload); + + _logger.LogWarning( + "Projection read-model index is missing. provider={Provider} readModelType={ReadModelType} index={Index} operation={Operation} behavior={Behavior}", + ProviderCapabilities.ProviderName, + typeof(TReadModel).FullName, + _indexName, + operation, + _missingIndexBehavior); + return true; + } + + private InvalidOperationException BuildMissingIndexException(string operation, string payload) + { + return new InvalidOperationException( + $"Elasticsearch index '{_indexName}' was not found during '{operation}' for read-model '{typeof(TReadModel).FullName}'. " + + $"Configure index bootstrap or set '{nameof(ElasticsearchProjectionReadModelStoreOptions.AutoCreateIndex)}=true'. " + + $"body={TruncatePayload(payload)}"); + } + + private static bool IsIndexNotFoundPayload(string payload) + { + return payload.Contains("index_not_found_exception", StringComparison.OrdinalIgnoreCase); + } + private void LogWriteFailure( string keyValue, DateTimeOffset startedAt, @@ -240,24 +386,63 @@ private string FormatKey(TKey key) private string BuildListPayloadJson(int size) { - if (_listSortField.Length == 0) - return JsonSerializer.Serialize(new { size, query = new { match_all = new { } } }); + var sort = _listSortField.Length == 0 + ? BuildDefaultSortSpec() + : BuildConfiguredSortSpec(_listSortField); return JsonSerializer.Serialize(new { size, - sort = new object[] + sort, + query = new + { + match_all = new { }, + }, + }); + } + + private static object[] BuildConfiguredSortSpec(string sortField) + { + return + [ + new Dictionary { - new Dictionary + [sortField] = new Dictionary { - [_listSortField] = new { order = "desc" }, + ["order"] = "desc", }, }, - query = new + new Dictionary { - match_all = new { }, + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, }, - }); + ]; + } + + private static object[] BuildDefaultSortSpec() + { + return + [ + new Dictionary + { + [DefaultListPrimarySortField] = new Dictionary + { + ["order"] = "desc", + ["missing"] = "_last", + ["unmapped_type"] = "date", + }, + }, + new Dictionary + { + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + ]; } private async Task EnsureIndexAsync(CancellationToken ct) @@ -350,17 +535,36 @@ private static string NormalizeToken(string token) return new string(chars).Trim('-'); } + private static string TruncatePayload(string payload) + { + const int maxLength = 512; + if (payload.Length <= maxLength) + return payload; + + return payload[..maxLength] + "...(truncated)"; + } + private static ProjectionReadModelProviderCapabilities BuildCapabilities(string providerName) => new( providerName, supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document], - supportsAliases: true, - supportsSchemaValidation: true); + supportsAliases: false, + supportsSchemaValidation: false); public void Dispose() { _httpClient.Dispose(); _indexInitializationLock.Dispose(); } + + private sealed record ElasticsearchDocumentSnapshot(TReadModel ReadModel, long SeqNo, long PrimaryTerm); + + private sealed class ElasticsearchOptimisticConcurrencyException : InvalidOperationException + { + public ElasticsearchOptimisticConcurrencyException(string message) + : base(message) + { + } + } } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs index 7da58fb70..6691d47f5 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs @@ -11,8 +11,8 @@ public ElasticsearchProjectionRelationStore( providerName, supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document], - supportsAliases: true, - supportsSchemaValidation: true, + supportsAliases: false, + supportsSchemaValidation: false, supportsRelations: false, supportsRelationTraversal: false); } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs index 0fdb4cfc6..229b6bd24 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs @@ -42,12 +42,6 @@ private static bool TryGetBinding( return false; } - if (readModelBindings.TryGetValue(readModelType.Name, out bindingValue!)) - { - bindingKey = readModelType.Name; - return true; - } - var fullName = readModelType.FullName ?? ""; if (fullName.Length > 0 && readModelBindings.TryGetValue(fullName, out bindingValue!)) { @@ -55,6 +49,15 @@ private static bool TryGetBinding( return true; } + if (readModelBindings.TryGetValue(readModelType.Name, out bindingValue!)) + { + throw new ProjectionReadModelBindingException( + readModelType, + readModelType.Name, + bindingValue, + $"Binding key must use full type name '{fullName}'."); + } + bindingKey = ""; bindingValue = ""; return false; diff --git a/src/Aevatar.Foundation.Projection/README.md b/src/Aevatar.Foundation.Projection/README.md index cf0b811c7..aefe415d0 100644 --- a/src/Aevatar.Foundation.Projection/README.md +++ b/src/Aevatar.Foundation.Projection/README.md @@ -4,7 +4,7 @@ ## 职责 -- 定义读模型基础字段:`AevatarReadModelBase` +- 定义读模型基础字段:`AevatarReadModelBase`(`RootActorId/CommandId/StateVersion/LastEventId/CreatedAt/UpdatedAt`) - 定义通用读侧能力接口: - `IHasProjectionTimeline` - `IHasProjectionRoleReplies` diff --git a/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs b/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs index 1fcdab20a..0fa183133 100644 --- a/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs +++ b/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs @@ -9,6 +9,6 @@ public abstract class AevatarReadModelBase public string CommandId { get; set; } = string.Empty; public long StateVersion { get; set; } public string LastEventId { get; set; } = string.Empty; - public DateTimeOffset StartedAt { get; set; } - public DateTimeOffset EndedAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs index 4e9591f55..8c8015720 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs @@ -107,6 +107,8 @@ public sealed class WorkflowRunReport public string CommandId { get; set; } = string.Empty; public long StateVersion { get; set; } public string LastEventId { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset StartedAt { get; set; } public DateTimeOffset EndedAt { get; set; } public double DurationMs { get; set; } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index 55fd2a1fb..d2c202aff 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -42,18 +42,31 @@ private static void ApplyGlobalReadModelOptions( IConfiguration configuration, WorkflowExecutionProjectionOptions options) { + var readModelSection = configuration.GetSection("Projection:ReadModel"); + if (!readModelSection.Exists()) + return; + var readModelOptions = new ProjectionReadModelRuntimeOptions(); - configuration.GetSection("Projection:ReadModel").Bind(readModelOptions); + readModelSection.Bind(readModelOptions); + + var configuredProvider = readModelSection["Provider"]; + if (!string.IsNullOrWhiteSpace(configuredProvider)) + options.ReadModelProvider = configuredProvider.Trim(); + var configuredRelationProvider = readModelSection["RelationProvider"]; + if (!string.IsNullOrWhiteSpace(configuredRelationProvider)) + options.RelationProvider = configuredRelationProvider.Trim(); - if (!string.IsNullOrWhiteSpace(readModelOptions.Provider)) - options.ReadModelProvider = readModelOptions.Provider.Trim(); - if (!string.IsNullOrWhiteSpace(readModelOptions.RelationProvider)) - options.RelationProvider = readModelOptions.RelationProvider.Trim(); + if (!string.IsNullOrWhiteSpace(readModelSection["Mode"])) + options.ReadModelMode = readModelOptions.Mode; + if (!string.IsNullOrWhiteSpace(readModelSection["FailOnUnsupportedCapabilities"])) + options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; - options.ReadModelMode = readModelOptions.Mode; - options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; - options.ReadModelBindings.Clear(); - foreach (var item in readModelOptions.Bindings) - options.ReadModelBindings[item.Key] = item.Value; + var bindingsSection = readModelSection.GetSection("Bindings"); + if (bindingsSection.Exists()) + { + options.ReadModelBindings.Clear(); + foreach (var item in readModelOptions.Bindings) + options.ReadModelBindings[item.Key] = item.Value; + } } } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/Reporting/WorkflowExecutionReportWriter.cs b/src/workflow/Aevatar.Workflow.Infrastructure/Reporting/WorkflowExecutionReportWriter.cs index c11e6095c..853136171 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/Reporting/WorkflowExecutionReportWriter.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/Reporting/WorkflowExecutionReportWriter.cs @@ -67,6 +67,8 @@ private static string BuildHtml(WorkflowRunReport report) AppendRow(sb, "TopologySource", report.TopologySource.ToString()); AppendRow(sb, "Success", report.Success?.ToString() ?? "(unknown)"); AppendRow(sb, "DurationMs", report.DurationMs.ToString("F2")); + AppendRow(sb, "CreatedAt", report.CreatedAt.ToString("O")); + AppendRow(sb, "UpdatedAt", report.UpdatedAt.ToString("O")); AppendRow(sb, "StartedAt", report.StartedAt.ToString("O")); AppendRow(sb, "EndedAt", report.EndedAt.ToString("O")); sb.AppendLine(""); diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index 42c6ed708..a616ede49 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -68,7 +68,7 @@ public bool EnableRunQueryEndpoints public bool ValidateRelationProviderOnStartup { get; set; } = true; /// - /// Optional read-model binding requirements (ReadModelName -> IndexKind). + /// Optional read-model binding requirements (ReadModelType.FullName -> IndexKind). /// public Dictionary ReadModelBindings { get; } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs index 7aeb75683..8fff12c53 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs @@ -1,4 +1,5 @@ using Aevatar.Workflow.Projection.ReadModels; +using Aevatar.Workflow.Projection.Reducers; namespace Aevatar.Workflow.Projection.Orchestration; @@ -20,16 +21,19 @@ public Task RefreshMetadataAsync( WorkflowExecutionProjectionContext context, CancellationToken ct = default) { + var updatedAt = _clock.UtcNow; return _store.MutateAsync(actorId, report => { report.CommandId = context.CommandId; report.WorkflowName = context.WorkflowName; report.Input = context.Input; + if (report.CreatedAt == default) + report.CreatedAt = context.StartedAt; report.StartedAt = context.StartedAt; if (report.EndedAt < report.StartedAt) report.EndedAt = report.StartedAt; - report.DurationMs = Math.Max(0, (report.EndedAt - report.StartedAt).TotalMilliseconds); + WorkflowExecutionProjectionMutations.RefreshDerivedFields(report, updatedAt); }, ct); } @@ -37,15 +41,16 @@ public Task MarkStoppedAsync( string actorId, CancellationToken ct = default) { + var updatedAt = _clock.UtcNow; return _store.MutateAsync(actorId, report => { if (report.CompletionStatus == WorkflowExecutionCompletionStatus.Running) report.CompletionStatus = WorkflowExecutionCompletionStatus.Stopped; if (report.EndedAt < report.StartedAt) - report.EndedAt = _clock.UtcNow; + report.EndedAt = updatedAt; - report.DurationMs = Math.Max(0, (report.EndedAt - report.StartedAt).TotalMilliseconds); + WorkflowExecutionProjectionMutations.RefreshDerivedFields(report, updatedAt); }, ct); } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs index b809039f8..4d6d2669e 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs @@ -21,15 +21,15 @@ public WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions o var readModelRequirements = _bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions { - RequestedProviderName = NormalizeProviderName(options.ReadModelProvider), + RequestedProviderName = NormalizeRequiredProviderName(options.ReadModelProvider), FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; var relationRequirements = BuildRelationRequirements(readModelRequirements); var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions { - RequestedProviderName = NormalizeProviderName( + RequestedProviderName = NormalizeRelationProviderName( options.RelationProvider, - options.ReadModelProvider), + readModelSelectionOptions.RequestedProviderName), FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; @@ -51,18 +51,25 @@ private static ProjectionReadModelRequirements BuildRelationRequirements( requiresSchemaValidation: readModelRequirements.RequiresSchemaValidation); } - private static string NormalizeProviderName(string providerName, string fallbackProviderName = "") + private static string NormalizeRequiredProviderName(string providerName) { if (string.IsNullOrWhiteSpace(providerName)) { - if (string.IsNullOrWhiteSpace(fallbackProviderName)) - return ProjectionReadModelProviderNames.InMemory; - return fallbackProviderName.Trim(); + throw new InvalidOperationException( + "WorkflowExecutionProjection:ReadModelProvider is required and cannot be empty."); } return providerName.Trim(); } + private static string NormalizeRelationProviderName(string relationProviderName, string fallbackProviderName) + { + if (string.IsNullOrWhiteSpace(relationProviderName)) + return fallbackProviderName; + + return relationProviderName.Trim(); + } + private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) { if (readModelMode != ProjectionReadModelMode.StateOnly) diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs index 8f2fb6845..7ce9c9000 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs @@ -40,6 +40,8 @@ public ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, Can WorkflowName = context.WorkflowName, RootActorId = context.RootActorId, CommandId = context.CommandId, + CreatedAt = context.StartedAt, + UpdatedAt = context.StartedAt, StartedAt = context.StartedAt, EndedAt = context.StartedAt, Input = context.Input, @@ -73,7 +75,7 @@ await _store.MutateAsync(context.RootActorId, report => return; WorkflowExecutionProjectionMutations.RecordProjectedEvent(report, envelope); - WorkflowExecutionProjectionMutations.RefreshDerivedFields(report); + WorkflowExecutionProjectionMutations.RefreshDerivedFields(report, now); }, ct); } @@ -82,15 +84,16 @@ public ValueTask CompleteAsync( IReadOnlyList topology, CancellationToken ct = default) { + var completedAt = DateTimeOffset.UtcNow; return new ValueTask(_store.MutateAsync(context.RootActorId, report => { report.Topology = topology.Select(x => new WorkflowExecutionTopologyEdge(x.Parent, x.Child)).ToList(); report.TopologySource = WorkflowExecutionTopologySource.RuntimeSnapshot; if (report.EndedAt < report.StartedAt) - report.EndedAt = DateTimeOffset.UtcNow; + report.EndedAt = completedAt; if (report.CompletionStatus == WorkflowExecutionCompletionStatus.Running) report.CompletionStatus = WorkflowExecutionCompletionStatus.Completed; - WorkflowExecutionProjectionMutations.RefreshDerivedFields(report); + WorkflowExecutionProjectionMutations.RefreshDerivedFields(report, completedAt); }, ct)); } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index edd3e81c0..a1226c2c9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -92,7 +92,7 @@ FAQ: - `WorkflowExecutionProjection:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) - `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:是否在 Host 启动阶段预校验 Provider 选择与能力(默认 `true`) - `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:是否在 Host 启动阶段预校验 Relation Provider(默认 `true`) -- `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(如 `WorkflowExecutionReport: Document`) +- `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(键必须为 `ReadModel` 的 `Type.FullName`,例如 `Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Document`) - 推荐统一配置入口:`Projection:ReadModel:*`(由 Infrastructure 映射到 Workflow 投影选项) - `Projection:ReadModel:Provider`:全局默认 Provider(当前由 `WorkflowCapabilityServiceCollectionExtensions` 覆盖到模块选项) - `Projection:ReadModel:RelationProvider`:全局默认 Relation Provider(覆盖到模块选项) @@ -102,7 +102,10 @@ FAQ: - `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix`:索引前缀 - `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs`:请求超时 - `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax`:`ListAsync` 上限 +- `Projection:ReadModel:Providers:Elasticsearch:ListSortField`:可选自定义排序字段;为空时默认 `CreatedAt desc -> _id desc` - `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex`:是否自动建索引 +- `Projection:ReadModel:Providers:Elasticsearch:MissingIndexBehavior`:索引缺失行为(`Throw` / `WarnAndReturnEmpty`,默认 `Throw`) +- `Projection:ReadModel:Providers:Elasticsearch:MutateMaxRetryCount`:`MutateAsync` OCC 冲突重试次数(默认 `3`) - `Projection:ReadModel:Providers:Elasticsearch:Username/Password`:可选基础认证 - 扩展 run 输出协议: - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index cb073ab85..4708d9c73 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -37,6 +37,8 @@ public sealed class WorkflowExecutionReport public WorkflowExecutionTopologySource TopologySource { get; set; } = WorkflowExecutionTopologySource.RuntimeSnapshot; public WorkflowExecutionCompletionStatus CompletionStatus { get; set; } = WorkflowExecutionCompletionStatus.Unknown; public string WorkflowName { get; set; } = ""; + public DateTimeOffset StartedAt { get; set; } + public DateTimeOffset EndedAt { get; set; } public double DurationMs { get; set; } public bool? Success { get; set; } public string Input { get; set; } = ""; diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs index 8d21181e0..3b5b30dfd 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs @@ -13,7 +13,7 @@ public WorkflowActorSnapshot ToActorSnapshot(WorkflowExecutionReport source) LastCommandId = source.CommandId, StateVersion = source.StateVersion, LastEventId = source.LastEventId, - LastUpdatedAt = source.EndedAt, + LastUpdatedAt = source.UpdatedAt, LastSuccess = source.Success, LastOutput = source.FinalOutput, LastError = source.FinalError, diff --git a/src/workflow/Aevatar.Workflow.Projection/Reducers/WorkflowExecutionProjectionMutations.cs b/src/workflow/Aevatar.Workflow.Projection/Reducers/WorkflowExecutionProjectionMutations.cs index 8532e2ea7..853732c14 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Reducers/WorkflowExecutionProjectionMutations.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Reducers/WorkflowExecutionProjectionMutations.cs @@ -50,9 +50,10 @@ public static void AddTimeline( }); } - public static void RefreshDerivedFields(WorkflowExecutionReport report) + public static void RefreshDerivedFields(WorkflowExecutionReport report, DateTimeOffset updatedAt) { report.DurationMs = Math.Max(0, (report.EndedAt - report.StartedAt).TotalMilliseconds); + report.UpdatedAt = updatedAt; var stepTypeCounts = report.Steps .Where(x => !string.IsNullOrWhiteSpace(x.StepType)) diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index d34135d77..2f62a1786 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; +using Aevatar.CQRS.Projection.Abstractions; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -23,46 +24,130 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( services.AddSingleton(); - services.AddInMemoryReadModelStoreRegistration( - keySelector: report => report.RootActorId, - keyFormatter: key => key, - listSortSelector: report => report.StartedAt, - listTakeMax: 200); - services.AddInMemoryRelationStoreRegistration(); - - services.AddElasticsearchReadModelStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); - return providerOptions; - }, - indexScope: "workflow-execution-reports", - keySelector: report => report.RootActorId, - keyFormatter: key => key); - services.AddElasticsearchRelationStoreRegistration(); - - services.AddNeo4jReadModelStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new Neo4jProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); - return providerOptions; - }, - scope: "workflow-execution-reports", - keySelector: report => report.RootActorId, - keyFormatter: key => key); - services.AddNeo4jRelationStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new Neo4jProjectionRelationStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); - return providerOptions; - }, - scope: WorkflowExecutionRelationConstants.Scope); + var providerSelection = ResolveProviderSelection(configuration); + RegisterReadModelProvider(services, configuration, providerSelection.ReadModelProvider); + RegisterRelationProvider(services, configuration, providerSelection.RelationProvider); return services; } + private static ProviderSelection ResolveProviderSelection(IConfiguration configuration) + { + var workflowReadModelProvider = configuration["WorkflowExecutionProjection:ReadModelProvider"]; + var workflowRelationProvider = configuration["WorkflowExecutionProjection:RelationProvider"]; + var globalReadModelProvider = configuration["Projection:ReadModel:Provider"]; + var globalRelationProvider = configuration["Projection:ReadModel:RelationProvider"]; + + var readModelProvider = NormalizeOrDefaultProvider( + !string.IsNullOrWhiteSpace(globalReadModelProvider) + ? globalReadModelProvider + : workflowReadModelProvider, + ProjectionReadModelProviderNames.InMemory, + "Projection:ReadModel:Provider"); + + var relationProvider = NormalizeOrDefaultProvider( + !string.IsNullOrWhiteSpace(globalRelationProvider) + ? globalRelationProvider + : workflowRelationProvider, + readModelProvider, + "Projection:ReadModel:RelationProvider"); + + return new ProviderSelection(readModelProvider, relationProvider); + } + + private static string NormalizeOrDefaultProvider( + string? configuredValue, + string fallbackValue, + string optionPath) + { + var candidate = string.IsNullOrWhiteSpace(configuredValue) + ? fallbackValue + : configuredValue.Trim(); + + if (string.Equals(candidate, ProjectionReadModelProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) + return ProjectionReadModelProviderNames.InMemory; + if (string.Equals(candidate, ProjectionReadModelProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) + return ProjectionReadModelProviderNames.Elasticsearch; + if (string.Equals(candidate, ProjectionReadModelProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) + return ProjectionReadModelProviderNames.Neo4j; + + throw new InvalidOperationException( + $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + + $"Allowed values: {ProjectionReadModelProviderNames.InMemory}, {ProjectionReadModelProviderNames.Elasticsearch}, {ProjectionReadModelProviderNames.Neo4j}."); + } + + private static void RegisterReadModelProvider( + IServiceCollection services, + IConfiguration configuration, + string providerName) + { + switch (providerName) + { + case ProjectionReadModelProviderNames.InMemory: + services.AddInMemoryReadModelStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.CreatedAt, + listTakeMax: 200); + break; + case ProjectionReadModelProviderNames.Elasticsearch: + services.AddElasticsearchReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); + return providerOptions; + }, + indexScope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + break; + case ProjectionReadModelProviderNames.Neo4j: + services.AddNeo4jReadModelStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new Neo4jProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + return providerOptions; + }, + scope: "workflow-execution-reports", + keySelector: report => report.RootActorId, + keyFormatter: key => key); + break; + default: + throw new InvalidOperationException($"Unsupported read-model provider '{providerName}'."); + } + } + + private static void RegisterRelationProvider( + IServiceCollection services, + IConfiguration configuration, + string providerName) + { + switch (providerName) + { + case ProjectionReadModelProviderNames.InMemory: + services.AddInMemoryRelationStoreRegistration(); + break; + case ProjectionReadModelProviderNames.Elasticsearch: + services.AddElasticsearchRelationStoreRegistration(); + break; + case ProjectionReadModelProviderNames.Neo4j: + services.AddNeo4jRelationStoreRegistration( + optionsFactory: _ => + { + var providerOptions = new Neo4jProjectionRelationStoreOptions(); + configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + return providerOptions; + }, + scope: WorkflowExecutionRelationConstants.Scope); + break; + default: + throw new InvalidOperationException($"Unsupported relation provider '{providerName}'."); + } + } + private sealed class WorkflowProjectionProviderRegistrationsMarker; + + private sealed record ProviderSelection(string ReadModelProvider, string RelationProvider); } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs new file mode 100644 index 000000000..2c0a5de43 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs @@ -0,0 +1,191 @@ +using System.Net; +using System.Text; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; +using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public sealed class ElasticsearchProjectionReadModelStoreBehaviorTests +{ + [Fact] + public void ProviderCapabilities_ShouldNotClaimAliasOrSchemaValidationSupport() + { + using var store = CreateStore( + new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = false, + }, + new ScriptedHttpMessageHandler()); + + var capabilities = store.ProviderCapabilities; + capabilities.SupportsAliases.Should().BeFalse(); + capabilities.SupportsSchemaValidation.Should().BeFalse(); + } + + [Fact] + public async Task GetAsync_WhenIndexMissingAndAutoCreateDisabled_ShouldThrowByDefault() + { + var handler = new ScriptedHttpMessageHandler(); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.NotFound, + """{"error":{"type":"index_not_found_exception"},"status":404}""")); + + using var store = CreateStore( + new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = false, + }, + handler); + + Func act = () => store.GetAsync("actor-1"); + + await act.Should().ThrowAsync() + .WithMessage("*index*not found*"); + } + + [Fact] + public async Task GetAsync_WhenIndexMissingAndWarnBehaviorEnabled_ShouldReturnNull() + { + var handler = new ScriptedHttpMessageHandler(); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.NotFound, + """{"error":{"type":"index_not_found_exception"},"status":404}""")); + + using var store = CreateStore( + new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = false, + MissingIndexBehavior = ElasticsearchMissingIndexBehavior.WarnAndReturnEmpty, + }, + handler); + + var result = await store.GetAsync("actor-1"); + + result.Should().BeNull(); + } + + [Fact] + public async Task ListAsync_WhenSortFieldNotConfigured_ShouldUseDeterministicDefaultSort() + { + var handler = new ScriptedHttpMessageHandler(); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"hits":{"hits":[]}}""")); + + using var store = CreateStore( + new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = false, + ListSortField = "", + }, + handler); + + _ = await store.ListAsync(); + + var searchRequest = handler.CapturedRequests.Should().ContainSingle().Subject; + searchRequest.PathAndQuery.Should().EndWith("/_search"); + searchRequest.Body.Should().Contain("\"sort\""); + searchRequest.Body.Should().Contain("\"CreatedAt\""); + searchRequest.Body.Should().Contain("\"_id\""); + } + + [Fact] + public async Task MutateAsync_WhenOptimisticConflictOccurs_ShouldRetryWithLatestSeqNoAndPrimaryTerm() + { + var handler = new ScriptedHttpMessageHandler(); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"_seq_no":7,"_primary_term":1,"found":true,"_source":{"Id":"actor-1","Value":"v1"}}""")); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.Conflict, + """{"error":{"type":"version_conflict_engine_exception"},"status":409}""")); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"_seq_no":8,"_primary_term":1,"found":true,"_source":{"Id":"actor-1","Value":"v1"}}""")); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"result":"updated"}""")); + + using var store = CreateStore( + new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = false, + MutateMaxRetryCount = 1, + }, + handler); + + await store.MutateAsync("actor-1", model => model.Value = "v2"); + + handler.CapturedRequests.Should().HaveCount(4); + handler.CapturedRequests[1].PathAndQuery.Should().Contain("if_seq_no=7"); + handler.CapturedRequests[1].PathAndQuery.Should().Contain("if_primary_term=1"); + handler.CapturedRequests[3].PathAndQuery.Should().Contain("if_seq_no=8"); + handler.CapturedRequests[3].PathAndQuery.Should().Contain("if_primary_term=1"); + handler.CapturedRequests[3].Body.Should().Contain("\"Value\":\"v2\""); + } + + private static ElasticsearchProjectionReadModelStore CreateStore( + ElasticsearchProjectionReadModelStoreOptions options, + HttpMessageHandler handler) + { + options.Endpoints = ["http://localhost:9200"]; + return new ElasticsearchProjectionReadModelStore( + options, + "workflow-execution-reports", + keySelector: model => model.Id, + keyFormatter: key => key, + httpMessageHandler: handler); + } + + private static HttpResponseMessage CreateJsonResponse(HttpStatusCode statusCode, string json) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + + private sealed class ScriptedHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + + public List CapturedRequests { get; } = []; + + public void EnqueueResponse(Func responseFactory) + { + _responses.Enqueue(responseFactory); + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var requestBody = request.Content == null + ? "" + : await request.Content.ReadAsStringAsync(cancellationToken); + + CapturedRequests.Add(new CapturedRequest( + request.Method.Method, + request.RequestUri?.PathAndQuery ?? "", + requestBody)); + + if (_responses.Count == 0) + { + throw new InvalidOperationException( + $"No scripted response available for request '{request.Method} {request.RequestUri}'."); + } + + return _responses.Dequeue().Invoke(request); + } + } + + private sealed record CapturedRequest(string Method, string PathAndQuery, string Body); + + private sealed class StoreReadModel + { + public string Id { get; set; } = ""; + + public string Value { get; set; } = ""; + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index c0adf5814..05ec9dc41 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -6,12 +6,13 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { [Fact] - public void BindingResolver_ShouldResolveRequirement_ByReadModelName() + public void BindingResolver_ShouldResolveRequirement_ByReadModelFullName() { var resolver = new ProjectionReadModelBindingResolver(); + var bindingKey = typeof(TestReadModel).FullName!; var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase) { - [nameof(TestReadModel)] = ProjectionReadModelIndexKind.Graph.ToString(), + [bindingKey] = ProjectionReadModelIndexKind.Graph.ToString(), }; var requirements = resolver.Resolve(bindings, typeof(TestReadModel)); @@ -21,6 +22,21 @@ public void BindingResolver_ShouldResolveRequirement_ByReadModelName() .Which.Should().Be(ProjectionReadModelIndexKind.Graph); } + [Fact] + public void BindingResolver_WhenBindingUsesShortTypeName_ShouldThrow() + { + var resolver = new ProjectionReadModelBindingResolver(); + var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [nameof(TestReadModel)] = ProjectionReadModelIndexKind.Document.ToString(), + }; + + Action act = () => resolver.Resolve(bindings, typeof(TestReadModel)); + + act.Should().Throw() + .WithMessage("*must use full type name*"); + } + [Fact] public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index a06697ba4..f3f1934d0 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -80,6 +80,32 @@ public void Select_WhenCapabilitiesUnsupportedAndFailFastEnabled_ShouldThrow() act.Should().Throw(); } + [Fact] + public void Select_WhenRequiredIndexKindsAreNotFullySupported_ShouldThrow() + { + var registrations = new[] + { + CreateRegistration( + "neo4j", + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph]), + }; + + Action act = () => ProjectionReadModelStoreSelector.Select( + registrations, + new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = "neo4j", + FailOnUnsupportedCapabilities = true, + }, + new ProjectionReadModelRequirements( + requiresIndexing: true, + requiredIndexKinds: [ProjectionReadModelIndexKind.Document, ProjectionReadModelIndexKind.Graph])); + + act.Should().Throw() + .WithMessage("*not fully supported*"); + } + [Fact] public void Select_WhenCapabilitiesUnsupportedAndFailFastDisabled_ShouldReturnProvider() { diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 807621b32..c2f607d02 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -45,7 +45,7 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationFindsU services.AddWorkflowExecutionProjectionCQRS(options => { options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = true; }); @@ -170,7 +170,7 @@ public void AddWorkflowExecutionProjectionCQRS_WhenBindingRequiresUnsupportedCap services.AddWorkflowExecutionProjectionCQRS(options => { options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = true; }); using var provider = services.BuildServiceProvider(); @@ -189,7 +189,7 @@ public void AddWorkflowExecutionProjectionCQRS_WhenFailFastDisabled_ShouldAllowU services.AddWorkflowExecutionProjectionCQRS(options => { options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); + options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = false; }); using var provider = services.BuildServiceProvider(); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 25972be12..9cf739971 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -197,6 +197,8 @@ public async Task AttachLiveSinkAsync_ShouldNotOverwriteRunMetadata() beforeAttach!.CommandId.Should().Be("cmd-1"); beforeAttach.WorkflowName.Should().Be("wf"); beforeAttach.Input.Should().Be("original-input"); + beforeAttach.CreatedAt.Should().Be(initialStartedAt); + beforeAttach.UpdatedAt.Should().Be(initialStartedAt); beforeAttach.StartedAt.Should().Be(initialStartedAt); clock.UtcNow = initialStartedAt.AddMinutes(10); @@ -208,6 +210,8 @@ public async Task AttachLiveSinkAsync_ShouldNotOverwriteRunMetadata() afterAttach!.CommandId.Should().Be("cmd-1"); afterAttach.WorkflowName.Should().Be("wf"); afterAttach.Input.Should().Be("original-input"); + afterAttach.CreatedAt.Should().Be(initialStartedAt); + afterAttach.UpdatedAt.Should().Be(initialStartedAt); afterAttach.StartedAt.Should().Be(initialStartedAt); await service.DetachLiveSinkAsync(lease!, sink); await sink.DisposeAsync(); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index f6858d54d..a3f05b978 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -108,6 +108,8 @@ await coordinator.ProjectAsync(context, Wrap(new WorkflowCompletedEvent var report = await store.GetAsync("root"); report.Should().NotBeNull(); report!.WorkflowName.Should().Be("direct"); + report.CreatedAt.Should().Be(context.StartedAt); + report.UpdatedAt.Should().BeOnOrAfter(report.CreatedAt); report.Success.Should().BeTrue(); report.FinalOutput.Should().Be("final answer"); report.Summary.TotalSteps.Should().Be(1); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 9784e8d86..3ac2f9280 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -68,12 +68,12 @@ public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProviders var providerRegistrations = builder.Services .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) .ToList(); - providerRegistrations.Should().HaveCount(3); + providerRegistrations.Should().HaveCount(1); var relationRegistrations = builder.Services .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) .ToList(); - relationRegistrations.Should().HaveCount(3); + relationRegistrations.Should().HaveCount(1); } [Fact] @@ -88,11 +88,54 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo var providerRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) .ToList(); - providerRegistrations.Should().HaveCount(3); + providerRegistrations.Should().HaveCount(1); var relationRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) .ToList(); - relationRegistrations.Should().HaveCount(3); + relationRegistrations.Should().HaveCount(1); + } + + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_ShouldRegisterConfiguredCombinationOnly() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:ReadModel:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, + ["Projection:ReadModel:RelationProvider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:ReadModel:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + }) + .Build(); + + services.AddWorkflowProjectionReadModelProviders(configuration); + + var providerRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .ToList(); + var relationRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .ToList(); + + providerRegistrations.Should().HaveCount(1); + relationRegistrations.Should().HaveCount(1); + } + + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenProviderConfiguredUnknown_ShouldThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:ReadModel:Provider"] = "UnknownProvider", + }) + .Build(); + + Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); + + act.Should().Throw() + .WithMessage("*Unsupported projection provider*"); } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs index fc40d3f8c..c3b3445f8 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs @@ -12,25 +12,17 @@ public sealed class WorkflowReadModelSelectionPlannerTests private readonly WorkflowReadModelSelectionPlanner _planner = new(new ProjectionReadModelBindingResolver()); [Fact] - public void Build_WhenProviderIsEmpty_ShouldFallbackToInMemoryAndResolveBindings() + public void Build_WhenProviderIsEmpty_ShouldThrow() { var options = new WorkflowExecutionProjectionOptions { ReadModelProvider = " ", - FailOnUnsupportedCapabilities = false, }; - options.ReadModelBindings[nameof(WorkflowExecutionReport)] = ProjectionReadModelIndexKind.Document.ToString(); - var plan = _planner.Build(options); + Action act = () => _planner.Build(options); - plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); - plan.ReadModelSelectionOptions.FailOnUnsupportedCapabilities.Should().BeFalse(); - plan.ReadModelRequirements.RequiresIndexing.Should().BeTrue(); - plan.ReadModelRequirements.RequiredIndexKinds.Should().ContainSingle() - .Which.Should().Be(ProjectionReadModelIndexKind.Document); - plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); - plan.RelationRequirements.RequiresRelations.Should().BeTrue(); - plan.RelationRequirements.RequiresRelationTraversal.Should().BeTrue(); + act.Should().Throw() + .WithMessage("*ReadModelProvider is required*"); } [Fact] @@ -47,6 +39,39 @@ public void Build_ShouldTrimConfiguredProviderName() plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); } + [Fact] + public void Build_ShouldResolveBindingsByReadModelFullName() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.InMemory, + }; + options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = + ProjectionReadModelIndexKind.Document.ToString(); + + var plan = _planner.Build(options); + + plan.ReadModelRequirements.RequiresIndexing.Should().BeTrue(); + plan.ReadModelRequirements.RequiredIndexKinds.Should().ContainSingle() + .Which.Should().Be(ProjectionReadModelIndexKind.Document); + } + + [Fact] + public void Build_WhenBindingUsesShortTypeName_ShouldThrow() + { + var options = new WorkflowExecutionProjectionOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.InMemory, + }; + options.ReadModelBindings[nameof(WorkflowExecutionReport)] = + ProjectionReadModelIndexKind.Document.ToString(); + + Action act = () => _planner.Build(options); + + act.Should().Throw() + .WithMessage("*must use full type name*"); + } + [Fact] public void Build_WhenRelationProviderConfigured_ShouldUseRelationProvider() { From 148046d280870d300fb340edb60f69b577ea93c9 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 21:40:17 +0800 Subject: [PATCH 23/46] Refactor Projection Architecture and Introduce Store Selection Mechanism - Enhanced the projection architecture by introducing `IProjectionStoreSelectionPlanner` and `IProjectionStoreSelectionRuntimeOptions` for improved provider selection and runtime options management. - Removed the legacy `WorkflowReadModelSelectionPlanner` to streamline the selection process and reduce complexity. - Updated the `WorkflowExecutionProjectionOptions` to eliminate direct provider configurations, centralizing them under the new projection runtime options. - Enhanced the `WorkflowExecutionQueryApplicationService` to support new query options for actor relations, allowing for more flexible querying capabilities. - Improved documentation to reflect architectural changes and provide clearer guidance on the new selection mechanisms and query options. --- ...review-remediation-blueprint-2026-02-24.md | 7 +- ...apability-refactor-blueprint-2026-02-24.md | 381 ++++++++++++++++++ .../IProjectionStoreSelectionPlanner.cs | 9 + ...IProjectionStoreSelectionRuntimeOptions.cs | 14 + .../ProjectionReadModelRuntimeOptions.cs | 12 +- .../ProjectionStoreSelectionPlan.cs | 7 + .../ServiceCollectionExtensions.cs | 1 + .../ProjectionStoreSelectionPlanner.cs | 89 ++++ .../ReadModels/AevatarReadModelBase.cs | 10 +- .../ReadModels/ProjectionReadModelBase.cs | 15 + .../IWorkflowExecutionProjectionQueryPort.cs | 2 + ...orkflowExecutionQueryApplicationService.cs | 2 + .../Queries/WorkflowExecutionQueryModels.cs | 14 + ...orkflowExecutionQueryApplicationService.cs | 6 +- .../CapabilityApi/ChatQueryEndpoints.cs | 43 +- ...owCapabilityServiceCollectionExtensions.cs | 39 +- .../WorkflowExecutionProjectionOptions.cs | 30 -- .../ServiceCollectionExtensions.cs | 28 +- .../IWorkflowProjectionQueryReader.cs | 2 + .../IWorkflowReadModelSelectionPlanner.cs | 15 - ...WorkflowExecutionProjectionQueryService.cs | 35 +- .../WorkflowProjectionQueryReader.cs | 34 +- .../WorkflowReadModelSelectionPlanner.cs | 82 ---- ...ReadModelStartupValidationHostedService.cs | 18 +- .../WorkflowExecutionReadModelProjector.cs | 1 + .../WorkflowExecutionRelationProjector.cs | 10 +- .../Aevatar.Workflow.Projection/README.md | 25 +- .../ReadModels/WorkflowExecutionReadModel.cs | 2 + ...tionProviderServiceCollectionExtensions.cs | 74 +++- .../ProjectionStoreSelectionPlannerTests.cs | 97 +++++ .../WorkflowApplicationLayerTests.cs | 4 + .../ChatEndpointsInternalTests.cs | 34 +- ...hatWebSocketCoordinatorAndProtocolTests.cs | 13 +- ...orkflowCapabilityEndpointsCoverageTests.cs | 2 + ...lowExecutionProjectionRegistrationTests.cs | 70 ++-- ...WorkflowExecutionProjectionServiceTests.cs | 6 +- ...WorkflowExecutionRelationProjectorTests.cs | 2 + .../WorkflowHostingExtensionsCoverageTests.cs | 29 ++ .../WorkflowReadModelSelectionPlannerTests.cs | 103 ----- 39 files changed, 1011 insertions(+), 356 deletions(-) create mode 100644 docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs create mode 100644 src/Aevatar.Foundation.Projection/ReadModels/ProjectionReadModelBase.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs delete mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs diff --git a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md index a78a33455..3cd7701a9 100644 --- a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md +++ b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md @@ -139,14 +139,15 @@ - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` - `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` - `src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs` -- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs` +- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs` +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs` - `src/workflow/Aevatar.Workflow.Projection/README.md` 改造项: 1. Provider 注册改为按需注册,不再全量注册三套 provider。 -2. 配置缺失与未知 provider 启动即失败。 -3. 移除隐式 provider 默认回退行为(保持配置显式化)。 +2. 未知 provider 启动即失败;缺失 provider 时仅在 Host 组合层应用默认值(`InMemory`)。 +3. Workflow 业务选项移除 provider 语义,统一由 `ProjectionReadModelRuntimeOptions` 承载。 4. 文档示例统一改为 `FullName` 绑定键。 ## 5. 测试计划(必须新增) diff --git a/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md b/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md new file mode 100644 index 000000000..2349822ee --- /dev/null +++ b/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md @@ -0,0 +1,381 @@ +# ReadModel 双速能力架构重构文档(Simple + Rich) + +## 1. 文档信息 +- 状态:Proposed(Breaking, 不保留兼容层) +- 版本:v1.0 +- 日期:2026-02-24 +- 适用范围:`src/Aevatar.CQRS.Projection.*`、`src/workflow/*`、`test/*`、`tools/ci/*` +- 目标:实现“默认简单开发路径 + 可治理的高级能力路径”,并保持单一 Projection 主链路 + +## 2. 背景与问题 + +### 2.1 当前问题 +1. ReadModel 与 Relation 能力已具备基础框架,但“简单场景”和“复杂场景”缺少统一分层策略。 +2. 一些领域语义(如 `StartedAt`)曾被误放到基础抽象,导致抽象层与 Workflow 语义耦合。 +3. 关系查询能力可用但表达力有限,难以覆盖复杂图查询(过滤、路径约束、分页游标、多根聚合)。 +4. Provider 组合虽支持 split(ReadModel/Relation 分离),但缺少环境分级模板与强门禁,易误配。 +5. 存在空类/薄封装类,基础抽象复用不足,增加维护成本。 + +### 2.2 根因 +1. “能力扩展点”与“默认路径”未显式分层。 +2. 能力声明、模型定义、Provider 选择存在,但缺少统一的“模型 Profile + 环境策略”收敛机制。 +3. 治理规则没有完整固化到启动校验和 CI 门禁(尤其生产环境组合约束)。 + +## 3. 重构目标 + +### 3.1 主目标 +1. 保持最小开发路径:普通业务只需实现 ReadModel + Reducer,不需要理解图能力细节。 +2. 提供丰富能力路径:复杂需求可启用 Graph/Relation/Advanced Query,不污染默认路径。 +3. 严格分层:`Domain / Application / Infrastructure / Host`,`Workflow` 不侵入基础抽象语义。 +4. 单一主链路:所有能力基于统一 Projection Pipeline 插件化扩展,不引入第二系统。 +5. 可治理:启动期 fail-fast + CI guard,确保环境配置、能力声明、实现行为一致。 +6. 职责边界清晰:`Workflow` 不负责 `Graph/Document` 与 provider 选择,选择权在业务方组合层。 + +### 3.2 非目标 +1. 不引入额外中间层事实缓存(禁止 `actorId -> context` 进程内事实态映射)。 +2. 不在本轮引入跨产品通用图分析 DSL 引擎。 +3. 不保留历史兼容壳层。 +4. 不在 `Workflow` 业务层编排 provider/index kind 选择逻辑。 + +## 4. 设计原则 +1. 默认极简:内核接口最小化,降低开发门槛。 +2. 能力叠加:高级能力通过可选接口/能力声明按需启用。 +3. 约束优先:任何扩展必须走能力校验和策略合并,禁止绕过选择器。 +4. 语义分层:基础层只保留通用语义(`CreatedAt/UpdatedAt`),领域语义下沉到领域模型。 +5. 一致性可选分级:默认最终一致;强一致需求通过可选模式(Outbox/Checkpoint)开启。 +6. 组合层决策:ReadModel 投影到哪个 provider 由业务方/Host 组合层决定,Workflow 只消费抽象端口。 + +## 5. 目标架构 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Domain Events"] --> B["Unified Projection Pipeline"] + B --> C["ReadModel Projector"] + B --> D["Relation Projector"] + C --> E["IProjectionReadModelStore"] + D --> F["IProjectionRelationStore"] + E --> G["Document ReadModels"] + F --> H["Graph Facts(Node/Edge)"] + I["ReadModel Profile"] --> J["Requirements Builder"] + K["Environment Policy"] --> J + J --> L["Capability Validator"] + L --> M["Provider Selector"] + M --> E + M --> F +``` + +### 5.1 关键点 +1. 只保留一个权威主链:`Event -> Projector -> Store`。 +2. 复杂能力通过 Profile 与 Policy 驱动 Provider 选择,不在 `Workflow` 业务代码里硬编码 Provider。 +3. Workflow/AI 等领域仅定义“自己的模型和关系语义”,不修改基础抽象含义。 +4. Provider 选择权在业务方组合层,Runtime 负责能力校验与 fail-fast。 + +## 6. 抽象层重构(Abstractions) + +### 6.1 统一基础实体基类(消除空类) +新增统一基类(示意): + +```csharp +public abstract class ProjectionReadModelBase + where TKey : notnull +{ + public TKey Id { get; init; } = default!; + public long StateVersion { get; set; } + public string LastEventId { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} +``` + +规则: +1. 基类只包含跨域通用语义,不包含 Workflow 特有字段(如 `StartedAt`)。 +2. 领域字段(例如 `StartedAt/EndedAt/Duration`)保留在领域 ReadModel 内。 + +### 6.2 双层能力接口 +保留最小接口: +1. `IProjectionReadModelStore` + +新增可选高级接口(示意): +1. `IProjectionReadModelQueryStore`:条件过滤、分页游标、多字段排序。 +2. `IProjectionRelationStore`:节点/边写入与基础邻接查询。 +3. `IProjectionRelationTraversalStore`:子图/路径遍历(可独立能力开关)。 +4. `IProjectionSchemaValidationCapability`:Schema 校验能力声明与执行入口。 +5. `IProjectionAliasCapability`:Alias 能力声明与执行入口。 + +说明: +1. 高级接口均为可选实现,不影响默认路径。 +2. 选择器根据 `Capabilities` 判定可用性,禁止运行时盲调。 + +### 6.3 ReadModel Profile(模型声明式扩展) +新增 `IReadModelProfile`(示意职责,由业务方在组合层声明): +1. 声明模型索引类型要求(`Document/Graph`)。 +2. 声明关系能力要求(`RequiresRelations/RequiresTraversal`)。 +3. 声明默认排序(建议 `CreatedAt desc, UpdatedAt desc, Id desc`)。 +4. 声明关系节点/边 ID 规则。 + +Profile 合并优先级: +1. `GlobalDefault < ModelProfile < EnvironmentOverride` +2. 只允许“收紧要求”,不允许“放松底线”。 + +## 7. 关系模型重构(Relations) + +### 7.1 关系属性类型升级 +当前 `Dictionary` 升级为可序列化值模型(示意): +1. `Dictionary` +2. `ProjectionValue` 支持 `string/number/bool/datetime/json` + +目标: +1. 保持可移植序列化。 +2. 支持复杂过滤和排序。 + +### 7.2 关系 ID 规则标准化 +统一规则: +1. 节点 ID 必须包含业务隔离维度(如 `tenant`、`rootActorId`、`commandId`)。 +2. Step 节点强制包含 run 维度,禁止 `step:{rootActorId}:{stepId}` 这种会跨 run 冲突的格式。 + +建议格式: +1. `run:{rootActorId}:{commandId}` +2. `step:{rootActorId}:{commandId}:{stepId}` + +### 7.3 关系查询分层 +1. 简单查询:邻居查询(默认 API)。 +2. 中级查询:有界深度子图。 +3. 高级查询:关系类型过滤 + 属性过滤 + 游标分页 + 路径约束(高级接口/高级端点)。 + +## 8. Provider 分级策略(Simple -> Rich) + +说明: +1. 本节模板由业务方在 Host/组合层应用。 +2. `Workflow` 层不读取、不判断 provider 类型;只通过抽象端口读写投影。 + +### 8.1 环境模板 +1. `LocalDev` +- ReadModel: `InMemory` 或 `Elasticsearch` +- Relation: `InMemory`(仅开发) +- 用途:快速反馈 + +2. `Standard(Production)` +- ReadModel: `Elasticsearch` +- Relation: `Neo4j` +- 用途:生产默认推荐,文档检索 + 图关系可用 + +3. `DocOnly(Production)` +- ReadModel: `Elasticsearch` +- Relation: Disabled +- 用途:不需要图关系的场景 + +4. `GraphAdvanced(Production)` +- ReadModel: `Neo4j` 或双写策略 +- Relation: `Neo4j` +- 用途:高图密度业务 + +### 8.2 强约束 +1. 生产环境禁止 `InMemoryRelationStore` 作为事实源(启动即失败)。 +2. 生产环境强制 `FailOnUnsupportedCapabilities=true`。 +3. 若启用关系 API,必须 `RequiresRelations=true` 且 relation provider 支持 traversal。 + +## 9. Runtime 选择与校验重构 + +### 9.1 选择流程 +1. 业务方在 Host 组合层提供 `GlobalReadModelOptions + ReadModelProfile`。 +2. Runtime 合并 `ReadModelProfile`。 +3. Runtime 应用 `EnvironmentPolicy`。 +4. Runtime 生成 `ProjectionReadModelRequirements`。 +5. Runtime 执行 `CapabilityValidator`。 +6. Runtime 选择 readmodel/relation provider。 +7. 启动校验(Host startup validator)在应用启动前 fail-fast。 + +### 9.2 错误模型 +1. Provider 未指定且多注册:明确抛错。 +2. 能力不匹配:结构化异常包含 `requirements/capabilities/violations`。 +3. 生产策略违规(如 in-memory relation):专用策略异常。 + +## 10. Workflow 分层收敛 + +### 10.1 基础抽象去 Workflow 语义 +1. 基类只保留 `CreatedAt/UpdatedAt`。 +2. `StartedAt/EndedAt` 保留在 `WorkflowExecutionReport`(领域模型)。 + +### 10.2 Workflow 与 Provider 边界 +1. Workflow 只依赖: +- `IProjectionReadModelStore` +- `IProjectionRelationStore` +2. Workflow 不包含: +- `Graph/Document` 选择分支 +- `Elasticsearch/Neo4j/InMemory` provider 分支 +- capability 协商逻辑 +3. 若 provider 能力不匹配,由 Runtime/启动校验报错,不在 Workflow 内做兜底分支。 + +### 10.3 Query 接口分层 +1. 基础端点保留: +- `/actors/{actorId}` +- `/actors/{actorId}/timeline` +- `/actors/{actorId}/relations` +- `/actors/{actorId}/relation-subgraph` + +2. 高级端点新增(可选): +- 支持 `direction`、`relationTypes`、`property filters`、`cursor`。 +- 这些是业务查询语义,不代表 Workflow 在做 provider/index kind 决策。 + +### 10.4 投影职责 +1. ReadModelProjector:只负责文档视图。 +2. RelationProjector:只负责图事实写入。 +3. Coordinator:保持统一调度,不在 Workflow 层引入二次编排系统。 + +## 11. 一致性与幂等策略 + +### 11.1 默认模式(最终一致) +1. ReadModel 和 Relation 独立写入。 +2. OCC + 去重保证单存储内正确性。 + +### 11.2 强一致增强模式(可选) +1. 引入 Projection Outbox(事件级写入意图记录)。 +2. 以 `eventId + projector` 作为幂等键。 +3. 使用 checkpoint/compensation 补偿部分成功。 + +### 11.3 幂等键规则 +1. `dedupKey = {projectionId}:{eventId}:{projectorName}` +2. relation edge upsert 使用确定性 edgeId,禁止随机 ID。 + +## 12. 索引与排序策略 + +### 12.1 默认排序 +1. 默认 `CreatedAt desc` +2. 次排序 `UpdatedAt desc` +3. 稳定 tie-break `Id desc`(或 `_id desc`) + +### 12.2 约束 +1. 任意 provider 的 `ListAsync` 必须提供稳定顺序保证。 +2. 若 provider 无法保证稳定排序,必须显式抛错,不允许“静默无序”。 + +## 13. 配置模型重构 + +### 13.1 配置分区 +1. `Projection:ReadModel:*`:业务方在 Host 组合层声明全局默认与 Provider 配置。 +2. `Projection:Policies:*`:环境策略(例如 production guard)。 +3. `Projection:Profiles:*`:按模型覆盖规则。 + +### 13.2 配置示例(生产标准模板) + +```yaml +Projection: + ReadModel: + Provider: Elasticsearch + RelationProvider: Neo4j + FailOnUnsupportedCapabilities: true + Bindings: + Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Graph + Providers: + Elasticsearch: + Endpoints: ["http://elasticsearch:9200"] + IndexPrefix: "aevatar" + MissingIndexBehavior: Throw + Neo4j: + Uri: "bolt://neo4j:7687" + AutoCreateConstraints: true + Policies: + Environment: Production + DenyInMemoryRelationFactStore: true +``` + +## 14. 代码级重构清单(按层) + +### 14.1 Abstractions +1. 新增 `ProjectionReadModelBase`(或等价通用基类)。 +2. 新增 `IReadModelProfile` 与 Profile 描述模型。 +3. 新增高级可选接口:Query/Traversal/Schema/Alias 能力接口。 +4. 升级关系属性类型定义,支持 typed value。 + +### 14.2 Runtime +1. 新增 Profile Registry + Requirements Builder。 +2. 新增 Policy Validator(环境策略校验)。 +3. 扩展 selector 日志,输出最终合并后的 requirements。 + +### 14.3 Providers +1. InMemory Provider 显式标注 `DevOnly` 元数据。 +2. Elasticsearch Provider 保持 Document 优先,明确高级查询能力边界。 +3. Neo4j Provider 承担 Relation + Traversal 主实现,并补齐 e2e 验证。 + +### 14.4 Workflow +1. 替换 Step 节点 ID 规则,纳入 `commandId` 维度。 +2. Query 只透传业务查询参数,不承载 provider/index kind 选择逻辑。 +3. 维持默认简单 API,不破坏使用门槛。 + +## 15. 测试与门禁 + +### 15.1 新增测试 +1. Profile 合并优先级测试(Global/Profile/Env)。 +2. 生产策略测试(禁止 InMemory Relation)。 +3. Relation Provider e2e(Neo4j): +- 节点/边 upsert +- 邻居查询 +- 深度子图 +- 过滤/分页(若启用高级查询) +4. Step 节点 ID 唯一性测试(跨 run 不冲突)。 + +### 15.2 CI 门禁 +1. `tools/ci/architecture_guards.sh`: +- 禁止基础抽象出现 Workflow 语义字段名。 +- 禁止中间层 `actor/run/session` 事实态字典字段。 +2. `tools/ci/projection_provider_e2e_smoke.sh`: +- 增加 relation provider e2e 目标与 executed==total 校验。 +3. 生产配置扫描: +- `Production + InMemoryRelationProvider` 直接 fail。 + +## 16. 分阶段实施计划 + +### Phase 1(基础抽象收敛) +1. 引入通用基类与 Profile 抽象。 +2. 清理空类与无价值薄封装。 +3. 完成基础单测。 + +### Phase 2(能力分层落地) +1. 引入高级可选接口与 capability 路径。 +2. Runtime 增加 Profile+Policy 合并与校验。 +3. 保持默认调用路径不变。 + +### Phase 3(Provider 与 Workflow 深化) +1. Workflow Step ID 规则修复。 +2. 高级关系查询链路贯通(端口到 API)。 +3. Relation e2e 与生产策略门禁落地。 + +### Phase 4(生产切换) +1. 默认生产模板切至 `Elasticsearch + Neo4j`。 +2. 灰度启用高级查询能力。 +3. 观察指标与告警收敛。 + +## 17. 可观测性与运维 +1. 指标: +- provider selection failure +- capability violation +- relation query p95/p99 +- subgraph result size +- OCC conflict retry count +2. 日志: +- 结构化输出 `requirements/capabilities/provider` +3. 告警: +- 生产策略违规配置 +- relation traversal error rate 超阈值 + +## 18. 风险与应对 +1. 风险:高级能力接口扩展过快导致复杂度回升。 +- 应对:接口增量必须有真实场景和 e2e 覆盖。 +2. 风险:强一致模式引入写放大。 +- 应对:默认保持最终一致,按域开启强一致。 +3. 风险:Provider 能力声明与实现漂移。 +- 应对:能力声明一致性测试 + 启动期校验 + e2e。 + +## 19. 验收标准(DoD) +1. 默认开发路径只需 ReadModel + Reducer 即可运行。 +2. 复杂能力通过 Profile/Policy 可启用且受校验治理。 +3. 基础抽象不含 Workflow 语义字段(仅通用元数据)。 +4. 生产标准模板不允许 InMemory Relation 事实源。 +5. Workflow 代码中不存在 provider/index kind 选择分支。 +6. Relation e2e + 架构门禁 + 全量 build/test 通过。 + +## 20. 结论 +本方案通过“默认极简 + 能力插件 + 治理前置”实现双速架构: +1. 让大多数开发者保持低认知成本。 +2. 让复杂需求获得可扩展、可验证、可运维的高级能力。 +3. 避免抽象层次倒挂和语义污染,维持长期演进质量。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs new file mode 100644 index 000000000..57bf0a36f --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionStoreSelectionPlanner +{ + ProjectionStoreSelectionPlan Build( + IProjectionStoreSelectionRuntimeOptions options, + Type readModelType, + ProjectionReadModelRequirements relationRequirements); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs new file mode 100644 index 000000000..85cc8147f --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionStoreSelectionRuntimeOptions +{ + string ReadModelProvider { get; } + + string RelationProvider { get; } + + bool FailOnUnsupportedCapabilities { get; } + + ProjectionReadModelMode ReadModelMode { get; } + + IReadOnlyDictionary ReadModelBindings { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs index 7132b612d..d141e0efa 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Abstractions; -public sealed class ProjectionReadModelRuntimeOptions +public sealed class ProjectionReadModelRuntimeOptions : IProjectionStoreSelectionRuntimeOptions { public ProjectionReadModelRuntimeOptions() { @@ -16,4 +16,14 @@ public ProjectionReadModelRuntimeOptions() public bool FailOnUnsupportedCapabilities { get; set; } = true; public Dictionary Bindings { get; } + + string IProjectionStoreSelectionRuntimeOptions.ReadModelProvider => Provider; + + string IProjectionStoreSelectionRuntimeOptions.RelationProvider => RelationProvider; + + bool IProjectionStoreSelectionRuntimeOptions.FailOnUnsupportedCapabilities => FailOnUnsupportedCapabilities; + + ProjectionReadModelMode IProjectionStoreSelectionRuntimeOptions.ReadModelMode => Mode; + + IReadOnlyDictionary IProjectionStoreSelectionRuntimeOptions.ReadModelBindings => Bindings; } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs new file mode 100644 index 000000000..93f5fdf27 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public readonly record struct ProjectionStoreSelectionPlan( + ProjectionReadModelRequirements ReadModelRequirements, + ProjectionReadModelStoreSelectionOptions ReadModelSelectionOptions, + ProjectionReadModelRequirements RelationRequirements, + ProjectionReadModelStoreSelectionOptions RelationSelectionOptions); diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 0d1c3f1fd..860fa1fc6 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ public static IServiceCollection AddProjectionReadModelRuntime(this IServiceColl services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs new file mode 100644 index 000000000..fabcb2120 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs @@ -0,0 +1,89 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionStoreSelectionPlanner : IProjectionStoreSelectionPlanner +{ + private readonly IProjectionReadModelBindingResolver _bindingResolver; + + public ProjectionStoreSelectionPlanner(IProjectionReadModelBindingResolver bindingResolver) + { + _bindingResolver = bindingResolver; + } + + public ProjectionStoreSelectionPlan Build( + IProjectionStoreSelectionRuntimeOptions options, + Type readModelType, + ProjectionReadModelRequirements relationRequirements) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(readModelType); + ArgumentNullException.ThrowIfNull(relationRequirements); + EnsureReadModelModeSupported(options.ReadModelMode); + + var readModelRequirements = _bindingResolver.Resolve(options.ReadModelBindings, readModelType); + var readModelProvider = NormalizeRequiredProviderName(options.ReadModelProvider); + var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = readModelProvider, + FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, + }; + + var mergedRelationRequirements = MergeRelationRequirements(readModelRequirements, relationRequirements); + var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions + { + RequestedProviderName = NormalizeRelationProviderName( + options.RelationProvider, + readModelProvider), + FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, + }; + + return new ProjectionStoreSelectionPlan( + readModelRequirements, + readModelSelectionOptions, + mergedRelationRequirements, + relationSelectionOptions); + } + + private static ProjectionReadModelRequirements MergeRelationRequirements( + ProjectionReadModelRequirements readModelRequirements, + ProjectionReadModelRequirements relationRequirements) + { + return new ProjectionReadModelRequirements( + requiresIndexing: relationRequirements.RequiresIndexing, + requiredIndexKinds: relationRequirements.RequiredIndexKinds, + requiresAliases: relationRequirements.RequiresAliases || readModelRequirements.RequiresAliases, + requiresSchemaValidation: relationRequirements.RequiresSchemaValidation || readModelRequirements.RequiresSchemaValidation, + requiresRelations: relationRequirements.RequiresRelations, + requiresRelationTraversal: relationRequirements.RequiresRelationTraversal); + } + + private static string NormalizeRequiredProviderName(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + { + throw new InvalidOperationException( + "Projection read-model provider is required and cannot be empty."); + } + + return providerName.Trim(); + } + + private static string NormalizeRelationProviderName( + string relationProviderName, + string fallbackProviderName) + { + if (string.IsNullOrWhiteSpace(relationProviderName)) + return fallbackProviderName; + + return relationProviderName.Trim(); + } + + private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) + { + if (readModelMode != ProjectionReadModelMode.StateOnly) + return; + + throw new InvalidOperationException( + "Projection store selection does not support Projection:ReadModel:Mode=StateOnly. " + + "Use CustomReadModel or DefaultReadModel."); + } +} diff --git a/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs b/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs index 0fa183133..4b9e163dc 100644 --- a/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs +++ b/src/Aevatar.Foundation.Projection/ReadModels/AevatarReadModelBase.cs @@ -1,14 +1,10 @@ namespace Aevatar.Foundation.Projection.ReadModels; /// -/// Minimal cross-domain read model envelope metadata. +/// Minimal cross-domain read model metadata. +/// Keep this base workflow-agnostic: domain-specific identifiers must stay in domain read models. /// public abstract class AevatarReadModelBase + : ProjectionReadModelBase { - public string RootActorId { get; set; } = string.Empty; - public string CommandId { get; set; } = string.Empty; - public long StateVersion { get; set; } - public string LastEventId { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; } diff --git a/src/Aevatar.Foundation.Projection/ReadModels/ProjectionReadModelBase.cs b/src/Aevatar.Foundation.Projection/ReadModels/ProjectionReadModelBase.cs new file mode 100644 index 000000000..a6e20f502 --- /dev/null +++ b/src/Aevatar.Foundation.Projection/ReadModels/ProjectionReadModelBase.cs @@ -0,0 +1,15 @@ +namespace Aevatar.Foundation.Projection.ReadModels; + +/// +/// Generic projection read-model metadata base. +/// Domain-specific identity fields should live in domain models. +/// +public abstract class ProjectionReadModelBase + where TKey : notnull +{ + public TKey Id { get; set; } = default!; + public long StateVersion { get; set; } + public string LastEventId { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs index 451207ba1..edb675e6c 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs @@ -22,11 +22,13 @@ Task> ListActorTimelineAsync( Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs index a50f6f8ac..a0babd74b 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs @@ -15,11 +15,13 @@ public interface IWorkflowExecutionQueryApplicationService Task> ListActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs index 8c8015720..065143f2c 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs @@ -45,6 +45,20 @@ public sealed class WorkflowActorRelationNode public Dictionary Properties { get; set; } = []; } +public enum WorkflowActorRelationDirection +{ + Outbound = 0, + Inbound = 1, + Both = 2, +} + +public sealed class WorkflowActorRelationQueryOptions +{ + public WorkflowActorRelationDirection Direction { get; set; } = WorkflowActorRelationDirection.Both; + + public IReadOnlyList RelationTypes { get; set; } = []; +} + public sealed class WorkflowActorRelationItem { public string EdgeId { get; set; } = string.Empty; diff --git a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs index e4164373b..56c34b67a 100644 --- a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs @@ -58,18 +58,20 @@ public async Task> ListActorTimelineAsy public async Task> ListActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) return []; - return await _projectionPort.GetActorRelationsAsync(actorId, take, ct); + return await _projectionPort.GetActorRelationsAsync(actorId, take, options, ct); } public async Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) @@ -78,6 +80,6 @@ public async Task GetActorRelationSubgraphAsync( RootNodeId = actorId ?? string.Empty, }; - return await _projectionPort.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + return await _projectionPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); } } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs index c1a85fafd..70f4ca4dc 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs @@ -63,9 +63,12 @@ internal static async Task ListActorRelations( string actorId, IWorkflowExecutionQueryApplicationService queryService, int take = 200, + string? direction = null, + string[]? relationTypes = null, CancellationToken ct = default) { - var relations = await queryService.ListActorRelationsAsync(actorId, take, ct); + var relationOptions = BuildRelationQueryOptions(direction, relationTypes); + var relations = await queryService.ListActorRelationsAsync(actorId, take, relationOptions, ct); return Results.Ok(relations); } @@ -74,10 +77,46 @@ internal static async Task GetActorRelationSubgraph( IWorkflowExecutionQueryApplicationService queryService, int depth = 2, int take = 200, + string? direction = null, + string[]? relationTypes = null, CancellationToken ct = default) { - var subgraph = await queryService.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + var relationOptions = BuildRelationQueryOptions(direction, relationTypes); + var subgraph = await queryService.GetActorRelationSubgraphAsync(actorId, depth, take, relationOptions, ct); return Results.Ok(subgraph); } + private static WorkflowActorRelationQueryOptions BuildRelationQueryOptions( + string? direction, + string[]? relationTypes) + { + return new WorkflowActorRelationQueryOptions + { + Direction = ParseDirection(direction), + RelationTypes = NormalizeRelationTypes(relationTypes), + }; + } + + private static WorkflowActorRelationDirection ParseDirection(string? direction) + { + if (string.IsNullOrWhiteSpace(direction)) + return WorkflowActorRelationDirection.Both; + + return Enum.TryParse(direction.Trim(), ignoreCase: true, out var parsed) + ? parsed + : WorkflowActorRelationDirection.Both; + } + + private static IReadOnlyList NormalizeRelationTypes(IReadOnlyList? relationTypes) + { + if (relationTypes == null || relationTypes.Count == 0) + return []; + + return relationTypes + .Select(x => x?.Trim() ?? "") + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs index d2c202aff..628e62e02 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs @@ -1,10 +1,8 @@ using Aevatar.Configuration; -using Aevatar.CQRS.Projection.Abstractions; using Aevatar.Workflow.Application.DependencyInjection; using Aevatar.Workflow.Core; using Aevatar.Workflow.Presentation.AGUIAdapter; using Aevatar.Workflow.Presentation.AGUIAdapter.DependencyInjection; -using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.DependencyInjection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,10 +17,7 @@ public static IServiceCollection AddWorkflowCapability( { services.AddAevatarWorkflow(); services.AddWorkflowExecutionProjectionCQRS(options => - { - configuration.GetSection("WorkflowExecutionProjection").Bind(options); - ApplyGlobalReadModelOptions(configuration, options); - }); + configuration.GetSection("WorkflowExecutionProjection").Bind(options)); services.AddWorkflowExecutionAGUIAdapter(); services.AddWorkflowExecutionProjectionProjector(); services.AddWorkflowApplication(); @@ -37,36 +32,4 @@ public static IServiceCollection AddWorkflowCapability( configuration.GetSection("WorkflowExecutionReportArtifacts").Bind(options)); return services; } - - private static void ApplyGlobalReadModelOptions( - IConfiguration configuration, - WorkflowExecutionProjectionOptions options) - { - var readModelSection = configuration.GetSection("Projection:ReadModel"); - if (!readModelSection.Exists()) - return; - - var readModelOptions = new ProjectionReadModelRuntimeOptions(); - readModelSection.Bind(readModelOptions); - - var configuredProvider = readModelSection["Provider"]; - if (!string.IsNullOrWhiteSpace(configuredProvider)) - options.ReadModelProvider = configuredProvider.Trim(); - var configuredRelationProvider = readModelSection["RelationProvider"]; - if (!string.IsNullOrWhiteSpace(configuredRelationProvider)) - options.RelationProvider = configuredRelationProvider.Trim(); - - if (!string.IsNullOrWhiteSpace(readModelSection["Mode"])) - options.ReadModelMode = readModelOptions.Mode; - if (!string.IsNullOrWhiteSpace(readModelSection["FailOnUnsupportedCapabilities"])) - options.FailOnUnsupportedCapabilities = readModelOptions.FailOnUnsupportedCapabilities; - - var bindingsSection = readModelSection.GetSection("Bindings"); - if (bindingsSection.Exists()) - { - options.ReadModelBindings.Clear(); - foreach (var item in readModelOptions.Bindings) - options.ReadModelBindings[item.Key] = item.Value; - } - } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index a616ede49..a9b3d1cc0 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -6,10 +6,6 @@ namespace Aevatar.Workflow.Projection.Configuration; public sealed class WorkflowExecutionProjectionOptions : IProjectionRuntimeOptions { - public WorkflowExecutionProjectionOptions() - { - ReadModelBindings = new Dictionary(StringComparer.OrdinalIgnoreCase); - } /// /// Enables projection pipeline registration. @@ -42,21 +38,6 @@ public bool EnableRunQueryEndpoints /// public int RunProjectionFinalizeGraceTimeoutMs { get; set; } = 1500; - /// - /// Read-model store provider name, e.g. InMemory/Elasticsearch. - /// - public string ReadModelProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - - /// - /// Relation store provider name. Empty means fallback to . - /// - public string RelationProvider { get; set; } = ""; - - /// - /// Whether unsupported provider capabilities should fail fast during startup registration. - /// - public bool FailOnUnsupportedCapabilities { get; set; } = true; - /// /// Whether to pre-validate read-model provider selection and capabilities during host startup. /// @@ -66,15 +47,4 @@ public bool EnableRunQueryEndpoints /// Whether to pre-validate relation provider selection and capabilities during host startup. /// public bool ValidateRelationProviderOnStartup { get; set; } = true; - - /// - /// Optional read-model binding requirements (ReadModelType.FullName -> IndexKind). - /// - public Dictionary ReadModelBindings { get; } - - /// - /// Read-model runtime mode. - /// Workflow keeps CustomReadModel as default; StateOnly is rejected during DI composition. - /// - public ProjectionReadModelMode ReadModelMode { get; set; } = ProjectionReadModelMode.CustomReadModel; } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index cc032d862..8830a00dc 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Aevatar.CQRS.Projection.Abstractions; using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.ReadModels; @@ -24,6 +25,11 @@ public static class ServiceCollectionExtensions private static readonly Type ProjectionProjectorContract = typeof(IProjectionProjector<,>); private static readonly Type WorkflowExecutionReducerContract = typeof(IProjectionEventReducer); private static readonly Type WorkflowExecutionProjectorContract = typeof(IProjectionProjector>); + private static readonly ProjectionReadModelRequirements WorkflowRelationRequirements = new( + requiresRelations: true, + requiresRelationTraversal: true, + requiresAliases: false, + requiresSchemaValidation: false); public static IServiceCollection AddWorkflowExecutionProjectionCQRS( this IServiceCollection services, @@ -34,9 +40,11 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.Replace(ServiceDescriptor.Singleton(options)); services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); - services.TryAddSingleton(); RegisterWorkflowReadModelStoreSelector(services); RegisterWorkflowRelationStoreSelector(services); services.TryAddSingleton(); @@ -120,10 +128,8 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se { services.Replace(ServiceDescriptor.Singleton>(sp => { - var options = sp.GetRequiredService(); - var selectionPlanner = sp.GetRequiredService(); var storeFactory = sp.GetRequiredService(); - var selectionPlan = selectionPlanner.Build(options); + var selectionPlan = BuildSelectionPlan(sp); return storeFactory.Create( sp, @@ -136,10 +142,8 @@ private static void RegisterWorkflowRelationStoreSelector(IServiceCollection ser { services.Replace(ServiceDescriptor.Singleton(sp => { - var options = sp.GetRequiredService(); - var selectionPlanner = sp.GetRequiredService(); var relationStoreFactory = sp.GetRequiredService(); - var selectionPlan = selectionPlanner.Build(options); + var selectionPlan = BuildSelectionPlan(sp); return relationStoreFactory.Create( sp, @@ -148,6 +152,16 @@ private static void RegisterWorkflowRelationStoreSelector(IServiceCollection ser })); } + private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider serviceProvider) + { + var selectionPlanner = serviceProvider.GetRequiredService(); + var runtimeOptions = serviceProvider.GetRequiredService(); + return selectionPlanner.Build( + runtimeOptions, + typeof(WorkflowExecutionReport), + WorkflowRelationRequirements); + } + private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs index 0d7547ad6..56b60c14d 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs @@ -20,11 +20,13 @@ Task> ListActorTimelineAsync( Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs deleted file mode 100644 index 0a4545779..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Aevatar.CQRS.Projection.Abstractions; -using Aevatar.Workflow.Projection.Configuration; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public readonly record struct WorkflowReadModelSelectionPlan( - ProjectionReadModelRequirements ReadModelRequirements, - ProjectionReadModelStoreSelectionOptions ReadModelSelectionOptions, - ProjectionReadModelRequirements RelationRequirements, - ProjectionReadModelStoreSelectionOptions RelationSelectionOptions); - -public interface IWorkflowReadModelSelectionPlanner -{ - WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions options); -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs index 742b2cae3..f916a7a70 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs @@ -40,15 +40,17 @@ public Task> ListActorTimelineAsync( public Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) => - GetRelationsAsync(actorId, take, ct); + GetRelationsInternalAsync(actorId, take, options, ct); public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) => - GetRelationSubgraphAsync(actorId, depth, take, ct); + GetRelationSubgraphInternalAsync(actorId, depth, take, options, ct); protected override Task ReadSnapshotCoreAsync( string entityId, @@ -70,14 +72,14 @@ protected override Task> ReadRelationsC string entityId, int take, CancellationToken ct) - => _queryReader.GetActorRelationsAsync(entityId, take, ct); + => _queryReader.GetActorRelationsAsync(entityId, take, options: null, ct); protected override Task ReadRelationSubgraphCoreAsync( string entityId, int depth, int take, CancellationToken ct) - => _queryReader.GetActorRelationSubgraphAsync(entityId, depth, take, ct); + => _queryReader.GetActorRelationSubgraphAsync(entityId, depth, take, options: null, ct); protected override WorkflowActorRelationSubgraph CreateEmptyRelationSubgraph(string entityId) { @@ -86,4 +88,29 @@ protected override WorkflowActorRelationSubgraph CreateEmptyRelationSubgraph(str RootNodeId = entityId ?? string.Empty, }; } + + private async Task> GetRelationsInternalAsync( + string actorId, + int take, + WorkflowActorRelationQueryOptions? options, + CancellationToken ct) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) + return []; + + return await _queryReader.GetActorRelationsAsync(actorId, take, options, ct); + } + + private async Task GetRelationSubgraphInternalAsync( + string actorId, + int depth, + int take, + WorkflowActorRelationQueryOptions? options, + CancellationToken ct) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) + return CreateEmptyRelationSubgraph(actorId); + + return await _queryReader.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index 1e3b988be..dd264704c 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -58,6 +58,7 @@ public async Task> ListActorTimelineAsy public async Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { var actorIdValue = actorId?.Trim() ?? ""; @@ -65,12 +66,15 @@ public async Task> GetActorRelationsAsy return []; var boundedTake = Math.Clamp(take, 1, 1000); + var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); + var relationTypes = NormalizeRelationTypes(options?.RelationTypes); var edges = await _relationStore.GetNeighborsAsync( new ProjectionRelationQuery { Scope = WorkflowExecutionRelationConstants.Scope, RootNodeId = actorIdValue, - Direction = ProjectionRelationDirection.Both, + Direction = direction, + RelationTypes = relationTypes, Take = boundedTake, }, ct); @@ -81,6 +85,7 @@ public async Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { var actorIdValue = actorId?.Trim() ?? ""; @@ -89,16 +94,41 @@ public async Task GetActorRelationSubgraphAsync( var boundedDepth = Math.Clamp(depth, 1, 8); var boundedTake = Math.Clamp(take, 1, 2000); + var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); + var relationTypes = NormalizeRelationTypes(options?.RelationTypes); var subgraph = await _relationStore.GetSubgraphAsync( new ProjectionRelationQuery { Scope = WorkflowExecutionRelationConstants.Scope, RootNodeId = actorIdValue, - Direction = ProjectionRelationDirection.Both, + Direction = direction, + RelationTypes = relationTypes, Depth = boundedDepth, Take = boundedTake, }, ct); return _mapper.ToActorRelationSubgraph(actorIdValue, subgraph); } + + private static ProjectionRelationDirection MapDirection(WorkflowActorRelationDirection direction) + { + return direction switch + { + WorkflowActorRelationDirection.Outbound => ProjectionRelationDirection.Outbound, + WorkflowActorRelationDirection.Inbound => ProjectionRelationDirection.Inbound, + _ => ProjectionRelationDirection.Both, + }; + } + + private static IReadOnlyList NormalizeRelationTypes(IReadOnlyList? relationTypes) + { + if (relationTypes == null || relationTypes.Count == 0) + return []; + + return relationTypes + .Select(x => x?.Trim() ?? "") + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs deleted file mode 100644 index 4d6d2669e..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs +++ /dev/null @@ -1,82 +0,0 @@ -using Aevatar.CQRS.Projection.Abstractions; -using Aevatar.Workflow.Projection.Configuration; -using Aevatar.Workflow.Projection.ReadModels; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public sealed class WorkflowReadModelSelectionPlanner : IWorkflowReadModelSelectionPlanner -{ - private readonly IProjectionReadModelBindingResolver _bindingResolver; - - public WorkflowReadModelSelectionPlanner(IProjectionReadModelBindingResolver bindingResolver) - { - _bindingResolver = bindingResolver; - } - - public WorkflowReadModelSelectionPlan Build(WorkflowExecutionProjectionOptions options) - { - ArgumentNullException.ThrowIfNull(options); - EnsureReadModelModeSupported(options.ReadModelMode); - - var readModelRequirements = _bindingResolver.Resolve(options.ReadModelBindings, typeof(WorkflowExecutionReport)); - var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions - { - RequestedProviderName = NormalizeRequiredProviderName(options.ReadModelProvider), - FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, - }; - var relationRequirements = BuildRelationRequirements(readModelRequirements); - var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions - { - RequestedProviderName = NormalizeRelationProviderName( - options.RelationProvider, - readModelSelectionOptions.RequestedProviderName), - FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, - }; - - return new WorkflowReadModelSelectionPlan( - readModelRequirements, - readModelSelectionOptions, - relationRequirements, - relationSelectionOptions); - } - - private static ProjectionReadModelRequirements BuildRelationRequirements( - ProjectionReadModelRequirements readModelRequirements) - { - // Workflow relation endpoints depend on relation storage + traversal as first-class capability. - return new ProjectionReadModelRequirements( - requiresRelations: true, - requiresRelationTraversal: true, - requiresAliases: readModelRequirements.RequiresAliases, - requiresSchemaValidation: readModelRequirements.RequiresSchemaValidation); - } - - private static string NormalizeRequiredProviderName(string providerName) - { - if (string.IsNullOrWhiteSpace(providerName)) - { - throw new InvalidOperationException( - "WorkflowExecutionProjection:ReadModelProvider is required and cannot be empty."); - } - - return providerName.Trim(); - } - - private static string NormalizeRelationProviderName(string relationProviderName, string fallbackProviderName) - { - if (string.IsNullOrWhiteSpace(relationProviderName)) - return fallbackProviderName; - - return relationProviderName.Trim(); - } - - private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) - { - if (readModelMode != ProjectionReadModelMode.StateOnly) - return; - - throw new InvalidOperationException( - "Workflow projection does not support Projection:ReadModel:Mode=StateOnly. " + - "Use CustomReadModel or DefaultReadModel."); - } -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index c5c4e9bc2..36b8ee605 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -9,22 +9,31 @@ namespace Aevatar.Workflow.Projection.Orchestration; internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedService { + private static readonly ProjectionReadModelRequirements WorkflowRelationRequirements = new( + requiresRelations: true, + requiresRelationTraversal: true, + requiresAliases: false, + requiresSchemaValidation: false); + private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; - private readonly IWorkflowReadModelSelectionPlanner _selectionPlanner; + private readonly IProjectionStoreSelectionPlanner _selectionPlanner; + private readonly IProjectionStoreSelectionRuntimeOptions _selectionRuntimeOptions; private readonly IProjectionStoreStartupValidator _startupValidator; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, - IWorkflowReadModelSelectionPlanner selectionPlanner, + IProjectionStoreSelectionPlanner selectionPlanner, + IProjectionStoreSelectionRuntimeOptions selectionRuntimeOptions, IProjectionStoreStartupValidator startupValidator, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; _selectionPlanner = selectionPlanner; + _selectionRuntimeOptions = selectionRuntimeOptions; _startupValidator = startupValidator; _logger = logger ?? NullLogger.Instance; } @@ -35,7 +44,10 @@ public Task StartAsync(CancellationToken cancellationToken) if (!_options.Enabled) return Task.CompletedTask; - var selectionPlan = _selectionPlanner.Build(_options); + var selectionPlan = _selectionPlanner.Build( + _selectionRuntimeOptions, + typeof(WorkflowExecutionReport), + WorkflowRelationRequirements); if (_options.ValidateReadModelProviderOnStartup) { diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs index 7ce9c9000..334307b16 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs @@ -33,6 +33,7 @@ public ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, Can { var report = new WorkflowExecutionReport { + Id = context.RootActorId, ReportVersion = "1.0", ProjectionScope = WorkflowExecutionProjectionScope.ActorShared, TopologySource = WorkflowExecutionTopologySource.RuntimeSnapshot, diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs index c37d875b3..48310783b 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs @@ -146,7 +146,7 @@ private async Task UpsertStepRelationAsync( return; var normalizedStepId = NormalizeToken(rawStepId); - var stepNodeId = BuildStepNodeId(context.RootActorId, normalizedStepId); + var stepNodeId = BuildStepNodeId(context.RootActorId, context.CommandId, normalizedStepId); var stepTypeValue = stepType?.Trim() ?? ""; var targetRoleValue = targetRole?.Trim() ?? ""; var workerIdValue = workerId?.Trim() ?? ""; @@ -190,6 +190,7 @@ await _relationStore.UpsertNodeAsync( BuildStepNode( stepNodeId, context.RootActorId, + context.CommandId, normalizedStepId, stepTypeValue, targetRoleValue, @@ -270,6 +271,7 @@ private static ProjectionRelationNode BuildRunNode( private static ProjectionRelationNode BuildStepNode( string stepNodeId, string rootActorId, + string commandId, string stepId, string stepType, string targetRole, @@ -285,6 +287,7 @@ private static ProjectionRelationNode BuildStepNode( Properties = new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = NormalizeToken(rootActorId), + ["commandId"] = NormalizeToken(commandId), ["stepId"] = NormalizeToken(stepId), ["stepType"] = stepType ?? "", ["targetRole"] = targetRole ?? "", @@ -323,11 +326,12 @@ private static string BuildRunNodeId(string rootActorId, string commandId) return $"run:{normalizedRootActorId}:{normalizedCommandId}"; } - private static string BuildStepNodeId(string rootActorId, string stepId) + private static string BuildStepNodeId(string rootActorId, string commandId, string stepId) { var normalizedRootActorId = NormalizeToken(rootActorId); + var normalizedCommandId = NormalizeToken(commandId); var normalizedStepId = NormalizeToken(stepId); - return $"step:{normalizedRootActorId}:{normalizedStepId}"; + return $"step:{normalizedRootActorId}:{normalizedCommandId}:{normalizedStepId}"; } private static string BuildEdgeId(string relationType, string fromNodeId, string toNodeId) diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index a1226c2c9..a12282abf 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -18,13 +18,13 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) - - `WorkflowReadModelSelectionPlanner`(统一 read model/relation provider 归一化、mode 校验与 capability 选择参数生成) + - Store 选择统一由 `IProjectionStoreSelectionPlanner` 执行(Workflow 仅提供 relation 需求,不持有 provider/index kind 决策) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` - Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionReadModelCapabilityValidator` 校验 -- ReadModel 选择规则统一:DI store 解析与 Startup validation 均复用 `WorkflowReadModelSelectionPlanner`,避免双处规则漂移 +- ReadModel 选择规则统一:DI store 解析与 Startup validation 均复用 `IProjectionStoreSelectionPlanner + IProjectionStoreSelectionRuntimeOptions`,避免双处规则漂移 本项目依赖: @@ -83,21 +83,19 @@ FAQ: - 扩展 ReadModel Provider(推荐): - 实现 `IProjectionReadModelStoreRegistration` - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - - 通过 `WorkflowExecutionProjection:ReadModelProvider` 或 `Projection:ReadModel:Provider` 选择 Provider + - 通过 `Projection:ReadModel:*` 配置选择 Provider(Workflow 层不再暴露 provider 选择字段) ## Provider 配置 -- `WorkflowExecutionProjection:ReadModelProvider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` -- `WorkflowExecutionProjection:RelationProvider`:关系存储 provider;留空时回退到 `ReadModelProvider` -- `WorkflowExecutionProjection:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) +- Provider 选择统一配置入口:`Projection:ReadModel:*`(绑定到 `ProjectionReadModelRuntimeOptions`) +- `Projection:ReadModel:Provider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` +- `Projection:ReadModel:RelationProvider`:关系 provider;留空时回退到 `Provider` +- `Projection:ReadModel:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) +- `Projection:ReadModel:Mode`:读模型运行模式(`StateOnly` 会在选择阶段 fail-fast) +- `Projection:ReadModel:Bindings:*`:ReadModel -> IndexKind 约束(键必须为 `ReadModel` 的 `Type.FullName`,例如 `Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Document`) - `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:是否在 Host 启动阶段预校验 Provider 选择与能力(默认 `true`) - `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:是否在 Host 启动阶段预校验 Relation Provider(默认 `true`) -- `WorkflowExecutionProjection:ReadModelBindings`:ReadModel -> IndexKind 约束(键必须为 `ReadModel` 的 `Type.FullName`,例如 `Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Document`) -- 推荐统一配置入口:`Projection:ReadModel:*`(由 Infrastructure 映射到 Workflow 投影选项) -- `Projection:ReadModel:Provider`:全局默认 Provider(当前由 `WorkflowCapabilityServiceCollectionExtensions` 覆盖到模块选项) -- `Projection:ReadModel:RelationProvider`:全局默认 Relation Provider(覆盖到模块选项) -- `Projection:ReadModel:FailOnUnsupportedCapabilities`:全局 fail-fast 策略 -- `Projection:ReadModel:Bindings:*`:全局 ReadModel -> IndexKind 约束 +- `Projection:Policies:DenyInMemoryRelationFactStore`:禁用 InMemory relation 作为事实源(生产建议开启) - `Projection:ReadModel:Providers:Elasticsearch:Endpoints`:Elasticsearch endpoint 列表 - `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix`:索引前缀 - `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs`:请求超时 @@ -107,6 +105,9 @@ FAQ: - `Projection:ReadModel:Providers:Elasticsearch:MissingIndexBehavior`:索引缺失行为(`Throw` / `WarnAndReturnEmpty`,默认 `Throw`) - `Projection:ReadModel:Providers:Elasticsearch:MutateMaxRetryCount`:`MutateAsync` OCC 冲突重试次数(默认 `3`) - `Projection:ReadModel:Providers:Elasticsearch:Username/Password`:可选基础认证 +- 关系查询参数: + - `/actors/{actorId}/relations` 支持 `direction` 与 `relationTypes` + - `/actors/{actorId}/relation-subgraph` 支持 `direction` 与 `relationTypes` - 扩展 run 输出协议: - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 - 不改 Application 用例编排代码 diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index 4708d9c73..bd4f21ecc 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -32,6 +32,8 @@ public sealed class WorkflowExecutionReport IHasProjectionTimeline, IHasProjectionRoleReplies { + public string RootActorId { get; set; } = ""; + public string CommandId { get; set; } = ""; public string ReportVersion { get; set; } = "1.0"; public WorkflowExecutionProjectionScope ProjectionScope { get; set; } = WorkflowExecutionProjectionScope.ActorShared; public WorkflowExecutionTopologySource TopologySource { get; set; } = WorkflowExecutionTopologySource.RuntimeSnapshot; diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 2f62a1786..e2cf5b76c 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aevatar.Workflow.Extensions.Hosting; @@ -24,37 +25,86 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( services.AddSingleton(); - var providerSelection = ResolveProviderSelection(configuration); + var runtimeOptions = ResolveRuntimeOptions(configuration); + var providerSelection = ResolveProviderSelection(runtimeOptions); + EnforceRelationProviderPolicy(configuration, providerSelection.RelationProvider); + + services.Replace(ServiceDescriptor.Singleton(runtimeOptions)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); + RegisterReadModelProvider(services, configuration, providerSelection.ReadModelProvider); RegisterRelationProvider(services, configuration, providerSelection.RelationProvider); return services; } - private static ProviderSelection ResolveProviderSelection(IConfiguration configuration) + private static ProjectionReadModelRuntimeOptions ResolveRuntimeOptions(IConfiguration configuration) { - var workflowReadModelProvider = configuration["WorkflowExecutionProjection:ReadModelProvider"]; - var workflowRelationProvider = configuration["WorkflowExecutionProjection:RelationProvider"]; - var globalReadModelProvider = configuration["Projection:ReadModel:Provider"]; - var globalRelationProvider = configuration["Projection:ReadModel:RelationProvider"]; + var options = new ProjectionReadModelRuntimeOptions(); + configuration.GetSection("Projection:ReadModel").Bind(options); + return options; + } + private static ProviderSelection ResolveProviderSelection(ProjectionReadModelRuntimeOptions runtimeOptions) + { var readModelProvider = NormalizeOrDefaultProvider( - !string.IsNullOrWhiteSpace(globalReadModelProvider) - ? globalReadModelProvider - : workflowReadModelProvider, + runtimeOptions.Provider, ProjectionReadModelProviderNames.InMemory, "Projection:ReadModel:Provider"); var relationProvider = NormalizeOrDefaultProvider( - !string.IsNullOrWhiteSpace(globalRelationProvider) - ? globalRelationProvider - : workflowRelationProvider, + runtimeOptions.RelationProvider, readModelProvider, "Projection:ReadModel:RelationProvider"); return new ProviderSelection(readModelProvider, relationProvider); } + private static void EnforceRelationProviderPolicy( + IConfiguration configuration, + string relationProviderName) + { + var denyInMemoryRelationProvider = ParseBool( + configuration["Projection:Policies:DenyInMemoryRelationFactStore"]); + var environment = ResolveRuntimeEnvironment(configuration["Projection:Policies:Environment"]); + var production = IsProductionEnvironment(environment); + + if ((denyInMemoryRelationProvider || production) && + string.Equals( + relationProviderName, + ProjectionReadModelProviderNames.InMemory, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "InMemory relation provider is not allowed by projection policy. " + + "Use a durable relation provider (for example Neo4j) for production/distributed deployments."); + } + } + + private static string ResolveRuntimeEnvironment(string? configuredEnvironment) + { + if (!string.IsNullOrWhiteSpace(configuredEnvironment)) + return configuredEnvironment.Trim(); + + var dotnetEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + if (!string.IsNullOrWhiteSpace(dotnetEnvironment)) + return dotnetEnvironment.Trim(); + + var aspnetEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return aspnetEnvironment?.Trim() ?? ""; + } + + private static bool IsProductionEnvironment(string environment) + { + return string.Equals(environment, "Production", StringComparison.OrdinalIgnoreCase); + } + + private static bool ParseBool(string? value) + { + return bool.TryParse(value, out var parsed) && parsed; + } + private static string NormalizeOrDefaultProvider( string? configuredValue, string fallbackValue, diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs new file mode 100644 index 000000000..ad5da3f5a --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs @@ -0,0 +1,97 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public sealed class ProjectionStoreSelectionPlannerTests +{ + private readonly ProjectionStoreSelectionPlanner _planner = + new(new ProjectionReadModelBindingResolver()); + + [Fact] + public void Build_WhenReadModelProviderIsEmpty_ShouldThrow() + { + var options = new FakeOptions + { + ReadModelProvider = " ", + }; + + Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements()); + + act.Should().Throw() + .WithMessage("*read-model provider is required*"); + } + + [Fact] + public void Build_WhenRelationProviderMissing_ShouldFallbackToReadModelProvider() + { + var options = new FakeOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.Neo4j, + RelationProvider = " ", + }; + + var plan = _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements( + requiresRelations: true, + requiresRelationTraversal: true)); + + plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + } + + [Fact] + public void Build_ShouldMergeRelationRequirementsWithReadModelAliasAndSchemaRequirements() + { + var options = new FakeOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.Neo4j, + }; + options.ReadModelBindings[typeof(TestReadModel).FullName!] = ProjectionReadModelIndexKind.Graph.ToString(); + var relationRequirements = new ProjectionReadModelRequirements( + requiresRelations: true, + requiresRelationTraversal: true, + requiresAliases: false, + requiresSchemaValidation: false); + + var plan = _planner.Build(options, typeof(TestReadModel), relationRequirements); + + plan.RelationRequirements.RequiresRelations.Should().BeTrue(); + plan.RelationRequirements.RequiresRelationTraversal.Should().BeTrue(); + plan.RelationRequirements.RequiresAliases.Should().BeFalse(); + plan.RelationRequirements.RequiresSchemaValidation.Should().BeFalse(); + } + + [Fact] + public void Build_WhenStateOnlyModeConfigured_ShouldThrow() + { + var options = new FakeOptions + { + ReadModelProvider = ProjectionReadModelProviderNames.InMemory, + ReadModelMode = ProjectionReadModelMode.StateOnly, + }; + + Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements()); + + act.Should().Throw() + .WithMessage("*does not support*StateOnly*"); + } + + private sealed class FakeOptions : IProjectionStoreSelectionRuntimeOptions + { + private readonly Dictionary _bindings = new(StringComparer.OrdinalIgnoreCase); + + public string ReadModelProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; + + public string RelationProvider { get; set; } = ""; + + public bool FailOnUnsupportedCapabilities { get; set; } = true; + + public ProjectionReadModelMode ReadModelMode { get; set; } = ProjectionReadModelMode.CustomReadModel; + + public Dictionary ReadModelBindings => _bindings; + + IReadOnlyDictionary IProjectionStoreSelectionRuntimeOptions.ReadModelBindings => _bindings; + } + + private sealed class TestReadModel; +} diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs index 2c4a37ea7..f410cb85e 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs @@ -475,8 +475,10 @@ public Task> ListActorTimelineAsync( public Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { + _ = options; _ = ct; if (!RelationsByActorId.TryGetValue(actorId, out var relations)) relations = []; @@ -488,10 +490,12 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { _ = depth; _ = take; + _ = options; _ = ct; if (!SubgraphByActorId.TryGetValue(actorId, out var subgraph)) { diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs index 0f4196afa..6866aff5d 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs @@ -291,7 +291,7 @@ public async Task ListActorRelations_ShouldReturnRelationItems() }, }; - var result = await ChatQueryEndpoints.ListActorRelations("actor-1", queryService, 50, CancellationToken.None); + var result = await ChatQueryEndpoints.ListActorRelations("actor-1", queryService, 50, ct: CancellationToken.None); var (statusCode, body) = await ExecuteResultAsync(result); using var doc = JsonDocument.Parse(body); @@ -300,6 +300,29 @@ public async Task ListActorRelations_ShouldReturnRelationItems() doc.RootElement[0].GetProperty("edgeId").GetString().Should().Be("edge-1"); } + [Fact] + public async Task ListActorRelations_WhenDirectionAndRelationTypesProvided_ShouldForwardQueryOptions() + { + var queryService = new FakeQueryService + { + ActorQueryEnabledValue = true, + }; + + var result = await ChatQueryEndpoints.ListActorRelations( + "actor-1", + queryService, + 50, + direction: "Outbound", + relationTypes: ["CHILD_OF", "OWNS"], + ct: CancellationToken.None); + var (statusCode, _) = await ExecuteResultAsync(result); + + statusCode.Should().Be(StatusCodes.Status200OK); + queryService.LastRelationQueryOptions.Should().NotBeNull(); + queryService.LastRelationQueryOptions!.Direction.Should().Be(WorkflowActorRelationDirection.Outbound); + queryService.LastRelationQueryOptions.RelationTypes.Should().BeEquivalentTo(["CHILD_OF", "OWNS"]); + } + [Fact] public async Task GetActorRelationSubgraph_ShouldReturnSubgraph() { @@ -338,7 +361,7 @@ public async Task GetActorRelationSubgraph_ShouldReturnSubgraph() }, }; - var result = await ChatQueryEndpoints.GetActorRelationSubgraph("actor-1", queryService, 2, 50, CancellationToken.None); + var result = await ChatQueryEndpoints.GetActorRelationSubgraph("actor-1", queryService, 2, 50, ct: CancellationToken.None); var (statusCode, body) = await ExecuteResultAsync(result); using var doc = JsonDocument.Parse(body); @@ -414,6 +437,7 @@ private sealed class FakeQueryService : public Dictionary> TimelineByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); + public WorkflowActorRelationQueryOptions? LastRelationQueryOptions { get; private set; } public bool ActorQueryEnabled => ActorQueryEnabledValue; @@ -439,8 +463,11 @@ public Task> ListActorTimelineAsync(str public Task> ListActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { + LastRelationQueryOptions = options; + _ = options; if (!RelationsByActorId.TryGetValue(actorId, out var items)) items = []; @@ -451,10 +478,13 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) { + LastRelationQueryOptions = options; _ = depth; _ = take; + _ = options; if (!SubgraphByActorId.TryGetValue(actorId, out var item)) { item = new WorkflowActorRelationSubgraph diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs index aa08858fd..df65fb628 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs @@ -189,8 +189,17 @@ private sealed class FakeQueryService : IWorkflowExecutionQueryApplicationServic return Task.FromResult(Snapshot); } public Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); - public Task> ListActorRelationsAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); - public Task GetActorRelationSubgraphAsync(string actorId, int depth = 2, int take = 200, CancellationToken ct = default) => + public Task> ListActorRelationsAsync( + string actorId, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) => Task.FromResult>([]); + public Task GetActorRelationSubgraphAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) => Task.FromResult(new WorkflowActorRelationSubgraph { RootNodeId = actorId, diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs index 91bd4236d..1941f161a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs @@ -248,6 +248,7 @@ public Task> ListActorTimelineAsync(str public Task> ListActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) => Task.FromResult>([]); @@ -255,6 +256,7 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) => Task.FromResult(new WorkflowActorRelationSubgraph { diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index c2f607d02..11303b37a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -18,6 +18,7 @@ using Google.Protobuf; using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; namespace Aevatar.Workflow.Host.Api.Tests; @@ -42,12 +43,13 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationFindsU { var services = new ServiceCollection(); RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => + ConfigureStoreSelectionOptions(services, options => { - options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); + options.Provider = ProjectionReadModelProviderNames.InMemory; + options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = true; }); + services.AddWorkflowExecutionProjectionCQRS(); await using var provider = services.BuildServiceProvider(); Func act = () => StartHostedServicesAsync(provider); @@ -92,8 +94,9 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfiguredWithou var services = new ServiceCollection(); RegisterInMemoryProvider(services); RegisterElasticsearchProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => - options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch); + ConfigureStoreSelectionOptions(services, options => + options.Provider = ProjectionReadModelProviderNames.Elasticsearch); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); @@ -113,11 +116,12 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchReadModelWithInM var services = new ServiceCollection(); RegisterInMemoryProvider(services); RegisterElasticsearchProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => + ConfigureStoreSelectionOptions(services, options => { - options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch; + options.Provider = ProjectionReadModelProviderNames.Elasticsearch; options.RelationProvider = ProjectionReadModelProviderNames.InMemory; }); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); @@ -133,8 +137,9 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNeo4jConfigured_ShouldR var services = new ServiceCollection(); RegisterInMemoryProvider(services); RegisterNeo4jProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => - options.ReadModelProvider = ProjectionReadModelProviderNames.Neo4j); + ConfigureStoreSelectionOptions(services, options => + options.Provider = ProjectionReadModelProviderNames.Neo4j); + services.AddWorkflowExecutionProjectionCQRS(); await using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); @@ -152,8 +157,9 @@ public void AddWorkflowExecutionProjectionCQRS_WhenProviderUnsupported_ShouldThr { var services = new ServiceCollection(); RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => - options.ReadModelProvider = "UnknownProvider"); + ConfigureStoreSelectionOptions(services, options => + options.Provider = "UnknownProvider"); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); Action act = () => provider.GetRequiredService>(); @@ -167,12 +173,13 @@ public void AddWorkflowExecutionProjectionCQRS_WhenBindingRequiresUnsupportedCap { var services = new ServiceCollection(); RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => + ConfigureStoreSelectionOptions(services, options => { - options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); + options.Provider = ProjectionReadModelProviderNames.InMemory; + options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = true; }); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); Action act = () => provider.GetRequiredService>(); @@ -186,12 +193,13 @@ public void AddWorkflowExecutionProjectionCQRS_WhenFailFastDisabled_ShouldAllowU { var services = new ServiceCollection(); RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => + ConfigureStoreSelectionOptions(services, options => { - options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; - options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); + options.Provider = ProjectionReadModelProviderNames.InMemory; + options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); options.FailOnUnsupportedCapabilities = false; }); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); Action act = () => provider.GetRequiredService>(); @@ -204,11 +212,12 @@ public void AddWorkflowExecutionProjectionCQRS_WhenStateOnlyModeConfigured_Shoul { var services = new ServiceCollection(); RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => + ConfigureStoreSelectionOptions(services, options => { - options.ReadModelMode = ProjectionReadModelMode.StateOnly; - options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory; + options.Mode = ProjectionReadModelMode.StateOnly; + options.Provider = ProjectionReadModelProviderNames.InMemory; }); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); Action act = () => provider.GetRequiredService>(); @@ -303,10 +312,12 @@ public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastProvid var services = new ServiceCollection(); RegisterInMemoryProvider(services); RegisterElasticsearchProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => - options.ReadModelProvider = ProjectionReadModelProviderNames.InMemory); - services.AddWorkflowExecutionProjectionCQRS(options => - options.ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch); + ConfigureStoreSelectionOptions(services, options => + options.Provider = ProjectionReadModelProviderNames.InMemory); + services.AddWorkflowExecutionProjectionCQRS(); + ConfigureStoreSelectionOptions(services, options => + options.Provider = ProjectionReadModelProviderNames.Elasticsearch); + services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); @@ -461,6 +472,17 @@ private static void RegisterNeo4jProvider(IServiceCollection services) scope: WorkflowExecutionRelationConstants.Scope); } + private static void ConfigureStoreSelectionOptions( + IServiceCollection services, + Action configure) + { + var options = new ProjectionReadModelRuntimeOptions(); + configure(options); + services.Replace(ServiceDescriptor.Singleton(options)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); + } + public sealed class CustomChatRequestReducer : WorkflowExecutionEventReducerBase { protected override bool Reduce( diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 9cf739971..e75913469 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -791,15 +791,17 @@ public Task> ListActorTimelineAsync( public Task> GetActorRelationsAsync( string actorId, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) - => _queryPort.GetActorRelationsAsync(actorId, take, ct); + => _queryPort.GetActorRelationsAsync(actorId, take, options, ct); public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, + WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) - => _queryPort.GetActorRelationSubgraphAsync(actorId, depth, take, ct); + => _queryPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); } private sealed class ObservableWorkflowExecutionReadModelStore diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs index 554942d1b..353c44825 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs @@ -76,6 +76,8 @@ await projector.ProjectAsync(context, Wrap(new StepCompletedEvent string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationContainsStep, StringComparison.Ordinal)); var stepNode = subgraph.Nodes.Single(x => string.Equals(x.NodeType, WorkflowExecutionRelationConstants.StepNodeType, StringComparison.Ordinal)); + stepNode.NodeId.Should().Be("step:root:cmd-1:step-1"); + stepNode.Properties["commandId"].Should().Be("cmd-1"); stepNode.Properties["stepType"].Should().Be("llm_call"); stepNode.Properties["targetRole"].Should().Be("assistant"); stepNode.Properties["workerId"].Should().Be("assistant-1"); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 3ac2f9280..6df50746e 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -117,9 +117,18 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S var relationRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) .ToList(); + var selectionOptionsRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionStoreSelectionRuntimeOptions)) + .ToList(); providerRegistrations.Should().HaveCount(1); relationRegistrations.Should().HaveCount(1); + selectionOptionsRegistrations.Should().HaveCount(1); + + using var provider = services.BuildServiceProvider(); + var selectionOptions = provider.GetRequiredService(); + selectionOptions.ReadModelProvider.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); + selectionOptions.RelationProvider.Should().Be(ProjectionReadModelProviderNames.InMemory); } [Fact] @@ -138,4 +147,24 @@ public void AddWorkflowProjectionReadModelProviders_WhenProviderConfiguredUnknow act.Should().Throw() .WithMessage("*Unsupported projection provider*"); } + + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRelationFactStore_ShouldThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:ReadModel:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, + ["Projection:ReadModel:RelationProvider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:ReadModel:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + ["Projection:Policies:DenyInMemoryRelationFactStore"] = "true", + }) + .Build(); + + Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); + + act.Should().Throw() + .WithMessage("*InMemory relation provider is not allowed*"); + } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs deleted file mode 100644 index c3b3445f8..000000000 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Aevatar.CQRS.Projection.Abstractions; -using Aevatar.CQRS.Projection.Runtime.Runtime; -using Aevatar.Workflow.Projection.Configuration; -using Aevatar.Workflow.Projection.Orchestration; -using Aevatar.Workflow.Projection.ReadModels; -using FluentAssertions; - -namespace Aevatar.Workflow.Host.Api.Tests; - -public sealed class WorkflowReadModelSelectionPlannerTests -{ - private readonly WorkflowReadModelSelectionPlanner _planner = new(new ProjectionReadModelBindingResolver()); - - [Fact] - public void Build_WhenProviderIsEmpty_ShouldThrow() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelProvider = " ", - }; - - Action act = () => _planner.Build(options); - - act.Should().Throw() - .WithMessage("*ReadModelProvider is required*"); - } - - [Fact] - public void Build_ShouldTrimConfiguredProviderName() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelProvider = " Neo4j ", - }; - - var plan = _planner.Build(options); - - plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); - plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); - } - - [Fact] - public void Build_ShouldResolveBindingsByReadModelFullName() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelProvider = ProjectionReadModelProviderNames.InMemory, - }; - options.ReadModelBindings[typeof(WorkflowExecutionReport).FullName!] = - ProjectionReadModelIndexKind.Document.ToString(); - - var plan = _planner.Build(options); - - plan.ReadModelRequirements.RequiresIndexing.Should().BeTrue(); - plan.ReadModelRequirements.RequiredIndexKinds.Should().ContainSingle() - .Which.Should().Be(ProjectionReadModelIndexKind.Document); - } - - [Fact] - public void Build_WhenBindingUsesShortTypeName_ShouldThrow() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelProvider = ProjectionReadModelProviderNames.InMemory, - }; - options.ReadModelBindings[nameof(WorkflowExecutionReport)] = - ProjectionReadModelIndexKind.Document.ToString(); - - Action act = () => _planner.Build(options); - - act.Should().Throw() - .WithMessage("*must use full type name*"); - } - - [Fact] - public void Build_WhenRelationProviderConfigured_ShouldUseRelationProvider() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelProvider = ProjectionReadModelProviderNames.Elasticsearch, - RelationProvider = " InMemory ", - }; - - var plan = _planner.Build(options); - - plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); - plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); - } - - [Fact] - public void Build_WhenStateOnlyModeConfigured_ShouldThrow() - { - var options = new WorkflowExecutionProjectionOptions - { - ReadModelMode = ProjectionReadModelMode.StateOnly, - }; - - Action act = () => _planner.Build(options); - - act.Should().Throw() - .WithMessage("*does not support*StateOnly*"); - } -} From cf03a23ddfb3b54bd8ac960e972eb97d659f6444 Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 22:18:08 +0800 Subject: [PATCH 24/46] Refactor Projection Abstractions and Introduce Core/Stores Separation - Split the existing `Aevatar.CQRS.Projection.Abstractions` into two distinct projects: `Aevatar.CQRS.Projection.Core.Abstractions` and `Aevatar.CQRS.Projection.Stores.Abstractions` to clarify responsibilities and reduce complexity. - Updated project references across the solution to align with the new structure, ensuring all dependencies point to the correct abstractions. - Enhanced documentation to reflect the architectural changes and provide guidance on the new project organization. - Removed the legacy `Aevatar.CQRS.Projection.Abstractions` project, streamlining the codebase and improving maintainability. --- aevatar.cqrs.slnf | 3 +- aevatar.slnx | 3 +- ...r.Demos.CaseProjection.Abstractions.csproj | 2 +- .../Aevatar.Demos.CaseProjection.csproj | 1 + ...ions-full-refactor-blueprint-2026-02-24.md | 102 ++++++++++++++++++ ...review-remediation-blueprint-2026-02-24.md | 10 +- ...odel-graph-relations-refactor-blueprint.md | 16 +-- .../Aevatar.AI.Projection.csproj | 2 +- ...ateProjectionReadModelStoreRegistration.cs | 33 ------ ...rojectionReadModelStoreProviderMetadata.cs | 6 -- .../IProjectionReadModelStoreRegistration.cs | 11 -- ...ProjectionRelationStoreProviderRegistry.cs | 6 -- .../IProjectionRelationStoreRegistration.cs | 10 -- .../README.md | 26 ----- .../Abstractions/Core}/IProjectionClock.cs | 0 .../Abstractions/Core}/IProjectionContext.cs | 0 .../Core}/IProjectionRuntimeOptions.cs | 0 .../IProjectionStreamSubscriptionContext.cs | 0 .../Pipeline}/IProjectionCoordinator.cs | 0 .../IProjectionDispatchFailureReporter.cs | 0 .../Pipeline}/IProjectionDispatcher.cs | 0 .../Pipeline}/IProjectionEventApplier.cs | 0 .../Pipeline}/IProjectionEventReducer.cs | 0 .../Pipeline}/IProjectionLifecycleService.cs | 0 .../IProjectionOwnershipCoordinator.cs | 0 .../Pipeline}/IProjectionProjector.cs | 0 .../IProjectionSubscriptionRegistry.cs | 0 .../IProjectionPortActivationService.cs | 0 .../IProjectionPortLiveSinkForwarder.cs | 0 .../Ports}/IProjectionPortReleaseService.cs | 0 .../IProjectionPortSinkSubscriptionManager.cs | 0 .../Streaming}/IActorStreamSubscriptionHub.cs | 0 .../IActorStreamSubscriptionLease.cs | 0 .../IProjectionSessionEventCodec.cs | 0 .../Streaming}/IProjectionSessionEventHub.cs | 0 ....CQRS.Projection.Core.Abstractions.csproj} | 2 +- .../GlobalUsings.cs | 0 .../README.md | 16 +++ .../Aevatar.CQRS.Projection.Core.csproj | 2 +- src/Aevatar.CQRS.Projection.Core/README.md | 2 +- ....Projection.Providers.Elasticsearch.csproj | 2 +- .../ServiceCollectionExtensions.cs | 8 +- .../README.md | 2 +- .../ElasticsearchProjectionReadModelStore.cs | 2 +- .../ElasticsearchProjectionRelationStore.cs | 2 +- ....CQRS.Projection.Providers.InMemory.csproj | 3 +- .../ServiceCollectionExtensions.cs | 8 +- .../InMemoryProjectionReadModelStore.cs | 2 +- .../Stores/InMemoryProjectionRelationStore.cs | 2 +- ...tar.CQRS.Projection.Providers.Neo4j.csproj | 2 +- .../ServiceCollectionExtensions.cs | 8 +- .../README.md | 2 +- .../Stores/Neo4jProjectionReadModelStore.cs | 2 +- .../Stores/Neo4jProjectionRelationStore.cs | 2 +- .../Aevatar.CQRS.Projection.Runtime.csproj | 2 +- .../ProjectionReadModelProviderRegistry.cs | 4 +- .../ProjectionReadModelProviderSelector.cs | 4 +- ...ProjectionRelationStoreProviderRegistry.cs | 4 +- ...ProjectionRelationStoreProviderSelector.cs | 57 ++-------- .../ProjectionStoreStartupValidator.cs | 4 +- ...Aevatar.CQRS.Projection.StateMirror.csproj | 3 - .../GlobalUsings.cs | 1 - .../DelegateProjectionStoreRegistration.cs} | 11 +- .../Core/IProjectionStoreProviderMetadata.cs} | 2 +- .../Core/IProjectionStoreRegistration.cs | 13 +++ .../IProjectionReadModelBindingResolver.cs | 0 ...IProjectionReadModelCapabilityValidator.cs | 0 .../IProjectionReadModelProviderRegistry.cs | 2 +- .../IProjectionReadModelProviderSelector.cs | 4 +- .../ReadModels}/IProjectionReadModelStore.cs | 0 .../IProjectionReadModelStoreFactory.cs | 0 .../ProjectionReadModelBindingException.cs | 0 ...nReadModelCapabilityValidationException.cs | 0 .../ProjectionReadModelCapabilityValidator.cs | 0 .../ProjectionReadModelIndexKind.cs | 0 .../ReadModels}/ProjectionReadModelMode.cs | 0 ...ProjectionReadModelProviderCapabilities.cs | 0 .../ProjectionReadModelProviderNames.cs | 0 .../ProjectionReadModelRequirements.cs | 0 .../ProjectionReadModelRuntimeOptions.cs | 0 ...rojectionReadModelStoreSelectionOptions.cs | 0 .../ProjectionReadModelStoreSelector.cs | 23 ++++ .../Relations}/IProjectionRelationStore.cs | 0 .../IProjectionRelationStoreFactory.cs | 0 ...ProjectionRelationStoreProviderRegistry.cs | 6 ++ ...ProjectionRelationStoreProviderSelector.cs | 4 +- .../Relations}/ProjectionRelationDirection.cs | 0 .../Relations}/ProjectionRelationEdge.cs | 0 .../Relations}/ProjectionRelationNode.cs | 0 .../Relations}/ProjectionRelationQuery.cs | 0 .../Relations}/ProjectionRelationSubgraph.cs | 0 .../IProjectionStoreSelectionPlanner.cs | 0 ...IProjectionStoreSelectionRuntimeOptions.cs | 0 .../IProjectionStoreStartupValidator.cs | 4 +- .../ProjectionProviderSelectionException.cs | 0 .../ProjectionStoreSelectionPlan.cs | 0 .../Selection/ProjectionStoreSelector.cs} | 47 ++++---- ...CQRS.Projection.Stores.Abstractions.csproj | 9 ++ .../README.md | 26 +++++ ...r.Workflow.Presentation.AGUIAdapter.csproj | 2 +- .../Aevatar.Workflow.Projection.csproj | 3 +- .../Aevatar.Workflow.Projection/README.md | 5 +- .../Aevatar.CQRS.Projection.Core.Tests.csproj | 3 +- .../ProjectionReadModelRuntimeTests.cs | 8 +- .../ProjectionReadModelStoreSelectorTests.cs | 4 +- .../Aevatar.Workflow.Host.Api.Tests.csproj | 3 +- ...lowExecutionProjectionRegistrationTests.cs | 6 +- .../WorkflowHostingExtensionsCoverageTests.cs | 12 +-- 108 files changed, 329 insertions(+), 247 deletions(-) create mode 100644 docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs delete mode 100644 src/Aevatar.CQRS.Projection.Abstractions/README.md rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core}/IProjectionClock.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core}/IProjectionContext.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core}/IProjectionRuntimeOptions.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core}/IProjectionStreamSubscriptionContext.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionCoordinator.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionDispatchFailureReporter.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionDispatcher.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionEventApplier.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionEventReducer.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionLifecycleService.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionOwnershipCoordinator.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionProjector.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline}/IProjectionSubscriptionRegistry.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports}/IProjectionPortActivationService.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports}/IProjectionPortLiveSinkForwarder.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports}/IProjectionPortReleaseService.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports}/IProjectionPortSinkSubscriptionManager.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming}/IActorStreamSubscriptionHub.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming}/IActorStreamSubscriptionLease.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming}/IProjectionSessionEventCodec.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming}/IProjectionSessionEventHub.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj => Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj} (84%) rename src/{Aevatar.CQRS.Projection.Abstractions => Aevatar.CQRS.Projection.Core.Abstractions}/GlobalUsings.cs (100%) create mode 100644 src/Aevatar.CQRS.Projection.Core.Abstractions/README.md delete mode 100644 src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs} (66%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs} (68%) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelBindingResolver.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelCapabilityValidator.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelProviderRegistry.cs (58%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelProviderSelector.cs (54%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelStore.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/IProjectionReadModelStoreFactory.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelBindingException.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelCapabilityValidationException.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelCapabilityValidator.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelIndexKind.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelMode.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelProviderCapabilities.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelProviderNames.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelRequirements.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelRuntimeOptions.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels}/ProjectionReadModelStoreSelectionOptions.cs (100%) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/IProjectionRelationStore.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/IProjectionRelationStoreFactory.cs (100%) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/IProjectionRelationStoreProviderSelector.cs (59%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/ProjectionRelationDirection.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/ProjectionRelationEdge.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/ProjectionRelationNode.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/ProjectionRelationQuery.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations}/ProjectionRelationSubgraph.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection}/IProjectionStoreSelectionPlanner.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection}/IProjectionStoreSelectionRuntimeOptions.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection}/IProjectionStoreStartupValidator.cs (69%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection}/ProjectionProviderSelectionException.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection}/ProjectionStoreSelectionPlan.cs (100%) rename src/{Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs => Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs} (61%) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md diff --git a/aevatar.cqrs.slnf b/aevatar.cqrs.slnf index 5d51f65c0..5615d906c 100644 --- a/aevatar.cqrs.slnf +++ b/aevatar.cqrs.slnf @@ -4,7 +4,8 @@ "projects": [ "src\\Aevatar.CQRS.Core.Abstractions\\Aevatar.CQRS.Core.Abstractions.csproj", "src\\Aevatar.CQRS.Core\\Aevatar.CQRS.Core.csproj", - "src\\Aevatar.CQRS.Projection.Abstractions\\Aevatar.CQRS.Projection.Abstractions.csproj", + "src\\Aevatar.CQRS.Projection.Core.Abstractions\\Aevatar.CQRS.Projection.Core.Abstractions.csproj", + "src\\Aevatar.CQRS.Projection.Stores.Abstractions\\Aevatar.CQRS.Projection.Stores.Abstractions.csproj", "src\\Aevatar.CQRS.Projection.Core\\Aevatar.CQRS.Projection.Core.csproj", "src\\Aevatar.Foundation.Projection\\Aevatar.Foundation.Projection.csproj", "test\\Aevatar.CQRS.Core.Tests\\Aevatar.CQRS.Core.Tests.csproj", diff --git a/aevatar.slnx b/aevatar.slnx index 6c1479a87..f89055595 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -37,7 +37,8 @@ - + + diff --git a/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj b/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj index e4e2a2d44..2d83ec38d 100644 --- a/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj +++ b/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj @@ -8,7 +8,7 @@ - + diff --git a/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj b/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj index 5391706de..c9b34160d 100644 --- a/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj +++ b/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj @@ -9,6 +9,7 @@ + diff --git a/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md b/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md new file mode 100644 index 000000000..bbfa5d6a9 --- /dev/null +++ b/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md @@ -0,0 +1,102 @@ +# Projection Abstractions 全量重构蓝图(2026-02-24) + +## 1. 文档信息 +- 状态:Implemented(Breaking) +- 兼容性策略:不保留兼容层,旧单体抽象项目直接删除 +- 范围:`Projection.Abstractions` 体系及所有直接依赖方(Core/Runtime/Providers/Workflow/AI/Tests) +- 决策:将抽象层拆分为两个项目:`Aevatar.CQRS.Projection.Core.Abstractions` 与 `Aevatar.CQRS.Projection.Stores.Abstractions` + +## 2. 重构目标 +1. 把“投影管线抽象”与“存储/能力选择抽象”强制分离。 +2. 消除单体抽象项目中职责混合导致的理解和演进成本。 +3. 在无兼容负担下,形成清晰依赖方向:业务可按能力按需依赖。 +4. 保持统一语义命名空间 `Aevatar.CQRS.Projection.Abstractions`,降低调用层改动噪声。 + +## 3. 目标架构 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + A["Aevatar.CQRS.Projection.Core.Abstractions"] --> A1["Core Context & Runtime"] + A --> A2["Pipeline Contracts"] + A --> A3["Ports Contracts"] + A --> A4["Streaming Contracts"] + + B["Aevatar.CQRS.Projection.Stores.Abstractions"] --> B1["Store Registration & Metadata"] + B --> B2["ReadModel Store Contracts"] + B --> B3["Relation Store Contracts"] + B --> B4["Selection Planner & Startup Validator"] + + C["Projection.Core"] --> A + D["Projection.Runtime"] --> B + E["Providers(InMemory/ES/Neo4j)"] --> B + F["Workflow.Projection"] --> A + F --> B + G["AI.Projection"] --> A +``` + +## 4. 项目拆分与职责 + +### 4.1 `Aevatar.CQRS.Projection.Core.Abstractions` +路径:`src/Aevatar.CQRS.Projection.Core.Abstractions/` + +包含: +1. `Abstractions/Core`:`IProjectionContext`、`IProjectionRuntimeOptions`、`IProjectionClock`、`IProjectionStreamSubscriptionContext` +2. `Abstractions/Pipeline`:`IProjectionCoordinator`、`IProjectionDispatcher`、`IProjectionProjector`、`IProjectionLifecycleService`、`IProjectionEventReducer`、`IProjectionEventApplier` 等 +3. `Abstractions/Ports`:activation/release/sink 端口协作契约 +4. `Abstractions/Streaming`:session event hub / actor stream subscription 契约 + +边界: +1. 不包含 provider 能力声明与选择逻辑。 +2. 不包含 readmodel/relation store 抽象。 + +### 4.2 `Aevatar.CQRS.Projection.Stores.Abstractions` +路径:`src/Aevatar.CQRS.Projection.Stores.Abstractions/` + +包含: +1. `Abstractions/Core`:`IProjectionStoreRegistration`、`DelegateProjectionStoreRegistration`、`IProjectionStoreProviderMetadata` +2. `Abstractions/ReadModels`:store/provider 能力、requirements、runtime options、binding、selector +3. `Abstractions/Relations`:relation store/provider 与图查询模型 +4. `Abstractions/Selection`:selection planner、selection runtime options、startup validator、provider selection exception + +边界: +1. 不包含投影生命周期/调度/订阅主链路抽象。 +2. 不包含业务模型与 provider 具体实现。 + +## 5. 依赖矩阵(重构后) +1. `Projection.Core` -> `Aevatar.CQRS.Projection.Core.Abstractions` +2. `Projection.Runtime` -> `Aevatar.CQRS.Projection.Stores.Abstractions` +3. `Projection.Providers.*` -> `Aevatar.CQRS.Projection.Stores.Abstractions` +4. `Workflow.Projection` -> `Aevatar.CQRS.Projection.Core.Abstractions` + `Aevatar.CQRS.Projection.Stores.Abstractions` +5. `Workflow.Presentation.AGUIAdapter` -> `Aevatar.CQRS.Projection.Core.Abstractions` +6. `AI.Projection` -> `Aevatar.CQRS.Projection.Core.Abstractions` +7. `Projection.StateMirror` -> 不再依赖 Projection Abstractions + +## 6. Breaking 变更清单 +1. 删除旧项目:`src/Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj` +2. 新增项目: +- `src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj` +- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj` +3. 解决方案与子解决方案改为引用新项目(`aevatar.slnx`、`aevatar.cqrs.slnf`) +4. 所有依赖方 `ProjectReference` 全量切换到新拆分项目 + +## 7. 选择链路治理(重构后) +1. 业务仅声明需求:`ProjectionReadModelRequirements` +2. Runtime 统一规划:`IProjectionStoreSelectionPlanner` +3. Provider 统一选择:`ProjectionStoreSelector` +4. 启动期统一 fail-fast:`IProjectionStoreStartupValidator` + +约束: +1. Workflow 业务层不再承载 provider 选择规则,只消费组合层注入结果。 +2. Provider capabilities 声明必须与真实实现一致,不允许“声明支持但未实现”。 + +## 8. 验证标准 +1. `dotnet build aevatar.slnx --nologo` 通过 +2. `dotnet test aevatar.slnx --nologo` 或关键分片测试通过 +3. `bash tools/ci/architecture_guards.sh` 通过 +4. `bash tools/ci/test_stability_guards.sh` 通过 + +## 9. 后续演进建议 +1. 增加架构门禁:禁止新增 `src/Aevatar.CQRS.Projection.Abstractions/*` 单体项目回流。 +2. 对 `Aevatar.CQRS.Projection.Core.Abstractions` 与 `Aevatar.CQRS.Projection.Stores.Abstractions` 做分片构建门禁,避免跨边界漂移。 +3. 新增抽象优先落在两个项目之一,禁止在 Workflow 层复制通用抽象。 diff --git a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md index 3cd7701a9..ace5667be 100644 --- a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md +++ b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md @@ -18,11 +18,11 @@ |---|---|---|---| | B1 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:353` | Elasticsearch 声明 `supportsAliases=true`、`supportsSchemaValidation=true`,但无对应实现。 | | B2 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:76` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:174` | `MutateAsync` 读改写 + 普通 PUT,缺少 OCC,重放/重试/并发存在覆盖风险。 | -| B3 | Blocking | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:49` | 同一 ReadModel 同时注册多个 Provider,未显式指定时选择歧义。 | +| B3 | Blocking | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs:49` | 同一 ReadModel 同时注册多个 Provider,未显式指定时选择歧义。 | | M1 | Major | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:105` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:127` | `AutoCreateIndex=false` 且索引缺失时被当成无数据,掩盖配置错误。 | | M2 | Major | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs:43` | 绑定解析 `Type.Name` 优先于 `FullName`,同名类型误绑定风险。 | | N1 | Minor | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:241` | `ListSortField` 为空时不排序,返回顺序不稳定。 | -| N2 | Minor | `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs:24` | `RequiredIndexKinds` 使用 `Overlaps`(任一匹配),语义偏宽松。 | +| N2 | Minor | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs:24` | `RequiredIndexKinds` 使用 `Overlaps`(任一匹配),语义偏宽松。 | ## 3. 目标架构决策(To-Be) @@ -91,8 +91,8 @@ 目标文件: -- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs` -- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs`(如需引入匹配模式) +- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs` +- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs`(如需引入匹配模式) 改造项: @@ -139,7 +139,7 @@ - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` - `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` - `src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs` -- `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs` +- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs` - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs` - `src/workflow/Aevatar.Workflow.Projection/README.md` diff --git a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md index 67fae2883..aa690c29d 100644 --- a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md +++ b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md @@ -71,17 +71,17 @@ flowchart LR ### 6.1 新增关系契约 新增文件: -1. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs` -2. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs` -3. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs` -4. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs` -5. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs` -6. `src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs` +1. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs` +2. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs` +3. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs` +4. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs` +5. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs` +6. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs` ### 6.2 新增关系 Provider 选择抽象 新增文件: -1. `IProjectionRelationStoreRegistration` -2. `DelegateProjectionRelationStoreRegistration` +1. `IProjectionStoreRegistration` +2. `DelegateProjectionStoreRegistration` 3. `IProjectionRelationStoreProviderRegistry` 4. `IProjectionRelationStoreProviderSelector` 5. `IProjectionRelationStoreFactory` diff --git a/src/Aevatar.AI.Projection/Aevatar.AI.Projection.csproj b/src/Aevatar.AI.Projection/Aevatar.AI.Projection.csproj index 46317fc55..4a81957cf 100644 --- a/src/Aevatar.AI.Projection/Aevatar.AI.Projection.csproj +++ b/src/Aevatar.AI.Projection/Aevatar.AI.Projection.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs deleted file mode 100644 index f3d760bad..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionReadModelStoreRegistration.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Aevatar.CQRS.Projection.Abstractions; - -public sealed class DelegateProjectionReadModelStoreRegistration - : IProjectionReadModelStoreRegistration - where TReadModel : class -{ - private readonly Func> _factory; - - public DelegateProjectionReadModelStoreRegistration( - string providerName, - ProjectionReadModelProviderCapabilities capabilities, - Func> factory) - { - if (string.IsNullOrWhiteSpace(providerName)) - throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); - ArgumentNullException.ThrowIfNull(capabilities); - ArgumentNullException.ThrowIfNull(factory); - - ProviderName = providerName.Trim(); - Capabilities = capabilities; - _factory = factory; - } - - public string ProviderName { get; } - - public ProjectionReadModelProviderCapabilities Capabilities { get; } - - public IProjectionReadModelStore Create(IServiceProvider serviceProvider) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - return _factory(serviceProvider); - } -} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs deleted file mode 100644 index 45561c08b..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreProviderMetadata.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Abstractions; - -public interface IProjectionReadModelStoreProviderMetadata -{ - ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs deleted file mode 100644 index 8902194c9..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreRegistration.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Aevatar.CQRS.Projection.Abstractions; - -public interface IProjectionReadModelStoreRegistration - where TReadModel : class -{ - string ProviderName { get; } - - ProjectionReadModelProviderCapabilities Capabilities { get; } - - IProjectionReadModelStore Create(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs deleted file mode 100644 index 146fb30d3..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderRegistry.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Abstractions; - -public interface IProjectionRelationStoreProviderRegistry -{ - IReadOnlyList GetRegistrations(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs deleted file mode 100644 index f350069af..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreRegistration.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aevatar.CQRS.Projection.Abstractions; - -public interface IProjectionRelationStoreRegistration -{ - string ProviderName { get; } - - ProjectionReadModelProviderCapabilities Capabilities { get; } - - IProjectionRelationStore Create(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Abstractions/README.md deleted file mode 100644 index 8c3abc60c..000000000 --- a/src/Aevatar.CQRS.Projection.Abstractions/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Aevatar.CQRS.Projection.Abstractions - -`Aevatar.CQRS.Projection.Abstractions` 只包含 CQRS 投影通用抽象。 - -## 包含内容 - -- 生命周期与编排:`IProjectionLifecycleService<,>`、`IProjectionCoordinator<,>`、`IProjectionDispatcher<>`、`IProjectionSubscriptionRegistry<>` -- 端口抽象:`IProjectionPortActivationService<>`、`IProjectionPortReleaseService<>`、`IProjectionPortSinkSubscriptionManager<,,>`、`IProjectionPortLiveSinkForwarder<,,>` -- 扩展抽象:`IProjectionProjector<,>`、`IProjectionEventReducer<,>` -- 失败回传:`IProjectionDispatchFailureReporter<>` -- 读模型存储:`IProjectionReadModelStore<,>` -- Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionReadModelStoreProviderMetadata` -- 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` -- Provider 注册与选择契约:`IProjectionReadModelStoreRegistration<,>`、`DelegateProjectionReadModelStoreRegistration<,>` -- Provider Runtime 契约:`IProjectionReadModelProviderRegistry`、`IProjectionReadModelProviderSelector`、`IProjectionReadModelBindingResolver`、`IProjectionReadModelStoreFactory` -- Provider 运行配置:`ProjectionReadModelRuntimeOptions`、`ProjectionReadModelMode` -- 运行时策略:`IProjectionRuntimeOptions`、`IProjectionClock` -- 流订阅复用:`IActorStreamSubscriptionHub` -- 投影上下文:`IProjectionContext` - -## 约束 - -1. 不放业务 read model、业务 context、业务 service。 -2. 不放 DI 装配、endpoint 或具体 store 实现。 -3. 任何子系统都应依赖这些泛型抽象,而不是再造别名接口层。 -4. `IProjectionEventReducer<,>` / `IProjectionEventApplier<,,>` 通过 `bool` 返回值表达是否发生读模型变更。 diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionClock.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionClock.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionContext.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionContext.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRuntimeOptions.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStreamSubscriptionContext.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStreamSubscriptionContext.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionCoordinator.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionCoordinator.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionDispatchFailureReporter.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionDispatchFailureReporter.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionDispatcher.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionDispatcher.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionEventApplier.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionEventApplier.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionEventReducer.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionEventReducer.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionLifecycleService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionLifecycleService.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionOwnershipCoordinator.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionOwnershipCoordinator.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionProjector.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionProjector.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSubscriptionRegistry.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSubscriptionRegistry.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortActivationService.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortLiveSinkForwarder.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortReleaseService.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionPortSinkSubscriptionManager.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IActorStreamSubscriptionHub.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IActorStreamSubscriptionHub.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IActorStreamSubscriptionLease.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IActorStreamSubscriptionLease.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSessionEventCodec.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSessionEventCodec.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSessionEventHub.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionSessionEventHub.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj b/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj similarity index 84% rename from src/Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj rename to src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj index d7ea37ea1..395670a6a 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj @@ -3,7 +3,7 @@ net10.0 enable enable - Aevatar.CQRS.Projection.Abstractions + Aevatar.CQRS.Projection.Core.Abstractions Aevatar.CQRS.Projection.Abstractions diff --git a/src/Aevatar.CQRS.Projection.Abstractions/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/GlobalUsings.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/GlobalUsings.cs rename to src/Aevatar.CQRS.Projection.Core.Abstractions/GlobalUsings.cs diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md new file mode 100644 index 000000000..212c8b873 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md @@ -0,0 +1,16 @@ +# Aevatar.CQRS.Projection.Core.Abstractions + +`Aevatar.CQRS.Projection.Core.Abstractions` 只包含投影运行时主链路的通用抽象,不包含任何 ReadModel/Relation provider 选择与存储能力。 + +## 目录结构 + +- `Abstractions/Core`:`IProjectionContext`、`IProjectionRuntimeOptions`、`IProjectionClock`、`IProjectionStreamSubscriptionContext` +- `Abstractions/Pipeline`:投影编排主链路契约(`Coordinator/Dispatcher/Reducer/Projector/Lifecycle`) +- `Abstractions/Ports`:投影端口协作接口(activation/release/sink 管理) +- `Abstractions/Streaming`:会话事件与 actor stream 订阅抽象 + +## 约束 + +1. 不包含 provider 选择策略、capability 校验与 store factory。 +2. 不包含业务 read model、业务 context、DI 装配或具体存储实现。 +3. 仅定义稳定运行时协议,面向跨业务复用。 diff --git a/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj b/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj index 7b3c69f28..6bf640ca4 100644 --- a/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj +++ b/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj @@ -7,7 +7,7 @@ Aevatar.CQRS.Projection.Core - + diff --git a/src/Aevatar.CQRS.Projection.Core/README.md b/src/Aevatar.CQRS.Projection.Core/README.md index fa0b94a4f..f4d658e58 100644 --- a/src/Aevatar.CQRS.Projection.Core/README.md +++ b/src/Aevatar.CQRS.Projection.Core/README.md @@ -4,7 +4,7 @@ ## 项目边界 -- `Aevatar.CQRS.Projection.Abstractions` +- `Aevatar.CQRS.Projection.Core.Abstractions` - 仅定义通用契约:`IProjection*`、`IActorStreamSubscriptionHub`。 - `Aevatar.CQRS.Projection.Core` - 通用运行时实现: diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj index 772146682..3d7dc4c00 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj @@ -7,7 +7,7 @@ Aevatar.CQRS.Projection.Providers.Elasticsearch - + diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 5fe6cc4bf..9f08bb157 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -20,8 +20,8 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration>( - new DelegateProjectionReadModelStoreRegistration( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, new ProjectionReadModelProviderCapabilities( providerName, @@ -44,8 +44,8 @@ public static IServiceCollection AddElasticsearchRelationStoreRegistration( this IServiceCollection services, string providerName = ProjectionReadModelProviderNames.Elasticsearch) { - services.AddSingleton( - new DelegateProjectionRelationStoreRegistration( + services.AddSingleton>( + new DelegateProjectionStoreRegistration( providerName, new ProjectionReadModelProviderCapabilities( providerName, diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index aac8f9bc4..7da4d67ad 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -3,7 +3,7 @@ 通用 Elasticsearch Document ReadModel Provider。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 +- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 - 能力声明:`Document` 索引(不声明 alias/schema validation 能力)。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 - `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index 94216dd28..0e9424d68 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -10,7 +10,7 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; public sealed class ElasticsearchProjectionReadModelStore : IProjectionReadModelStore, - IProjectionReadModelStoreProviderMetadata, + IProjectionStoreProviderMetadata, IDisposable where TReadModel : class { diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs index 6691d47f5..435e5e366 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs @@ -2,7 +2,7 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; public sealed class ElasticsearchProjectionRelationStore : IProjectionRelationStore, - IProjectionRelationStoreProviderMetadata + IProjectionStoreProviderMetadata { public ElasticsearchProjectionRelationStore( string providerName = ProjectionReadModelProviderNames.Elasticsearch) diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj index c97263c86..bf038d668 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj @@ -7,10 +7,11 @@ Aevatar.CQRS.Projection.Providers.InMemory - + + diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 452cec1c7..f1bdabbf7 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -17,8 +17,8 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration>( - new DelegateProjectionReadModelStoreRegistration( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, new ProjectionReadModelProviderCapabilities( providerName, @@ -40,8 +40,8 @@ public static IServiceCollection AddInMemoryRelationStoreRegistration( this IServiceCollection services, string providerName = ProjectionReadModelProviderNames.InMemory) { - services.AddSingleton( - new DelegateProjectionRelationStoreRegistration( + services.AddSingleton>( + new DelegateProjectionStoreRegistration( providerName, new ProjectionReadModelProviderCapabilities( providerName, diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index cb7d26722..809b927de 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -6,7 +6,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; public sealed class InMemoryProjectionReadModelStore : IProjectionReadModelStore, - IProjectionReadModelStoreProviderMetadata + IProjectionStoreProviderMetadata where TReadModel : class { private readonly object _gate = new(); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs index a011b30e2..b19df3b36 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs @@ -4,7 +4,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; public sealed class InMemoryProjectionRelationStore : IProjectionRelationStore, - IProjectionRelationStoreProviderMetadata + IProjectionStoreProviderMetadata { private readonly object _gate = new(); private readonly Dictionary _nodes = new(StringComparer.Ordinal); diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj index 9dbda873a..b8fbdceef 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj @@ -7,7 +7,7 @@ Aevatar.CQRS.Projection.Providers.Neo4j - + diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 5b026c871..193d1b32e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -20,8 +20,8 @@ public static IServiceCollection AddNeo4jReadModelStoreRegistration>( - new DelegateProjectionReadModelStoreRegistration( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, new ProjectionReadModelProviderCapabilities( providerName, @@ -51,8 +51,8 @@ public static IServiceCollection AddNeo4jRelationStoreRegistration( ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentException.ThrowIfNullOrWhiteSpace(scope); - services.AddSingleton( - new DelegateProjectionRelationStoreRegistration( + services.AddSingleton>( + new DelegateProjectionStoreRegistration( providerName, new ProjectionReadModelProviderCapabilities( providerName, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index b5d96d5cd..19b0532aa 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -3,7 +3,7 @@ 通用 Neo4j Graph ReadModel Provider。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionReadModelStoreRegistration` 与上层模块解耦集成。 +- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 - 能力声明:`Graph` 索引、schema validation。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index d52d5264a..197501287 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -8,7 +8,7 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; public sealed class Neo4jProjectionReadModelStore : IProjectionReadModelStore, - IProjectionReadModelStoreProviderMetadata, + IProjectionStoreProviderMetadata, IAsyncDisposable where TReadModel : class { diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs index eea9e5a65..22712939e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs @@ -8,7 +8,7 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; public sealed class Neo4jProjectionRelationStore : IProjectionRelationStore, - IProjectionRelationStoreProviderMetadata, + IProjectionStoreProviderMetadata, IAsyncDisposable { private readonly IDriver _driver; diff --git a/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj index a60881234..c75aea117 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj +++ b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj @@ -7,7 +7,7 @@ Aevatar.CQRS.Projection.Runtime - + diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs index 909106d55..7784b8631 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs @@ -4,13 +4,13 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionReadModelProviderRegistry : IProjectionReadModelProviderRegistry { - public IReadOnlyList> GetRegistrations( + public IReadOnlyList>> GetRegistrations( IServiceProvider serviceProvider) where TReadModel : class { ArgumentNullException.ThrowIfNull(serviceProvider); return serviceProvider - .GetServices>() + .GetServices>>() .ToList(); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs index f8b96cd29..78db1b275 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs @@ -17,8 +17,8 @@ public ProjectionReadModelProviderSelector( _logger = logger ?? NullLogger.Instance; } - public IProjectionReadModelStoreRegistration Select( - IReadOnlyList> registrations, + public IProjectionStoreRegistration> Select( + IReadOnlyList>> registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) where TReadModel : class diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs index c22b7eab2..06b0e7497 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs @@ -4,11 +4,11 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionRelationStoreProviderRegistry : IProjectionRelationStoreProviderRegistry { - public IReadOnlyList GetRegistrations(IServiceProvider serviceProvider) + public IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); return serviceProvider - .GetServices() + .GetServices>() .ToList(); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs index ba8a5542c..e3012113e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs @@ -17,8 +17,8 @@ public ProjectionRelationStoreProviderSelector( _logger = logger ?? NullLogger.Instance; } - public IProjectionRelationStoreRegistration Select( - IReadOnlyList registrations, + public IProjectionStoreRegistration Select( + IReadOnlyList> registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) { @@ -26,50 +26,15 @@ public IProjectionRelationStoreRegistration Select( ArgumentNullException.ThrowIfNull(selectionOptions); ArgumentNullException.ThrowIfNull(requirements); - var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; - if (registrations.Count == 0) - { - throw new ProjectionProviderSelectionException( - typeof(ProjectionRelationNode), - requestedProviderName, - [], - "No relation store provider registrations were found."); - } - - IProjectionRelationStoreRegistration selected; - if (requestedProviderName.Length == 0) - { - if (registrations.Count != 1) - { - throw new ProjectionProviderSelectionException( - typeof(ProjectionRelationNode), - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - "Multiple relation store providers are registered but no explicit provider was requested."); - } - - selected = registrations[0]; - } - else - { - selected = registrations.FirstOrDefault(x => - string.Equals(x.ProviderName, requestedProviderName, StringComparison.OrdinalIgnoreCase)) - ?? throw new ProjectionProviderSelectionException( - typeof(ProjectionRelationNode), - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - "Requested relation store provider is not registered."); - } - - var violations = _capabilityValidator.Validate(requirements, selected.Capabilities); - if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) - { - throw new ProjectionReadModelCapabilityValidationException( - typeof(ProjectionRelationNode), - requirements, - selected.Capabilities, - violations); - } + var selected = ProjectionStoreSelector.Select>( + registrations, + selectionOptions, + requirements, + typeof(ProjectionRelationNode), + noRegistrationsReason: "No relation store provider registrations were found.", + multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", + providerNotRegisteredReason: "Requested relation store provider is not registered.", + _capabilityValidator); _logger.LogInformation( "Projection relation provider selected. provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs index cc57725c5..eba8da2ca 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs @@ -19,7 +19,7 @@ public ProjectionStoreStartupValidator( _relationProviderSelector = relationProviderSelector; } - public IProjectionReadModelStoreRegistration ValidateReadModelProvider( + public IProjectionStoreRegistration> ValidateReadModelProvider( IServiceProvider serviceProvider, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) @@ -33,7 +33,7 @@ public IProjectionReadModelStoreRegistration ValidateReadModel return _readModelProviderSelector.Select(registrations, selectionOptions, requirements); } - public IProjectionRelationStoreRegistration ValidateRelationProvider( + public IProjectionStoreRegistration ValidateRelationProvider( IServiceProvider serviceProvider, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj index aa8535575..535fe63aa 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj +++ b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj @@ -6,9 +6,6 @@ Aevatar.CQRS.Projection.StateMirror Aevatar.CQRS.Projection.StateMirror - - - diff --git a/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs deleted file mode 100644 index b07fa3dbd..000000000 --- a/src/Aevatar.CQRS.Projection.StateMirror/GlobalUsings.cs +++ /dev/null @@ -1 +0,0 @@ -global using Aevatar.CQRS.Projection.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs similarity index 66% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 51c877116..1ac20a2f7 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/DelegateProjectionRelationStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -1,14 +1,13 @@ namespace Aevatar.CQRS.Projection.Abstractions; -public sealed class DelegateProjectionRelationStoreRegistration - : IProjectionRelationStoreRegistration +public sealed class DelegateProjectionStoreRegistration : IProjectionStoreRegistration { - private readonly Func _factory; + private readonly Func _factory; - public DelegateProjectionRelationStoreRegistration( + public DelegateProjectionStoreRegistration( string providerName, ProjectionReadModelProviderCapabilities capabilities, - Func factory) + Func factory) { if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); @@ -24,7 +23,7 @@ public DelegateProjectionRelationStoreRegistration( public ProjectionReadModelProviderCapabilities Capabilities { get; } - public IProjectionRelationStore Create(IServiceProvider serviceProvider) + public TStore Create(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); return _factory(serviceProvider); diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs similarity index 68% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs index c8fc3fcce..e78e4a136 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderMetadata.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Abstractions; -public interface IProjectionRelationStoreProviderMetadata +public interface IProjectionStoreProviderMetadata { ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs new file mode 100644 index 000000000..25dd5989c --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionStoreRegistration +{ + string ProviderName { get; } + + ProjectionReadModelProviderCapabilities Capabilities { get; } +} + +public interface IProjectionStoreRegistration : IProjectionStoreRegistration +{ + TStore Create(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelBindingResolver.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelCapabilityValidator.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs similarity index 58% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs index a33e22da7..b225d9190 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs @@ -2,7 +2,7 @@ namespace Aevatar.CQRS.Projection.Abstractions; public interface IProjectionReadModelProviderRegistry { - IReadOnlyList> GetRegistrations( + IReadOnlyList>> GetRegistrations( IServiceProvider serviceProvider) where TReadModel : class; } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs similarity index 54% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs index 6bda674a9..290401c87 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs @@ -2,8 +2,8 @@ namespace Aevatar.CQRS.Projection.Abstractions; public interface IProjectionReadModelProviderSelector { - IProjectionReadModelStoreRegistration Select( - IReadOnlyList> registrations, + IProjectionStoreRegistration> Select( + IReadOnlyList>> registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) where TReadModel : class; diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStoreFactory.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelBindingException.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidationException.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelMode.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderNames.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRuntimeOptions.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelectionOptions.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs new file mode 100644 index 000000000..f9d453420 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs @@ -0,0 +1,23 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public static class ProjectionReadModelStoreSelector +{ + public static IProjectionStoreRegistration> Select( + IEnumerable>> registrations, + ProjectionReadModelStoreSelectionOptions selectionOptions, + ProjectionReadModelRequirements requirements, + IProjectionReadModelCapabilityValidator? capabilityValidator = null) + where TReadModel : class + { + return ProjectionStoreSelector.Select< + IProjectionStoreRegistration>>( + registrations, + selectionOptions, + requirements, + typeof(TReadModel), + noRegistrationsReason: "No provider registrations were found.", + multipleRegistrationsReason: "Multiple providers are registered but no explicit provider was requested.", + providerNotRegisteredReason: "Requested provider is not registered.", + capabilityValidator); + } +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStore.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreFactory.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs new file mode 100644 index 000000000..57c07d906 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Abstractions; + +public interface IProjectionRelationStoreProviderRegistry +{ + IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs similarity index 59% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs index a4a755fb0..8770d5b2a 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionRelationStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs @@ -2,8 +2,8 @@ namespace Aevatar.CQRS.Projection.Abstractions; public interface IProjectionRelationStoreProviderSelector { - IProjectionRelationStoreRegistration Select( - IReadOnlyList registrations, + IProjectionStoreRegistration Select( + IReadOnlyList> registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements); } diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationDirection.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationEdge.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationNode.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationQuery.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionRelationSubgraph.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionPlanner.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreSelectionRuntimeOptions.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs similarity index 69% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs index d66074f97..8486ebcdf 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionStoreStartupValidator.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs @@ -2,13 +2,13 @@ namespace Aevatar.CQRS.Projection.Abstractions; public interface IProjectionStoreStartupValidator { - IProjectionReadModelStoreRegistration ValidateReadModelProvider( + IProjectionStoreRegistration> ValidateReadModelProvider( IServiceProvider serviceProvider, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements) where TReadModel : class; - IProjectionRelationStoreRegistration ValidateRelationProvider( + IProjectionStoreRegistration ValidateRelationProvider( IServiceProvider serviceProvider, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements); diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionProviderSelectionException.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs similarity index 100% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionStoreSelectionPlan.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs diff --git a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs similarity index 61% rename from src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs index fc7eaed75..e40d52f71 100644 --- a/src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs @@ -1,38 +1,47 @@ namespace Aevatar.CQRS.Projection.Abstractions; -public static class ProjectionReadModelStoreSelector +public static class ProjectionStoreSelector { - public static IProjectionReadModelStoreRegistration Select( - IEnumerable> registrations, + public static TRegistration Select( + IEnumerable registrations, ProjectionReadModelStoreSelectionOptions selectionOptions, ProjectionReadModelRequirements requirements, + Type logicalModelType, + string noRegistrationsReason, + string multipleRegistrationsReason, + string providerNotRegisteredReason, IProjectionReadModelCapabilityValidator? capabilityValidator = null) - where TReadModel : class + where TRegistration : IProjectionStoreRegistration { ArgumentNullException.ThrowIfNull(registrations); ArgumentNullException.ThrowIfNull(selectionOptions); ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(logicalModelType); var candidates = registrations.ToList(); var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; if (candidates.Count == 0) { throw new ProjectionProviderSelectionException( - typeof(TReadModel), + logicalModelType, requestedProviderName, [], - "No provider registrations were found."); + noRegistrationsReason); } - var selected = ResolveRegistration(candidates, requestedProviderName); - + var selected = ResolveRegistration( + candidates, + requestedProviderName, + logicalModelType, + multipleRegistrationsReason, + providerNotRegisteredReason); var violations = capabilityValidator == null ? ProjectionReadModelCapabilityValidator.Validate(requirements, selected.Capabilities) : capabilityValidator.Validate(requirements, selected.Capabilities); if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) { throw new ProjectionReadModelCapabilityValidationException( - typeof(TReadModel), + logicalModelType, requirements, selected.Capabilities, violations); @@ -41,10 +50,13 @@ public static IProjectionReadModelStoreRegistration Select ResolveRegistration( - IReadOnlyList> registrations, - string requestedProviderName) - where TReadModel : class + private static TRegistration ResolveRegistration( + IReadOnlyList registrations, + string requestedProviderName, + Type logicalModelType, + string multipleRegistrationsReason, + string providerNotRegisteredReason) + where TRegistration : IProjectionStoreRegistration { if (requestedProviderName.Length == 0) { @@ -52,10 +64,10 @@ private static IProjectionReadModelStoreRegistration ResolveRe return registrations[0]; throw new ProjectionProviderSelectionException( - typeof(TReadModel), + logicalModelType, requestedProviderName, registrations.Select(x => x.ProviderName).ToList(), - "Multiple providers are registered but no explicit provider was requested."); + multipleRegistrationsReason); } var matched = registrations @@ -63,14 +75,13 @@ private static IProjectionReadModelStoreRegistration ResolveRe x.ProviderName, requestedProviderName, StringComparison.OrdinalIgnoreCase)); - if (matched != null) return matched; throw new ProjectionProviderSelectionException( - typeof(TReadModel), + logicalModelType, requestedProviderName, registrations.Select(x => x.ProviderName).ToList(), - "Requested provider is not registered."); + providerNotRegisteredReason); } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj new file mode 100644 index 000000000..93e4f3e64 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Stores.Abstractions + Aevatar.CQRS.Projection.Abstractions + + diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md new file mode 100644 index 000000000..094b93b09 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -0,0 +1,26 @@ +# Aevatar.CQRS.Projection.Stores.Abstractions + +`Aevatar.CQRS.Projection.Stores.Abstractions` 只包含投影存储、能力声明、provider 选择与启动校验相关抽象。 + +## 目录结构 + +- `Abstractions/Core`:provider 元数据与通用注册契约(`IProjectionStoreRegistration`) +- `Abstractions/ReadModels`:ReadModel store/provider 能力、选择与校验抽象 +- `Abstractions/Relations`:Relation store/provider 与图关系模型抽象 +- `Abstractions/Selection`:跨 readmodel/relation 的统一选择计划与启动校验契约 + +## 包含内容 + +- 读模型存储:`IProjectionReadModelStore<,>` +- 关系存储:`IProjectionRelationStore` +- Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionStoreProviderMetadata` +- 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` +- Provider 注册与选择:`IProjectionStoreRegistration`、`DelegateProjectionStoreRegistration` +- Provider Runtime 契约:`IProjectionReadModelProviderRegistry`、`IProjectionReadModelProviderSelector`、`IProjectionReadModelStoreFactory` +- 选择编排:`IProjectionStoreSelectionPlanner`、`IProjectionStoreStartupValidator` + +## 约束 + +1. 不包含投影主链路编排接口(这些在 `Aevatar.CQRS.Projection.Core.Abstractions`)。 +2. 不包含业务模型、DI 装配与具体 provider 实现。 +3. capability 声明必须与 provider 真实实现一致,不允许“声明支持但未实现”。 diff --git a/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/Aevatar.Workflow.Presentation.AGUIAdapter.csproj b/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/Aevatar.Workflow.Presentation.AGUIAdapter.csproj index eb2761291..327c87270 100644 --- a/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/Aevatar.Workflow.Presentation.AGUIAdapter.csproj +++ b/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/Aevatar.Workflow.Presentation.AGUIAdapter.csproj @@ -8,7 +8,7 @@ - + diff --git a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj index 2042a1f12..52f16ff40 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj +++ b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj @@ -8,7 +8,8 @@ - + + diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index a12282abf..1d9056a19 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -28,7 +28,8 @@ 本项目依赖: -- `Aevatar.CQRS.Projection.Abstractions`(通用抽象) +- `Aevatar.CQRS.Projection.Core.Abstractions`(投影管线/端口抽象) +- `Aevatar.CQRS.Projection.Stores.Abstractions`(ReadModel/Relation/选择抽象) - `Aevatar.CQRS.Projection.Core`(通用生命周期/订阅/协调实现) - `Aevatar.Foundation.Projection`(最小 read model 基类与读侧能力接口) - `Aevatar.Workflow.Extensions.AIProjection`(可选扩展:组合 `Aevatar.AI.Projection` 的通用 reducer/applier) @@ -81,7 +82,7 @@ FAQ: - 实现 `IProjectionProjector>` - 在 DI 中注册 - 扩展 ReadModel Provider(推荐): - - 实现 `IProjectionReadModelStoreRegistration` + - 实现 `IProjectionStoreRegistration>` - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - 通过 `Projection:ReadModel:*` 配置选择 Provider(Workflow 层不再暴露 provider 选择字段) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj index 3e769d0e1..541238305 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj +++ b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj @@ -10,7 +10,8 @@ - + + diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index 05ec9dc41..b9cadd4db 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -42,7 +42,7 @@ public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() { var selector = new ProjectionReadModelProviderSelector( new ProjectionReadModelCapabilityValidatorService()); - var registrations = new List> + var registrations = new List>> { CreateRegistration( "InMemory", @@ -69,7 +69,7 @@ public void ProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowSt { var selector = new ProjectionReadModelProviderSelector( new ProjectionReadModelCapabilityValidatorService()); - var registrations = new List> + var registrations = new List>> { CreateRegistration("InMemory", supportsIndexing: false, indexKinds: []), CreateRegistration("Elasticsearch", supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document]), @@ -84,7 +84,7 @@ public void ProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowSt .Where(ex => ex.ReadModelType == typeof(TestReadModel)); } - private static IProjectionReadModelStoreRegistration CreateRegistration( + private static IProjectionStoreRegistration> CreateRegistration( string providerName, bool supportsIndexing, IReadOnlyList indexKinds) @@ -94,7 +94,7 @@ private static IProjectionReadModelStoreRegistration Crea supportsIndexing, indexKinds); - return new DelegateProjectionReadModelStoreRegistration( + return new DelegateProjectionStoreRegistration>( providerName, capabilities, _ => new NoopStore()); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index f3f1934d0..6612e3d48 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -128,7 +128,7 @@ public void Select_WhenCapabilitiesUnsupportedAndFailFastDisabled_ShouldReturnPr selected.ProviderName.Should().Be("inmemory"); } - private static IProjectionReadModelStoreRegistration CreateRegistration( + private static IProjectionStoreRegistration> CreateRegistration( string providerName, bool supportsIndexing, IEnumerable? indexKinds = null) @@ -138,7 +138,7 @@ private static IProjectionReadModelStoreRegistration Crea supportsIndexing, indexKinds); - return new DelegateProjectionReadModelStoreRegistration( + return new DelegateProjectionStoreRegistration>( providerName, capabilities, _ => new NoopStore()); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj b/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj index 2f044fa3b..990d69098 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj +++ b/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj @@ -9,7 +9,8 @@ Aevatar.Workflow.Host.Api.Tests - + + diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 11303b37a..94906a4bf 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -84,7 +84,7 @@ public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefaul store.Should().BeOfType>(); relationStore.Should().BeOfType(); - var metadata = store.Should().BeAssignableTo().Subject; + var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.ProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); } @@ -101,7 +101,7 @@ public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfiguredWithou using var provider = services.BuildServiceProvider(); var store = provider.GetRequiredService>(); store.Should().BeOfType>(); - var metadata = store.Should().BeAssignableTo().Subject; + var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); @@ -147,7 +147,7 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNeo4jConfigured_ShouldR store.Should().BeOfType>(); relationStore.Should().BeOfType(); - var metadata = store.Should().BeAssignableTo().Subject; + var metadata = store.Should().BeAssignableTo().Subject; metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Graph); } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 6df50746e..dadfb3ea8 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -66,12 +66,12 @@ public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProviders builder.AddWorkflowCapabilityWithAIDefaults(); var providerRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); providerRegistrations.Should().HaveCount(1); var relationRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); relationRegistrations.Should().HaveCount(1); } @@ -86,12 +86,12 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo services.AddWorkflowProjectionReadModelProviders(configuration); var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); providerRegistrations.Should().HaveCount(1); var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); relationRegistrations.Should().HaveCount(1); } @@ -112,10 +112,10 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S services.AddWorkflowProjectionReadModelProviders(configuration); var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionReadModelStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionRelationStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); var selectionOptionsRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionStoreSelectionRuntimeOptions)) From 4b55344565bbbb0a5be6f37caf6730e443949d6c Mon Sep 17 00:00:00 2001 From: Auric Date: Tue, 24 Feb 2026 22:24:08 +0800 Subject: [PATCH 25/46] Refactor Projection Abstractions and Update Global Usings - Updated global using directives across multiple projects to reflect the new structure, replacing references to `Aevatar.CQRS.Projection.Abstractions` with `Aevatar.CQRS.Projection.Core.Abstractions` and `Aevatar.CQRS.Projection.Stores.Abstractions`. - Removed the legacy `Aevatar.CQRS.Projection.Abstractions` references from the codebase, ensuring all components align with the refactored architecture. - Enhanced overall code clarity and maintainability by streamlining the namespace usage in the projection-related components. --- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 3 +- .../Orchestration/CaseProjectionService.cs | 2 +- ...ions-full-refactor-blueprint-2026-02-24.md | 102 ----- ...review-remediation-blueprint-2026-02-24.md | 256 ------------ ...odel-graph-relations-refactor-blueprint.md | 339 ---------------- ...apability-refactor-blueprint-2026-02-24.md | 381 ------------------ src/Aevatar.AI.Projection/GlobalUsings.cs | 2 +- .../Abstractions/Core/IProjectionClock.cs | 2 +- .../Abstractions/Core/IProjectionContext.cs | 2 +- .../Core/IProjectionRuntimeOptions.cs | 2 +- .../IProjectionStreamSubscriptionContext.cs | 2 +- .../Pipeline/IProjectionCoordinator.cs | 2 +- .../IProjectionDispatchFailureReporter.cs | 2 +- .../Pipeline/IProjectionDispatcher.cs | 2 +- .../Pipeline/IProjectionEventApplier.cs | 2 +- .../Pipeline/IProjectionEventReducer.cs | 2 +- .../Pipeline/IProjectionLifecycleService.cs | 2 +- .../IProjectionOwnershipCoordinator.cs | 2 +- .../Pipeline/IProjectionProjector.cs | 2 +- .../IProjectionSubscriptionRegistry.cs | 2 +- .../Ports/IProjectionPortActivationService.cs | 2 +- .../Ports/IProjectionPortLiveSinkForwarder.cs | 2 +- .../Ports/IProjectionPortReleaseService.cs | 2 +- .../IProjectionPortSinkSubscriptionManager.cs | 2 +- .../Streaming/IActorStreamSubscriptionHub.cs | 2 +- .../IActorStreamSubscriptionLease.cs | 2 +- .../Streaming/IProjectionSessionEventCodec.cs | 2 +- .../Streaming/IProjectionSessionEventHub.cs | 2 +- ...r.CQRS.Projection.Core.Abstractions.csproj | 2 +- .../GlobalUsings.cs | 2 +- .../ActorProjectionOwnershipCoordinator.cs | 2 +- .../Streaming/ProjectionSessionEventHub.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../DelegateProjectionStoreRegistration.cs | 2 +- .../Core/IProjectionStoreProviderMetadata.cs | 2 +- .../Core/IProjectionStoreRegistration.cs | 2 +- .../IProjectionReadModelBindingResolver.cs | 2 +- ...IProjectionReadModelCapabilityValidator.cs | 2 +- .../IProjectionReadModelProviderRegistry.cs | 2 +- .../IProjectionReadModelProviderSelector.cs | 2 +- .../ReadModels/IProjectionReadModelStore.cs | 2 +- .../IProjectionReadModelStoreFactory.cs | 2 +- .../ProjectionReadModelBindingException.cs | 2 +- ...nReadModelCapabilityValidationException.cs | 2 +- .../ProjectionReadModelCapabilityValidator.cs | 2 +- .../ProjectionReadModelIndexKind.cs | 2 +- .../ReadModels/ProjectionReadModelMode.cs | 2 +- ...ProjectionReadModelProviderCapabilities.cs | 2 +- .../ProjectionReadModelProviderNames.cs | 2 +- .../ProjectionReadModelRequirements.cs | 2 +- .../ProjectionReadModelRuntimeOptions.cs | 2 +- ...rojectionReadModelStoreSelectionOptions.cs | 2 +- .../ProjectionReadModelStoreSelector.cs | 2 +- .../Relations/IProjectionRelationStore.cs | 2 +- .../IProjectionRelationStoreFactory.cs | 2 +- ...ProjectionRelationStoreProviderRegistry.cs | 2 +- ...ProjectionRelationStoreProviderSelector.cs | 2 +- .../Relations/ProjectionRelationDirection.cs | 2 +- .../Relations/ProjectionRelationEdge.cs | 2 +- .../Relations/ProjectionRelationNode.cs | 2 +- .../Relations/ProjectionRelationQuery.cs | 2 +- .../Relations/ProjectionRelationSubgraph.cs | 2 +- .../IProjectionStoreSelectionPlanner.cs | 2 +- ...IProjectionStoreSelectionRuntimeOptions.cs | 2 +- .../IProjectionStoreStartupValidator.cs | 2 +- .../ProjectionProviderSelectionException.cs | 2 +- .../Selection/ProjectionStoreSelectionPlan.cs | 2 +- .../Selection/ProjectionStoreSelector.cs | 2 +- ...CQRS.Projection.Stores.Abstractions.csproj | 2 +- .../WorkflowExecutionAGUIEventProjector.cs | 2 +- .../ServiceCollectionExtensions.cs | 2 +- .../GlobalUsings.cs | 3 +- .../IWorkflowProjectionActivationService.cs | 2 +- .../IWorkflowProjectionLiveSinkForwarder.cs | 2 +- .../IWorkflowProjectionReleaseService.cs | 2 +- ...rkflowProjectionSinkSubscriptionManager.cs | 2 +- .../WorkflowProjectionActivationService.cs | 2 +- ...rkflowProjectionDispatchFailureReporter.cs | 2 +- .../WorkflowProjectionReleaseService.cs | 2 +- ...ReadModelStartupValidationHostedService.cs | 2 +- .../WorkflowRunEventSessionCodec.cs | 2 +- ...tionProviderServiceCollectionExtensions.cs | 3 +- .../GlobalUsings.cs | 3 +- .../GlobalUsings.cs | 2 + .../ProjectionCoordinatorTests.cs | 2 +- ...WorkflowExecutionProjectionContextTests.cs | 2 +- ...lowExecutionProjectionRegistrationTests.cs | 2 +- ...WorkflowExecutionProjectionServiceTests.cs | 2 +- ...orkflowExecutionReadModelProjectorTests.cs | 2 +- ...WorkflowExecutionRelationProjectorTests.cs | 2 +- .../WorkflowHostingExtensionsCoverageTests.cs | 2 +- ...wProjectionDispatchFailureReporterTests.cs | 2 +- ...owProjectionOrchestrationComponentTests.cs | 2 +- 97 files changed, 98 insertions(+), 1170 deletions(-) delete mode 100644 docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md delete mode 100644 docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md delete mode 100644 docs/architecture/readmodel-graph-relations-refactor-blueprint.md delete mode 100644 docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md diff --git a/demos/Aevatar.Demos.CaseProjection.Abstractions/GlobalUsings.cs b/demos/Aevatar.Demos.CaseProjection.Abstractions/GlobalUsings.cs index dfed9c05f..5f93dae7e 100644 --- a/demos/Aevatar.Demos.CaseProjection.Abstractions/GlobalUsings.cs +++ b/demos/Aevatar.Demos.CaseProjection.Abstractions/GlobalUsings.cs @@ -1,3 +1,3 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.Demos.CaseProjection.Abstractions.ReadModels; global using Aevatar.Foundation.Abstractions; diff --git a/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs b/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs index 57853c267..103add2eb 100644 --- a/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs +++ b/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs @@ -1,4 +1,5 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; global using Aevatar.CQRS.Projection.Core.Orchestration; global using Aevatar.CQRS.Projection.Core.Streaming; global using Aevatar.Demos.CaseProjection.Abstractions; diff --git a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs index b1d3d1e88..7f7365ee0 100644 --- a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs +++ b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs @@ -1,5 +1,5 @@ using Aevatar.Demos.CaseProjection.Configuration; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using System.Collections.Concurrent; namespace Aevatar.Demos.CaseProjection.Orchestration; diff --git a/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md b/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md deleted file mode 100644 index bbfa5d6a9..000000000 --- a/docs/architecture/projection-abstractions-full-refactor-blueprint-2026-02-24.md +++ /dev/null @@ -1,102 +0,0 @@ -# Projection Abstractions 全量重构蓝图(2026-02-24) - -## 1. 文档信息 -- 状态:Implemented(Breaking) -- 兼容性策略:不保留兼容层,旧单体抽象项目直接删除 -- 范围:`Projection.Abstractions` 体系及所有直接依赖方(Core/Runtime/Providers/Workflow/AI/Tests) -- 决策:将抽象层拆分为两个项目:`Aevatar.CQRS.Projection.Core.Abstractions` 与 `Aevatar.CQRS.Projection.Stores.Abstractions` - -## 2. 重构目标 -1. 把“投影管线抽象”与“存储/能力选择抽象”强制分离。 -2. 消除单体抽象项目中职责混合导致的理解和演进成本。 -3. 在无兼容负担下,形成清晰依赖方向:业务可按能力按需依赖。 -4. 保持统一语义命名空间 `Aevatar.CQRS.Projection.Abstractions`,降低调用层改动噪声。 - -## 3. 目标架构 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A["Aevatar.CQRS.Projection.Core.Abstractions"] --> A1["Core Context & Runtime"] - A --> A2["Pipeline Contracts"] - A --> A3["Ports Contracts"] - A --> A4["Streaming Contracts"] - - B["Aevatar.CQRS.Projection.Stores.Abstractions"] --> B1["Store Registration & Metadata"] - B --> B2["ReadModel Store Contracts"] - B --> B3["Relation Store Contracts"] - B --> B4["Selection Planner & Startup Validator"] - - C["Projection.Core"] --> A - D["Projection.Runtime"] --> B - E["Providers(InMemory/ES/Neo4j)"] --> B - F["Workflow.Projection"] --> A - F --> B - G["AI.Projection"] --> A -``` - -## 4. 项目拆分与职责 - -### 4.1 `Aevatar.CQRS.Projection.Core.Abstractions` -路径:`src/Aevatar.CQRS.Projection.Core.Abstractions/` - -包含: -1. `Abstractions/Core`:`IProjectionContext`、`IProjectionRuntimeOptions`、`IProjectionClock`、`IProjectionStreamSubscriptionContext` -2. `Abstractions/Pipeline`:`IProjectionCoordinator`、`IProjectionDispatcher`、`IProjectionProjector`、`IProjectionLifecycleService`、`IProjectionEventReducer`、`IProjectionEventApplier` 等 -3. `Abstractions/Ports`:activation/release/sink 端口协作契约 -4. `Abstractions/Streaming`:session event hub / actor stream subscription 契约 - -边界: -1. 不包含 provider 能力声明与选择逻辑。 -2. 不包含 readmodel/relation store 抽象。 - -### 4.2 `Aevatar.CQRS.Projection.Stores.Abstractions` -路径:`src/Aevatar.CQRS.Projection.Stores.Abstractions/` - -包含: -1. `Abstractions/Core`:`IProjectionStoreRegistration`、`DelegateProjectionStoreRegistration`、`IProjectionStoreProviderMetadata` -2. `Abstractions/ReadModels`:store/provider 能力、requirements、runtime options、binding、selector -3. `Abstractions/Relations`:relation store/provider 与图查询模型 -4. `Abstractions/Selection`:selection planner、selection runtime options、startup validator、provider selection exception - -边界: -1. 不包含投影生命周期/调度/订阅主链路抽象。 -2. 不包含业务模型与 provider 具体实现。 - -## 5. 依赖矩阵(重构后) -1. `Projection.Core` -> `Aevatar.CQRS.Projection.Core.Abstractions` -2. `Projection.Runtime` -> `Aevatar.CQRS.Projection.Stores.Abstractions` -3. `Projection.Providers.*` -> `Aevatar.CQRS.Projection.Stores.Abstractions` -4. `Workflow.Projection` -> `Aevatar.CQRS.Projection.Core.Abstractions` + `Aevatar.CQRS.Projection.Stores.Abstractions` -5. `Workflow.Presentation.AGUIAdapter` -> `Aevatar.CQRS.Projection.Core.Abstractions` -6. `AI.Projection` -> `Aevatar.CQRS.Projection.Core.Abstractions` -7. `Projection.StateMirror` -> 不再依赖 Projection Abstractions - -## 6. Breaking 变更清单 -1. 删除旧项目:`src/Aevatar.CQRS.Projection.Abstractions/Aevatar.CQRS.Projection.Abstractions.csproj` -2. 新增项目: -- `src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj` -- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj` -3. 解决方案与子解决方案改为引用新项目(`aevatar.slnx`、`aevatar.cqrs.slnf`) -4. 所有依赖方 `ProjectReference` 全量切换到新拆分项目 - -## 7. 选择链路治理(重构后) -1. 业务仅声明需求:`ProjectionReadModelRequirements` -2. Runtime 统一规划:`IProjectionStoreSelectionPlanner` -3. Provider 统一选择:`ProjectionStoreSelector` -4. 启动期统一 fail-fast:`IProjectionStoreStartupValidator` - -约束: -1. Workflow 业务层不再承载 provider 选择规则,只消费组合层注入结果。 -2. Provider capabilities 声明必须与真实实现一致,不允许“声明支持但未实现”。 - -## 8. 验证标准 -1. `dotnet build aevatar.slnx --nologo` 通过 -2. `dotnet test aevatar.slnx --nologo` 或关键分片测试通过 -3. `bash tools/ci/architecture_guards.sh` 通过 -4. `bash tools/ci/test_stability_guards.sh` 通过 - -## 9. 后续演进建议 -1. 增加架构门禁:禁止新增 `src/Aevatar.CQRS.Projection.Abstractions/*` 单体项目回流。 -2. 对 `Aevatar.CQRS.Projection.Core.Abstractions` 与 `Aevatar.CQRS.Projection.Stores.Abstractions` 做分片构建门禁,避免跨边界漂移。 -3. 新增抽象优先落在两个项目之一,禁止在 Workflow 层复制通用抽象。 diff --git a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md b/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md deleted file mode 100644 index ace5667be..000000000 --- a/docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md +++ /dev/null @@ -1,256 +0,0 @@ -# Projection Provider 评审问题重构蓝图(2026-02-24) - -- 状态:Implemented -- 变更类型:Breaking Refactor(不考虑兼容性) -- 输入来源:本轮 code review 7 项问题(3 Blocking / 2 Major / 2 Minor) - -## 1. 执行目标 - -1. 让 Provider 能力声明与真实实现严格一致,避免误导选择器与启动校验。 -2. 为 Elasticsearch ReadModel 写入引入可验证的并发安全(OCC),消除 stale write 覆盖。 -3. 清理 Host 组合层多 Provider 默认并存导致的选择歧义。 -4. 对索引缺失、绑定冲突、排序稳定性、能力匹配语义做 fail-fast 与确定性收敛。 -5. 通过测试与 CI 门禁固化上述规则,避免回归。 - -## 2. 问题清单(Review 输入映射) - -| ID | Severity | 证据位置 | 问题摘要 | -|---|---|---|---| -| B1 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:353` | Elasticsearch 声明 `supportsAliases=true`、`supportsSchemaValidation=true`,但无对应实现。 | -| B2 | Blocking | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:76` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:174` | `MutateAsync` 读改写 + 普通 PUT,缺少 OCC,重放/重试/并发存在覆盖风险。 | -| B3 | Blocking | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:26` + `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs:49` | 同一 ReadModel 同时注册多个 Provider,未显式指定时选择歧义。 | -| M1 | Major | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:105` + `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:127` | `AutoCreateIndex=false` 且索引缺失时被当成无数据,掩盖配置错误。 | -| M2 | Major | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs:43` | 绑定解析 `Type.Name` 优先于 `FullName`,同名类型误绑定风险。 | -| N1 | Minor | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:241` | `ListSortField` 为空时不排序,返回顺序不稳定。 | -| N2 | Minor | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs:24` | `RequiredIndexKinds` 使用 `Overlaps`(任一匹配),语义偏宽松。 | - -## 3. 目标架构决策(To-Be) - -### 3.1 B1 能力声明真实性(Capability Truthfulness) - -1. 立即收敛为“声明即实现”:Elasticsearch ReadModel/Relation provider 的 `supportsAliases` 与 `supportsSchemaValidation` 统一改为 `false`。 -2. 不在本轮引入“空能力声明 + 未来补实现”的过渡状态。 -3. README 与运行日志输出同步能力矩阵,避免二义性。 - -设计原则: - -- 能力字段仅表达已落地、可被测试验证的行为。 -- Provider 选择与启动校验不允许依赖“计划能力”。 - -### 3.2 B2 写入并发安全(OCC) - -1. Elasticsearch Provider 引入 `seq_no + primary_term` 的乐观并发控制。 -2. `MutateAsync` 改为:`GET(_seq_no/_primary_term/_source) -> mutate -> PUT(if_seq_no/if_primary_term)`,冲突时有限重试。 -3. 冲突超过重试上限后抛出明确并发异常(包含 `index/key/retries`),不静默覆盖。 -4. `UpsertAsync` 保持直接写入,但对“更新路径”也支持可选 OCC 参数扩展点。 - -设计原则: - -- 读改写必须具备并发冲突检测能力。 -- 重放/重试场景下禁止“最后写入者覆盖”。 - -### 3.3 B3 Provider 组合层去歧义 - -1. Host 组合层不再无条件注册 InMemory/Elasticsearch/Neo4j 三套 ReadModel Provider。 -2. 改为“按配置按需注册”:仅注册 `ReadModelProvider` 与 `RelationProvider` 实际需要的 provider。 -3. 若配置为空或未知 provider,启动阶段直接 fail-fast 并给出配置路径提示(`Projection:ReadModel:Provider` / `Projection:ReadModel:RelationProvider`)。 -4. 删除隐式默认推断,避免环境切换时行为漂移。 - -设计原则: - -- 组合层负责消除二义性,不把歧义下放到运行时选择器。 -- 配置错误要在启动期暴露,不延迟到首个请求。 - -### 3.4 M1 索引缺失策略显式化 - -1. 新增 `MissingIndexBehavior`(建议枚举):`Throw` / `WarnAndReturnEmpty`。 -2. 默认 `Throw`(breaking):`AutoCreateIndex=false` 且索引不存在时,`Get/List` 抛错。 -3. `WarnAndReturnEmpty` 仅作为开发调试模式,不作为生产默认。 -4. 日志与指标输出:`provider/index/operation/behavior`。 - -### 3.5 M2 绑定解析确定性 - -1. 绑定键只接受 `Type.FullName`(breaking)。不再使用 `Type.Name` 回退。 -2. 解析失败时抛出结构化异常,明确给出期望键名示例。 -3. 启动校验增加“绑定键格式检查”,禁止短名键混入。 - -### 3.6 N1 List 顺序稳定性 - -1. `ListAsync` 始终带排序条件。 -2. 当 `ListSortField` 为空时,默认按 `CreatedAt desc -> _id desc` 排序,优先按创建时间倒序并保证稳定输出。 -3. 在 README 中明确排序语义与默认行为。 - -### 3.7 N2 RequiredIndexKinds 语义收紧 - -1. 能力校验由“任一命中(Overlaps)”改为“全部包含(AllContained)”。 -2. 未来如需“任一命中”语义,必须通过显式匹配模式字段表达,不能默认放宽。 - -## 4. 详细改造清单(按代码层次) - -### 4.1 Abstractions 层 - -目标文件: - -- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs` -- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs`(如需引入匹配模式) - -改造项: - -1. `RequiredIndexKinds` 校验从 `Overlaps` 切换为 `All(...)`。 -2. 如保留双语义,新增显式 `IndexKindMatchMode`,默认 `All`。 - -### 4.2 Runtime 层 - -目标文件: - -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs` -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs` - -改造项: - -1. Binding resolver 仅解析 `FullName` 键。 -2. 异常消息增强:输出 read model type + 期望配置键 + 实际键列表(截断)。 -3. 选择器错误日志保持结构化,补充“配置缺失/未知 provider”的专有 reason。 - -### 4.3 Elasticsearch Provider 层 - -目标文件: - -- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs` -- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs` -- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs` -- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md` - -改造项: - -1. 能力声明修正:`supportsAliases=false`、`supportsSchemaValidation=false`。 -2. OCC 落地: - - 读取文档时抓取 `_seq_no`、`_primary_term`。 - - 更新请求带 `if_seq_no`、`if_primary_term`。 - - 冲突重试(可配置上限)。 -3. 索引缺失行为策略化:新增 `MissingIndexBehavior` 与默认 `Throw`。 -4. `ListAsync` 默认排序兜底为 `CreatedAt desc -> _id desc`。 -5. 文档更新:能力矩阵、排序语义、索引缺失策略、并发冲突行为。 - -### 4.4 Workflow Host 组合层 - -目标文件: - -- `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` -- `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs` -- `src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs` -- `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs` -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs` -- `src/workflow/Aevatar.Workflow.Projection/README.md` - -改造项: - -1. Provider 注册改为按需注册,不再全量注册三套 provider。 -2. 未知 provider 启动即失败;缺失 provider 时仅在 Host 组合层应用默认值(`InMemory`)。 -3. Workflow 业务选项移除 provider 语义,统一由 `ProjectionReadModelRuntimeOptions` 承载。 -4. 文档示例统一改为 `FullName` 绑定键。 - -## 5. 测试计划(必须新增) - -### 5.1 单元测试 - -目标项目:`test/Aevatar.CQRS.Projection.Core.Tests` - -新增/改造用例: - -1. 能力声明一致性:Elasticsearch capability 不再声明 alias/schema 支持。 -2. `RequiredIndexKinds` 全包含语义测试(正例/反例)。 -3. Binding resolver 仅 FullName:短名键失败、FullName 成功。 - -### 5.2 组件/集成测试 - -目标项目: - -- `test/Aevatar.CQRS.Projection.Core.Tests` -- `test/Aevatar.Workflow.Host.Api.Tests` - -新增/改造用例: - -1. Elasticsearch OCC 并发冲突测试:并发 mutate 不发生静默覆盖,冲突可观测。 -2. `AutoCreateIndex=false` 且索引缺失:默认抛错;`WarnAndReturnEmpty` 下返回空并记录警告。 -3. Host 按需注册:仅配置单 provider 时无歧义;空配置启动失败;未知 provider 启动失败。 -4. `ListAsync` 默认排序稳定性测试(同一数据集多次读取顺序一致)。 - -### 5.3 回归验证命令 - -1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -4. `dotnet test aevatar.slnx --nologo` -5. `bash tools/ci/architecture_guards.sh` -6. `bash tools/ci/test_stability_guards.sh` - -## 6. 实施阶段(WBS) - -### Phase 0(Blocking,必须先完成) - -1. 修正 Elasticsearch capability 声明(B1)。 -2. 引入 OCC 并发控制与冲突异常(B2)。 -3. Host 组合层改为按需注册 + 显式配置 fail-fast(B3)。 - -交付标准: - -1. 3 个 blocking 问题对应测试全部通过。 -2. 无配置歧义路径可进入运行期。 - -### Phase 1(Major) - -1. 索引缺失策略改为显式配置且默认抛错(M1)。 -2. Binding resolver 改为 FullName-only 并补齐启动校验(M2)。 - -交付标准: - -1. 配置错误在启动阶段暴露。 -2. 读模型绑定无短名误绑定路径。 - -### Phase 2(Minor + 文档收口) - -1. List 默认排序兜底(N1)。 -2. IndexKind 全包含语义(N2)。 -3. README/架构文档与配置示例统一更新。 - -交付标准: - -1. 行为确定性增强且文档可执行。 -2. 全量构建/测试/门禁通过。 - -## 7. 验收标准(Definition of Done) - -1. 所有 review 项对应的代码路径均有测试覆盖。 -2. Provider 能力声明与实现一致,不存在“声明支持但无实现”的字段。 -3. Elasticsearch 读改写路径具备 OCC,冲突有确定性行为(重试或失败)。 -4. Workflow Host Provider 组合无歧义,配置缺失/错误启动即失败。 -5. Binding 只接受 FullName,消除跨命名空间同名冲突风险。 -6. List 默认顺序稳定。 -7. CI 门禁与全量测试通过,文档与实现一致。 - -## 8. 风险与控制 - -1. 风险:去除短名绑定与隐式 provider 默认值会导致旧配置启动失败。 -2. 控制:错误信息必须指向具体配置路径,并在 README 给出新配置示例。 -3. 风险:OCC 重试增加写延迟。 -4. 控制:重试次数与超时可配置,冲突率纳入日志/指标观察。 - -## 9. 文档同步清单 - -需要同步更新: - -1. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md` -2. `src/workflow/Aevatar.Workflow.Projection/README.md` -3. `docs/CQRS_ARCHITECTURE.md`(Provider 选择与 binding 规则) -4. `docs/architecture/readmodel-graph-relations-refactor-blueprint.md`(补充本次 hardening 决策链接) - -## 10. 本轮实施结果(2026-02-24) - -1. 已完成 B1/B2/B3、M1/M2、N1/N2 全部代码改造与测试补齐。 -2. 验证结果: - - `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo`:通过。 - - `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo`:通过。 - - `dotnet test aevatar.slnx --nologo`:通过。 - - `bash tools/ci/architecture_guards.sh`:通过。 - - `bash tools/ci/test_stability_guards.sh`:通过。 diff --git a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md b/docs/architecture/readmodel-graph-relations-refactor-blueprint.md deleted file mode 100644 index aa690c29d..000000000 --- a/docs/architecture/readmodel-graph-relations-refactor-blueprint.md +++ /dev/null @@ -1,339 +0,0 @@ -# ReadModel 图关系能力重构文档(实施版) - -## 1. 文档信息 -- 状态:Phase2 Refactor Implemented(Breaking) -- 版本:v3.0 -- 日期:2026-02-24 -- 分支:`feat/readmodel-graph-relations` -- 适用目录:`src/`、`test/`、`docs/architecture/` - -## 2. 目标与边界 - -### 2.1 目标 -1. 在现有单一 Projection 主链路中引入“图关系事实”能力,不新增平行系统。 -2. 让 `ReadModel` 的 `Graph` 绑定不仅代表“存储类型”,还代表“可用关系能力”。 -3. 在 Workflow 读侧提供关系查询能力(邻接、子图),并保持 `Command -> Event -> Projection -> Query` 主干不变。 - -### 2.2 非目标 -1. 不引入第二套 Projection Pipeline。 -2. 不在中间层新增 `actorId -> context` 之类进程内事实态缓存。 -3. 不引入跨业务域的通用图 DSL。 - -## 3. 重构前现状分析(As-Is) - -### 3.1 ReadModel 索引能力 -1. 运行时已存在统一选择链:`Bindings -> Requirements -> Capabilities -> Selector -> StoreFactory`。 -2. `ProjectionReadModelIndexKind` 已支持 `Document` / `Graph`。 -3. Provider 已支持统一注册与能力协商:InMemory / Elasticsearch / Neo4j。 - -### 3.2 关系能力缺口 -1. 抽象层缺少关系一等接口:只有 `IProjectionReadModelStore`,没有节点/边读写与遍历契约。 -2. `Graph` 绑定未约束“关系能力”:Provider 选择只关注索引,不关注关系存储/遍历。 -3. Workflow 读侧关系数据仅以 `Topology` 列表存在于文档读模型,未形成图事实源。 -4. API 没有关系端点,仅有 actor snapshot/timeline。 - -## 4. 设计原则(与仓库顶级规则对齐) -1. 单主干:关系能力挂接既有 Runtime 选择器与 Projector 协调器。 -2. 分层清晰: -- `Abstractions` 定义关系契约。 -- `Runtime` 做能力协商与创建。 -- `Providers` 做关系存储实现。 -- `Workflow` 做关系语义映射与查询暴露。 -3. 事实源唯一:关系事实进入 `IProjectionRelationStore`;`WorkflowExecutionReport.Topology` 保留为派生视图。 -4. 可验证:构建、测试、架构门禁、测试稳定性门禁全部可执行。 - -## 5. 目标架构(To-Be) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A["Projection Bindings"] --> B["ProjectionReadModelBindingResolver"] - B --> C["ProjectionReadModelRequirements"] - C --> D["ReadModel Provider Selector"] - C --> E["Relation Provider Selector"] - D --> F["IProjectionReadModelStore"] - E --> G["IProjectionRelationStore"] - F --> H["WorkflowExecutionReadModelProjector"] - F --> I["WorkflowExecutionRelationProjector"] - G --> I - I --> J["Graph Facts (Node/Edge)"] - H --> K["WorkflowExecutionReport"] - K --> L["WorkflowProjectionQueryReader"] - J --> L -``` - -### 5.1 说明 -1. 上图描述的是当前已落地主链路(Phase1 + Phase2)。 -2. Phase2 已完成:Projection 端口拆分为 Lifecycle/Query,ReadModel/Relation provider 配置与校验解耦。 -3. 运行时启动校验抽象已下沉到 CQRS Runtime:`IProjectionStoreStartupValidator`。 - -## 6. 抽象层重构内容 - -### 6.1 新增关系契约 -新增文件: -1. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs` -2. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs` -3. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs` -4. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs` -5. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs` -6. `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs` - -### 6.2 新增关系 Provider 选择抽象 -新增文件: -1. `IProjectionStoreRegistration` -2. `DelegateProjectionStoreRegistration` -3. `IProjectionRelationStoreProviderRegistry` -4. `IProjectionRelationStoreProviderSelector` -5. `IProjectionRelationStoreFactory` - -### 6.3 能力模型扩展 -扩展文件: -1. `ProjectionReadModelRequirements` -- `RequiresRelations` -- `RequiresRelationTraversal` -2. `ProjectionReadModelProviderCapabilities` -- `SupportsRelations` -- `SupportsRelationTraversal` -3. `ProjectionReadModelCapabilityValidator` -- 新增关系能力校验规则。 - -## 7. Runtime 重构内容 - -### 7.1 新增关系 Runtime 组件 -新增文件: -1. `ProjectionRelationStoreProviderRegistry` -2. `ProjectionRelationStoreProviderSelector` -3. `ProjectionRelationStoreFactory` - -### 7.2 运行时注入扩展 -文件:`src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` -1. 注册关系 Provider Registry/Selector/Factory。 - -### 7.3 绑定语义增强 -文件:`ProjectionReadModelBindingResolver.cs` -1. 当 binding 为 `Graph` 时,要求: -- `requiresRelations = true` -- `requiresRelationTraversal = true` - -## 8. Provider 层重构内容 - -### 8.1 InMemory -新增: -1. `InMemoryProjectionRelationStore` - -扩展: -1. `AddInMemoryRelationStoreRegistration(...)` -2. InMemory ReadModel capability 增加 relation 支持声明。 - -能力: -1. 节点/边 upsert -2. 邻居查询 -3. 有界深度子图查询(BFS 风格) - -### 8.2 Elasticsearch -新增: -1. `ElasticsearchProjectionRelationStore`(显式 no-op) - -扩展: -1. `AddElasticsearchRelationStoreRegistration(...)` - -能力声明: -1. `supportsRelations=false` -2. `supportsRelationTraversal=false` - -### 8.3 Neo4j -新增: -1. `Neo4jProjectionRelationStore` -2. `Neo4jProjectionRelationStoreOptions` - -扩展: -1. `AddNeo4jRelationStoreRegistration(...)` -2. Neo4j ReadModel capability 增加 relation 支持声明。 - -能力: -1. 节点/边 upsert/delete -2. 邻居查询(入/出/双向) -3. 有界深度子图查询 -4. 节点唯一约束自动初始化(可配置) - -## 9. Workflow 层重构内容 - -### 9.1 关系投影器 -新增文件: -1. `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs` - -策略: -1. 复用同一 `IProjectionProjector>` 主链。 -2. `InitializeAsync` 创建根 actor 与 run 节点,并写 `OWNS` 边。 -3. `CompleteAsync` 按 runtime topology 写 `CHILD_OF` 边,并按 step 写 `CONTAINS_STEP` 边。 -4. 关系 scope 固定为 `WorkflowExecutionRelationConstants.Scope`。 - -### 9.2 关系查询链路 -扩展: -1. `IWorkflowProjectionQueryReader` -2. `WorkflowProjectionQueryReader` -3. `IWorkflowExecutionProjectionLifecyclePort` -4. `IWorkflowExecutionProjectionQueryPort` -5. `WorkflowExecutionProjectionLifecycleService` -6. `WorkflowExecutionProjectionQueryService` -7. `IWorkflowExecutionQueryApplicationService` -8. `WorkflowExecutionQueryApplicationService` -9. `WorkflowExecutionReadModelMapper` -10. `WorkflowExecutionQueryModels` - -新增: -1. `WorkflowExecutionRelationConstants` - -### 9.3 Workflow DI 选择器接入 -文件:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` -1. 在既有 ReadModel selector 基础上增加 Relation selector。 -2. `IProjectionRelationStore` 使用独立 relation selection(`RelationSelectionOptions + RelationRequirements`)。 - -### 9.4 启动期 fail-fast 校验增强 -文件:`WorkflowReadModelStartupValidationHostedService.cs` -1. 启动时同时校验: -- ReadModel Provider 选择与能力 -- Relation Provider 选择与能力 - -## 10. Host 与 API 改造 - -### 10.1 Provider 组合层 -文件:`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` -1. 为三类 provider 全部注册 relation store: -- InMemory -- Elasticsearch -- Neo4j - -### 10.2 新增关系查询端点 -文件:`src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs` -1. `GET /api/actors/{actorId}/relations?take=200` -2. `GET /api/actors/{actorId}/relation-subgraph?depth=2&take=200` - -## 11. 关系模型约定(Workflow) -1. NodeType -- `Actor` -- `WorkflowRun` -- `WorkflowStep` -2. RelationType -- `OWNS` -- `CHILD_OF` -- `CONTAINS_STEP` -3. Scope -- `workflow-execution-relations` - -## 12. Provider 能力矩阵 - -| Provider | IndexKinds | SupportsRelations | SupportsRelationTraversal | 说明 | -|---|---|---:|---:|---| -| InMemory | None | Yes | Yes | 开发/测试优先,内存实现 | -| Elasticsearch | Document | No | No | 文档索引,不作为关系事实源 | -| Neo4j | Graph | Yes | Yes | 生产图关系能力 | - -## 13. 迁移与上线建议(最佳实践) -1. Phase 1:合并结构改造(已完成) -- 接口、Runtime、Provider、Workflow API 全链路到位。 -2. Phase 2:架构收敛(已完成) -- 拆分 `ProjectionPort` 读写职责,删除混合接口。 -- 将启动校验下沉到 Runtime 通用抽象。 -- 分离 ReadModel 与 Relation 的 provider 选择配置。 -- 删除 QueryReader relation fallback,错误配置 fail-fast。 -3. Phase 3:关系数据回填(可选) -- 从历史 `WorkflowExecutionReport.Topology` 扫描并幂等写回关系边。 -4. Phase 4:灰度切流 -- 对关键租户/环境启用 `Graph + Neo4j` 绑定。 -5. Phase 5:运行优化 -- 根据业务规模补充 relation 查询指标、告警与容量基线。 - -## 14. 验证与门禁 - -### 14.1 已执行验证命令 -1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test test/Aevatar.Workflow.Application.Tests/Aevatar.Workflow.Application.Tests.csproj --nologo` -3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -4. `dotnet test aevatar.slnx --nologo` -5. `bash tools/ci/architecture_guards.sh` -6. `bash tools/ci/test_stability_guards.sh` - -### 14.2 验证结果 -1. 全部通过,无编译错误。 -2. 受影响测试与全量测试通过。 -3. 架构门禁与测试稳定性门禁通过。 - -## 15. 风险与后续优化 - -### 15.1 当前风险 -1. Elasticsearch relation store 为 no-op,若将其配置为 `RelationProvider` 将在选择阶段 fail-fast(需显式配置支持关系能力的 provider)。 -2. `CHILD_OF` 关系在 `CompleteAsync` 阶段写入;超长运行期间拓扑中间态不可见。 -3. `StepCompletedEvent` 缺少 `step_type/target_role`,关系投影需要读取已存在 step 节点做字段合并,对 relation store 的节点读取语义有依赖。 -4. ReadModel/Relation 双 provider 部署下,文档视图与关系视图在异步投影时可能出现短暂最终一致性窗口。 - -### 15.2 建议优化 -1. 增加 relation query 指标(QPS、P95、结果规模)与告警阈值。 -2. 增加 Neo4j relation E2E(容器化)专项测试。 -3. 如需实时关系可视化,可在 `ProjectAsync` 逐步增量写入关键边类型。 -4. 在配置中心增加 `ReadModelProvider/RelationProvider` 组合审计,防止发布时误配。 - -## 16. 关键变更清单(按层) - -### 16.1 Abstractions -1. 新增关系模型与 relation provider 选择抽象。 -2. 扩展 requirements/capabilities/validator。 - -### 16.2 Runtime -1. 新增 relation provider registry/selector/factory。 -2. Graph binding 对应关系能力要求。 - -### 16.3 Providers -1. InMemory/Neo4j 完整 relation store。 -2. Elasticsearch 显式不支持关系。 - -### 16.4 Workflow -1. 新增 `WorkflowExecutionRelationProjector`。 -2. 应用端口拆分为 `LifecyclePort + QueryPort`,Application 层按职责依赖端口。 -3. Query/Application/API 全链路支持 relations/subgraph。 -4. 启动期 relation provider fail-fast。 - -### 16.5 Tests -1. 全部 fake 接口实现同步。 -2. 新增关系查询应用层测试与 API 端点测试。 -3. Host provider 注册覆盖扩展到 relation registration。 - -## 17. Phase2 整改完成记录(2026-02-24) - -### 17.1 已完成整改项 -1. 端口拆分完成: -- `IWorkflowExecutionProjectionLifecyclePort`(Ensure/Attach/Detach/Release) -- `IWorkflowExecutionProjectionQueryPort`(Snapshot/Timeline/Relations/Subgraph) -2. Provider 解耦完成: -- 新增 `RelationProvider` 独立配置,支持与 `ReadModelProvider` 不同。 -- 选择计划拆分为 `ReadModelSelectionOptions/Requirements` 与 `RelationSelectionOptions/Requirements`。 -3. Runtime 抽象下沉完成: -- 新增 `IProjectionStoreStartupValidator`,启动校验通过 Runtime 抽象执行。 -- 新增通用端口基类:`ProjectionLifecyclePortServiceBase<>`、`ProjectionQueryPortServiceBase<>`,Workflow 端只保留领域类型绑定。 -4. 失败语义收敛完成: -- 删除 QueryReader relation fallback。 -- 关系 provider 不满足能力时在选择阶段 fail-fast。 -5. 投影耦合收敛完成: -- `WorkflowExecutionRelationProjector` 不再依赖 ReadModelStore。 -- step 关系字段通过 relation store 读写合并,避免覆盖已写入属性。 -6. 治理门禁同步完成: -- `architecture_guards.sh` 已升级为校验 Lifecycle/Query 双端口约束。 - -### 17.2 DoD 验收结果 -1. `Workflow.Application` 已不依赖混合 ProjectionPort:通过。 -2. 生产可验证 `ReadModelProvider != RelationProvider`:通过(新增 Elasticsearch + InMemory 组合测试)。 -3. 错误 relation 配置 fail-fast:通过(新增 Elasticsearch 未配置 RelationProvider 失败测试)。 -4. 架构与稳定性门禁:通过。 -5. `dotnet build/test` 全量验证:通过。 - -### 17.3 兼容性声明 -1. 本次重构为不兼容变更,已删除 `IWorkflowExecutionProjectionPort`。 -2. 旧接口调用方必须迁移到 Lifecycle/Query 双端口。 - -## 18. Provider 硬化补充(2026-02-24) -1. Elasticsearch 能力声明已收敛为真实实现:不再声明 alias/schema validation 能力。 -2. Elasticsearch `MutateAsync` 已引入 `seq_no/primary_term` OCC,冲突路径具备重试与失败语义。 -3. `AutoCreateIndex=false` 且索引缺失默认 fail-fast,支持 `MissingIndexBehavior=WarnAndReturnEmpty` 调试模式。 -4. ReadModel binding 已收敛为 `Type.FullName` 键,短名键直接抛配置异常。 -5. Workflow Host provider 注册改为按配置按需注册,消除多 provider 未指定歧义。 -6. 详见:`docs/architecture/projection-provider-review-remediation-blueprint-2026-02-24.md`。 diff --git a/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md b/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md deleted file mode 100644 index 2349822ee..000000000 --- a/docs/architecture/readmodel-simple-rich-capability-refactor-blueprint-2026-02-24.md +++ /dev/null @@ -1,381 +0,0 @@ -# ReadModel 双速能力架构重构文档(Simple + Rich) - -## 1. 文档信息 -- 状态:Proposed(Breaking, 不保留兼容层) -- 版本:v1.0 -- 日期:2026-02-24 -- 适用范围:`src/Aevatar.CQRS.Projection.*`、`src/workflow/*`、`test/*`、`tools/ci/*` -- 目标:实现“默认简单开发路径 + 可治理的高级能力路径”,并保持单一 Projection 主链路 - -## 2. 背景与问题 - -### 2.1 当前问题 -1. ReadModel 与 Relation 能力已具备基础框架,但“简单场景”和“复杂场景”缺少统一分层策略。 -2. 一些领域语义(如 `StartedAt`)曾被误放到基础抽象,导致抽象层与 Workflow 语义耦合。 -3. 关系查询能力可用但表达力有限,难以覆盖复杂图查询(过滤、路径约束、分页游标、多根聚合)。 -4. Provider 组合虽支持 split(ReadModel/Relation 分离),但缺少环境分级模板与强门禁,易误配。 -5. 存在空类/薄封装类,基础抽象复用不足,增加维护成本。 - -### 2.2 根因 -1. “能力扩展点”与“默认路径”未显式分层。 -2. 能力声明、模型定义、Provider 选择存在,但缺少统一的“模型 Profile + 环境策略”收敛机制。 -3. 治理规则没有完整固化到启动校验和 CI 门禁(尤其生产环境组合约束)。 - -## 3. 重构目标 - -### 3.1 主目标 -1. 保持最小开发路径:普通业务只需实现 ReadModel + Reducer,不需要理解图能力细节。 -2. 提供丰富能力路径:复杂需求可启用 Graph/Relation/Advanced Query,不污染默认路径。 -3. 严格分层:`Domain / Application / Infrastructure / Host`,`Workflow` 不侵入基础抽象语义。 -4. 单一主链路:所有能力基于统一 Projection Pipeline 插件化扩展,不引入第二系统。 -5. 可治理:启动期 fail-fast + CI guard,确保环境配置、能力声明、实现行为一致。 -6. 职责边界清晰:`Workflow` 不负责 `Graph/Document` 与 provider 选择,选择权在业务方组合层。 - -### 3.2 非目标 -1. 不引入额外中间层事实缓存(禁止 `actorId -> context` 进程内事实态映射)。 -2. 不在本轮引入跨产品通用图分析 DSL 引擎。 -3. 不保留历史兼容壳层。 -4. 不在 `Workflow` 业务层编排 provider/index kind 选择逻辑。 - -## 4. 设计原则 -1. 默认极简:内核接口最小化,降低开发门槛。 -2. 能力叠加:高级能力通过可选接口/能力声明按需启用。 -3. 约束优先:任何扩展必须走能力校验和策略合并,禁止绕过选择器。 -4. 语义分层:基础层只保留通用语义(`CreatedAt/UpdatedAt`),领域语义下沉到领域模型。 -5. 一致性可选分级:默认最终一致;强一致需求通过可选模式(Outbox/Checkpoint)开启。 -6. 组合层决策:ReadModel 投影到哪个 provider 由业务方/Host 组合层决定,Workflow 只消费抽象端口。 - -## 5. 目标架构 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A["Domain Events"] --> B["Unified Projection Pipeline"] - B --> C["ReadModel Projector"] - B --> D["Relation Projector"] - C --> E["IProjectionReadModelStore"] - D --> F["IProjectionRelationStore"] - E --> G["Document ReadModels"] - F --> H["Graph Facts(Node/Edge)"] - I["ReadModel Profile"] --> J["Requirements Builder"] - K["Environment Policy"] --> J - J --> L["Capability Validator"] - L --> M["Provider Selector"] - M --> E - M --> F -``` - -### 5.1 关键点 -1. 只保留一个权威主链:`Event -> Projector -> Store`。 -2. 复杂能力通过 Profile 与 Policy 驱动 Provider 选择,不在 `Workflow` 业务代码里硬编码 Provider。 -3. Workflow/AI 等领域仅定义“自己的模型和关系语义”,不修改基础抽象含义。 -4. Provider 选择权在业务方组合层,Runtime 负责能力校验与 fail-fast。 - -## 6. 抽象层重构(Abstractions) - -### 6.1 统一基础实体基类(消除空类) -新增统一基类(示意): - -```csharp -public abstract class ProjectionReadModelBase - where TKey : notnull -{ - public TKey Id { get; init; } = default!; - public long StateVersion { get; set; } - public string LastEventId { get; set; } = string.Empty; - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; -} -``` - -规则: -1. 基类只包含跨域通用语义,不包含 Workflow 特有字段(如 `StartedAt`)。 -2. 领域字段(例如 `StartedAt/EndedAt/Duration`)保留在领域 ReadModel 内。 - -### 6.2 双层能力接口 -保留最小接口: -1. `IProjectionReadModelStore` - -新增可选高级接口(示意): -1. `IProjectionReadModelQueryStore`:条件过滤、分页游标、多字段排序。 -2. `IProjectionRelationStore`:节点/边写入与基础邻接查询。 -3. `IProjectionRelationTraversalStore`:子图/路径遍历(可独立能力开关)。 -4. `IProjectionSchemaValidationCapability`:Schema 校验能力声明与执行入口。 -5. `IProjectionAliasCapability`:Alias 能力声明与执行入口。 - -说明: -1. 高级接口均为可选实现,不影响默认路径。 -2. 选择器根据 `Capabilities` 判定可用性,禁止运行时盲调。 - -### 6.3 ReadModel Profile(模型声明式扩展) -新增 `IReadModelProfile`(示意职责,由业务方在组合层声明): -1. 声明模型索引类型要求(`Document/Graph`)。 -2. 声明关系能力要求(`RequiresRelations/RequiresTraversal`)。 -3. 声明默认排序(建议 `CreatedAt desc, UpdatedAt desc, Id desc`)。 -4. 声明关系节点/边 ID 规则。 - -Profile 合并优先级: -1. `GlobalDefault < ModelProfile < EnvironmentOverride` -2. 只允许“收紧要求”,不允许“放松底线”。 - -## 7. 关系模型重构(Relations) - -### 7.1 关系属性类型升级 -当前 `Dictionary` 升级为可序列化值模型(示意): -1. `Dictionary` -2. `ProjectionValue` 支持 `string/number/bool/datetime/json` - -目标: -1. 保持可移植序列化。 -2. 支持复杂过滤和排序。 - -### 7.2 关系 ID 规则标准化 -统一规则: -1. 节点 ID 必须包含业务隔离维度(如 `tenant`、`rootActorId`、`commandId`)。 -2. Step 节点强制包含 run 维度,禁止 `step:{rootActorId}:{stepId}` 这种会跨 run 冲突的格式。 - -建议格式: -1. `run:{rootActorId}:{commandId}` -2. `step:{rootActorId}:{commandId}:{stepId}` - -### 7.3 关系查询分层 -1. 简单查询:邻居查询(默认 API)。 -2. 中级查询:有界深度子图。 -3. 高级查询:关系类型过滤 + 属性过滤 + 游标分页 + 路径约束(高级接口/高级端点)。 - -## 8. Provider 分级策略(Simple -> Rich) - -说明: -1. 本节模板由业务方在 Host/组合层应用。 -2. `Workflow` 层不读取、不判断 provider 类型;只通过抽象端口读写投影。 - -### 8.1 环境模板 -1. `LocalDev` -- ReadModel: `InMemory` 或 `Elasticsearch` -- Relation: `InMemory`(仅开发) -- 用途:快速反馈 - -2. `Standard(Production)` -- ReadModel: `Elasticsearch` -- Relation: `Neo4j` -- 用途:生产默认推荐,文档检索 + 图关系可用 - -3. `DocOnly(Production)` -- ReadModel: `Elasticsearch` -- Relation: Disabled -- 用途:不需要图关系的场景 - -4. `GraphAdvanced(Production)` -- ReadModel: `Neo4j` 或双写策略 -- Relation: `Neo4j` -- 用途:高图密度业务 - -### 8.2 强约束 -1. 生产环境禁止 `InMemoryRelationStore` 作为事实源(启动即失败)。 -2. 生产环境强制 `FailOnUnsupportedCapabilities=true`。 -3. 若启用关系 API,必须 `RequiresRelations=true` 且 relation provider 支持 traversal。 - -## 9. Runtime 选择与校验重构 - -### 9.1 选择流程 -1. 业务方在 Host 组合层提供 `GlobalReadModelOptions + ReadModelProfile`。 -2. Runtime 合并 `ReadModelProfile`。 -3. Runtime 应用 `EnvironmentPolicy`。 -4. Runtime 生成 `ProjectionReadModelRequirements`。 -5. Runtime 执行 `CapabilityValidator`。 -6. Runtime 选择 readmodel/relation provider。 -7. 启动校验(Host startup validator)在应用启动前 fail-fast。 - -### 9.2 错误模型 -1. Provider 未指定且多注册:明确抛错。 -2. 能力不匹配:结构化异常包含 `requirements/capabilities/violations`。 -3. 生产策略违规(如 in-memory relation):专用策略异常。 - -## 10. Workflow 分层收敛 - -### 10.1 基础抽象去 Workflow 语义 -1. 基类只保留 `CreatedAt/UpdatedAt`。 -2. `StartedAt/EndedAt` 保留在 `WorkflowExecutionReport`(领域模型)。 - -### 10.2 Workflow 与 Provider 边界 -1. Workflow 只依赖: -- `IProjectionReadModelStore` -- `IProjectionRelationStore` -2. Workflow 不包含: -- `Graph/Document` 选择分支 -- `Elasticsearch/Neo4j/InMemory` provider 分支 -- capability 协商逻辑 -3. 若 provider 能力不匹配,由 Runtime/启动校验报错,不在 Workflow 内做兜底分支。 - -### 10.3 Query 接口分层 -1. 基础端点保留: -- `/actors/{actorId}` -- `/actors/{actorId}/timeline` -- `/actors/{actorId}/relations` -- `/actors/{actorId}/relation-subgraph` - -2. 高级端点新增(可选): -- 支持 `direction`、`relationTypes`、`property filters`、`cursor`。 -- 这些是业务查询语义,不代表 Workflow 在做 provider/index kind 决策。 - -### 10.4 投影职责 -1. ReadModelProjector:只负责文档视图。 -2. RelationProjector:只负责图事实写入。 -3. Coordinator:保持统一调度,不在 Workflow 层引入二次编排系统。 - -## 11. 一致性与幂等策略 - -### 11.1 默认模式(最终一致) -1. ReadModel 和 Relation 独立写入。 -2. OCC + 去重保证单存储内正确性。 - -### 11.2 强一致增强模式(可选) -1. 引入 Projection Outbox(事件级写入意图记录)。 -2. 以 `eventId + projector` 作为幂等键。 -3. 使用 checkpoint/compensation 补偿部分成功。 - -### 11.3 幂等键规则 -1. `dedupKey = {projectionId}:{eventId}:{projectorName}` -2. relation edge upsert 使用确定性 edgeId,禁止随机 ID。 - -## 12. 索引与排序策略 - -### 12.1 默认排序 -1. 默认 `CreatedAt desc` -2. 次排序 `UpdatedAt desc` -3. 稳定 tie-break `Id desc`(或 `_id desc`) - -### 12.2 约束 -1. 任意 provider 的 `ListAsync` 必须提供稳定顺序保证。 -2. 若 provider 无法保证稳定排序,必须显式抛错,不允许“静默无序”。 - -## 13. 配置模型重构 - -### 13.1 配置分区 -1. `Projection:ReadModel:*`:业务方在 Host 组合层声明全局默认与 Provider 配置。 -2. `Projection:Policies:*`:环境策略(例如 production guard)。 -3. `Projection:Profiles:*`:按模型覆盖规则。 - -### 13.2 配置示例(生产标准模板) - -```yaml -Projection: - ReadModel: - Provider: Elasticsearch - RelationProvider: Neo4j - FailOnUnsupportedCapabilities: true - Bindings: - Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Graph - Providers: - Elasticsearch: - Endpoints: ["http://elasticsearch:9200"] - IndexPrefix: "aevatar" - MissingIndexBehavior: Throw - Neo4j: - Uri: "bolt://neo4j:7687" - AutoCreateConstraints: true - Policies: - Environment: Production - DenyInMemoryRelationFactStore: true -``` - -## 14. 代码级重构清单(按层) - -### 14.1 Abstractions -1. 新增 `ProjectionReadModelBase`(或等价通用基类)。 -2. 新增 `IReadModelProfile` 与 Profile 描述模型。 -3. 新增高级可选接口:Query/Traversal/Schema/Alias 能力接口。 -4. 升级关系属性类型定义,支持 typed value。 - -### 14.2 Runtime -1. 新增 Profile Registry + Requirements Builder。 -2. 新增 Policy Validator(环境策略校验)。 -3. 扩展 selector 日志,输出最终合并后的 requirements。 - -### 14.3 Providers -1. InMemory Provider 显式标注 `DevOnly` 元数据。 -2. Elasticsearch Provider 保持 Document 优先,明确高级查询能力边界。 -3. Neo4j Provider 承担 Relation + Traversal 主实现,并补齐 e2e 验证。 - -### 14.4 Workflow -1. 替换 Step 节点 ID 规则,纳入 `commandId` 维度。 -2. Query 只透传业务查询参数,不承载 provider/index kind 选择逻辑。 -3. 维持默认简单 API,不破坏使用门槛。 - -## 15. 测试与门禁 - -### 15.1 新增测试 -1. Profile 合并优先级测试(Global/Profile/Env)。 -2. 生产策略测试(禁止 InMemory Relation)。 -3. Relation Provider e2e(Neo4j): -- 节点/边 upsert -- 邻居查询 -- 深度子图 -- 过滤/分页(若启用高级查询) -4. Step 节点 ID 唯一性测试(跨 run 不冲突)。 - -### 15.2 CI 门禁 -1. `tools/ci/architecture_guards.sh`: -- 禁止基础抽象出现 Workflow 语义字段名。 -- 禁止中间层 `actor/run/session` 事实态字典字段。 -2. `tools/ci/projection_provider_e2e_smoke.sh`: -- 增加 relation provider e2e 目标与 executed==total 校验。 -3. 生产配置扫描: -- `Production + InMemoryRelationProvider` 直接 fail。 - -## 16. 分阶段实施计划 - -### Phase 1(基础抽象收敛) -1. 引入通用基类与 Profile 抽象。 -2. 清理空类与无价值薄封装。 -3. 完成基础单测。 - -### Phase 2(能力分层落地) -1. 引入高级可选接口与 capability 路径。 -2. Runtime 增加 Profile+Policy 合并与校验。 -3. 保持默认调用路径不变。 - -### Phase 3(Provider 与 Workflow 深化) -1. Workflow Step ID 规则修复。 -2. 高级关系查询链路贯通(端口到 API)。 -3. Relation e2e 与生产策略门禁落地。 - -### Phase 4(生产切换) -1. 默认生产模板切至 `Elasticsearch + Neo4j`。 -2. 灰度启用高级查询能力。 -3. 观察指标与告警收敛。 - -## 17. 可观测性与运维 -1. 指标: -- provider selection failure -- capability violation -- relation query p95/p99 -- subgraph result size -- OCC conflict retry count -2. 日志: -- 结构化输出 `requirements/capabilities/provider` -3. 告警: -- 生产策略违规配置 -- relation traversal error rate 超阈值 - -## 18. 风险与应对 -1. 风险:高级能力接口扩展过快导致复杂度回升。 -- 应对:接口增量必须有真实场景和 e2e 覆盖。 -2. 风险:强一致模式引入写放大。 -- 应对:默认保持最终一致,按域开启强一致。 -3. 风险:Provider 能力声明与实现漂移。 -- 应对:能力声明一致性测试 + 启动期校验 + e2e。 - -## 19. 验收标准(DoD) -1. 默认开发路径只需 ReadModel + Reducer 即可运行。 -2. 复杂能力通过 Profile/Policy 可启用且受校验治理。 -3. 基础抽象不含 Workflow 语义字段(仅通用元数据)。 -4. 生产标准模板不允许 InMemory Relation 事实源。 -5. Workflow 代码中不存在 provider/index kind 选择分支。 -6. Relation e2e + 架构门禁 + 全量 build/test 通过。 - -## 20. 结论 -本方案通过“默认极简 + 能力插件 + 治理前置”实现双速架构: -1. 让大多数开发者保持低认知成本。 -2. 让复杂需求获得可扩展、可验证、可运维的高级能力。 -3. 避免抽象层次倒挂和语义污染,维持长期演进质量。 diff --git a/src/Aevatar.AI.Projection/GlobalUsings.cs b/src/Aevatar.AI.Projection/GlobalUsings.cs index 1d58e7bdb..b3efef5dd 100644 --- a/src/Aevatar.AI.Projection/GlobalUsings.cs +++ b/src/Aevatar.AI.Projection/GlobalUsings.cs @@ -1,2 +1,2 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.Foundation.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs index 400358364..567976163 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Provides current UTC time for projection runtime. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs index 08d2d5368..f0307f837 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic projection context contract. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs index 87af1a043..92393e048 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Runtime switches for projection lifecycle and query/report capabilities. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs index 71a685781..7060f625f 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Projection context that carries its stream subscription lease. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs index 402f7ede6..e26c62c86 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic coordinator contract for projection pipeline lifecycle. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs index bd72b5ae5..3ce5d7126 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Reports projection dispatch failures to an upstream channel. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs index a78ddd731..abe09b979 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Unified projection dispatch entry for one envelope. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs index e28bc49ea..35f473938 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Applies one strongly-typed event onto one read model. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs index 428a1b084..a838522d7 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic reducer contract for projecting one event envelope into one read model. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs index 57af8e678..3654ed95e 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic projection lifecycle service. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs index e171ea8b2..18839f0ea 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Coordinates projection ownership arbitration by scope/session key. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs index 174f24889..26b502d1a 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic projector contract for applying envelopes to one projection context. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs index 33312ce58..996f11d67 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic registry for actor-level projection subscription lifecycle. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs index 407ade9c0..79abfc4d9 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic activation contract for projection runtime lease acquisition. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs index 14129a60b..13cc81639 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic forwarder contract for projection runtime events to external sinks. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs index 689c66054..18cf57ed7 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic release contract for projection runtime lease lifecycle. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs index 3332fcbc5..84553938e 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Generic sink subscription manager for projection live-stream delivery. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs index 7cdcd53db..a2ec5872c 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs @@ -1,6 +1,6 @@ using Google.Protobuf; -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Shared actor-stream subscription hub that creates per-handler subscriptions diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs index 86e76f53c..6196187a5 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Lease handle for one actor stream subscription. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs index b551969b0..edb821ee6 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Encodes and decodes projection session events for stream transport. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs index fc2b1b5b0..d7ddc925b 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Core.Abstractions; /// /// Publishes and subscribes projection session events by scope/session key. diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj b/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj index 395670a6a..7f1facde5 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/Aevatar.CQRS.Projection.Core.Abstractions.csproj @@ -4,7 +4,7 @@ enable enable Aevatar.CQRS.Projection.Core.Abstractions - Aevatar.CQRS.Projection.Abstractions + Aevatar.CQRS.Projection.Core.Abstractions diff --git a/src/Aevatar.CQRS.Projection.Core/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Core/GlobalUsings.cs index 1d58e7bdb..b3efef5dd 100644 --- a/src/Aevatar.CQRS.Projection.Core/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Core/GlobalUsings.cs @@ -1,2 +1,2 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.Foundation.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs index d6f2f4132..de399d1a1 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Foundation.Abstractions.TypeSystem; using Google.Protobuf; using Google.Protobuf.WellKnownTypes; diff --git a/src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs b/src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs index 7e2d712e5..5fdfb7be1 100644 --- a/src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs +++ b/src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.CQRS.Projection.Core.Streaming; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs index b07fa3dbd..16f01928e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs @@ -1 +1 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs index b07fa3dbd..16f01928e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs @@ -1 +1 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs index b07fa3dbd..16f01928e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs @@ -1 +1 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs index b07fa3dbd..16f01928e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs @@ -1 +1 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 1ac20a2f7..6a6045656 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class DelegateProjectionStoreRegistration : IProjectionStoreRegistration { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs index e78e4a136..39230890b 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreProviderMetadata { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index 25dd5989c..3d1048a5e 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreRegistration { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs index 61630e519..0ac47587d 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionReadModelBindingResolver { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs index c18b16f62..233f9c348 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionReadModelCapabilityValidator { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs index b225d9190..b127038b4 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionReadModelProviderRegistry { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs index 290401c87..c8beba36c 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionReadModelProviderSelector { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs index 94bade5d0..994c0a730 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; /// /// Generic read-model store contract for projection state persistence/query. diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs index 4d7e88640..21d30fac8 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionReadModelStoreFactory { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs index f43b5efef..9492a0d4b 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelBindingException : InvalidOperationException { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs index 290ee852b..ec99e02cf 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelCapabilityValidationException : InvalidOperationException { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs index c03febeca..92fdb3d3c 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public static class ProjectionReadModelCapabilityValidator { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs index bfc18fc2c..8f1c61aef 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public enum ProjectionReadModelIndexKind { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs index c6fcbdacc..0ed8be4fe 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public enum ProjectionReadModelMode { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs index fd7721284..9f97f6356 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelProviderCapabilities { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs index 3f3aef17e..bee736261 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public static class ProjectionReadModelProviderNames { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs index 7b5d7a75b..3a17d6527 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelRequirements { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs index d141e0efa..ad8f60b93 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelRuntimeOptions : IProjectionStoreSelectionRuntimeOptions { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs index f144b3e72..15cb74b61 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelStoreSelectionOptions { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs index f9d453420..3a1a5c8ff 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public static class ProjectionReadModelStoreSelector { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs index d792b7969..ae32579ca 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionRelationStore { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs index d07980700..ad2169f58 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionRelationStoreFactory { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs index 57c07d906..38ab2e4f0 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionRelationStoreProviderRegistry { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs index 8770d5b2a..868c36add 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionRelationStoreProviderSelector { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs index 681c0ff70..7acbeef1a 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public enum ProjectionRelationDirection { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs index 9272a0cf7..13f0b8ca8 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionRelationEdge { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs index f8978e67b..6d7304aa7 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionRelationNode { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs index a1f20d155..5bbe45c7b 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionRelationQuery { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs index f879d3d47..d73c16533 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionRelationSubgraph { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs index 57bf0a36f..6021e7fc1 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreSelectionPlanner { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs index 85cc8147f..9240b26f4 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreSelectionRuntimeOptions { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs index 8486ebcdf..c7536b985 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreStartupValidator { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs index 93b470eb7..abd131a43 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionProviderSelectionException : InvalidOperationException { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs index 93f5fdf27..49e3fa131 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public readonly record struct ProjectionStoreSelectionPlan( ProjectionReadModelRequirements ReadModelRequirements, diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs index e40d52f71..e76c8ee8f 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Abstractions; +namespace Aevatar.CQRS.Projection.Stores.Abstractions; public static class ProjectionStoreSelector { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj index 93e4f3e64..0216e5f63 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Aevatar.CQRS.Projection.Stores.Abstractions.csproj @@ -4,6 +4,6 @@ enable enable Aevatar.CQRS.Projection.Stores.Abstractions - Aevatar.CQRS.Projection.Abstractions + Aevatar.CQRS.Projection.Stores.Abstractions diff --git a/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs b/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs index 4451fdb89..a40051bbf 100644 --- a/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs +++ b/src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.ReadModels; diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 8830a00dc..ee421bc71 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.ReadModels; diff --git a/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs b/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs index 753f48b1d..263294b8a 100644 --- a/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs +++ b/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs @@ -1,4 +1,5 @@ -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; global using Aevatar.Workflow.Projection.ReadModels; global using Aevatar.Foundation.Abstractions; global using Aevatar.Workflow.Abstractions; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs index eb487dd04..6d056f3d6 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs index 2b8b9c228..5705aeab1 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs @@ -1,5 +1,5 @@ using Aevatar.Workflow.Application.Abstractions.Runs; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs index df7a43274..e8446ba2f 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs index d0e5192b5..5a908e31f 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs @@ -1,5 +1,5 @@ using Aevatar.Workflow.Application.Abstractions.Runs; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs index c1206d3db..5f183d7a7 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs index bb7fc160f..30f2822f8 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs index 89c783db1..2ec9277ec 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 36b8ee605..077ebe3b5 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Hosting; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowRunEventSessionCodec.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowRunEventSessionCodec.cs index e0e1bd1cc..304242d8d 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowRunEventSessionCodec.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowRunEventSessionCodec.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; namespace Aevatar.Workflow.Projection.Orchestration; diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index e2cf5b76c..8c35b0680 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -3,7 +3,8 @@ using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs b/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs index 49e524e9b..0c3254ec5 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs @@ -1,3 +1,4 @@ global using Xunit; global using Aevatar.Foundation.Abstractions; -global using Aevatar.CQRS.Projection.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs b/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs index 2e4ed57b2..cef2ea8fb 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs @@ -1,4 +1,6 @@ global using Aevatar.AI.Abstractions; +global using Aevatar.CQRS.Projection.Core.Abstractions; +global using Aevatar.CQRS.Projection.Stores.Abstractions; global using Aevatar.Foundation.Abstractions; global using Aevatar.Workflow.Abstractions; global using Xunit; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ProjectionCoordinatorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ProjectionCoordinatorTests.cs index d555ed2c7..e98a3b0c5 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ProjectionCoordinatorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ProjectionCoordinatorTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.Foundation.Abstractions; using FluentAssertions; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionContextTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionContextTests.cs index d0426a7fd..9cbf17623 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionContextTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionContextTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection; using FluentAssertions; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 94906a4bf..493d39780 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index e75913469..b000f6a4d 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -1,6 +1,6 @@ using Aevatar.AI.Projection.Reducers; using Aevatar.AI.Projection.Appliers; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index a3f05b978..cab5c203a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -1,6 +1,6 @@ using Aevatar.AI.Projection.Reducers; using Aevatar.AI.Projection.Appliers; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Foundation.Abstractions.Deduplication; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs index 353c44825..e0061e721 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Workflow.Core; using Aevatar.Workflow.Projection; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index dadfb3ea8..b24e322f2 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -2,7 +2,7 @@ using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.ToolProviders.MCP; using Aevatar.AI.ToolProviders.Skills; -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Application.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Application.Runs; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchFailureReporterTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchFailureReporterTests.cs index 3be8263e7..178823017 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchFailureReporterTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchFailureReporterTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.Orchestration; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index f977e9f02..dbf88f5c4 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -1,4 +1,4 @@ -using Aevatar.CQRS.Projection.Abstractions; +using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Projection; From cae5229c5c37a36b2b6ff2afa3713ef16bc0d022 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 22:31:58 +0800 Subject: [PATCH 26/46] Remove Deprecated Audit Scorecard Documents - Deleted outdated audit scorecard documents for Projection ReadModel, ReadModel Index, and Workflow subsolution to streamline the documentation and maintain focus on current architecture evaluations. - Ensured that the removal aligns with the ongoing refactoring efforts and the transition to updated scorecard formats, enhancing overall documentation clarity and relevance. --- ...ojection-readmodel-scorecard-2026-02-23.md | 109 --------------- .../readmodel-index-scorecard-2026-02-24.md | 110 --------------- ...rkflow-subsolution-scorecard-2026-02-21.md | 125 ------------------ 3 files changed, 344 deletions(-) delete mode 100644 docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md delete mode 100644 docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md delete mode 100644 docs/audit-scorecard/workflow-subsolution-scorecard-2026-02-21.md diff --git a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md deleted file mode 100644 index 0ce9bce6d..000000000 --- a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-23.md +++ /dev/null @@ -1,109 +0,0 @@ -# Aevatar Projection ReadModel 架构评分卡(2026-02-23,重评终版) - -## 1. 审计范围与方法 - -1. 审计对象:Projection ReadModel 主链路(Abstractions + Runtime + Providers + Workflow 集成 + CI 门禁)。 -2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 -3. 证据来源:当前分支源码、CI 脚本、定向命令实跑结果(重构后复核)。 - -## 2. 审计边界 - -1. 抽象与能力模型: -`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/IProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs`。 -2. Runtime 选择与装配: -`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs`。 -3. Provider 实现: -`src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs`。 -4. Workflow 读侧集成: -`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs`、`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs`、`src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs`。 -5. CI 与治理: -`tools/ci/architecture_guards.sh`、`tools/ci/projection_route_mapping_guard.sh`、`tools/ci/projection_provider_e2e_smoke.sh`、`.github/workflows/ci.yml`。 - -## 3. Projection ReadModel 主链 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A["Host Wiring"] --> B["AddWorkflowProjectionReadModelProviders"] - B --> C["AddWorkflowExecutionProjectionCQRS"] - C --> D["WorkflowReadModelSelectionPlanner.Build"] - D --> E["ProjectionReadModelStoreFactory.Create"] - E --> F["ProviderRegistry.GetRegistrations"] - E --> G["ProviderSelector.Select"] - G --> H["ProjectionReadModelStoreSelector.Select"] - H --> I["Selected Registration.Create Store"] - I --> J["WorkflowExecutionReadModelProjector"] - J --> K["UpsertAsync / MutateAsync"] - C --> L["ActorProjectionOwnershipCoordinator"] - L --> M["WorkflowExecutionRuntimeLease"] -``` - -## 4. 客观验证结果(重评复核) - -| 检查项 | 命令 | 结果 | -|---|---|---| -| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | -| 路由映射专项门禁 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | -| Provider E2E 烟雾回归(容器 + 完整执行校验) | `bash tools/ci/projection_provider_e2e_smoke.sh` | 通过(2 passed / 0 skipped,`total=2 executed=2`) | -| Projection Core 定向回归 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests|FullyQualifiedName~ProjectionReadModelStoreSelectorTests|FullyQualifiedName~ProjectionProviderE2EIntegrationTests"` | 通过(8 passed / 0 failed / 2 skipped) | -| Workflow Host 定向回归 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests|FullyQualifiedName~WorkflowReadModelSelectionPlannerTests"` | 通过(20 passed / 0 failed / 0 skipped) | - -## 5. 整体评分(100 分制) - -**总分:100 / 100(A+)** - -| 维度 | 权重 | 得分 | 评分依据 | -|---|---:|---:|---| -| 分层与依赖反转 | 20 | 20 | 选择、能力校验、存储实现、业务集成边界明确;上层依赖抽象。 | -| CQRS 与统一投影链路 | 20 | 20 | Workflow/AGUI 共用统一投影输入链路,ReadModel 入口已收敛到统一 Provider 选择链。 | -| Projection 编排与状态约束 | 20 | 20 | ownership actor 化,lease/session 句柄传递,无中间层事实态 ID 映射字典。 | -| 读写分离与会话语义 | 15 | 15 | `Projector/Updater` 写、`QueryReader` 读,应用层仅经 projection port 访问。 | -| 命名语义与冗余清理 | 10 | 10 | 已消除双实现/重复规则,命名与职责保持一致。 | -| 可验证性(门禁/构建/测试) | 15 | 15 | guards + route-mapping + provider e2e(含执行完整性校验)形成闭环。 | - -## 6. 分模块评分 - -| 模块 | 得分 | 结论 | -|---|---:|---| -| Abstractions(契约/能力模型) | 100 | 单一权威选择器 + 结构化异常,契约稳定清晰。 | -| Runtime(选择/绑定/工厂) | 100 | Runtime 复用权威选择逻辑并增强日志,避免语义分叉。 | -| Provider(InMemory/ES/Neo4j) | 100 | 能力声明一致,写路径日志门禁到位,e2e 烟雾验证通过。 | -| Workflow 集成(DI/Projector/Orchestration) | 100 | ReadModel 规划规则统一,DI 与启动校验一致。 | -| CI + Guards(治理) | 100 | 触发路径覆盖补齐,provider e2e 执行完整性可机器验证。 | - -## 7. 关键证据(终版) - -1. 统一选择权威入口:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:5`。 -2. 选择失败采用结构化异常:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:20`。 -3. Runtime selector 复用权威选择器:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32`。 -4. Runtime 能力校验失败日志:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:44`。 -5. Workflow 统一规划器接口:`src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowReadModelSelectionPlanner.cs:10`。 -6. Workflow 规划器统一 provider/mode/binding 规则:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs:16`。 -7. DI 解析链路复用规划器:`src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:127`。 -8. Startup 校验链路复用规划器:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:41`。 -9. CI 触发范围补齐(Workflow projection/provider 装配路径):`.github/workflows/ci.yml:47`。 -10. Provider e2e 强制完整执行校验:`tools/ci/projection_provider_e2e_smoke.sh:81`。 -11. Selector 语义回归测试:`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs:24`。 -12. 规划器语义回归测试:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs:15`。 - -## 8. 问题闭环状态(相对上一版) - -1. 已关闭:Provider 选择双实现语义漂移风险(改为单一权威选择逻辑)。 -2. 已关闭:Workflow ReadModel 规则在 DI/Startup 双处重复维护问题(改为统一规划器)。 -3. 已关闭:CI `projection_provider` 变更筛选漏覆盖 Workflow 装配路径问题。 -4. 已关闭:Provider e2e 仅依赖 skip 语义导致“假通过”风险(新增 TRX 完整执行校验)。 - -## 9. 主要扣分项 - -### P1 - -1. 无。 - -### P2 - -1. 无。 - -## 10. 后续建议(非扣分) - -1. 将 `projection_provider_e2e` 的 `total/executed/notExecuted` 指标上报到 CI summary,便于趋势观测。 -2. 为 Provider e2e 增加失败时自动抓取容器关键日志,提升诊断效率。 diff --git a/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md b/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md deleted file mode 100644 index d7511b3da..000000000 --- a/docs/audit-scorecard/readmodel-index-scorecard-2026-02-24.md +++ /dev/null @@ -1,110 +0,0 @@ -# Aevatar ReadModel Index 架构评分卡(2026-02-24,专项审计) - -## 1. 审计范围与方法 - -1. 审计对象:ReadModel Index 选择与校验主链(Bindings -> Requirements -> Provider Capabilities -> Runtime Selection -> Provider Store)。 -2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 -3. 证据来源:当前分支源码、CI 脚本、专项命令实跑结果(2026-02-24)。 - -## 2. 审计边界 - -1. Index 抽象与约束: -`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs`。 -2. Runtime 绑定与选择: -`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs`、`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs`、`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs`。 -3. Provider capability 声明与索引实现: -`src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs`、`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs`、`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs`。 -4. Workflow 接入与启动校验: -`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`、`src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs`、`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs`。 -5. CI 与门禁: -`.github/workflows/ci.yml`、`tools/ci/architecture_guards.sh`、`tools/ci/projection_route_mapping_guard.sh`、`tools/ci/projection_provider_e2e_smoke.sh`。 - -## 3. ReadModel Index 主链 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A["Projection:ReadModel:Bindings"] --> B["ApplyGlobalReadModelOptions"] - B --> C["WorkflowReadModelSelectionPlanner.Build"] - C --> D["ProjectionReadModelBindingResolver.Resolve"] - D --> E["ProjectionReadModelRequirements(RequiredIndexKinds)"] - C --> F["ProjectionReadModelStoreFactory.Create"] - F --> G["ProjectionReadModelProviderSelector.Select"] - G --> H["ProjectionReadModelStoreSelector.Select"] - H --> I["ProjectionReadModelCapabilityValidator.Validate"] - I --> J["ProviderCapabilities(IndexKinds/Aliases/Schema)"] - J --> K["Selected ReadModel Store"] - C --> L["WorkflowReadModelStartupValidationHostedService"] - L --> G -``` - -## 4. 客观验证结果(2026-02-24) - -| 检查项 | 命令 | 结果 | -|---|---|---| -| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | -| 路由映射专项门禁 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | -| Projection Core 定向回归 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests|FullyQualifiedName~ProjectionReadModelStoreSelectorTests|FullyQualifiedName~ProjectionProviderE2EIntegrationTests"` | 通过(8 passed / 0 failed / 2 skipped) | -| Workflow Host 定向回归 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests|FullyQualifiedName~WorkflowReadModelSelectionPlannerTests"` | 通过(20 passed / 0 failed / 0 skipped) | -| Provider E2E 烟雾(容器 + TRX 全执行校验) | `bash tools/ci/projection_provider_e2e_smoke.sh` | 通过(2 passed / 0 skipped,`total=2 executed=2`) | - -## 5. 整体评分(100 分制) - -**总分:100 / 100(A+)** - -| 维度 | 权重 | 得分 | 评分依据 | -|---|---:|---:|---| -| 分层与依赖反转 | 20 | 20 | Index 需求建模、选择器、Provider 注册和 Workflow 规划边界清晰,上层统一依赖抽象接口。 | -| CQRS 与统一投影链路 | 20 | 20 | ReadModel Index 需求从统一 `Projection:ReadModel` 入口注入,链路无平行第二实现。 | -| Projection 编排与状态约束 | 20 | 20 | Index 选择由 runtime selector + startup validation 承担,无中间层事实态映射字典。 | -| 读写分离与会话语义 | 15 | 15 | Index 仅约束读侧存储能力,不污染命令/事件写侧语义。 | -| 命名语义与冗余清理 | 10 | 10 | `IndexKind/Requirements/Capabilities` 语义一致,异常模型结构化。 | -| 可验证性(门禁/构建/测试) | 15 | 15 | guards + 定向测试 + provider e2e(含 executed=total)形成闭环。 | - -## 6. 分模块评分 - -| 模块 | 得分 | 结论 | -|---|---:|---| -| Abstractions(Index 语义模型) | 100 | `Requirements/Capabilities/Validator` 三件套语义闭环,约束表达完整。 | -| Runtime(绑定解析 + 选择 + 工厂) | 100 | 绑定到需求、需求到选择、选择到实例化全链路统一。 | -| Providers(InMemory/Elasticsearch/Neo4j) | 100 | 能力声明与实际索引实现对齐,Document/Graph 分工明确。 | -| Workflow 集成(配置/规划/启动校验) | 100 | 全局配置覆盖业务 options,启动期即可 fail-fast 暴露能力错配。 | -| CI + Guards(治理) | 100 | path filter、门禁脚本、容器化 e2e 及执行完整性检查都已覆盖。 | - -## 7. 关键证据 - -1. Index 枚举统一语义:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelIndexKind.cs:3`。 -2. Requirements 去除 `None` 并标准化集合:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelRequirements.cs:18`。 -3. Capabilities 禁止“未开启索引却声明索引种类”:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelProviderCapabilities.cs:27`。 -4. Capability validator 对 `RequiredIndexKinds` 执行约束:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelCapabilityValidator.cs:17`。 -5. 统一权威选择器入口:`src/Aevatar.CQRS.Projection.Abstractions/Abstractions/ProjectionReadModelStoreSelector.cs:5`。 -6. Runtime selector 复用权威选择器并记录结构化日志:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32`。 -7. Binding 仅允许 `Document/Graph`,非法配置抛结构化异常:`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs:15`。 -8. InMemory 明确声明 `supportsIndexing: false`:`src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:23`。 -9. Elasticsearch 声明 `Document` 能力:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:29`。 -10. Neo4j 声明 `Graph` 能力:`src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:29`。 -11. Elasticsearch Store 能力元数据与写链路:`src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs:353`。 -12. Neo4j Store 能力元数据与 schema 约束初始化:`src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs:58`。 -13. 全局 `Projection:ReadModel` 配置映射到 Workflow options:`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs:41`。 -14. Workflow 规划器统一 provider + bindings -> selection plan:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelSelectionPlanner.cs:16`。 -15. 启动期预校验 provider 能力:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:41`。 -16. CI 路径筛选覆盖 projection provider 与 workflow 装配路径:`.github/workflows/ci.yml:47`。 -17. Provider e2e 必须 `executed == total`:`tools/ci/projection_provider_e2e_smoke.sh:90`。 -18. Runtime/selector 回归测试覆盖索引能力选择:`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs:9`、`test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs:62`。 -19. Workflow 集成测试覆盖 index 约束 fail-fast:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs:41`。 -20. Planner 测试覆盖 binding 解析与 provider 归一化:`test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelSelectionPlannerTests.cs:15`。 - -## 8. 主要扣分项 - -### P1 - -1. 无。 - -### P2 - -1. 无。 - -## 9. 后续建议(非扣分) - -1. 增加 “binding 值大小写/非法空白” 的参数化测试,进一步收紧配置输入面。 -2. 在 CI summary 输出 `projection_provider_e2e` 的 `total/executed/notExecuted` 指标,便于趋势跟踪。 diff --git a/docs/audit-scorecard/workflow-subsolution-scorecard-2026-02-21.md b/docs/audit-scorecard/workflow-subsolution-scorecard-2026-02-21.md deleted file mode 100644 index a64a0ab27..000000000 --- a/docs/audit-scorecard/workflow-subsolution-scorecard-2026-02-21.md +++ /dev/null @@ -1,125 +0,0 @@ -# Aevatar Workflow 子解决方案评分卡(2026-02-21,增量复核) - -## 1. 审计范围与方法 - -1. 审计对象:`aevatar.workflow.slnf`(单一子解决方案)。 -2. 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度)。 -3. 证据来源:`slnf/csproj` 依赖、核心编排源码、测试源码、CI guard、本地命令结果。 - -## 2. 子解决方案组成 - -`aevatar.workflow.slnf` 覆盖 Workflow 主干(`Core/Application/Projection/Infrastructure/Host`)、扩展(AIProjection/Hosting/Maker)与 3 个测试项目。 -证据:`aevatar.workflow.slnf`。 - -## 3. 相关源码架构分析 - -### 3.1 分层与依赖方向 - -1. `Core` 保持领域编排职责(`WorkflowGAgent`、模块工厂),无 Host/Infrastructure 反向耦合。 -证据:`src/workflow/Aevatar.Workflow.Core/WorkflowGAgent.cs`、`src/workflow/Aevatar.Workflow.Core/WorkflowModuleFactory.cs`。 -2. `Application` 依赖抽象层与基础能力,保持上层依赖抽象。 -证据:`src/workflow/Aevatar.Workflow.Application/Aevatar.Workflow.Application.csproj`。 -3. `Projection` 依赖 `CQRS.Projection.*` 与 `Foundation.Projection`,由通用内核承载生命周期/订阅。 -证据:`src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj`。 -4. `Host.Api` 保持薄宿主,通过扩展方法组合能力。 -证据:`src/workflow/Aevatar.Workflow.Host.Api/Program.cs`、`src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`。 - -### 3.2 统一 CQRS/Projection 主链路(本轮重点) - -1. 命令入口编排职责已下沉拆分: -`WorkflowChatRunApplicationService`(入口) + -`WorkflowRunContextFactory`(上下文创建) + -`WorkflowRunExecutionEngine`(执行/流转) + -`WorkflowRunCompletionPolicy`(终态判定) + -`WorkflowRunResourceFinalizer`(资源回收)。 -证据:`src/workflow/Aevatar.Workflow.Application/Runs/WorkflowChatRunApplicationService.cs`、`src/workflow/Aevatar.Workflow.Application/Runs/WorkflowRunExecutionEngine.cs`。 -2. 投影端口实现拆分为 facade + 策略组件: -`WorkflowExecutionProjectionService` + -`WorkflowProjectionActivationService` + -`WorkflowProjectionReleaseService` + -`WorkflowProjectionLeaseManager` + -`WorkflowProjectionSinkSubscriptionManager` + -`WorkflowProjectionLiveSinkForwarder` + -`WorkflowProjectionSinkFailurePolicy` + -`WorkflowProjectionReadModelUpdater` + -`WorkflowProjectionQueryReader`。 -证据:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionService.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLiveSinkForwarder.cs`。 -3. 架构门禁新增编排类体量限制(行数/依赖数),防止职责反弹。 -证据:`tools/ci/architecture_guards.sh`。 - -### 3.3 Projection 编排与会话语义 - -1. 投影端口使用显式 lease/session(`Ensure/Attach/Detach/Release`),符合句柄化生命周期。 -证据:`src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionPort.cs`。 -2. ownership + lease 协调生命周期,防并发重复启动。 -证据:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLeaseManager.cs`、`test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs`。 -3. sink 背压/写入失败有显式异常策略与错误事件回传。 -证据:`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkFailurePolicy.cs`、`test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs`。 - -### 3.4 子解结构图 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - A1["Workflow.Host.Api"] - A2["Workflow.Extensions.Hosting"] - A3["Workflow.Infrastructure"] - B1["Workflow.Application"] - B2["Workflow.Projection"] - C1["Workflow.Core"] - D1["CQRS.Projection.Core"] - D2["Projection Ownership Actor"] - E1["Workflow.Extensions.AIProjection"] - E2["Workflow.Extensions.Maker"] - - A1 --> A2 - A2 --> A3 - A3 --> B1 - A3 --> B2 - A3 --> C1 - B1 --> B2 - B2 --> D1 - B2 --> D2 - A2 --> E1 - C1 --> E2 -``` - -## 4. 客观验证结果 - -| 检查项 | 命令 | 结果 | -|---|---|---| -| 子解构建 | `dotnet build aevatar.workflow.slnf --nologo --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false` | 通过(0 warning / 0 error) | -| 子解测试 | `dotnet test aevatar.workflow.slnf --nologo --tl:off -m:1 -p:UseSharedCompilation=false -p:NuGetAudit=false --no-build` | 通过(`188 passed / 0 failed`) | -| 架构门禁 | `bash tools/ci/architecture_guards.sh` | 通过 | -| 覆盖率采集(Workflow 程序集过滤) | `dotnet test aevatar.workflow.slnf ... --collect:"XPlat Code Coverage" + reportgenerator` | 行覆盖率 `64.3%`,分支覆盖率 `42.1%`,方法覆盖率 `72.8%` | - -覆盖率证据(最近一次采集):`artifacts/coverage/20260222-025047-workflow-slnf/report/Summary.json`。 - -## 5. 评分结果(100 分制) - -**总分:98 / 100(A+)** - -| 维度 | 权重 | 得分 | 说明 | -|---|---:|---:|---| -| 分层与依赖反转 | 20 | 20 | `Core/Application/Projection/Infrastructure/Host` 边界清晰。 | -| CQRS 与统一投影链路 | 20 | 19 | 主链路统一,编排职责拆分已落地。 | -| Projection 编排与状态约束 | 20 | 20 | facade + 激活/释放/转发/订阅组件分层明确,职责边界清晰。 | -| 读写分离与会话语义 | 15 | 15 | 命令与查询职责分离,lease/session 模型清晰。 | -| 命名语义与冗余清理 | 10 | 10 | 命名与职责基本一致。 | -| 可验证性(门禁/构建/测试) | 15 | 14 | build/test/guard 全绿;覆盖率仍有提升空间。 | - -## 6. 主要扣分项(按影响度) - -### P1 - -1. 暂无 P1 阻断项。 - -### P2 - -1. Workflow 子解覆盖率尚未进入高位区间(特别是分支覆盖率),建议继续补关键异常路径。 -证据:`artifacts/coverage/20260222-025047-workflow-slnf/report/Summary.json`。 - -## 7. 改进建议(优先级) - -1. P1:为 workflow 子解增加覆盖率阈值门禁(line/branch 双阈值),并纳入 `tools/ci/solution_split_test_guards.sh` 或独立 guard。 -2. P2:补齐投影异常路径与并发边界测试(sink 失败、lease 抢占、attach/detach 竞态)。 From c824b364074a068d72e8ed1ed6a9727a74870cdb Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 23:05:09 +0800 Subject: [PATCH 27/46] Add Projection ReadModel Refactor Plan and Audit Scorecard - Introduced a detailed refactor plan for the Projection ReadModel, outlining core objectives, implementation strategies, and architectural changes aimed at enhancing clarity and maintainability. - Added an audit scorecard for the Projection ReadModel, providing a comprehensive evaluation of the architecture with a scoring system and detailed validation results, ensuring transparency and accountability in the assessment process. - Both documents align with ongoing refactoring efforts and aim to improve overall documentation quality and project organization. --- ...readmodel-full-refactor-plan-2026-02-24.md | 414 ++++++++++++++++++ ...ojection-readmodel-scorecard-2026-02-24.md | 218 +++++++++ 2 files changed, 632 insertions(+) create mode 100644 docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md create mode 100644 docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md new file mode 100644 index 000000000..17a68b1d0 --- /dev/null +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -0,0 +1,414 @@ +# Projection ReadModel 全量重构实施计划(v2,按仓库现状细化) + +## 1. 本版更新说明 + +1. 采用你提出的核心规则:**索引 metadata 由 `TReadModel` 泛型 provider 提供**,不挂在 readmodel 实例方法上。 +2. 计划已按当前仓库真实结构重写,覆盖到项目/目录/文件级实施方式。 +3. 继续执行“无兼容重构”:删除旧链路,不保留兼容层和回退开关。 + +## 2. 目标与边界 + +1. 目标:开发者只定义 `State -> ReadModel`,并通过 readmodel 能力接口自动决定写入文档库/图库。 +2. 目标:消除 `Bindings[Type.FullName]` 配置路由,改为类型能力路由。 +3. 目标:消除 `ReadModel` 与 `Relation` 双体系心智割裂。 +4. 非目标:不做旧配置兼容,不做灰度并存。 + +## 3. 仓库现状映射(实施基准) + +| 层 | 项目 | 现状职责 | 重构动作 | +|---|---|---|---| +| 抽象 | `src/Aevatar.CQRS.Projection.Stores.Abstractions` | ReadModel/Relation/Selection 抽象 | 重构为 capability-first 抽象中心 | +| 运行时 | `src/Aevatar.CQRS.Projection.Runtime` | provider 选择、binding 解析、factory | 删除 binding 路径,新增 capability router | +| Provider | `src/Aevatar.CQRS.Projection.Providers.InMemory` | InMemory ReadModel + Relation | 拆分 Doc/Graph provider 能力注册 | +| Provider | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` | ES ReadModel + (弱)Relation | 收敛为 Document provider | +| Provider | `src/Aevatar.CQRS.Projection.Providers.Neo4j` | Neo4j ReadModel + Relation | 收敛为 Graph provider(可选保留 Doc) | +| 读侧业务 | `src/workflow/Aevatar.Workflow.Projection` | ReadModel projector + Relation projector | 合并为单 readmodel materialization 主链 | +| 应用层 | `src/workflow/Aevatar.Workflow.Application` | 查询/运行编排 | 查询改为统一 facade/port 聚合 | +| 接口层 | `src/workflow/Aevatar.Workflow.Infrastructure` | endpoints 协议适配 | 新增 graph-enriched endpoint,禁止手工两跳拼装 | +| Host 组合 | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` | provider 配置与注册 | 改为 Document/Graph provider 独立配置 | +| 门禁 | `tools/ci/architecture_guards.sh` 等 | 架构与路由守卫 | 新增 capability-first 规则守卫 | + +## 4. 目标架构(最终) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + subgraph D["Domain ReadModel"] + D1["WorkflowExecutionReport"] + D2["IDocumentReadModel"] + D3["IGraphReadModel"] + D4["Graph Node/Edges Data"] + end + + subgraph A["Projection Application"] + A1["Reducers"] + A2["ReadModel Projector"] + A3["Materialization Router"] + A4["Projection Query Facade"] + end + + subgraph I["Infrastructure Stores"] + I1["Document Store (ES/InMemory)"] + I2["Graph Store (Neo4j/InMemory)"] + I3["ReadModel Metadata Provider"] + end + + subgraph H["Host/API"] + H1["/actors/{id}"] + H2["/actors/{id}/relation-subgraph"] + H3["/actors/{id}/graph-enriched"] + end + + D1 --> D2 + D1 --> D3 + D3 --> D4 + + A1 --> A2 --> A3 + A3 --> I1 + A3 --> I2 + D2 --> I3 --> I1 + + H1 --> A4 + H2 --> A4 + H3 --> A4 + A4 --> I1 + A4 --> I2 +``` + +## 5. 核心契约(重构后) + +### 5.1 ReadModel 能力接口 + +```csharp +public interface IProjectionReadModel +{ + string Id { get; } +} + +public interface IDocumentReadModel : IProjectionReadModel +{ + string DocumentScope { get; } +} + +public interface IGraphReadModel : IProjectionReadModel +{ + GraphNodeDescriptor GraphNode { get; } + IReadOnlyList GraphEdges { get; } +} + +public sealed record GraphNodeDescriptor( + string NodeId, + string NodeType, + IReadOnlyDictionary Properties); + +public sealed record GraphEdgeDescriptor( + string EdgeId, + string RelationType, + string FromNodeId, + string ToNodeId, + IReadOnlyDictionary Properties); +``` + +### 5.2 索引 metadata(泛型 provider) + +```csharp +public sealed record DocumentIndexMetadata( + string IndexName, + string MappingJson, + IReadOnlyDictionary Settings, + IReadOnlyDictionary Aliases); + +public interface IReadModelDocumentMetadataProvider + where TReadModel : class, IDocumentReadModel +{ + DocumentIndexMetadata Metadata { get; } +} +``` + +约束: + +1. `DocumentIndexMetadata` 必须由 `TReadModel` 对应 provider 给出,禁止从运行时对象反射/动态拼接。 +2. `IReadModelDocumentMetadataProvider` 在 DI 中必须唯一注册。 + +### 5.3 路由与写入契约 + +```csharp +public interface IDocumentProjectionStore + where TReadModel : class, IDocumentReadModel +{ + Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); + Task GetAsync(TKey key, CancellationToken ct = default); + Task> ListAsync(int take = 50, CancellationToken ct = default); +} + +public interface IGraphProjectionStore + where TReadModel : class, IGraphReadModel +{ + Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); + Task GetSubgraphAsync(string nodeId, int depth, int take, CancellationToken ct = default); +} + +public interface IProjectionMaterializationRouter + where TReadModel : class, IProjectionReadModel +{ + Task MaterializeAsync(TReadModel readModel, TKey key, CancellationToken ct = default); +} +``` + +## 6. 关键实施策略 + +1. 单一 projector 产出 `ReadModel`,materialization router 根据接口能力执行 Doc/Graph 单写或双写。 +2. Graph 不再由单独 relation projector 维护;关系由 `IGraphReadModel.GraphEdges` 数据驱动。 +3. Graph 写入必须执行边差异收敛(`toAdd/toUpdate/toDelete`),不能只 upsert。 +4. 查询统一由 projection query facade 完成,endpoint 不手工先查图再查文档。 +5. 默认 deduplicator 改为持久化实现,passthrough 只在测试 profile 注入。 +6. 统一时间源到 `IProjectionClock`。 + +## 7. 文件级改造清单(按项目) + +## 7.1 `src/Aevatar.CQRS.Projection.Stores.Abstractions` + +新增: + +1. `Abstractions/ReadModels/IProjectionReadModel.cs` +2. `Abstractions/ReadModels/IDocumentReadModel.cs` +3. `Abstractions/ReadModels/IGraphReadModel.cs` +4. `Abstractions/ReadModels/GraphNodeDescriptor.cs` +5. `Abstractions/ReadModels/GraphEdgeDescriptor.cs` +6. `Abstractions/ReadModels/DocumentIndexMetadata.cs` +7. `Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs` +8. `Abstractions/ReadModels/IDocumentProjectionStore.cs` +9. `Abstractions/ReadModels/IGraphProjectionStore.cs` +10. `Abstractions/Selection/IProjectionMaterializationRouter.cs` + +修改: + +1. `Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs`(移除 `Bindings`) +2. `Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs`(改为 Document/Graph provider 选项) +3. `Abstractions/ReadModels/ProjectionReadModelRequirements.cs`(改为 capability bool 集合) + +删除: + +1. `Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs` +2. `Abstractions/ReadModels/ProjectionReadModelBindingException.cs` +3. `Abstractions/ReadModels/ProjectionReadModelIndexKind.cs` + +## 7.2 `src/Aevatar.CQRS.Projection.Runtime` + +新增: + +1. `Runtime/ProjectionReadModelCapabilityInspector.cs` +2. `Runtime/ProjectionMaterializationRouter.cs` +3. `Runtime/ProjectionDocumentMetadataResolver.cs` +4. `Runtime/ProjectionGraphWritePlanner.cs` + +修改: + +1. `Runtime/ProjectionStoreSelectionPlanner.cs`(基于 capability,不再读取 binding) +2. `Runtime/ProjectionReadModelProviderSelector.cs`(Document provider 选择) +3. `Runtime/ProjectionRelationStoreProviderSelector.cs`(重命名为 Graph provider selector) +4. `DependencyInjection/ServiceCollectionExtensions.cs`(注册新 router/resolver) + +删除: + +1. `Runtime/ProjectionReadModelBindingResolver.cs` + +## 7.3 `src/Aevatar.CQRS.Projection.Providers.InMemory` + +新增: + +1. `Stores/InMemoryDocumentProjectionStore.cs` +2. `Stores/InMemoryGraphProjectionStore.cs` + +修改: + +1. `DependencyInjection/ServiceCollectionExtensions.cs`(改为注册 `IDocumentProjectionStore` / `IGraphProjectionStore`) + +删除: + +1. `Stores/InMemoryProjectionReadModelStore.cs` +2. `Stores/InMemoryProjectionRelationStore.cs` + +## 7.4 `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` + +新增: + +1. `Stores/ElasticsearchDocumentProjectionStore.cs` +2. `Stores/ElasticsearchIndexMetadataBootstrapper.cs` + +修改: + +1. `DependencyInjection/ServiceCollectionExtensions.cs`(仅注册 document provider) + +删除: + +1. `Stores/ElasticsearchProjectionRelationStore.cs` +2. `Stores/ElasticsearchProjectionReadModelStore.cs` + +## 7.5 `src/Aevatar.CQRS.Projection.Providers.Neo4j` + +新增: + +1. `Stores/Neo4jGraphProjectionStore.cs` +2. `Stores/Neo4jGraphDiffWriter.cs` + +修改: + +1. `DependencyInjection/ServiceCollectionExtensions.cs`(默认注册 graph provider) + +删除: + +1. `Stores/Neo4jProjectionRelationStore.cs` +2. `Stores/Neo4jProjectionReadModelStore.cs`(若不保留图库文档能力) + +## 7.6 `src/workflow/Aevatar.Workflow.Projection` + +新增: + +1. `Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs` +2. `Orchestration/WorkflowProjectionQueryFacade.cs` + +修改: + +1. `ReadModels/WorkflowExecutionReadModel.cs`(实现 `IDocumentReadModel` + `IGraphReadModel`) +2. `Projectors/WorkflowExecutionReadModelProjector.cs`(完成 reduce 后只调用 materialization router) +3. `DependencyInjection/ServiceCollectionExtensions.cs`(移除 relation projector 注册,注册 metadata provider) +4. `Orchestration/WorkflowProjectionQueryReader.cs`(调用 facade,支持 graph-enriched) +5. `Orchestration/WorkflowProjectionReadModelUpdater.cs`(统一 clock 与 materialization) + +删除: + +1. `Projectors/WorkflowExecutionRelationProjector.cs` +2. `ReadModels/WorkflowExecutionRelationConstants.cs`(关系常量下沉到 graph descriptor) + +## 7.7 `src/workflow/Aevatar.Workflow.Application.Abstractions` + +修改: + +1. `Projections/IWorkflowExecutionProjectionQueryPort.cs`(新增 graph-enriched 查询方法) +2. `Queries/WorkflowExecutionQueryModels.cs`(新增 graph-enriched DTO) + +## 7.8 `src/workflow/Aevatar.Workflow.Application` + +修改: + +1. `Queries/WorkflowExecutionQueryApplicationService.cs`(统一走新 query port/facade) + +## 7.9 `src/workflow/Aevatar.Workflow.Infrastructure` + +修改: + +1. `CapabilityApi/ChatQueryEndpoints.cs`(新增 `/actors/{actorId}/graph-enriched`) +2. `DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`(去掉 relation 分支显式依赖) + +## 7.10 `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` + +修改: + +1. `WorkflowProjectionProviderServiceCollectionExtensions.cs` + 1. 删除 `Projection:ReadModel:Bindings` 路径。 + 2. 新增 `Projection:Document:Provider` 与 `Projection:Graph:Provider`。 + 3. provider 注册改为 capability 对应注册。 + +## 7.11 测试项目 + +修改: + +1. `test/Aevatar.CQRS.Projection.Core.Tests` + 1. 删除 binding resolver 相关测试。 + 2. 新增 metadata provider、capability router、dual-write 路径测试。 +2. `test/Aevatar.Workflow.Host.Api.Tests` + 1. 删除 `WorkflowExecutionRelationProjectorTests.cs`。 + 2. 新增 graph-enriched 查询与统一 projector 行为测试。 + +## 7.12 CI / Guard + +修改: + +1. `tools/ci/architecture_guards.sh` + 1. 新增禁用 `Bindings[` / `Type.FullName` 路由规则。 + 2. 新增 `IReadModelDocumentMetadataProvider` 唯一注册守卫。 +2. `tools/ci/projection_route_mapping_guard.sh` + 1. 保留 TypeUrl 精确路由守卫。 +3. `tools/ci/projection_provider_e2e_smoke.sh` + 1. 增加 dual-write(ES + Neo4j)完整执行断言。 + +## 8. 实施阶段(可执行方式) + +### Phase 0:基线冻结(0.5 天) + +1. 执行并留档: + 1. `bash tools/ci/architecture_guards.sh` + 2. `bash tools/ci/projection_route_mapping_guard.sh` + 3. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` + 4. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +2. 输出 ADR:确认无兼容策略与删除清单。 + +### Phase 1:抽象改造(2 天) + +1. 先改 `Stores.Abstractions` 新接口与 metadata provider 泛型。 +2. 删 binding 抽象与引用。 +3. 修复编译并补最小单测。 + +### Phase 2:Runtime + Provider(3 天) + +1. 落地 capability inspector/router/metadata resolver。 +2. InMemory/ES/Neo4j provider 按 doc/graph 重建。 +3. 补 dual-write 失败上报与重试策略测试。 + +### Phase 3:Workflow Projection 主链收敛(3 天) + +1. `WorkflowExecutionReport` 实现双能力。 +2. 删除 relation projector,统一写入 router。 +3. QueryReader 改为统一 facade/port。 + +### Phase 4:Application + API(2 天) + +1. 应用查询服务与 endpoint 全部接新 query port。 +2. 新增 graph-enriched endpoint。 +3. 删除 endpoint 手工二次拼装逻辑。 + +### Phase 5:门禁与文档收口(1.5 天) + +1. 更新 CI 守卫与 E2E。 +2. 删除遗留文档、遗留配置示例、遗留测试。 +3. 重新执行全量验证命令。 + +## 9. 验证命令(阶段完成即跑) + +1. `dotnet restore aevatar.slnx --nologo` +2. `dotnet build aevatar.slnx --nologo` +3. `dotnet test aevatar.slnx --nologo` +4. `bash tools/ci/architecture_guards.sh` +5. `bash tools/ci/projection_route_mapping_guard.sh` +6. `bash tools/ci/solution_split_guards.sh` +7. `bash tools/ci/solution_split_test_guards.sh` +8. `bash tools/ci/test_stability_guards.sh` + +## 10. 验收标准(DoD) + +1. 新增 readmodel 时不再需要 `Bindings` 配置。 +2. `TReadModel` 的索引 metadata 必须由 `IReadModelDocumentMetadataProvider` 提供且可被 runtime 解析。 +3. 同一事件输入可稳定触发 doc/graph 双写(按能力接口自动路由)。 +4. 图查询调用方不再手写两跳逻辑。 +5. 全量门禁、构建、测试通过。 + +## 11. 不做项(明确删除) + +1. 不保留旧 `Projection:ReadModel:Bindings` 配置。 +2. 不保留旧 relation projector 与新 router 并存。 +3. 不保留 compatibility adapter 或 feature flag 回退。 + +## 12. 阶段执行图 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TB + P0["Phase 0 基线冻结"] --> P1["Phase 1 抽象改造"] + P1 --> P2["Phase 2 Runtime + Provider"] + P2 --> P3["Phase 3 Workflow Projection 收敛"] + P3 --> P4["Phase 4 Application + API"] + P4 --> P5["Phase 5 门禁与文档收口"] + P5 --> DONE["Build/Test/Guards 全绿"] +``` + diff --git a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md new file mode 100644 index 000000000..1d33d9d25 --- /dev/null +++ b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md @@ -0,0 +1,218 @@ +# Projection ReadModel 架构评分卡(2026-02-24,严格详细版) + +## 1. 审计范围与方法 + +1. 审计对象:`Projection ReadModel` 端到端链路(Host 接线、Application 端口、Projection Core 编排、Runtime 选型、Provider 存储、AGUI 同链路分支、CI 门禁与测试)。 +2. 评分规范:`docs/audit-scorecard/README.md`(100 分,6 维度)。 +3. 评分原则:按“先满分后扣分”,仅对已落地且可复现的问题扣分;每项扣分绑定代码证据(文件+行号)或命令结果。 +4. 审计基线:遵循评分规范第 2 节(InMemory/Local Actor/ProjectReference 不作为扣分项)。 + +## 2. 关键验证结果(本次实跑) + +| 检查项 | 命令 | 结果 | +|---|---|---| +| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | +| 路由映射专项 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | +| Projection Core 定向测试 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests\|FullyQualifiedName~ProjectionReadModelStoreSelectorTests\|FullyQualifiedName~ProjectionProviderE2EIntegrationTests" -m:1 -p:UseSharedCompilation=false` | 通过(`10 passed / 0 failed / 2 skipped`) | +| Workflow Projection 定向测试 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests\|FullyQualifiedName~WorkflowExecutionReadModelProjectorTests\|FullyQualifiedName~WorkflowProjectionOrchestrationComponentTests" -m:1 -p:UseSharedCompilation=false` | 通过(`36 passed / 0 failed / 0 skipped`) | + +## 3. 总分与等级 + +**总分:95 / 100(A+)** + +| 维度 | 权重 | 得分 | 扣分说明 | +|---|---:|---:|---| +| 分层与依赖反转 | 20 | 20 | 未发现跨层反向依赖与宿主承载核心业务编排问题。 | +| CQRS 与统一投影链路 | 20 | 19 | 默认去重器为透传实现,降低 at-least-once 场景下 readmodel 幂等鲁棒性。 | +| Projection 编排与状态约束 | 20 | 17 | live sink 订阅关系保存在进程内 lease 对象,未 actor/distributed 化。 | +| 读写分离与会话语义 | 15 | 14 | 完成阶段时间源使用 `DateTimeOffset.UtcNow`,未统一走 `IProjectionClock`。 | +| 命名语义与冗余清理 | 10 | 10 | 命名、职责边界与扩展点语义一致,未见重复空壳层。 | +| 可验证性(门禁/构建/测试) | 15 | 15 | 架构门禁 + 路由门禁 + 相关定向测试均通过。 | + +## 4. 详细扣分项(严格) + +### 4.1 P2-1:Live Sink 订阅运行态仍为进程内事实(-3) + +1. 证据:`WorkflowExecutionRuntimeLease` 内维护 `_liveSinkSubscriptions` 列表,并提供 attach/detach/count 逻辑。 + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:8` + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:31` + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:60` +2. 证据:`ReleaseIfIdleAsync` 依赖该本地计数决定是否释放投影 ownership。 + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs:31` +3. 影响:在多节点/连接迁移场景,sink 订阅事实与 ownership 释放决策可能出现节点本地视角偏差。 +4. 结论:不属于“actorId->context 字典”硬违规,但与“投影会话/订阅运行态 actor 化”目标相比仍有架构欠账。 + +### 4.2 P2-2:默认去重策略为透传(-1) + +1. 证据:默认注入 `PassthroughEventDeduplicator`。 + `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:46` +2. 证据:透传实现始终返回 `true`,不记录任何去重状态。 + `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:165` +3. 证据:projector 去重逻辑依赖该接口。 + `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs:61` +4. 影响:在重复投递或重放噪声下,timeline/summary 可能重复累计,影响 readmodel 一致性鲁棒性。 + +### 4.3 P3-1:时间源未完全统一(-1) + +1. 证据:`CompleteAsync` 直接使用 `DateTimeOffset.UtcNow`。 + `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs:88` +2. 对比:同链路其他组件已使用 `IProjectionClock`(activation/updater/failure reporter)。 + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs:39` + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs:24` + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs:42` +3. 影响:测试可控性与跨组件时间语义一致性略受影响。 + +## 5. 正向证据(加分项) + +1. 统一选择权威入口:Runtime selector 复用抽象层权威 `ProjectionReadModelStoreSelector.Select(...)`。 + `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32` + `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs:5` +2. ReadModel/Relation 统一规划:单一 `ProjectionStoreSelectionPlanner` 产出双存储 selection plan。 + `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs:12` + `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:155` +3. startup 校验与运行时选择同源:`WorkflowReadModelStartupValidationHostedService` 复用同一 selection plan。 + `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:47` +4. 生命周期显式 lease/session:Lifecycle port 不暴露 `actorId` 反查,Attach/Detach/Release 都基于 lease。 + `src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs:16` +5. ownership actor 化:acquire/release 通过 `ActorProjectionOwnershipCoordinator -> ProjectionOwnershipCoordinatorGAgent`。 + `src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs:23` + `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs:25` +6. 统一入口一对多分发:同一 `EventEnvelope` 在 coordinator 中分发到多个 projector 分支。 + `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionCoordinator.cs:19` +7. AGUI 与 ReadModel 共用同链路输入:AGUI projector 作为同一 projector 分支发布 run event。 + `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs:22` + `src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs:15` +8. Route mapping 守卫要求 `TypeUrl` 派生 + 精确键匹配 + `TryGetValue` 命中。 + `tools/ci/projection_route_mapping_guard.sh:11` + `tools/ci/projection_route_mapping_guard.sh:71` +9. 中间层 ID 映射字典门禁已落地(full-scan)。 + `tools/ci/architecture_guards.sh:548` + `tools/ci/architecture_guards.sh:590` + +## 6. 分模块评分 + +| 模块 | 得分 | 结论 | +|---|---:|---| +| Projection Core(编排/订阅/ownership) | 95 | 主链清晰,ownership actor 化到位;runtime sink 运行态仍有本地化残留。 | +| Runtime(provider registry/selector/factory/planner) | 100 | 单一权威选择逻辑 + 统一规划,结构化日志与失败语义完整。 | +| Workflow Projection(port/orchestration/projector) | 92 | 端口分离、链路完整;默认去重透传与完成时间源不统一需收敛。 | +| Provider(InMemory/ES/Neo4j + relation) | 96 | 能力声明与写路径日志规范较完整;可观测性和一致性策略仍可增强。 | +| Host/API 组合层 | 96 | API 主要负责协议适配与组合;未下沉到 projection 内核细节。 | +| Guards + Tests | 98 | 守卫覆盖关键架构约束,相关定向测试通过,验证闭环较完整。 | + +## 7. 详细架构图 + +### 7.1 Projection ReadModel 主链路(命令驱动 + 同链路 AGUI) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + subgraph H["Host / API"] + H1["/api/chat /api/ws/chat"] + H2["ICommandExecutionService"] + H3["IWorkflowExecutionQueryApplicationService"] + end + + subgraph A["Application Layer"] + A1["WorkflowRunContextFactory"] + A2["IWorkflowExecutionProjectionLifecyclePort"] + A3["WorkflowRunExecutionEngine"] + A4["WorkflowRunResourceFinalizer"] + A5["IWorkflowExecutionProjectionQueryPort"] + end + + subgraph P["Projection Ports + Orchestration"] + P1["WorkflowExecutionProjectionLifecycleService"] + P2["WorkflowProjectionActivationService"] + P3["WorkflowProjectionLeaseManager"] + P4["ActorProjectionOwnershipCoordinator"] + P5["ProjectionOwnershipCoordinatorGAgent(State)"] + P6["ProjectionLifecycleService"] + P7["ProjectionSubscriptionRegistry"] + P8["ActorStreamSubscriptionHub"] + end + + subgraph B["Projector Branches (Same Envelope)"] + B1["WorkflowExecutionReadModelProjector"] + B2["WorkflowExecutionRelationProjector"] + B3["WorkflowExecutionAGUIEventProjector"] + end + + subgraph R["Runtime Selection + Stores"] + R1["ProjectionStoreSelectionPlanner"] + R2["ProjectionReadModelBindingResolver"] + R3["ProjectionReadModelStoreFactory"] + R4["ProjectionRelationStoreFactory"] + R5["InMemory / Elasticsearch / Neo4j"] + end + + subgraph S["Session Stream + Live Sink"] + S1["ProjectionSessionEventHub"] + S2["WorkflowRunEventChannel / Sink"] + end + + H1 --> H2 + H2 --> A1 + A1 --> A2 + A2 --> P1 + P1 --> P2 + P2 --> P3 --> P4 --> P5 + P2 --> P6 --> P7 --> P8 + P8 --> B1 + P8 --> B2 + P8 --> B3 + + R1 --> R2 + P2 --> R1 + B1 --> R3 --> R5 + B2 --> R4 --> R5 + B3 --> S1 --> S2 + A3 --> A4 --> A2 + + H3 --> A5 + A5 --> R3 + A5 --> R4 +``` + +### 7.2 Store 选型与能力校验链(ReadModel + Relation) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TB + O1["ProjectionReadModelRuntimeOptions"] --> P1["ProjectionStoreSelectionPlanner.Build"] + P1 --> P2["ReadModelSelectionOptions"] + P1 --> P3["RelationSelectionOptions"] + O2["ReadModel Bindings(Type.FullName -> IndexKind)"] --> B1["ProjectionReadModelBindingResolver.Resolve"] --> P1 + + P2 --> F1["ProjectionReadModelStoreFactory.Create"] + F1 --> G1["ProviderRegistry.GetRegistrations"] + G1 --> S1["ProjectionReadModelProviderSelector.Select"] + S1 --> X1["ProjectionReadModelStoreSelector.Select(Authority)"] + X1 --> C1["ProjectionReadModelCapabilityValidator.EnsureSupported"] + C1 --> RM["IProjectionReadModelStore"] + + P3 --> F2["ProjectionRelationStoreFactory.Create"] + F2 --> G2["RelationProviderRegistry.GetRegistrations"] + G2 --> S2["ProjectionRelationStoreProviderSelector.Select"] + S2 --> X2["ProjectionStoreSelector.Select(Authority)"] + X2 --> C2["ProjectionReadModelCapabilityValidator.EnsureSupported"] + C2 --> RS["IProjectionRelationStore"] + + RM --> V1["WorkflowExecutionReadModelProjector"] + RS --> V2["WorkflowExecutionRelationProjector"] +``` + +## 8. 结论与优先级建议 + +### P1 + +1. 无。 + +### P2 + +1. 将 live sink 订阅计数/绑定关系从 `WorkflowExecutionRuntimeLease` 迁移到 actor 持久态或抽象化分布式状态,避免多节点本地视角偏差。 +2. 用可替换的持久化 `IEventDeduplicator` 作为默认实现(至少按 `rootActorId + envelopeId` 做幂等记录),透传实现仅用于 dev/test。 + +### P3 + +1. 将 `WorkflowExecutionReadModelProjector.CompleteAsync` 的时间源统一到 `IProjectionClock`,消除链路内时间语义分叉。 From 5b18a6b82850edd37c4688ad05e98ae0962e8c33 Mon Sep 17 00:00:00 2001 From: Loning Date: Tue, 24 Feb 2026 23:30:06 +0800 Subject: [PATCH 28/46] Refactor Projection ReadModel Architecture and Enhance Provider Capabilities - Introduced new abstractions for document and graph read models, including `IDocumentReadModel`, `IGraphReadModel`, and their respective projection stores. - Updated existing providers to support the new document and graph capabilities, including Elasticsearch and InMemory providers. - Implemented a `ProjectionMaterializationRouter` for dual-write capabilities, allowing seamless integration between document and graph stores. - Enhanced dependency injection configurations to register new services and ensure proper integration of the updated architecture. - Improved documentation to reflect the architectural changes and provide clearer guidance on the new abstractions and provider capabilities. --- ...readmodel-full-refactor-plan-2026-02-24.md | 10 +- .../ServiceCollectionExtensions.cs | 28 +- .../README.md | 6 +- .../ElasticsearchProjectionRelationStore.cs | 59 --- .../ServiceCollectionExtensions.cs | 14 +- .../README.md | 10 +- .../InMemoryProjectionReadModelStore.cs | 7 +- .../Stores/InMemoryProjectionRelationStore.cs | 3 +- .../ServiceCollectionExtensions.cs | 16 +- .../README.md | 14 +- .../Stores/Neo4jProjectionReadModelStore.cs | 2 +- .../ServiceCollectionExtensions.cs | 4 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 3 +- .../ProjectionDocumentMetadataResolver.cs | 20 + .../Runtime/ProjectionGraphStoreAdapter.cs | 172 +++++++ .../ProjectionMaterializationRouter.cs | 101 ++++ .../ProjectionStoreSelectionPlanner.cs | 52 +- .../ReadModels/DocumentIndexMetadata.cs | 7 + .../ReadModels/GraphEdgeDescriptor.cs | 9 + .../ReadModels/GraphNodeDescriptor.cs | 7 + .../ReadModels/IDocumentProjectionStore.cs | 13 + .../ReadModels/IDocumentReadModel.cs | 6 + .../ReadModels/IGraphProjectionStore.cs | 15 + .../ReadModels/IGraphReadModel.cs | 10 + .../IProjectionDocumentMetadataResolver.cs | 7 + .../ReadModels/IProjectionReadModel.cs | 6 + .../ReadModels/IProjectionReadModelStore.cs | 12 +- .../IReadModelDocumentMetadataProvider.cs | 7 + .../ProjectionReadModelRuntimeOptions.cs | 17 +- .../IProjectionMaterializationRouter.cs | 13 + ...IProjectionStoreSelectionRuntimeOptions.cs | 6 +- .../README.md | 3 + .../IWorkflowExecutionProjectionQueryPort.cs | 7 + ...orkflowExecutionQueryApplicationService.cs | 7 + .../Queries/WorkflowExecutionQueryModels.cs | 7 + ...orkflowExecutionQueryApplicationService.cs | 13 + .../CapabilityApi/ChatQueryEndpoints.cs | 18 + .../ServiceCollectionExtensions.cs | 34 +- ...ExecutionReportDocumentMetadataProvider.cs | 13 + .../IWorkflowProjectionQueryReader.cs | 7 + ...WorkflowExecutionProjectionQueryService.cs | 21 + .../WorkflowProjectionQueryReader.cs | 41 +- .../WorkflowProjectionReadModelUpdater.cs | 10 +- ...ReadModelStartupValidationHostedService.cs | 8 +- .../WorkflowExecutionReadModelProjector.cs | 23 +- .../WorkflowExecutionRelationProjector.cs | 361 -------------- .../Aevatar.Workflow.Projection/README.md | 41 +- .../ReadModels/WorkflowExecutionReadModel.cs | 193 +++++++- ...tionProviderServiceCollectionExtensions.cs | 102 ++-- .../ProjectionStoreSelectionPlannerTests.cs | 36 +- .../WorkflowApplicationLayerTests.cs | 19 + .../ChatEndpointsInternalTests.cs | 19 + ...hatWebSocketCoordinatorAndProtocolTests.cs | 20 + ...orkflowCapabilityEndpointsCoverageTests.cs | 8 + ...lowExecutionProjectionRegistrationTests.cs | 462 ++---------------- ...WorkflowExecutionProjectionServiceTests.cs | 32 +- ...orkflowExecutionReadModelProjectorTests.cs | 36 +- ...WorkflowExecutionRelationProjectorTests.cs | 144 ------ .../WorkflowHostingExtensionsCoverageTests.cs | 22 +- ...owProjectionOrchestrationComponentTests.cs | 9 +- 60 files changed, 1126 insertions(+), 1246 deletions(-) delete mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs delete mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 17a68b1d0..a9667f370 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -6,6 +6,15 @@ 2. 计划已按当前仓库真实结构重写,覆盖到项目/目录/文件级实施方式。 3. 继续执行“无兼容重构”:删除旧链路,不保留兼容层和回退开关。 +### 1.1 已落地实现(2026-02-24) + +1. 已新增 capability-first 抽象:`IProjectionReadModel`、`IDocumentReadModel`、`IGraphReadModel`、`IDocumentProjectionStore`、`IGraphProjectionStore`、`IProjectionMaterializationRouter`。 +2. 已落地泛型 metadata 链路:`IReadModelDocumentMetadataProvider` + `IProjectionDocumentMetadataResolver`。 +3. 已落地 Runtime 双写路由:`ProjectionMaterializationRouter` 与 `ProjectionGraphStoreAdapter`。 +4. 已删除 `WorkflowExecutionRelationProjector`,关系写入改为 `WorkflowExecutionReport` 的 `GraphNodes/GraphEdges` 驱动。 +5. Host 配置已切换为 `Projection:Document:*` / `Projection:Graph:*`,并禁用 Elasticsearch 作为 graph provider。 +6. Workflow Query 已新增 `graph-enriched` 聚合查询与 endpoint:`/actors/{actorId}/graph-enriched`。 + ## 2. 目标与边界 1. 目标:开发者只定义 `State -> ReadModel`,并通过 readmodel 能力接口自动决定写入文档库/图库。 @@ -411,4 +420,3 @@ flowchart TB P4 --> P5["Phase 5 门禁与文档收口"] P5 --> DONE["Build/Test/Guards 全绿"] ``` - diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 9f08bb157..bace1df7c 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,17 +7,17 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddElasticsearchReadModelStoreRegistration( + public static IServiceCollection AddElasticsearchDocumentStoreRegistration( this IServiceCollection services, Func optionsFactory, - string indexScope, + Func indexScopeFactory, Func keySelector, Func? keyFormatter = null, string providerName = ProjectionReadModelProviderNames.Elasticsearch) where TReadModel : class { ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentNullException.ThrowIfNull(indexScope); + ArgumentNullException.ThrowIfNull(indexScopeFactory); ArgumentNullException.ThrowIfNull(keySelector); services.AddSingleton>>( @@ -31,7 +31,7 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration new ElasticsearchProjectionReadModelStore( optionsFactory(provider), - indexScope, + indexScopeFactory(provider), keySelector, keyFormatter, providerName, @@ -39,24 +39,4 @@ public static IServiceCollection AddElasticsearchReadModelStoreRegistration>( - new DelegateProjectionStoreRegistration( - providerName, - new ProjectionReadModelProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], - supportsAliases: false, - supportsSchemaValidation: false, - supportsRelations: false, - supportsRelationTraversal: false), - _ => new ElasticsearchProjectionRelationStore(providerName))); - - return services; - } } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 7da4d67ad..116ddde54 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -14,11 +14,11 @@ 使用扩展方法: -- `AddElasticsearchReadModelStoreRegistration(...)` +- `AddElasticsearchDocumentStoreRegistration(...)` 关键参数: -- `optionsFactory`:绑定 `Projection:ReadModel:Providers:Elasticsearch:*` 配置。 -- `indexScope`:按业务语义隔离索引(会与 `IndexPrefix` 组合)。 +- `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 +- `indexScopeFactory`:由 `IReadModelDocumentMetadataProvider` 派生索引名。 - `keySelector/keyFormatter`:ReadModel 主键映射。 - `providerName`:默认 `Elasticsearch`(与 `ProjectionReadModelProviderNames.Elasticsearch` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs deleted file mode 100644 index 435e5e366..000000000 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionRelationStore.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; - -public sealed class ElasticsearchProjectionRelationStore - : IProjectionRelationStore, - IProjectionStoreProviderMetadata -{ - public ElasticsearchProjectionRelationStore( - string providerName = ProjectionReadModelProviderNames.Elasticsearch) - { - ProviderCapabilities = new ProjectionReadModelProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], - supportsAliases: false, - supportsSchemaValidation: false, - supportsRelations: false, - supportsRelationTraversal: false); - } - - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } - - public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(node); - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - } - - public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(edge); - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - } - - public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - } - - public Task> GetNeighborsAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(query); - ct.ThrowIfCancellationRequested(); - return Task.FromResult>([]); - } - - public Task GetSubgraphAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(query); - ct.ThrowIfCancellationRequested(); - return Task.FromResult(new ProjectionRelationSubgraph()); - } -} diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index f1bdabbf7..010155451 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddInMemoryReadModelStoreRegistration( + public static IServiceCollection AddInMemoryDocumentStoreRegistration( this IServiceCollection services, Func keySelector, Func? keyFormatter = null, @@ -22,9 +22,10 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -36,7 +37,7 @@ public static IServiceCollection AddInMemoryReadModelStoreRegistration new InMemoryProjectionRelationStore(providerName))); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index 68c950386..8c0cc060b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -1,17 +1,19 @@ # Aevatar.CQRS.Projection.Providers.InMemory -通用 InMemory ReadModel Provider。 +通用 InMemory Provider(支持 Document/Graph 两类能力)。 - 不依赖业务域模型。 -- 支持按 keySelector 注册任意 `IProjectionReadModelStore`。 -- 默认能力:非索引型(`SupportsIndexing=false`)。 +- 支持按 keySelector 注册任意 `IProjectionReadModelStore`(Document)。 +- 支持关系图存储注册(Graph)。 +- 默认能力:Document 索引 / Graph 索引(仅用于开发和测试语义)。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 ## DI 注册 使用扩展方法: -- `AddInMemoryReadModelStoreRegistration(...)` +- `AddInMemoryDocumentStoreRegistration(...)` +- `AddInMemoryGraphStoreRegistration(...)` 关键参数: diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index 809b927de..c56ae7e65 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -34,9 +34,10 @@ public InMemoryProjectionReadModelStore( _logger = logger ?? NullLogger>.Instance; ProviderCapabilities = new ProjectionReadModelProviderCapabilities( providerName, - supportsIndexing: false, - supportsRelations: true, - supportsRelationTraversal: true); + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Document], + supportsRelations: false, + supportsRelationTraversal: false); } public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs index b19df3b36..0d74a6ecd 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs @@ -16,7 +16,8 @@ public InMemoryProjectionRelationStore( { ProviderCapabilities = new ProjectionReadModelProviderCapabilities( providerName, - supportsIndexing: false, + supportsIndexing: true, + indexKinds: [ProjectionReadModelIndexKind.Graph], supportsRelations: true, supportsRelationTraversal: true); } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 193d1b32e..afe9de1bf 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,17 +7,17 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddNeo4jReadModelStoreRegistration( + public static IServiceCollection AddNeo4jDocumentStoreRegistration( this IServiceCollection services, Func optionsFactory, - string scope, + Func scopeFactory, Func keySelector, Func? keyFormatter = null, string providerName = ProjectionReadModelProviderNames.Neo4j) where TReadModel : class { ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(scopeFactory); ArgumentNullException.ThrowIfNull(keySelector); services.AddSingleton>>( @@ -33,7 +33,7 @@ public static IServiceCollection AddNeo4jReadModelStoreRegistration new Neo4jProjectionReadModelStore( optionsFactory(provider), - scope, + scopeFactory(provider), keySelector, keyFormatter, providerName, @@ -42,14 +42,14 @@ public static IServiceCollection AddNeo4jReadModelStoreRegistration optionsFactory, - string scope, + Func scopeFactory, string providerName = ProjectionReadModelProviderNames.Neo4j) { ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentException.ThrowIfNullOrWhiteSpace(scope); + ArgumentNullException.ThrowIfNull(scopeFactory); services.AddSingleton>( new DelegateProjectionStoreRegistration( @@ -64,7 +64,7 @@ public static IServiceCollection AddNeo4jRelationStoreRegistration( supportsRelationTraversal: true), provider => new Neo4jProjectionRelationStore( optionsFactory(provider), - scope, + scopeFactory(provider), providerName, provider.GetService>()))); diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index 19b0532aa..d07738149 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -1,10 +1,11 @@ # Aevatar.CQRS.Projection.Providers.Neo4j -通用 Neo4j Graph ReadModel Provider。 +通用 Neo4j Provider(支持 Document/Graph 两类能力)。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 -- 能力声明:`Graph` 索引、schema validation。 +- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成(Document)。 +- 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 +- 能力声明:`Document/Graph` 索引、schema validation。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 @@ -12,11 +13,12 @@ 使用扩展方法: -- `AddNeo4jReadModelStoreRegistration(...)` +- `AddNeo4jDocumentStoreRegistration(...)` +- `AddNeo4jGraphStoreRegistration(...)` 关键参数: -- `optionsFactory`:绑定 `Projection:ReadModel:Providers:Neo4j:*` 配置。 -- `scope`:图存储作用域(等价于 document provider 的 indexScope)。 +- `optionsFactory`:绑定 `Projection:Document:Providers:Neo4j:*` 或 `Projection:Graph:Providers:Neo4j:*` 配置。 +- `scopeFactory`:文档 scope 或 graph scope 提供器。 - `keySelector/keyFormatter`:ReadModel 主键映射。 - `providerName`:默认 `Neo4j`(与 `ProjectionReadModelProviderNames.Neo4j` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index 197501287..8f68e14d2 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -58,7 +58,7 @@ public Neo4jProjectionReadModelStore( ProviderCapabilities = new ProjectionReadModelProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], + indexKinds: [ProjectionReadModelIndexKind.Document, ProjectionReadModelIndexKind.Graph], supportsAliases: false, supportsSchemaValidation: true, supportsRelations: true, diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 860fa1fc6..dda05d3d8 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -11,12 +11,14 @@ public static IServiceCollection AddProjectionReadModelRuntime(this IServiceColl services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(typeof(IGraphProjectionStore<>), typeof(ProjectionGraphStoreAdapter<>)); + services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); + services.TryAddSingleton(); services.TryAddSingleton(); return services; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index 308cc8e98..f7dc5829b 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -6,8 +6,9 @@ - 收敛 Provider 注册查询(`IProjectionReadModelProviderRegistry`)。 - 执行 Provider 选择策略(`IProjectionReadModelProviderSelector`)。 -- 解析 ReadModel 绑定需求(`IProjectionReadModelBindingResolver`)。 +- 按 `IDocumentReadModel/IGraphReadModel` 能力推导选择需求(`IProjectionStoreSelectionPlanner`)。 - 统一创建 Store 并输出结构化创建日志(`IProjectionReadModelStoreFactory`)。 +- 提供 `IProjectionMaterializationRouter` 与 `ProjectionGraphStoreAdapter` 双写路由能力。 ## DI 入口 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs new file mode 100644 index 000000000..c63f2d44a --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionDocumentMetadataResolver : IProjectionDocumentMetadataResolver +{ + private readonly IServiceProvider _serviceProvider; + + public ProjectionDocumentMetadataResolver(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public DocumentIndexMetadata Resolve() + where TReadModel : class, IDocumentReadModel + { + var provider = _serviceProvider.GetRequiredService>(); + return provider.Metadata; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs new file mode 100644 index 000000000..2642fe261 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs @@ -0,0 +1,172 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionGraphStoreAdapter + : IGraphProjectionStore + where TReadModel : class +{ + private const string ManagedMarkerKey = "projectionManaged"; + private const string ManagedMarkerValue = "true"; + private readonly IProjectionRelationStore _relationStore; + + public ProjectionGraphStoreAdapter(IProjectionRelationStore relationStore) + { + _relationStore = relationStore; + } + + public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + if (readModel is not IGraphReadModel graphReadModel) + return; + + var scope = NormalizeToken(graphReadModel.GraphScope); + if (scope.Length == 0) + { + throw new InvalidOperationException( + $"Graph scope is required for read model '{typeof(TReadModel).FullName}'."); + } + + var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope); + foreach (var node in normalizedNodes) + await _relationStore.UpsertNodeAsync(node, ct); + + var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope); + foreach (var edge in normalizedEdges) + await _relationStore.UpsertEdgeAsync(edge, ct); + + var anchorNodeId = ResolveAnchorNodeId(graphReadModel, normalizedNodes, normalizedEdges); + if (anchorNodeId.Length == 0) + return; + + var existing = await _relationStore.GetSubgraphAsync( + new ProjectionRelationQuery + { + Scope = scope, + RootNodeId = anchorNodeId, + Direction = ProjectionRelationDirection.Both, + Depth = 8, + Take = 5000, + }, + ct); + + var targetEdgeIds = normalizedEdges + .Select(x => x.EdgeId) + .ToHashSet(StringComparer.Ordinal); + + foreach (var edge in existing.Edges.Where(IsManagedEdge)) + { + if (targetEdgeIds.Contains(edge.EdgeId)) + continue; + + await _relationStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); + } + } + + public Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) => + _relationStore.GetNeighborsAsync(query, ct); + + public Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default) => + _relationStore.GetSubgraphAsync(query, ct); + + private static string ResolveAnchorNodeId( + IGraphReadModel readModel, + IReadOnlyList nodes, + IReadOnlyList edges) + { + var firstNodeId = nodes.FirstOrDefault()?.NodeId ?? ""; + if (firstNodeId.Length > 0) + return firstNodeId; + + var readModelId = NormalizeToken(readModel.Id); + if (readModelId.Length > 0) + return readModelId; + + return edges.FirstOrDefault()?.FromNodeId ?? ""; + } + + private static bool IsManagedEdge(ProjectionRelationEdge edge) + { + return edge.Properties.TryGetValue(ManagedMarkerKey, out var markerValue) && + string.Equals(markerValue, ManagedMarkerValue, StringComparison.Ordinal); + } + + private static IReadOnlyList NormalizeNodes( + IReadOnlyList graphNodes, + string scope) + { + if (graphNodes.Count == 0) + return []; + + var nodesById = new Dictionary(StringComparer.Ordinal); + foreach (var graphNode in graphNodes) + { + var nodeId = NormalizeToken(graphNode.NodeId); + if (nodeId.Length == 0) + continue; + + var nodeType = NormalizeToken(graphNode.NodeType); + if (nodeType.Length == 0) + nodeType = "Unknown"; + + nodesById[nodeId] = new ProjectionRelationNode + { + Scope = scope, + NodeId = nodeId, + NodeType = nodeType, + Properties = new Dictionary(graphNode.Properties, StringComparer.Ordinal), + UpdatedAt = graphNode.UpdatedAt == default ? DateTimeOffset.UtcNow : graphNode.UpdatedAt, + }; + } + + return nodesById.Values.ToList(); + } + + private static IReadOnlyList NormalizeEdges( + IReadOnlyList graphEdges, + string scope) + { + if (graphEdges.Count == 0) + return []; + + var edgesById = new Dictionary(StringComparer.Ordinal); + foreach (var graphEdge in graphEdges) + { + var edgeId = NormalizeToken(graphEdge.EdgeId); + var relationType = NormalizeToken(graphEdge.RelationType); + var fromNodeId = NormalizeToken(graphEdge.FromNodeId); + var toNodeId = NormalizeToken(graphEdge.ToNodeId); + if (edgeId.Length == 0 || + relationType.Length == 0 || + fromNodeId.Length == 0 || + toNodeId.Length == 0) + { + continue; + } + + var properties = new Dictionary(graphEdge.Properties, StringComparer.Ordinal) + { + [ManagedMarkerKey] = ManagedMarkerValue, + }; + + edgesById[edgeId] = new ProjectionRelationEdge + { + Scope = scope, + EdgeId = edgeId, + RelationType = relationType, + FromNodeId = fromNodeId, + ToNodeId = toNodeId, + Properties = properties, + UpdatedAt = graphEdge.UpdatedAt == default ? DateTimeOffset.UtcNow : graphEdge.UpdatedAt, + }; + } + + return edgesById.Values.ToList(); + } + + private static string NormalizeToken(string? token) => token?.Trim() ?? ""; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs new file mode 100644 index 000000000..64c163880 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionMaterializationRouter + : IProjectionMaterializationRouter + where TReadModel : class, IProjectionReadModel +{ + private readonly IDocumentProjectionStore? _documentStore; + private readonly IGraphProjectionStore? _graphStore; + private readonly ILogger> _logger; + private readonly bool _requiresDocumentStore = typeof(IDocumentReadModel).IsAssignableFrom(typeof(TReadModel)); + private readonly bool _requiresGraphStore = typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel)); + + public ProjectionMaterializationRouter( + IDocumentProjectionStore? documentStore = null, + IGraphProjectionStore? graphStore = null, + ILogger>? logger = null) + { + _documentStore = documentStore; + _graphStore = graphStore; + _logger = logger ?? NullLogger>.Instance; + } + + public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + + EnsureStoresReady(); + if (_requiresDocumentStore) + await _documentStore!.UpsertAsync(readModel, ct); + + if (_requiresGraphStore) + await _graphStore!.UpsertGraphAsync(readModel, ct); + } + + public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + EnsureStoresReady(); + if (_documentStore == null) + throw new InvalidOperationException( + $"Projection materialization mutate requires a document store for read model '{typeof(TReadModel).FullName}'."); + + await _documentStore.MutateAsync(key, mutate, ct); + if (!_requiresGraphStore) + return; + + var updated = await _documentStore.GetAsync(key, ct); + if (updated == null) + { + _logger.LogWarning( + "Projection materialization graph refresh skipped because the document snapshot is missing. readModelType={ReadModelType}", + typeof(TReadModel).FullName); + return; + } + + await _graphStore!.UpsertGraphAsync(updated, ct); + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + if (_documentStore == null) + { + throw new InvalidOperationException( + $"Projection materialization query requires a document store for read model '{typeof(TReadModel).FullName}'."); + } + + return _documentStore.GetAsync(key, ct); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + if (_documentStore == null) + { + throw new InvalidOperationException( + $"Projection materialization query requires a document store for read model '{typeof(TReadModel).FullName}'."); + } + + return _documentStore.ListAsync(take, ct); + } + + private void EnsureStoresReady() + { + if (_requiresDocumentStore && _documentStore == null) + { + throw new InvalidOperationException( + $"Document capability is required by read model '{typeof(TReadModel).FullName}', but no document projection store is registered."); + } + + if (_requiresGraphStore && _graphStore == null) + { + throw new InvalidOperationException( + $"Graph capability is required by read model '{typeof(TReadModel).FullName}', but no graph projection store is registered."); + } + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs index fabcb2120..bb1cb8e63 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs @@ -2,13 +2,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionStoreSelectionPlanner : IProjectionStoreSelectionPlanner { - private readonly IProjectionReadModelBindingResolver _bindingResolver; - - public ProjectionStoreSelectionPlanner(IProjectionReadModelBindingResolver bindingResolver) - { - _bindingResolver = bindingResolver; - } - public ProjectionStoreSelectionPlan Build( IProjectionStoreSelectionRuntimeOptions options, Type readModelType, @@ -19,19 +12,20 @@ public ProjectionStoreSelectionPlan Build( ArgumentNullException.ThrowIfNull(relationRequirements); EnsureReadModelModeSupported(options.ReadModelMode); - var readModelRequirements = _bindingResolver.Resolve(options.ReadModelBindings, readModelType); - var readModelProvider = NormalizeRequiredProviderName(options.ReadModelProvider); + var readModelRequirements = BuildReadModelRequirements(readModelType); + var readModelRequiresGraph = typeof(IGraphReadModel).IsAssignableFrom(readModelType); + var readModelProvider = NormalizeRequiredProviderName(options.DocumentProvider); var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions { RequestedProviderName = readModelProvider, FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; - var mergedRelationRequirements = MergeRelationRequirements(readModelRequirements, relationRequirements); + var mergedRelationRequirements = MergeRelationRequirements(relationRequirements, readModelRequiresGraph); var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions { - RequestedProviderName = NormalizeRelationProviderName( - options.RelationProvider, + RequestedProviderName = NormalizeGraphProviderName( + options.GraphProvider, readModelProvider), FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; @@ -44,16 +38,28 @@ public ProjectionStoreSelectionPlan Build( } private static ProjectionReadModelRequirements MergeRelationRequirements( - ProjectionReadModelRequirements readModelRequirements, - ProjectionReadModelRequirements relationRequirements) + ProjectionReadModelRequirements relationRequirements, + bool readModelRequiresGraph) { return new ProjectionReadModelRequirements( requiresIndexing: relationRequirements.RequiresIndexing, requiredIndexKinds: relationRequirements.RequiredIndexKinds, - requiresAliases: relationRequirements.RequiresAliases || readModelRequirements.RequiresAliases, - requiresSchemaValidation: relationRequirements.RequiresSchemaValidation || readModelRequirements.RequiresSchemaValidation, - requiresRelations: relationRequirements.RequiresRelations, - requiresRelationTraversal: relationRequirements.RequiresRelationTraversal); + requiresAliases: relationRequirements.RequiresAliases, + requiresSchemaValidation: relationRequirements.RequiresSchemaValidation, + requiresRelations: relationRequirements.RequiresRelations || readModelRequiresGraph, + requiresRelationTraversal: relationRequirements.RequiresRelationTraversal || readModelRequiresGraph); + } + + private static ProjectionReadModelRequirements BuildReadModelRequirements(Type readModelType) + { + var requiredIndexKinds = new List(); + + if (typeof(IDocumentReadModel).IsAssignableFrom(readModelType)) + requiredIndexKinds.Add(ProjectionReadModelIndexKind.Document); + + return new ProjectionReadModelRequirements( + requiresIndexing: requiredIndexKinds.Count > 0, + requiredIndexKinds: requiredIndexKinds); } private static string NormalizeRequiredProviderName(string providerName) @@ -67,14 +73,14 @@ private static string NormalizeRequiredProviderName(string providerName) return providerName.Trim(); } - private static string NormalizeRelationProviderName( - string relationProviderName, + private static string NormalizeGraphProviderName( + string graphProviderName, string fallbackProviderName) { - if (string.IsNullOrWhiteSpace(relationProviderName)) + if (string.IsNullOrWhiteSpace(graphProviderName)) return fallbackProviderName; - return relationProviderName.Trim(); + return graphProviderName.Trim(); } private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) @@ -83,7 +89,7 @@ private static void EnsureReadModelModeSupported(ProjectionReadModelMode readMod return; throw new InvalidOperationException( - "Projection store selection does not support Projection:ReadModel:Mode=StateOnly. " + + "Projection store selection does not support Projection:Document:Mode=StateOnly. " + "Use CustomReadModel or DefaultReadModel."); } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs new file mode 100644 index 000000000..7abc355aa --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public sealed record DocumentIndexMetadata( + string IndexName, + string MappingJson, + IReadOnlyDictionary Settings, + IReadOnlyDictionary Aliases); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs new file mode 100644 index 000000000..02bbd941c --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public sealed record GraphEdgeDescriptor( + string EdgeId, + string RelationType, + string FromNodeId, + string ToNodeId, + IReadOnlyDictionary Properties, + DateTimeOffset UpdatedAt); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs new file mode 100644 index 000000000..f719b3bb8 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public sealed record GraphNodeDescriptor( + string NodeId, + string NodeType, + IReadOnlyDictionary Properties, + DateTimeOffset UpdatedAt); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs new file mode 100644 index 000000000..035789d27 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IDocumentProjectionStore + where TReadModel : class +{ + Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); + + Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default); + + Task GetAsync(TKey key, CancellationToken ct = default); + + Task> ListAsync(int take = 50, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs new file mode 100644 index 000000000..311541a99 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IDocumentReadModel : IProjectionReadModel +{ + string DocumentScope { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs new file mode 100644 index 000000000..43aeedcb1 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IGraphProjectionStore + where TReadModel : class +{ + Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); + + Task> GetNeighborsAsync( + ProjectionRelationQuery query, + CancellationToken ct = default); + + Task GetSubgraphAsync( + ProjectionRelationQuery query, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs new file mode 100644 index 000000000..9a1e725b3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IGraphReadModel : IProjectionReadModel +{ + string GraphScope { get; } + + IReadOnlyList GraphNodes { get; } + + IReadOnlyList GraphEdges { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs new file mode 100644 index 000000000..60d4af5df --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IProjectionDocumentMetadataResolver +{ + DocumentIndexMetadata Resolve() + where TReadModel : class, IDocumentReadModel; +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs new file mode 100644 index 000000000..c4a09e667 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IProjectionReadModel +{ + string Id { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs index 994c0a730..3f7664892 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs @@ -4,13 +4,5 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; /// Generic read-model store contract for projection state persistence/query. /// public interface IProjectionReadModelStore - where TReadModel : class -{ - Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); - - Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default); - - Task GetAsync(TKey key, CancellationToken ct = default); - - Task> ListAsync(int take = 50, CancellationToken ct = default); -} + : IDocumentProjectionStore + where TReadModel : class; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs new file mode 100644 index 000000000..9dc887eb3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IReadModelDocumentMetadataProvider + where TReadModel : class, IDocumentReadModel +{ + DocumentIndexMetadata Metadata { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs index ad8f60b93..05704c7ba 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs @@ -2,28 +2,19 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed class ProjectionReadModelRuntimeOptions : IProjectionStoreSelectionRuntimeOptions { - public ProjectionReadModelRuntimeOptions() - { - Bindings = new Dictionary(StringComparer.OrdinalIgnoreCase); - } - public ProjectionReadModelMode Mode { get; set; } = ProjectionReadModelMode.CustomReadModel; - public string Provider { get; set; } = ProjectionReadModelProviderNames.InMemory; + public string DocumentProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - public string RelationProvider { get; set; } = ""; + public string GraphProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; public bool FailOnUnsupportedCapabilities { get; set; } = true; - public Dictionary Bindings { get; } - - string IProjectionStoreSelectionRuntimeOptions.ReadModelProvider => Provider; + string IProjectionStoreSelectionRuntimeOptions.DocumentProvider => DocumentProvider; - string IProjectionStoreSelectionRuntimeOptions.RelationProvider => RelationProvider; + string IProjectionStoreSelectionRuntimeOptions.GraphProvider => GraphProvider; bool IProjectionStoreSelectionRuntimeOptions.FailOnUnsupportedCapabilities => FailOnUnsupportedCapabilities; ProjectionReadModelMode IProjectionStoreSelectionRuntimeOptions.ReadModelMode => Mode; - - IReadOnlyDictionary IProjectionStoreSelectionRuntimeOptions.ReadModelBindings => Bindings; } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs new file mode 100644 index 000000000..f66d7d1b1 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IProjectionMaterializationRouter + where TReadModel : class, IProjectionReadModel +{ + Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); + + Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default); + + Task GetAsync(TKey key, CancellationToken ct = default); + + Task> ListAsync(int take = 50, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs index 9240b26f4..c3770f599 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs @@ -2,13 +2,11 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionStoreSelectionRuntimeOptions { - string ReadModelProvider { get; } + string DocumentProvider { get; } - string RelationProvider { get; } + string GraphProvider { get; } bool FailOnUnsupportedCapabilities { get; } ProjectionReadModelMode ReadModelMode { get; } - - IReadOnlyDictionary ReadModelBindings { get; } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md index 094b93b09..140de72e9 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -13,6 +13,9 @@ - 读模型存储:`IProjectionReadModelStore<,>` - 关系存储:`IProjectionRelationStore` +- ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`、`IGraphReadModel` +- 双写能力抽象:`IDocumentProjectionStore<,>`、`IGraphProjectionStore<>`、`IProjectionMaterializationRouter<,>` +- 文档索引元数据抽象:`DocumentIndexMetadata`、`IReadModelDocumentMetadataProvider`、`IProjectionDocumentMetadataResolver` - Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionStoreProviderMetadata` - 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` - Provider 注册与选择:`IProjectionStoreRegistration`、`DelegateProjectionStoreRegistration` diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs index edb675e6c..3daf8f1e4 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs @@ -31,4 +31,11 @@ Task GetActorRelationSubgraphAsync( int take = 200, WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); + + Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs index a0babd74b..3ba84833a 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs @@ -24,4 +24,11 @@ Task GetActorRelationSubgraphAsync( int take = 200, WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); + + Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs index 065143f2c..71a5d7443 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs @@ -83,6 +83,13 @@ public sealed class WorkflowActorRelationSubgraph public List Edges { get; set; } = []; } +public sealed class WorkflowActorGraphEnrichedSnapshot +{ + public WorkflowActorSnapshot Snapshot { get; set; } = new(); + + public WorkflowActorRelationSubgraph Subgraph { get; set; } = new(); +} + public sealed record WorkflowTopologyEdge(string Parent, string Child); public enum WorkflowRunProjectionScope diff --git a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs index 56c34b67a..d0e0a68bc 100644 --- a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs @@ -82,4 +82,17 @@ public async Task GetActorRelationSubgraphAsync( return await _projectionPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); } + + public async Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + { + if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) + return null; + + return await _projectionPort.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, options, ct); + } } diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs index 70f4ca4dc..74a59399f 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs @@ -27,6 +27,10 @@ public static void Map(RouteGroupBuilder group) group.MapGet("/actors/{actorId}/relation-subgraph", GetActorRelationSubgraph) .Produces(StatusCodes.Status200OK); + + group.MapGet("/actors/{actorId}/graph-enriched", GetActorGraphEnrichedSnapshot) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); } internal static async Task ListAgents( @@ -86,6 +90,20 @@ internal static async Task GetActorRelationSubgraph( return Results.Ok(subgraph); } + internal static async Task GetActorGraphEnrichedSnapshot( + string actorId, + IWorkflowExecutionQueryApplicationService queryService, + int depth = 2, + int take = 200, + string? direction = null, + string[]? relationTypes = null, + CancellationToken ct = default) + { + var relationOptions = BuildRelationQueryOptions(direction, relationTypes); + var graphEnriched = await queryService.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, relationOptions, ct); + return graphEnriched == null ? Results.NotFound() : Results.Ok(graphEnriched); + } + private static WorkflowActorRelationQueryOptions BuildRelationQueryOptions( string? direction, string[]? relationTypes) diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index ee421bc71..52e9ce1d7 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,11 +1,13 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.Metadata; using Aevatar.Workflow.Projection.Orchestration; using Aevatar.Workflow.Projection.ReadModels; using Aevatar.Workflow.Application.Abstractions.Projections; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.CQRS.Projection.Runtime.DependencyInjection; +using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.CQRS.Projection.Core.DependencyInjection; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; @@ -25,11 +27,6 @@ public static class ServiceCollectionExtensions private static readonly Type ProjectionProjectorContract = typeof(IProjectionProjector<,>); private static readonly Type WorkflowExecutionReducerContract = typeof(IProjectionEventReducer); private static readonly Type WorkflowExecutionProjectorContract = typeof(IProjectionProjector>); - private static readonly ProjectionReadModelRequirements WorkflowRelationRequirements = new( - requiresRelations: true, - requiresRelationTraversal: true, - requiresAliases: false, - requiresSchemaValidation: false); public static IServiceCollection AddWorkflowExecutionProjectionCQRS( this IServiceCollection services, @@ -45,8 +42,10 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); - RegisterWorkflowReadModelStoreSelector(services); - RegisterWorkflowRelationStoreSelector(services); + services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); + RegisterWorkflowDocumentStoreSelector(services); + RegisterWorkflowGraphStoreSelector(services); + RegisterWorkflowMaterializationRouter(services); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -124,7 +123,7 @@ private static void RegisterFromAssembly(IServiceCollection services, Assembly a ProjectionProjectorContract); } - private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection services) + private static void RegisterWorkflowDocumentStoreSelector(IServiceCollection services) { services.Replace(ServiceDescriptor.Singleton>(sp => { @@ -136,9 +135,12 @@ private static void RegisterWorkflowReadModelStoreSelector(IServiceCollection se selectionPlan.ReadModelSelectionOptions, selectionPlan.ReadModelRequirements); })); + + services.Replace(ServiceDescriptor.Singleton>(sp => + sp.GetRequiredService>())); } - private static void RegisterWorkflowRelationStoreSelector(IServiceCollection services) + private static void RegisterWorkflowGraphStoreSelector(IServiceCollection services) { services.Replace(ServiceDescriptor.Singleton(sp => { @@ -150,6 +152,18 @@ private static void RegisterWorkflowRelationStoreSelector(IServiceCollection ser selectionPlan.RelationSelectionOptions, selectionPlan.RelationRequirements); })); + + services.Replace(ServiceDescriptor.Singleton>(sp => + new ProjectionGraphStoreAdapter( + sp.GetRequiredService()))); + } + + private static void RegisterWorkflowMaterializationRouter(IServiceCollection services) + { + services.Replace(ServiceDescriptor.Singleton>(sp => + new ProjectionMaterializationRouter( + sp.GetRequiredService>(), + sp.GetRequiredService>()))); } private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider serviceProvider) @@ -159,7 +173,7 @@ private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider return selectionPlanner.Build( runtimeOptions, typeof(WorkflowExecutionReport), - WorkflowRelationRequirements); + new ProjectionReadModelRequirements()); } private sealed class PassthroughEventDeduplicator : IEventDeduplicator diff --git a/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs new file mode 100644 index 000000000..e61a80954 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs @@ -0,0 +1,13 @@ +using Aevatar.Workflow.Projection.ReadModels; + +namespace Aevatar.Workflow.Projection.Metadata; + +public sealed class WorkflowExecutionReportDocumentMetadataProvider + : IReadModelDocumentMetadataProvider +{ + public DocumentIndexMetadata Metadata { get; } = new( + IndexName: "workflow-execution-reports", + MappingJson: "{}", + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs index 56b60c14d..59f6eae73 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs @@ -29,4 +29,11 @@ Task GetActorRelationSubgraphAsync( int take = 200, WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default); + + Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs index f916a7a70..96e263352 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs @@ -52,6 +52,14 @@ public Task GetActorRelationSubgraphAsync( CancellationToken ct = default) => GetRelationSubgraphInternalAsync(actorId, depth, take, options, ct); + public Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) => + GetGraphEnrichedInternalAsync(actorId, depth, take, options, ct); + protected override Task ReadSnapshotCoreAsync( string entityId, CancellationToken ct) @@ -113,4 +121,17 @@ private async Task GetRelationSubgraphInternalAsy return await _queryReader.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); } + + private async Task GetGraphEnrichedInternalAsync( + string actorId, + int depth, + int take, + WorkflowActorRelationQueryOptions? options, + CancellationToken ct) + { + if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) + return null; + + return await _queryReader.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, options, ct); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index dd264704c..d9da2d3c1 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -5,25 +5,25 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionQueryReader : IWorkflowProjectionQueryReader { - private readonly IProjectionReadModelStore _store; - private readonly IProjectionRelationStore _relationStore; + private readonly IDocumentProjectionStore _documentStore; + private readonly IGraphProjectionStore _graphStore; private readonly WorkflowExecutionReadModelMapper _mapper; public WorkflowProjectionQueryReader( - IProjectionReadModelStore store, + IDocumentProjectionStore documentStore, WorkflowExecutionReadModelMapper mapper, - IProjectionRelationStore relationStore) + IGraphProjectionStore graphStore) { - _store = store; + _documentStore = documentStore; _mapper = mapper; - _relationStore = relationStore; + _graphStore = graphStore; } public async Task GetActorSnapshotAsync( string actorId, CancellationToken ct = default) { - var report = await _store.GetAsync(actorId, ct); + var report = await _documentStore.GetAsync(actorId, ct); return report == null ? null : _mapper.ToActorSnapshot(report); } @@ -32,7 +32,7 @@ public async Task> ListActorSnapshotsAsync( CancellationToken ct = default) { var boundedTake = Math.Clamp(take, 1, 1000); - var reports = await _store.ListAsync(boundedTake, ct); + var reports = await _documentStore.ListAsync(boundedTake, ct); return reports .Select(_mapper.ToActorSnapshot) .ToList(); @@ -44,7 +44,7 @@ public async Task> ListActorTimelineAsy CancellationToken ct = default) { var boundedTake = Math.Clamp(take, 1, 1000); - var report = await _store.GetAsync(actorId, ct); + var report = await _documentStore.GetAsync(actorId, ct); if (report == null) return []; @@ -68,7 +68,7 @@ public async Task> GetActorRelationsAsy var boundedTake = Math.Clamp(take, 1, 1000); var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); var relationTypes = NormalizeRelationTypes(options?.RelationTypes); - var edges = await _relationStore.GetNeighborsAsync( + var edges = await _graphStore.GetNeighborsAsync( new ProjectionRelationQuery { Scope = WorkflowExecutionRelationConstants.Scope, @@ -96,7 +96,7 @@ public async Task GetActorRelationSubgraphAsync( var boundedTake = Math.Clamp(take, 1, 2000); var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); var relationTypes = NormalizeRelationTypes(options?.RelationTypes); - var subgraph = await _relationStore.GetSubgraphAsync( + var subgraph = await _graphStore.GetSubgraphAsync( new ProjectionRelationQuery { Scope = WorkflowExecutionRelationConstants.Scope, @@ -110,6 +110,25 @@ public async Task GetActorRelationSubgraphAsync( return _mapper.ToActorRelationSubgraph(actorIdValue, subgraph); } + public async Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + { + var snapshot = await GetActorSnapshotAsync(actorId, ct); + if (snapshot == null) + return null; + + var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + return new WorkflowActorGraphEnrichedSnapshot + { + Snapshot = snapshot, + Subgraph = subgraph, + }; + } + private static ProjectionRelationDirection MapDirection(WorkflowActorRelationDirection direction) { return direction switch diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs index 8fff12c53..3044a45fd 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs @@ -5,14 +5,14 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionReadModelUpdater : IWorkflowProjectionReadModelUpdater { - private readonly IProjectionReadModelStore _store; + private readonly IProjectionMaterializationRouter _materializationRouter; private readonly IProjectionClock _clock; public WorkflowProjectionReadModelUpdater( - IProjectionReadModelStore store, + IProjectionMaterializationRouter materializationRouter, IProjectionClock clock) { - _store = store; + _materializationRouter = materializationRouter; _clock = clock; } @@ -22,7 +22,7 @@ public Task RefreshMetadataAsync( CancellationToken ct = default) { var updatedAt = _clock.UtcNow; - return _store.MutateAsync(actorId, report => + return _materializationRouter.MutateAsync(actorId, report => { report.CommandId = context.CommandId; report.WorkflowName = context.WorkflowName; @@ -42,7 +42,7 @@ public Task MarkStoppedAsync( CancellationToken ct = default) { var updatedAt = _clock.UtcNow; - return _store.MutateAsync(actorId, report => + return _materializationRouter.MutateAsync(actorId, report => { if (report.CompletionStatus == WorkflowExecutionCompletionStatus.Running) report.CompletionStatus = WorkflowExecutionCompletionStatus.Stopped; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 077ebe3b5..7e9b26ada 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -9,12 +9,6 @@ namespace Aevatar.Workflow.Projection.Orchestration; internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedService { - private static readonly ProjectionReadModelRequirements WorkflowRelationRequirements = new( - requiresRelations: true, - requiresRelationTraversal: true, - requiresAliases: false, - requiresSchemaValidation: false); - private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; private readonly IProjectionStoreSelectionPlanner _selectionPlanner; @@ -47,7 +41,7 @@ public Task StartAsync(CancellationToken cancellationToken) var selectionPlan = _selectionPlanner.Build( _selectionRuntimeOptions, typeof(WorkflowExecutionReport), - WorkflowRelationRequirements); + new ProjectionReadModelRequirements()); if (_options.ValidateReadModelProviderOnStartup) { diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs index 334307b16..f85dd6006 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs @@ -10,17 +10,20 @@ namespace Aevatar.Workflow.Projection.Projectors; public sealed class WorkflowExecutionReadModelProjector : IProjectionProjector> { - private readonly IProjectionReadModelStore _store; + private readonly IProjectionMaterializationRouter _materializationRouter; private readonly IEventDeduplicator _deduplicator; + private readonly IProjectionClock _clock; private readonly IReadOnlyDictionary>> _reducersByType; public WorkflowExecutionReadModelProjector( - IProjectionReadModelStore store, + IProjectionMaterializationRouter materializationRouter, IEventDeduplicator deduplicator, + IProjectionClock clock, IEnumerable> reducers) { - _store = store; + _materializationRouter = materializationRouter; _deduplicator = deduplicator; + _clock = clock; _reducersByType = reducers .GroupBy(x => x.EventTypeUrl, StringComparer.Ordinal) .ToDictionary( @@ -48,7 +51,7 @@ public ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, Can Input = context.Input, }; report.Summary = new WorkflowExecutionSummary(); - return new ValueTask(_store.UpsertAsync(report, ct)); + return new ValueTask(_materializationRouter.UpsertAsync(report, ct)); } public async ValueTask ProjectAsync(WorkflowExecutionProjectionContext context, EventEnvelope envelope, CancellationToken ct = default) @@ -65,8 +68,8 @@ public async ValueTask ProjectAsync(WorkflowExecutionProjectionContext context, return; } - var now = ResolveEventTimestamp(envelope); - await _store.MutateAsync(context.RootActorId, report => + var now = ResolveEventTimestamp(envelope, _clock.UtcNow); + await _materializationRouter.MutateAsync(context.RootActorId, report => { var mutated = false; foreach (var reducer in reducers) @@ -85,8 +88,8 @@ public ValueTask CompleteAsync( IReadOnlyList topology, CancellationToken ct = default) { - var completedAt = DateTimeOffset.UtcNow; - return new ValueTask(_store.MutateAsync(context.RootActorId, report => + var completedAt = _clock.UtcNow; + return new ValueTask(_materializationRouter.MutateAsync(context.RootActorId, report => { report.Topology = topology.Select(x => new WorkflowExecutionTopologyEdge(x.Parent, x.Child)).ToList(); report.TopologySource = WorkflowExecutionTopologySource.RuntimeSnapshot; @@ -98,11 +101,11 @@ public ValueTask CompleteAsync( }, ct)); } - private static DateTimeOffset ResolveEventTimestamp(EventEnvelope envelope) + private static DateTimeOffset ResolveEventTimestamp(EventEnvelope envelope, DateTimeOffset fallbackUtcNow) { var ts = envelope.Timestamp; if (ts == null) - return DateTimeOffset.UtcNow; + return fallbackUtcNow; var dt = ts.ToDateTime(); if (dt.Kind != DateTimeKind.Utc) diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs deleted file mode 100644 index 48310783b..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionRelationProjector.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace Aevatar.Workflow.Projection.Projectors; - -public sealed class WorkflowExecutionRelationProjector - : IProjectionProjector> -{ - private const string UnknownToken = "unknown"; - private readonly IProjectionRelationStore _relationStore; - - public WorkflowExecutionRelationProjector(IProjectionRelationStore relationStore) - { - _relationStore = relationStore; - } - - public async ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, CancellationToken ct = default) - { - var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); - var now = context.StartedAt; - await _relationStore.UpsertNodeAsync( - BuildActorNode(context.RootActorId, context.WorkflowName, now), - ct); - await _relationStore.UpsertNodeAsync( - BuildRunNode(runNodeId, context.RootActorId, context.WorkflowName, context.CommandId, context.Input, now), - ct); - await _relationStore.UpsertEdgeAsync( - BuildEdge( - context.RootActorId, - runNodeId, - WorkflowExecutionRelationConstants.RelationOwns, - now), - ct); - } - - public async ValueTask ProjectAsync( - WorkflowExecutionProjectionContext context, - EventEnvelope envelope, - CancellationToken ct = default) - { - var payload = envelope.Payload; - if (payload == null) - return; - - var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); - var now = ResolveEventTimestamp(envelope); - - if (payload.Is(StepRequestEvent.Descriptor)) - { - var evt = payload.Unpack(); - await UpsertStepRelationAsync( - context, - runNodeId, - evt.StepId, - evt.StepType, - evt.TargetRole, - workerId: "", - success: null, - now, - ct); - return; - } - - if (payload.Is(StepCompletedEvent.Descriptor)) - { - var evt = payload.Unpack(); - await UpsertStepRelationAsync( - context, - runNodeId, - evt.StepId, - stepType: "", - targetRole: "", - evt.WorkerId, - evt.Success, - now, - ct); - } - } - - public async ValueTask CompleteAsync( - WorkflowExecutionProjectionContext context, - IReadOnlyList topology, - CancellationToken ct = default) - { - var completedAt = DateTimeOffset.UtcNow; - var runNodeId = BuildRunNodeId(context.RootActorId, context.CommandId); - - await _relationStore.UpsertNodeAsync( - BuildActorNode(context.RootActorId, context.WorkflowName, completedAt), - ct); - await _relationStore.UpsertNodeAsync( - BuildRunNode( - runNodeId, - context.RootActorId, - context.WorkflowName, - context.CommandId, - context.Input, - completedAt), - ct); - await _relationStore.UpsertEdgeAsync( - BuildEdge( - context.RootActorId, - runNodeId, - WorkflowExecutionRelationConstants.RelationOwns, - completedAt), - ct); - - foreach (var edge in topology) - { - var rawParentNodeId = edge.Parent?.Trim() ?? ""; - var rawChildNodeId = edge.Child?.Trim() ?? ""; - if (rawParentNodeId.Length == 0 || rawChildNodeId.Length == 0) - continue; - var parentNodeId = NormalizeToken(rawParentNodeId); - var childNodeId = NormalizeToken(rawChildNodeId); - - await _relationStore.UpsertNodeAsync( - BuildActorNode(parentNodeId, context.WorkflowName, completedAt), - ct); - await _relationStore.UpsertNodeAsync( - BuildActorNode(childNodeId, context.WorkflowName, completedAt), - ct); - await _relationStore.UpsertEdgeAsync( - BuildEdge( - parentNodeId, - childNodeId, - WorkflowExecutionRelationConstants.RelationChildOf, - completedAt), - ct); - } - } - - private async Task UpsertStepRelationAsync( - WorkflowExecutionProjectionContext context, - string runNodeId, - string stepId, - string stepType, - string targetRole, - string workerId, - bool? success, - DateTimeOffset updatedAt, - CancellationToken ct) - { - var rawStepId = stepId?.Trim() ?? ""; - if (rawStepId.Length == 0) - return; - - var normalizedStepId = NormalizeToken(rawStepId); - var stepNodeId = BuildStepNodeId(context.RootActorId, context.CommandId, normalizedStepId); - var stepTypeValue = stepType?.Trim() ?? ""; - var targetRoleValue = targetRole?.Trim() ?? ""; - var workerIdValue = workerId?.Trim() ?? ""; - var successValue = success; - if (stepTypeValue.Length == 0 || - targetRoleValue.Length == 0 || - workerIdValue.Length == 0 || - !successValue.HasValue) - { - var existingNode = await TryGetNodeAsync(stepNodeId, ct); - if (existingNode != null) - { - if (stepTypeValue.Length == 0 && - existingNode.Properties.TryGetValue("stepType", out var existingStepType)) - { - stepTypeValue = existingStepType; - } - - if (targetRoleValue.Length == 0 && - existingNode.Properties.TryGetValue("targetRole", out var existingTargetRole)) - { - targetRoleValue = existingTargetRole; - } - - if (workerIdValue.Length == 0 && - existingNode.Properties.TryGetValue("workerId", out var existingWorkerId)) - { - workerIdValue = existingWorkerId; - } - - if (!successValue.HasValue && - existingNode.Properties.TryGetValue("success", out var existingSuccess) && - bool.TryParse(existingSuccess, out var parsedSuccess)) - { - successValue = parsedSuccess; - } - } - } - - await _relationStore.UpsertNodeAsync( - BuildStepNode( - stepNodeId, - context.RootActorId, - context.CommandId, - normalizedStepId, - stepTypeValue, - targetRoleValue, - workerIdValue, - successValue, - updatedAt), - ct); - await _relationStore.UpsertEdgeAsync( - BuildEdge( - runNodeId, - stepNodeId, - WorkflowExecutionRelationConstants.RelationContainsStep, - updatedAt), - ct); - } - - private async Task TryGetNodeAsync( - string nodeId, - CancellationToken ct) - { - var subgraph = await _relationStore.GetSubgraphAsync( - new ProjectionRelationQuery - { - Scope = WorkflowExecutionRelationConstants.Scope, - RootNodeId = nodeId, - Direction = ProjectionRelationDirection.Both, - Depth = 1, - Take = 1, - }, - ct); - return subgraph.Nodes.FirstOrDefault(x => - string.Equals(x.NodeId, nodeId, StringComparison.Ordinal) && - x.Properties.Count > 0); - } - - private static ProjectionRelationNode BuildActorNode( - string actorId, - string workflowName, - DateTimeOffset updatedAt) - { - return new ProjectionRelationNode - { - Scope = WorkflowExecutionRelationConstants.Scope, - NodeId = NormalizeToken(actorId), - NodeType = WorkflowExecutionRelationConstants.ActorNodeType, - Properties = new Dictionary(StringComparer.Ordinal) - { - ["workflowName"] = workflowName ?? "", - }, - UpdatedAt = updatedAt, - }; - } - - private static ProjectionRelationNode BuildRunNode( - string runNodeId, - string rootActorId, - string workflowName, - string commandId, - string input, - DateTimeOffset updatedAt) - { - return new ProjectionRelationNode - { - Scope = WorkflowExecutionRelationConstants.Scope, - NodeId = runNodeId, - NodeType = WorkflowExecutionRelationConstants.RunNodeType, - Properties = new Dictionary(StringComparer.Ordinal) - { - ["rootActorId"] = NormalizeToken(rootActorId), - ["workflowName"] = workflowName ?? "", - ["commandId"] = NormalizeToken(commandId), - ["input"] = input ?? "", - }, - UpdatedAt = updatedAt, - }; - } - - private static ProjectionRelationNode BuildStepNode( - string stepNodeId, - string rootActorId, - string commandId, - string stepId, - string stepType, - string targetRole, - string workerId, - bool? success, - DateTimeOffset updatedAt) - { - return new ProjectionRelationNode - { - Scope = WorkflowExecutionRelationConstants.Scope, - NodeId = stepNodeId, - NodeType = WorkflowExecutionRelationConstants.StepNodeType, - Properties = new Dictionary(StringComparer.Ordinal) - { - ["rootActorId"] = NormalizeToken(rootActorId), - ["commandId"] = NormalizeToken(commandId), - ["stepId"] = NormalizeToken(stepId), - ["stepType"] = stepType ?? "", - ["targetRole"] = targetRole ?? "", - ["workerId"] = workerId ?? "", - ["success"] = success?.ToString() ?? "", - }, - UpdatedAt = updatedAt, - }; - } - - private static ProjectionRelationEdge BuildEdge( - string fromNodeId, - string toNodeId, - string relationType, - DateTimeOffset updatedAt) - { - var normalizedFromNodeId = NormalizeToken(fromNodeId); - var normalizedToNodeId = NormalizeToken(toNodeId); - var normalizedRelationType = NormalizeToken(relationType); - return new ProjectionRelationEdge - { - Scope = WorkflowExecutionRelationConstants.Scope, - EdgeId = BuildEdgeId(normalizedRelationType, normalizedFromNodeId, normalizedToNodeId), - FromNodeId = normalizedFromNodeId, - ToNodeId = normalizedToNodeId, - RelationType = normalizedRelationType, - UpdatedAt = updatedAt, - Properties = new Dictionary(StringComparer.Ordinal), - }; - } - - private static string BuildRunNodeId(string rootActorId, string commandId) - { - var normalizedRootActorId = NormalizeToken(rootActorId); - var normalizedCommandId = NormalizeToken(commandId); - return $"run:{normalizedRootActorId}:{normalizedCommandId}"; - } - - private static string BuildStepNodeId(string rootActorId, string commandId, string stepId) - { - var normalizedRootActorId = NormalizeToken(rootActorId); - var normalizedCommandId = NormalizeToken(commandId); - var normalizedStepId = NormalizeToken(stepId); - return $"step:{normalizedRootActorId}:{normalizedCommandId}:{normalizedStepId}"; - } - - private static string BuildEdgeId(string relationType, string fromNodeId, string toNodeId) - { - var payload = $"{relationType}|{fromNodeId}|{toNodeId}"; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); - return $"{relationType}:{Convert.ToHexString(hash.AsSpan(0, 8))}"; - } - - private static string NormalizeToken(string token) - { - var normalized = token?.Trim() ?? ""; - return normalized.Length == 0 ? UnknownToken : normalized; - } - - private static DateTimeOffset ResolveEventTimestamp(EventEnvelope envelope) - { - var ts = envelope.Timestamp; - if (ts == null) - return DateTimeOffset.UtcNow; - - var dt = ts.ToDateTime(); - if (dt.Kind != DateTimeKind.Utc) - dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); - return new DateTimeOffset(dt); - } -} diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 1d9056a19..f30838fcc 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -6,7 +6,7 @@ - 应用层投影端口实现: - `IWorkflowExecutionProjectionLifecyclePort`(`Ensure/Attach/Detach/Release`) - - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/Relations/Subgraph`) + - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/Relations/Subgraph/GraphEnriched`) - 默认实现分别为 `WorkflowExecutionProjectionLifecycleService` 与 `WorkflowExecutionProjectionQueryService` - 两个实现分别继承 `ProjectionLifecyclePortServiceBase` / `ProjectionQueryPortServiceBase`,通用端口编排已下沉到 `Aevatar.CQRS.Projection.Core` - 编排组件拆分(避免单类过重): @@ -18,7 +18,7 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) - - Store 选择统一由 `IProjectionStoreSelectionPlanner` 执行(Workflow 仅提供 relation 需求,不持有 provider/index kind 决策) + - Store 选择统一由 `IProjectionStoreSelectionPlanner` 执行(基于 `IDocumentReadModel/IGraphReadModel` 能力自动决策) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) @@ -38,7 +38,7 @@ 1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionLifecycleService` 转发到 `WorkflowProjectionActivationService`,先通过 `WorkflowProjectionLeaseManager`(底层复用 `Aevatar.CQRS.Projection.Core` ownership coordinator)申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 2. 每条 `EventEnvelope` 进入统一 coordinator,一对多调用已注册 projector -3. `WorkflowExecutionReadModelProjector` 驱动 reducers 生成并更新 read model +3. `WorkflowExecutionReadModelProjector` 驱动 reducers 生成并更新 read model,并通过 `IProjectionMaterializationRouter` 执行 Document/Graph 单写或双写 4. AI 通用事件通过 `Aevatar.Workflow.Extensions.AIProjection` 扩展接入,扩展内部复用 `Aevatar.AI.Projection` 的默认 applier + reducer,将事件写入 `WorkflowExecutionReport` 的 AI 能力字段,业务层无需重复维护映射代码 5. AGUI 分支与读模型分支共享同一输入事件流;AGUI projector 将 run 输出发布到 `workflow-run:{actorId}:{commandId}` 事件流 @@ -82,33 +82,28 @@ FAQ: - 实现 `IProjectionProjector>` - 在 DI 中注册 - 扩展 ReadModel Provider(推荐): - - 实现 `IProjectionStoreRegistration>` + - 文档存储注册:`IProjectionStoreRegistration>` + - 图存储注册:`IProjectionStoreRegistration` - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - - 通过 `Projection:ReadModel:*` 配置选择 Provider(Workflow 层不再暴露 provider 选择字段) + - 通过 `Projection:Document:*` 与 `Projection:Graph:*` 配置选择 Provider ## Provider 配置 -- Provider 选择统一配置入口:`Projection:ReadModel:*`(绑定到 `ProjectionReadModelRuntimeOptions`) -- `Projection:ReadModel:Provider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` -- `Projection:ReadModel:RelationProvider`:关系 provider;留空时回退到 `Provider` -- `Projection:ReadModel:FailOnUnsupportedCapabilities`:能力不匹配时是否 fail-fast(默认 `true`) -- `Projection:ReadModel:Mode`:读模型运行模式(`StateOnly` 会在选择阶段 fail-fast) -- `Projection:ReadModel:Bindings:*`:ReadModel -> IndexKind 约束(键必须为 `ReadModel` 的 `Type.FullName`,例如 `Aevatar.Workflow.Projection.ReadModels.WorkflowExecutionReport: Document`) -- `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:是否在 Host 启动阶段预校验 Provider 选择与能力(默认 `true`) -- `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:是否在 Host 启动阶段预校验 Relation Provider(默认 `true`) -- `Projection:Policies:DenyInMemoryRelationFactStore`:禁用 InMemory relation 作为事实源(生产建议开启) -- `Projection:ReadModel:Providers:Elasticsearch:Endpoints`:Elasticsearch endpoint 列表 -- `Projection:ReadModel:Providers:Elasticsearch:IndexPrefix`:索引前缀 -- `Projection:ReadModel:Providers:Elasticsearch:RequestTimeoutMs`:请求超时 -- `Projection:ReadModel:Providers:Elasticsearch:ListTakeMax`:`ListAsync` 上限 -- `Projection:ReadModel:Providers:Elasticsearch:ListSortField`:可选自定义排序字段;为空时默认 `CreatedAt desc -> _id desc` -- `Projection:ReadModel:Providers:Elasticsearch:AutoCreateIndex`:是否自动建索引 -- `Projection:ReadModel:Providers:Elasticsearch:MissingIndexBehavior`:索引缺失行为(`Throw` / `WarnAndReturnEmpty`,默认 `Throw`) -- `Projection:ReadModel:Providers:Elasticsearch:MutateMaxRetryCount`:`MutateAsync` OCC 冲突重试次数(默认 `3`) -- `Projection:ReadModel:Providers:Elasticsearch:Username/Password`:可选基础认证 +- Provider 选择统一配置入口: + - `Projection:Document:Provider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` + - `Projection:Graph:Provider`:`InMemory`(默认)/`Neo4j` +- `Projection:Policies:DenyInMemoryGraphFactStore`:禁用 InMemory graph 作为事实源(生产建议开启) +- 文档 Provider 配置: + - `Projection:Document:Providers:Elasticsearch:*` + - `Projection:Document:Providers:Neo4j:*` +- 图 Provider 配置: + - `Projection:Graph:Providers:Neo4j:*` +- `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) +- `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:启动阶段预校验 graph provider(默认 `true`) - 关系查询参数: - `/actors/{actorId}/relations` 支持 `direction` 与 `relationTypes` - `/actors/{actorId}/relation-subgraph` 支持 `direction` 与 `relationTypes` + - `/actors/{actorId}/graph-enriched` 支持 `direction` 与 `relationTypes` - 扩展 run 输出协议: - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 - 不改 Application 用例编排代码 diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index bd4f21ecc..f2da8ddd3 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using Aevatar.Foundation.Projection.ReadModels; namespace Aevatar.Workflow.Projection.ReadModels; @@ -30,8 +32,20 @@ public enum WorkflowExecutionCompletionStatus public sealed class WorkflowExecutionReport : AevatarReadModelBase, IHasProjectionTimeline, - IHasProjectionRoleReplies + IHasProjectionRoleReplies, + IDocumentReadModel, + IGraphReadModel { + private const string UnknownToken = "unknown"; + + public string DocumentScope => "workflow-execution-reports"; + + public string GraphScope => WorkflowExecutionRelationConstants.Scope; + + public IReadOnlyList GraphNodes => BuildGraphNodes(); + + public IReadOnlyList GraphEdges => BuildGraphEdges(); + public string RootActorId { get; set; } = ""; public string CommandId { get; set; } = ""; public string ReportVersion { get; set; } = "1.0"; @@ -82,6 +96,183 @@ public void AddRoleReply(ProjectionRoleReply roleReply) ContentLength = roleReply.ContentLength, }); } + + private IReadOnlyList BuildGraphNodes() + { + var updatedAt = UpdatedAt == default ? DateTimeOffset.UtcNow : UpdatedAt; + var rootActorId = NormalizeToken(RootActorId); + var runNodeId = BuildRunNodeId(rootActorId, CommandId); + var nodes = new Dictionary(StringComparer.Ordinal); + + nodes[rootActorId] = new GraphNodeDescriptor( + rootActorId, + WorkflowExecutionRelationConstants.ActorNodeType, + new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = WorkflowName ?? "", + }, + updatedAt); + + nodes[runNodeId] = new GraphNodeDescriptor( + runNodeId, + WorkflowExecutionRelationConstants.RunNodeType, + new Dictionary(StringComparer.Ordinal) + { + ["rootActorId"] = rootActorId, + ["workflowName"] = WorkflowName ?? "", + ["commandId"] = NormalizeToken(CommandId), + ["input"] = Input ?? "", + }, + updatedAt); + + foreach (var step in Steps) + { + var stepNodeId = BuildStepNodeId(rootActorId, CommandId, step.StepId); + nodes[stepNodeId] = new GraphNodeDescriptor( + stepNodeId, + WorkflowExecutionRelationConstants.StepNodeType, + new Dictionary(StringComparer.Ordinal) + { + ["rootActorId"] = rootActorId, + ["commandId"] = NormalizeToken(CommandId), + ["stepId"] = NormalizeToken(step.StepId), + ["stepType"] = step.StepType ?? "", + ["targetRole"] = step.TargetRole ?? "", + ["workerId"] = step.WorkerId ?? "", + ["success"] = step.Success?.ToString() ?? "", + }, + updatedAt); + } + + foreach (var topologyEdge in Topology) + { + var parentId = NormalizeToken(topologyEdge.Parent); + var childId = NormalizeToken(topologyEdge.Child); + if (parentId.Length > 0 && !nodes.ContainsKey(parentId)) + { + nodes[parentId] = new GraphNodeDescriptor( + parentId, + WorkflowExecutionRelationConstants.ActorNodeType, + new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = WorkflowName ?? "", + }, + updatedAt); + } + + if (childId.Length > 0 && !nodes.ContainsKey(childId)) + { + nodes[childId] = new GraphNodeDescriptor( + childId, + WorkflowExecutionRelationConstants.ActorNodeType, + new Dictionary(StringComparer.Ordinal) + { + ["workflowName"] = WorkflowName ?? "", + }, + updatedAt); + } + } + + return nodes.Values.ToList(); + } + + private IReadOnlyList BuildGraphEdges() + { + var updatedAt = UpdatedAt == default ? DateTimeOffset.UtcNow : UpdatedAt; + var rootActorId = NormalizeToken(RootActorId); + var runNodeId = BuildRunNodeId(rootActorId, CommandId); + var edges = new Dictionary(StringComparer.Ordinal); + + var ownsEdge = CreateEdge( + WorkflowExecutionRelationConstants.RelationOwns, + rootActorId, + runNodeId, + new Dictionary(StringComparer.Ordinal), + updatedAt); + edges[ownsEdge.EdgeId] = ownsEdge; + + foreach (var step in Steps) + { + var stepNodeId = BuildStepNodeId(rootActorId, CommandId, step.StepId); + var containsEdge = CreateEdge( + WorkflowExecutionRelationConstants.RelationContainsStep, + runNodeId, + stepNodeId, + new Dictionary(StringComparer.Ordinal) + { + ["stepId"] = NormalizeToken(step.StepId), + ["stepType"] = step.StepType ?? "", + }, + updatedAt); + edges[containsEdge.EdgeId] = containsEdge; + } + + foreach (var topologyEdge in Topology) + { + var parentId = NormalizeToken(topologyEdge.Parent); + var childId = NormalizeToken(topologyEdge.Child); + if (parentId.Length == 0 || childId.Length == 0) + continue; + + var childOfEdge = CreateEdge( + WorkflowExecutionRelationConstants.RelationChildOf, + parentId, + childId, + new Dictionary(StringComparer.Ordinal), + updatedAt); + edges[childOfEdge.EdgeId] = childOfEdge; + } + + return edges.Values.ToList(); + } + + private static GraphEdgeDescriptor CreateEdge( + string relationType, + string fromNodeId, + string toNodeId, + IReadOnlyDictionary properties, + DateTimeOffset updatedAt) + { + var normalizedFromNodeId = NormalizeToken(fromNodeId); + var normalizedToNodeId = NormalizeToken(toNodeId); + var normalizedRelationType = NormalizeToken(relationType); + var edgeId = BuildEdgeId(normalizedRelationType, normalizedFromNodeId, normalizedToNodeId); + return new GraphEdgeDescriptor( + edgeId, + normalizedRelationType, + normalizedFromNodeId, + normalizedToNodeId, + new Dictionary(properties, StringComparer.Ordinal), + updatedAt); + } + + private static string BuildRunNodeId(string rootActorId, string commandId) + { + var normalizedRootActorId = NormalizeToken(rootActorId); + var normalizedCommandId = NormalizeToken(commandId); + return $"run:{normalizedRootActorId}:{normalizedCommandId}"; + } + + private static string BuildStepNodeId(string rootActorId, string commandId, string stepId) + { + var normalizedRootActorId = NormalizeToken(rootActorId); + var normalizedCommandId = NormalizeToken(commandId); + var normalizedStepId = NormalizeToken(stepId); + return $"step:{normalizedRootActorId}:{normalizedCommandId}:{normalizedStepId}"; + } + + private static string BuildEdgeId(string relationType, string fromNodeId, string toNodeId) + { + var payload = $"{relationType}|{fromNodeId}|{toNodeId}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return $"{relationType}:{Convert.ToHexString(hash.AsSpan(0, 8))}"; + } + + private static string NormalizeToken(string? token) + { + var normalized = token?.Trim() ?? ""; + return normalized.Length == 0 ? UnknownToken : normalized; + } } public sealed class WorkflowExecutionSummary diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 8c35b0680..3449198fc 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; -using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; @@ -26,60 +25,59 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( services.AddSingleton(); - var runtimeOptions = ResolveRuntimeOptions(configuration); - var providerSelection = ResolveProviderSelection(runtimeOptions); - EnforceRelationProviderPolicy(configuration, providerSelection.RelationProvider); + var providerSelection = ResolveProviderSelection(configuration); + EnforceGraphProviderPolicy(configuration, providerSelection.GraphProvider); + var runtimeOptions = new ProjectionReadModelRuntimeOptions + { + DocumentProvider = providerSelection.DocumentProvider, + GraphProvider = providerSelection.GraphProvider, + FailOnUnsupportedCapabilities = true, + Mode = ProjectionReadModelMode.CustomReadModel, + }; services.Replace(ServiceDescriptor.Singleton(runtimeOptions)); services.Replace(ServiceDescriptor.Singleton(sp => sp.GetRequiredService())); - RegisterReadModelProvider(services, configuration, providerSelection.ReadModelProvider); - RegisterRelationProvider(services, configuration, providerSelection.RelationProvider); + RegisterDocumentProvider(services, configuration, providerSelection.DocumentProvider); + RegisterGraphProvider(services, configuration, providerSelection.GraphProvider); return services; } - private static ProjectionReadModelRuntimeOptions ResolveRuntimeOptions(IConfiguration configuration) + private static ProviderSelection ResolveProviderSelection(IConfiguration configuration) { - var options = new ProjectionReadModelRuntimeOptions(); - configuration.GetSection("Projection:ReadModel").Bind(options); - return options; - } - - private static ProviderSelection ResolveProviderSelection(ProjectionReadModelRuntimeOptions runtimeOptions) - { - var readModelProvider = NormalizeOrDefaultProvider( - runtimeOptions.Provider, + var documentProvider = NormalizeOrDefaultProvider( + configuration["Projection:Document:Provider"], ProjectionReadModelProviderNames.InMemory, - "Projection:ReadModel:Provider"); + "Projection:Document:Provider"); - var relationProvider = NormalizeOrDefaultProvider( - runtimeOptions.RelationProvider, - readModelProvider, - "Projection:ReadModel:RelationProvider"); + var graphProvider = NormalizeOrDefaultProvider( + configuration["Projection:Graph:Provider"], + documentProvider, + "Projection:Graph:Provider"); - return new ProviderSelection(readModelProvider, relationProvider); + return new ProviderSelection(documentProvider, graphProvider); } - private static void EnforceRelationProviderPolicy( + private static void EnforceGraphProviderPolicy( IConfiguration configuration, - string relationProviderName) + string graphProviderName) { - var denyInMemoryRelationProvider = ParseBool( - configuration["Projection:Policies:DenyInMemoryRelationFactStore"]); + var denyInMemoryGraphProvider = ParseBool( + configuration["Projection:Policies:DenyInMemoryGraphFactStore"]); var environment = ResolveRuntimeEnvironment(configuration["Projection:Policies:Environment"]); var production = IsProductionEnvironment(environment); - if ((denyInMemoryRelationProvider || production) && + if ((denyInMemoryGraphProvider || production) && string.Equals( - relationProviderName, + graphProviderName, ProjectionReadModelProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( - "InMemory relation provider is not allowed by projection policy. " + - "Use a durable relation provider (for example Neo4j) for production/distributed deployments."); + "InMemory graph provider is not allowed by projection policy. " + + "Use a durable graph provider (for example Neo4j) for production/distributed deployments."); } } @@ -127,7 +125,7 @@ private static string NormalizeOrDefaultProvider( $"Allowed values: {ProjectionReadModelProviderNames.InMemory}, {ProjectionReadModelProviderNames.Elasticsearch}, {ProjectionReadModelProviderNames.Neo4j}."); } - private static void RegisterReadModelProvider( + private static void RegisterDocumentProvider( IServiceCollection services, IConfiguration configuration, string providerName) @@ -135,42 +133,50 @@ private static void RegisterReadModelProvider( switch (providerName) { case ProjectionReadModelProviderNames.InMemory: - services.AddInMemoryReadModelStoreRegistration( + services.AddInMemoryDocumentStoreRegistration( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); break; case ProjectionReadModelProviderNames.Elasticsearch: - services.AddElasticsearchReadModelStoreRegistration( + services.AddElasticsearchDocumentStoreRegistration( optionsFactory: _ => { var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Elasticsearch").Bind(providerOptions); + configuration.GetSection("Projection:Document:Providers:Elasticsearch").Bind(providerOptions); return providerOptions; }, - indexScope: "workflow-execution-reports", + indexScopeFactory: sp => + { + var metadataResolver = sp.GetRequiredService(); + return metadataResolver.Resolve().IndexName; + }, keySelector: report => report.RootActorId, keyFormatter: key => key); break; case ProjectionReadModelProviderNames.Neo4j: - services.AddNeo4jReadModelStoreRegistration( + services.AddNeo4jDocumentStoreRegistration( optionsFactory: _ => { var providerOptions = new Neo4jProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + configuration.GetSection("Projection:Document:Providers:Neo4j").Bind(providerOptions); return providerOptions; }, - scope: "workflow-execution-reports", + scopeFactory: sp => + { + var metadataResolver = sp.GetRequiredService(); + return metadataResolver.Resolve().IndexName; + }, keySelector: report => report.RootActorId, keyFormatter: key => key); break; default: - throw new InvalidOperationException($"Unsupported read-model provider '{providerName}'."); + throw new InvalidOperationException($"Unsupported document provider '{providerName}'."); } } - private static void RegisterRelationProvider( + private static void RegisterGraphProvider( IServiceCollection services, IConfiguration configuration, string providerName) @@ -178,27 +184,27 @@ private static void RegisterRelationProvider( switch (providerName) { case ProjectionReadModelProviderNames.InMemory: - services.AddInMemoryRelationStoreRegistration(); + services.AddInMemoryGraphStoreRegistration(); break; case ProjectionReadModelProviderNames.Elasticsearch: - services.AddElasticsearchRelationStoreRegistration(); - break; + throw new InvalidOperationException( + "Elasticsearch cannot be used as graph provider. Use InMemory (dev/test) or Neo4j."); case ProjectionReadModelProviderNames.Neo4j: - services.AddNeo4jRelationStoreRegistration( + services.AddNeo4jGraphStoreRegistration( optionsFactory: _ => { var providerOptions = new Neo4jProjectionRelationStoreOptions(); - configuration.GetSection("Projection:ReadModel:Providers:Neo4j").Bind(providerOptions); + configuration.GetSection("Projection:Graph:Providers:Neo4j").Bind(providerOptions); return providerOptions; }, - scope: WorkflowExecutionRelationConstants.Scope); + scopeFactory: _ => WorkflowExecutionRelationConstants.Scope); break; default: - throw new InvalidOperationException($"Unsupported relation provider '{providerName}'."); + throw new InvalidOperationException($"Unsupported graph provider '{providerName}'."); } } private sealed class WorkflowProjectionProviderRegistrationsMarker; - private sealed record ProviderSelection(string ReadModelProvider, string RelationProvider); + private sealed record ProviderSelection(string DocumentProvider, string GraphProvider); } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs index ad5da3f5a..032ae3ec2 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs @@ -6,14 +6,14 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public sealed class ProjectionStoreSelectionPlannerTests { private readonly ProjectionStoreSelectionPlanner _planner = - new(new ProjectionReadModelBindingResolver()); + new(); [Fact] public void Build_WhenReadModelProviderIsEmpty_ShouldThrow() { var options = new FakeOptions { - ReadModelProvider = " ", + DocumentProvider = " ", }; Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements()); @@ -27,8 +27,8 @@ public void Build_WhenRelationProviderMissing_ShouldFallbackToReadModelProvider( { var options = new FakeOptions { - ReadModelProvider = ProjectionReadModelProviderNames.Neo4j, - RelationProvider = " ", + DocumentProvider = ProjectionReadModelProviderNames.Neo4j, + GraphProvider = " ", }; var plan = _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements( @@ -44,16 +44,15 @@ public void Build_ShouldMergeRelationRequirementsWithReadModelAliasAndSchemaRequ { var options = new FakeOptions { - ReadModelProvider = ProjectionReadModelProviderNames.Neo4j, + DocumentProvider = ProjectionReadModelProviderNames.Neo4j, }; - options.ReadModelBindings[typeof(TestReadModel).FullName!] = ProjectionReadModelIndexKind.Graph.ToString(); var relationRequirements = new ProjectionReadModelRequirements( requiresRelations: true, requiresRelationTraversal: true, requiresAliases: false, requiresSchemaValidation: false); - var plan = _planner.Build(options, typeof(TestReadModel), relationRequirements); + var plan = _planner.Build(options, typeof(TestGraphReadModel), relationRequirements); plan.RelationRequirements.RequiresRelations.Should().BeTrue(); plan.RelationRequirements.RequiresRelationTraversal.Should().BeTrue(); @@ -66,7 +65,7 @@ public void Build_WhenStateOnlyModeConfigured_ShouldThrow() { var options = new FakeOptions { - ReadModelProvider = ProjectionReadModelProviderNames.InMemory, + DocumentProvider = ProjectionReadModelProviderNames.InMemory, ReadModelMode = ProjectionReadModelMode.StateOnly, }; @@ -78,20 +77,25 @@ public void Build_WhenStateOnlyModeConfigured_ShouldThrow() private sealed class FakeOptions : IProjectionStoreSelectionRuntimeOptions { - private readonly Dictionary _bindings = new(StringComparer.OrdinalIgnoreCase); + public string DocumentProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - public string ReadModelProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - - public string RelationProvider { get; set; } = ""; + public string GraphProvider { get; set; } = ""; public bool FailOnUnsupportedCapabilities { get; set; } = true; public ProjectionReadModelMode ReadModelMode { get; set; } = ProjectionReadModelMode.CustomReadModel; - - public Dictionary ReadModelBindings => _bindings; - - IReadOnlyDictionary IProjectionStoreSelectionRuntimeOptions.ReadModelBindings => _bindings; } private sealed class TestReadModel; + + private sealed class TestGraphReadModel : IGraphReadModel + { + public string Id => "test"; + + public string GraphScope => "test"; + + public IReadOnlyList GraphNodes => []; + + public IReadOnlyList GraphEdges => []; + } } diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs index f410cb85e..ab439a4c7 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs @@ -508,6 +508,25 @@ public Task GetActorRelationSubgraphAsync( return Task.FromResult(subgraph); } + public async Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + { + var snapshot = await GetActorSnapshotAsync(actorId, ct); + if (snapshot == null) + return null; + + var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + return new WorkflowActorGraphEnrichedSnapshot + { + Snapshot = snapshot, + Subgraph = subgraph, + }; + } + private sealed record FakeProjectionLease(string ActorId, string CommandId) : IWorkflowExecutionProjectionLease; } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs index 6866aff5d..9f00d65d6 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs @@ -495,6 +495,25 @@ public Task GetActorRelationSubgraphAsync( return Task.FromResult(item); } + + public async Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + { + var snapshot = await GetActorSnapshotAsync(actorId, ct); + if (snapshot == null) + return null; + + var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + return new WorkflowActorGraphEnrichedSnapshot + { + Snapshot = snapshot, + Subgraph = subgraph, + }; + } } private static CommandExecutionResult ToCoreResult( diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs index df65fb628..5de50f0ea 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs @@ -204,6 +204,26 @@ public Task GetActorRelationSubgraphAsync( { RootNodeId = actorId, }); + + public Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + { + if (Snapshot == null) + return Task.FromResult(null); + + return Task.FromResult(new WorkflowActorGraphEnrichedSnapshot + { + Snapshot = Snapshot, + Subgraph = new WorkflowActorRelationSubgraph + { + RootNodeId = actorId, + }, + }); + } } private sealed class FakeWebSocket : WebSocket diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs index 1941f161a..b6695c22a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs @@ -262,6 +262,14 @@ public Task GetActorRelationSubgraphAsync( { RootNodeId = actorId, }); + + public Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) => + Task.FromResult(null); } private sealed class FakeWebSocketFeature : IHttpWebSocketFeature diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 493d39780..8dcf7d9c0 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -4,19 +4,11 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; -using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; -using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.Neo4j.Stores; -using Aevatar.AI.Projection.Reducers; -using Aevatar.Workflow.Extensions.AIProjection; -using Aevatar.Workflow.Projection; -using Aevatar.Workflow.Projection.ReadModels; -using Aevatar.Workflow.Projection.Configuration; +using Aevatar.CQRS.Projection.Runtime.Runtime; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.DependencyInjection; -using Aevatar.Workflow.Projection.Reducers; +using Aevatar.Workflow.Projection.ReadModels; using FluentAssertions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; @@ -26,7 +18,7 @@ namespace Aevatar.Workflow.Host.Api.Tests; public class WorkflowExecutionProjectionRegistrationTests { [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationEnabledAndProviderMissing_ShouldFailFast() + public async Task AddWorkflowExecutionProjectionCQRS_WhenNoProvidersRegistered_ShouldFailFast() { var services = new ServiceCollection(); services.AddWorkflowExecutionProjectionCQRS(); @@ -39,437 +31,93 @@ await act.Should().ThrowAsync() } [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationFindsUnsupportedCapabilities_ShouldFailFast() + public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveInMemoryDocumentAndGraphStores() { var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - ConfigureStoreSelectionOptions(services, options => - { - options.Provider = ProjectionReadModelProviderNames.InMemory; - options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); - options.FailOnUnsupportedCapabilities = true; - }); + RegisterInMemoryProviders(services); services.AddWorkflowExecutionProjectionCQRS(); await using var provider = services.BuildServiceProvider(); - Func act = () => StartHostedServicesAsync(provider); - - await act.Should().ThrowAsync() - .Where(ex => ex.ReadModelType == typeof(WorkflowExecutionReport)); - } - - [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_WhenStartupValidationConfiguredCorrectly_ShouldPass() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - - await using var provider = services.BuildServiceProvider(); - Func act = () => StartHostedServicesAsync(provider); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_ShouldUseInMemoryProviderByDefault() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - - using var provider = services.BuildServiceProvider(); - var store = provider.GetRequiredService>(); + var documentStore = provider.GetRequiredService>(); + var readModelStore = provider.GetRequiredService>(); var relationStore = provider.GetRequiredService(); + var graphStore = provider.GetRequiredService>(); + var router = provider.GetRequiredService>(); - store.Should().BeOfType>(); + documentStore.Should().BeOfType>(); + readModelStore.Should().BeOfType>(); relationStore.Should().BeOfType(); - var metadata = store.Should().BeAssignableTo().Subject; - metadata.ProviderCapabilities.ProviderName.Should().Be(ProjectionReadModelProviderNames.InMemory); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchConfiguredWithoutRelationProvider_ShouldFailFastOnRelationStore() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - RegisterElasticsearchProvider(services); - ConfigureStoreSelectionOptions(services, options => - options.Provider = ProjectionReadModelProviderNames.Elasticsearch); - services.AddWorkflowExecutionProjectionCQRS(); - - using var provider = services.BuildServiceProvider(); - var store = provider.GetRequiredService>(); - store.Should().BeOfType>(); - var metadata = store.Should().BeAssignableTo().Subject; - metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); - metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Document); + graphStore.Should().BeOfType>(); + router.Should().NotBeNull(); - Action act = () => provider.GetRequiredService(); - act.Should().Throw() - .Where(ex => ex.ReadModelType == typeof(ProjectionRelationNode)); + Func act = () => StartHostedServicesAsync(provider); + await act.Should().NotThrowAsync(); } [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenElasticsearchReadModelWithInMemoryRelationConfigured_ShouldResolveSplitProviders() + public void AddWorkflowExecutionProjectionCQRS_WhenDocumentElasticsearchAndGraphInMemoryConfigured_ShouldResolveSplitProviders() { var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - RegisterElasticsearchProvider(services); + RegisterInMemoryProviders(services); + RegisterElasticsearchDocumentProvider(services); ConfigureStoreSelectionOptions(services, options => { - options.Provider = ProjectionReadModelProviderNames.Elasticsearch; - options.RelationProvider = ProjectionReadModelProviderNames.InMemory; + options.DocumentProvider = ProjectionReadModelProviderNames.Elasticsearch; + options.GraphProvider = ProjectionReadModelProviderNames.InMemory; }); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); - var store = provider.GetRequiredService>(); + var readModelStore = provider.GetRequiredService>(); var relationStore = provider.GetRequiredService(); - store.Should().BeOfType>(); + readModelStore.Should().BeOfType>(); relationStore.Should().BeOfType(); } [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_WhenNeo4jConfigured_ShouldResolveNeo4jStore() + public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldThrowOnGraphStoreResolution() { var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - RegisterNeo4jProvider(services); - ConfigureStoreSelectionOptions(services, options => - options.Provider = ProjectionReadModelProviderNames.Neo4j); - services.AddWorkflowExecutionProjectionCQRS(); - - await using var provider = services.BuildServiceProvider(); - var store = provider.GetRequiredService>(); - var relationStore = provider.GetRequiredService(); - - store.Should().BeOfType>(); - relationStore.Should().BeOfType(); - var metadata = store.Should().BeAssignableTo().Subject; - metadata.ProviderCapabilities.SupportsIndexing.Should().BeTrue(); - metadata.ProviderCapabilities.IndexKinds.Should().Contain(ProjectionReadModelIndexKind.Graph); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenProviderUnsupported_ShouldThrow() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - ConfigureStoreSelectionOptions(services, options => - options.Provider = "UnknownProvider"); - services.AddWorkflowExecutionProjectionCQRS(); - using var provider = services.BuildServiceProvider(); - - Action act = () => provider.GetRequiredService>(); - - act.Should().Throw() - .WithMessage("*Requested provider*is not registered*"); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenBindingRequiresUnsupportedCapabilities_ShouldFailFast() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - ConfigureStoreSelectionOptions(services, options => - { - options.Provider = ProjectionReadModelProviderNames.InMemory; - options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); - options.FailOnUnsupportedCapabilities = true; - }); - services.AddWorkflowExecutionProjectionCQRS(); - using var provider = services.BuildServiceProvider(); - - Action act = () => provider.GetRequiredService>(); - - act.Should().Throw() - .Where(ex => ex.ReadModelType == typeof(WorkflowExecutionReport)); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenFailFastDisabled_ShouldAllowUnsupportedCapabilities() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - ConfigureStoreSelectionOptions(services, options => - { - options.Provider = ProjectionReadModelProviderNames.InMemory; - options.Bindings[typeof(WorkflowExecutionReport).FullName!] = ProjectionReadModelIndexKind.Document.ToString(); - options.FailOnUnsupportedCapabilities = false; - }); - services.AddWorkflowExecutionProjectionCQRS(); - using var provider = services.BuildServiceProvider(); - - Action act = () => provider.GetRequiredService>(); - - act.Should().NotThrow(); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenStateOnlyModeConfigured_ShouldThrow() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); + RegisterElasticsearchDocumentProvider(services); ConfigureStoreSelectionOptions(services, options => { - options.Mode = ProjectionReadModelMode.StateOnly; - options.Provider = ProjectionReadModelProviderNames.InMemory; + options.DocumentProvider = ProjectionReadModelProviderNames.Elasticsearch; + options.GraphProvider = ProjectionReadModelProviderNames.Elasticsearch; }); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); - Action act = () => provider.GetRequiredService>(); - - act.Should().Throw() - .WithMessage("*does not support*StateOnly*"); - } - - [Fact] - public async Task AddWorkflowExecutionProjectionReducer_ShouldSupportExternalReducer() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - services.AddWorkflowExecutionProjectionReducer(); - - await using var provider = services.BuildServiceProvider(); - var coordinator = provider.GetRequiredService>>(); - var store = provider.GetRequiredService>(); - - var context = new WorkflowExecutionProjectionContext - { - ProjectionId = "ext-1", - CommandId = "cmd-ext-1", - RootActorId = "root", - WorkflowName = "direct", - StartedAt = DateTimeOffset.UtcNow, - Input = "hello", - }; - - await coordinator.InitializeAsync(context); - await coordinator.ProjectAsync(context, Wrap(new ChatRequestEvent { Prompt = "hello" })); - await coordinator.CompleteAsync(context, []); - - var report = await store.GetAsync(context.RootActorId); - report.Should().NotBeNull(); - report!.Timeline.Should().ContainSingle(x => x.Stage == "custom.chat.request"); - } - - [Fact] - public async Task AddWorkflowExecutionProjectionExtensionsFromAssembly_ShouldAutoRegisterReducer() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - services.AddWorkflowExecutionProjectionExtensionsFromAssembly(typeof(CustomChatRequestReducer).Assembly); - - await using var provider = services.BuildServiceProvider(); - var coordinator = provider.GetRequiredService>>(); - var store = provider.GetRequiredService>(); - - var context = new WorkflowExecutionProjectionContext - { - ProjectionId = "ext-2", - CommandId = "cmd-ext-2", - RootActorId = "root", - WorkflowName = "direct", - StartedAt = DateTimeOffset.UtcNow, - Input = "hello", - }; - - await coordinator.InitializeAsync(context); - await coordinator.ProjectAsync(context, Wrap(new ChatRequestEvent { Prompt = "hello" })); - await coordinator.CompleteAsync(context, []); - - var report = await store.GetAsync(context.RootActorId); - report.Should().NotBeNull(); - report!.Timeline.Should().ContainSingle(x => x.Stage == "custom.chat.request"); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastOptions() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(options => options.Enabled = true); - services.AddWorkflowExecutionProjectionCQRS(options => options.Enabled = false); - - using var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService(); - var coordinator = provider.GetRequiredService>>(); - var store = provider.GetRequiredService>(); - - options.Enabled.Should().BeFalse(); - coordinator.Should().NotBeNull(); - store.Should().NotBeNull(); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_MultipleCalls_ShouldUseLastProvider() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - RegisterElasticsearchProvider(services); - ConfigureStoreSelectionOptions(services, options => - options.Provider = ProjectionReadModelProviderNames.InMemory); - services.AddWorkflowExecutionProjectionCQRS(); - ConfigureStoreSelectionOptions(services, options => - options.Provider = ProjectionReadModelProviderNames.Elasticsearch); - services.AddWorkflowExecutionProjectionCQRS(); - - using var provider = services.BuildServiceProvider(); - var store = provider.GetRequiredService>(); - - store.Should().BeOfType>(); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_ShouldExposeGenericProjectionAbstractions() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - - using var provider = services.BuildServiceProvider(); - var coordinator = provider.GetRequiredService>>(); - var store = provider.GetRequiredService>(); - var reducers = provider.GetServices>(); - var projectors = provider.GetServices>>(); - - coordinator.Should().NotBeNull(); - store.Should().NotBeNull(); - reducers.Should().NotBeEmpty(); - projectors.Should().NotBeEmpty(); - } - - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldRegisterDefaultAIReducers() - { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - services.AddWorkflowAIProjectionExtensions(); - - using var provider = services.BuildServiceProvider(); - var reducerTypes = provider - .GetServices>() - .Select(x => x.GetType()) - .ToList(); + Action act = () => provider.GetRequiredService(); - reducerTypes.Should().Contain(x => - x.IsGenericType && - x.GetGenericTypeDefinition() == typeof(TextMessageEndProjectionReducer<,>)); - reducerTypes.Should().Contain(x => - x.IsGenericType && - x.GetGenericTypeDefinition() == typeof(TextMessageStartProjectionReducer<,>)); - reducerTypes.Should().Contain(x => - x.IsGenericType && - x.GetGenericTypeDefinition() == typeof(TextMessageContentProjectionReducer<,>)); - reducerTypes.Should().Contain(x => - x.IsGenericType && - x.GetGenericTypeDefinition() == typeof(ToolCallProjectionReducer<,>)); - reducerTypes.Should().Contain(x => - x.IsGenericType && - x.GetGenericTypeDefinition() == typeof(ToolResultProjectionReducer<,>)); + act.Should().Throw() + .WithMessage("*No relation store provider registrations were found*"); } - [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_WithAIExtensions_ShouldProjectAIEventsWithoutWorkflowApplier() + private static void RegisterInMemoryProviders(IServiceCollection services) { - var services = new ServiceCollection(); - RegisterInMemoryProvider(services); - services.AddWorkflowExecutionProjectionCQRS(); - services.AddWorkflowAIProjectionExtensions(); - - await using var provider = services.BuildServiceProvider(); - var coordinator = provider.GetRequiredService>>(); - var store = provider.GetRequiredService>(); - - var context = new WorkflowExecutionProjectionContext - { - ProjectionId = "ai-layer-1", - CommandId = "cmd-ai-layer-1", - RootActorId = "root", - WorkflowName = "wf", - StartedAt = DateTimeOffset.UtcNow, - Input = "hello", - }; - - await coordinator.InitializeAsync(context); - await coordinator.ProjectAsync(context, Wrap(new TextMessageStartEvent { SessionId = "s1" }, "assistant")); - await coordinator.ProjectAsync(context, Wrap(new TextMessageContentEvent { SessionId = "s1", Delta = "hi" }, "assistant")); - await coordinator.ProjectAsync(context, Wrap(new TextMessageEndEvent { SessionId = "s1", Content = "hello" }, "assistant")); - await coordinator.ProjectAsync(context, Wrap(new ToolCallEvent { ToolName = "search", CallId = "c1" }, "assistant")); - await coordinator.ProjectAsync(context, Wrap(new ToolResultEvent { CallId = "c1", Success = true }, "assistant")); - await coordinator.CompleteAsync(context, []); - - var report = await store.GetAsync(context.RootActorId); - report.Should().NotBeNull(); - report!.Timeline.Should().Contain(x => x.Stage == "llm.start"); - report.Timeline.Should().Contain(x => x.Stage == "llm.content"); - report.Timeline.Should().Contain(x => x.Stage == "llm.end"); - report.Timeline.Should().Contain(x => x.Stage == "tool.call"); - report.Timeline.Should().Contain(x => x.Stage == "tool.result"); - report.RoleReplies.Should().ContainSingle(x => x.RoleId == "assistant"); + services.AddInMemoryDocumentStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.CreatedAt, + listTakeMax: 200); + services.AddInMemoryGraphStoreRegistration(); } - private static EventEnvelope Wrap(IMessage evt, string publisherId = "test") => new() - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), - Payload = Any.Pack(evt), - PublisherId = publisherId, - Direction = EventDirection.Down, - }; - - private static void RegisterElasticsearchProvider(IServiceCollection services) + private static void RegisterElasticsearchDocumentProvider(IServiceCollection services) { - services.AddElasticsearchReadModelStoreRegistration( + services.AddElasticsearchDocumentStoreRegistration( optionsFactory: _ => new ElasticsearchProjectionReadModelStoreOptions { Endpoints = ["http://localhost:9200"], - IndexPrefix = "aevatar-test", - AutoCreateIndex = false, }, - indexScope: "workflow-execution-reports", - keySelector: report => report.RootActorId, - keyFormatter: key => key); - services.AddElasticsearchRelationStoreRegistration(); - } - - private static void RegisterInMemoryProvider(IServiceCollection services) - { - services.AddInMemoryReadModelStoreRegistration( - keySelector: report => report.RootActorId, - keyFormatter: key => key, - listSortSelector: report => report.StartedAt); - services.AddInMemoryRelationStoreRegistration(); - } - - private static void RegisterNeo4jProvider(IServiceCollection services) - { - services.AddNeo4jReadModelStoreRegistration( - optionsFactory: _ => new Neo4jProjectionReadModelStoreOptions + indexScopeFactory: sp => { - Uri = "bolt://localhost:7687", - Username = "neo4j", - Password = "test", - AutoCreateConstraints = false, + var metadataResolver = sp.GetRequiredService(); + return metadataResolver.Resolve().IndexName; }, - scope: "workflow-execution-reports", keySelector: report => report.RootActorId, keyFormatter: key => key); - services.AddNeo4jRelationStoreRegistration( - optionsFactory: _ => new Neo4jProjectionRelationStoreOptions - { - Uri = "bolt://localhost:7687", - Username = "neo4j", - Password = "test", - AutoCreateConstraints = false, - }, - scope: WorkflowExecutionRelationConstants.Scope); } private static void ConfigureStoreSelectionOptions( @@ -483,31 +131,9 @@ private static void ConfigureStoreSelectionOptions( sp.GetRequiredService())); } - public sealed class CustomChatRequestReducer : WorkflowExecutionEventReducerBase - { - protected override bool Reduce( - WorkflowExecutionReport report, - WorkflowExecutionProjectionContext context, - EventEnvelope envelope, - ChatRequestEvent evt, - DateTimeOffset now) - { - report.Timeline.Add(new WorkflowExecutionTimelineEvent - { - Timestamp = now, - Stage = "custom.chat.request", - Message = evt.Prompt ?? "", - AgentId = envelope.PublisherId ?? "", - EventType = envelope.Payload?.TypeUrl ?? "", - }); - - return true; - } - } - - private static async Task StartHostedServicesAsync(ServiceProvider provider) + private static async Task StartHostedServicesAsync(IServiceProvider provider) { - var hostedServices = provider.GetServices(); + var hostedServices = provider.GetServices().ToList(); foreach (var hostedService in hostedServices) await hostedService.StartAsync(CancellationToken.None); } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index b000f6a4d..977470bbd 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -4,6 +4,7 @@ using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.Foundation.Abstractions.Persistence; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.Foundation.Core.EventSourcing; @@ -538,9 +539,16 @@ private static ProjectionPortsHarness CreateService( forwardingRegistry); var subscriptionHub = new ActorStreamSubscriptionHub(streams); store = new ObservableWorkflowExecutionReadModelStore(); - var projector = new WorkflowExecutionReadModelProjector( + var resolvedClock = clock ?? new SystemProjectionClock(); + var relationStore = new InMemoryProjectionRelationStore(); + var graphStore = new ProjectionGraphStoreAdapter(relationStore); + var materializationRouter = new ProjectionMaterializationRouter( store, + graphStore); + var projector = new WorkflowExecutionReadModelProjector( + materializationRouter, new TestEventDeduplicator(), + resolvedClock, BuildReducers()); var coordinator = new ProjectionCoordinator>([projector]); var dispatcher = new ProjectionDispatcher>(coordinator); @@ -570,7 +578,6 @@ private static ProjectionPortsHarness CreateService( var ownershipCoordinator = new ActorProjectionOwnershipCoordinator( runtime, ownershipTypeVerifier); - var resolvedClock = clock ?? new SystemProjectionClock(); runEventStreamHub = new ProjectionSessionEventHub( streams, new WorkflowRunEventSessionCodec()); @@ -578,11 +585,11 @@ private static ProjectionPortsHarness CreateService( var leaseManager = new WorkflowProjectionLeaseManager(ownershipCoordinator); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventStreamHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventStreamHub, resolvedClock); - var readModelUpdater = new WorkflowProjectionReadModelUpdater(store, resolvedClock); + var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, resolvedClock); var queryReader = new WorkflowProjectionQueryReader( store, mapper, - new InMemoryProjectionRelationStore()); + graphStore); var activationService = new WorkflowProjectionActivationService( lifecycle, resolvedClock, @@ -614,16 +621,21 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( { var store = CreateStore(); var clock = new SystemProjectionClock(); + var relationStore = new InMemoryProjectionRelationStore(); + var graphStore = new ProjectionGraphStoreAdapter(relationStore); + var materializationRouter = new ProjectionMaterializationRouter( + store, + graphStore); var runEventHub = new NoOpWorkflowRunEventHub(); var mapper = new WorkflowExecutionReadModelMapper(); var leaseManager = new WorkflowProjectionLeaseManager(ownershipCoordinator); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventHub, clock); - var readModelUpdater = new WorkflowProjectionReadModelUpdater(store, clock); + var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, clock); var queryReader = new WorkflowProjectionQueryReader( store, mapper, - new InMemoryProjectionRelationStore()); + graphStore); var activationService = new WorkflowProjectionActivationService( lifecycle, clock, @@ -802,6 +814,14 @@ public Task GetActorRelationSubgraphAsync( WorkflowActorRelationQueryOptions? options = null, CancellationToken ct = default) => _queryPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + + public Task GetActorGraphEnrichedSnapshotAsync( + string actorId, + int depth = 2, + int take = 200, + WorkflowActorRelationQueryOptions? options = null, + CancellationToken ct = default) + => _queryPort.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, options, ct); } private sealed class ObservableWorkflowExecutionReadModelStore diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index cab5c203a..aee77fa68 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -3,6 +3,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.ReadModels; @@ -24,6 +25,11 @@ public class WorkflowExecutionReadModelProjectorTests keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.StartedAt); + private static IProjectionMaterializationRouter CreateRouter( + InMemoryProjectionReadModelStore store) => + new ProjectionMaterializationRouter( + store, + new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())); private static IReadOnlyList> BuildReducers() => [ @@ -60,7 +66,11 @@ private static EventEnvelope Wrap( public async Task Projector_ShouldBuildRunReadModel_EndToEnd() { var store = CreateStore(); - var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); + var projector = new WorkflowExecutionReadModelProjector( + CreateRouter(store), + CreateDeduplicator(), + new SystemProjectionClock(), + BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); var context = new WorkflowExecutionProjectionContext @@ -124,7 +134,11 @@ await coordinator.ProjectAsync(context, Wrap(new WorkflowCompletedEvent public async Task Projector_ShouldIgnoreUnknownEvents() { var store = CreateStore(); - var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); + var projector = new WorkflowExecutionReadModelProjector( + CreateRouter(store), + CreateDeduplicator(), + new SystemProjectionClock(), + BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); var context = new WorkflowExecutionProjectionContext @@ -154,7 +168,11 @@ await coordinator.ProjectAsync(context, Wrap(new ChatRequestEvent public async Task Projector_ShouldDeduplicateByEnvelopeId() { var store = CreateStore(); - var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); + var projector = new WorkflowExecutionReadModelProjector( + CreateRouter(store), + CreateDeduplicator(), + new SystemProjectionClock(), + BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); var context = new WorkflowExecutionProjectionContext @@ -194,7 +212,11 @@ public async Task Projector_NoOpReducer_ShouldNotAdvanceStateVersion() [ new TextMessageStartProjectionReducer([]), ]; - var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), reducers); + var projector = new WorkflowExecutionReadModelProjector( + CreateRouter(store), + CreateDeduplicator(), + new SystemProjectionClock(), + reducers); var coordinator = new ProjectionCoordinator>([projector]); var context = new WorkflowExecutionProjectionContext @@ -226,7 +248,11 @@ await coordinator.ProjectAsync(context, Wrap(new AIEvents.TextMessageStartEvent public async Task Projector_ShouldUseEnvelopeTimestamp_WhenProvided() { var store = CreateStore(); - var projector = new WorkflowExecutionReadModelProjector(store, CreateDeduplicator(), BuildReducers()); + var projector = new WorkflowExecutionReadModelProjector( + CreateRouter(store), + CreateDeduplicator(), + new SystemProjectionClock(), + BuildReducers()); var coordinator = new ProjectionCoordinator>( [projector]); var context = new WorkflowExecutionProjectionContext diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs deleted file mode 100644 index e0061e721..000000000 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionRelationProjectorTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Abstractions; -using Aevatar.CQRS.Projection.Providers.InMemory.Stores; -using Aevatar.Workflow.Core; -using Aevatar.Workflow.Projection; -using Aevatar.Workflow.Projection.Projectors; -using Aevatar.Workflow.Projection.ReadModels; -using FluentAssertions; -using Google.Protobuf; -using Google.Protobuf.WellKnownTypes; - -namespace Aevatar.Workflow.Host.Api.Tests; - -public sealed class WorkflowExecutionRelationProjectorTests -{ - [Fact] - public async Task ProjectAsync_WhenStepIdIsBlank_ShouldSkipContainsStepEdge() - { - var relationStore = new InMemoryProjectionRelationStore(); - var projector = new WorkflowExecutionRelationProjector(relationStore); - var context = CreateContext(); - - await projector.InitializeAsync(context); - await projector.ProjectAsync(context, Wrap(new StepRequestEvent - { - StepId = " ", - StepType = "llm_call", - TargetRole = "assistant", - })); - - var runEdges = await relationStore.GetNeighborsAsync(new ProjectionRelationQuery - { - Scope = WorkflowExecutionRelationConstants.Scope, - RootNodeId = BuildRunNodeId(context), - Direction = ProjectionRelationDirection.Outbound, - Take = 50, - }); - - runEdges.Should().NotContain(x => - string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationContainsStep, StringComparison.Ordinal)); - } - - [Fact] - public async Task ProjectAsync_ShouldUpsertStepNodeFromRequestAndCompletionEvents() - { - var relationStore = new InMemoryProjectionRelationStore(); - var projector = new WorkflowExecutionRelationProjector(relationStore); - var context = CreateContext(); - var requestTime = new DateTime(2026, 2, 24, 8, 0, 0, DateTimeKind.Utc); - var completedTime = requestTime.AddSeconds(5); - - await projector.InitializeAsync(context); - await projector.ProjectAsync(context, Wrap(new StepRequestEvent - { - StepId = "step-1", - StepType = "llm_call", - TargetRole = "assistant", - }, requestTime)); - await projector.ProjectAsync(context, Wrap(new StepCompletedEvent - { - StepId = "step-1", - WorkerId = "assistant-1", - Success = true, - Output = "done", - }, completedTime)); - - var subgraph = await relationStore.GetSubgraphAsync(new ProjectionRelationQuery - { - Scope = WorkflowExecutionRelationConstants.Scope, - RootNodeId = BuildRunNodeId(context), - Direction = ProjectionRelationDirection.Outbound, - Depth = 2, - Take = 50, - }); - - subgraph.Edges.Should().ContainSingle(x => - string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationContainsStep, StringComparison.Ordinal)); - var stepNode = subgraph.Nodes.Single(x => - string.Equals(x.NodeType, WorkflowExecutionRelationConstants.StepNodeType, StringComparison.Ordinal)); - stepNode.NodeId.Should().Be("step:root:cmd-1:step-1"); - stepNode.Properties["commandId"].Should().Be("cmd-1"); - stepNode.Properties["stepType"].Should().Be("llm_call"); - stepNode.Properties["targetRole"].Should().Be("assistant"); - stepNode.Properties["workerId"].Should().Be("assistant-1"); - stepNode.Properties["success"].Should().Be("True"); - stepNode.UpdatedAt.Should().Be(new DateTimeOffset(completedTime)); - } - - [Fact] - public async Task CompleteAsync_WhenTopologyContainsBlankNodes_ShouldSkipUnknownRelations() - { - var relationStore = new InMemoryProjectionRelationStore(); - var projector = new WorkflowExecutionRelationProjector(relationStore); - var context = CreateContext(); - - await projector.InitializeAsync(context); - await projector.CompleteAsync( - context, - [ - new WorkflowExecutionTopologyEdge("root-1", "worker-1"), - new WorkflowExecutionTopologyEdge(" ", "worker-2"), - new WorkflowExecutionTopologyEdge("root-2", " "), - ]); - - var subgraph = await relationStore.GetSubgraphAsync(new ProjectionRelationQuery - { - Scope = WorkflowExecutionRelationConstants.Scope, - RootNodeId = "root-1", - Direction = ProjectionRelationDirection.Both, - Depth = 2, - Take = 100, - }); - - subgraph.Nodes.Should().NotContain(x => string.Equals(x.NodeId, "unknown", StringComparison.Ordinal)); - subgraph.Edges.Should().Contain(x => - string.Equals(x.RelationType, WorkflowExecutionRelationConstants.RelationChildOf, StringComparison.Ordinal) && - string.Equals(x.FromNodeId, "root-1", StringComparison.Ordinal) && - string.Equals(x.ToNodeId, "worker-1", StringComparison.Ordinal)); - subgraph.Edges.Should().NotContain(x => - string.Equals(x.FromNodeId, "unknown", StringComparison.Ordinal) || - string.Equals(x.ToNodeId, "unknown", StringComparison.Ordinal)); - } - - private static WorkflowExecutionProjectionContext CreateContext() => new() - { - ProjectionId = "projection-relation", - CommandId = "cmd-1", - RootActorId = "root", - WorkflowName = "direct", - StartedAt = new DateTimeOffset(2026, 2, 24, 7, 0, 0, TimeSpan.Zero), - Input = "hello", - }; - - private static EventEnvelope Wrap(IMessage evt, DateTime? utcTimestamp = null) => new() - { - Id = Guid.NewGuid().ToString("N"), - Timestamp = Timestamp.FromDateTime((utcTimestamp ?? DateTime.UtcNow).ToUniversalTime()), - Payload = Any.Pack(evt), - PublisherId = "root", - Direction = EventDirection.Down, - }; - - private static string BuildRunNodeId(WorkflowExecutionProjectionContext context) => - $"run:{context.RootActorId}:{context.CommandId}"; -} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index b24e322f2..c3da3857b 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -103,9 +103,9 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:ReadModel:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, - ["Projection:ReadModel:RelationProvider"] = ProjectionReadModelProviderNames.InMemory, - ["Projection:ReadModel:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + ["Projection:Document:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, + ["Projection:Graph:Provider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", }) .Build(); @@ -127,8 +127,8 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S using var provider = services.BuildServiceProvider(); var selectionOptions = provider.GetRequiredService(); - selectionOptions.ReadModelProvider.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); - selectionOptions.RelationProvider.Should().Be(ProjectionReadModelProviderNames.InMemory); + selectionOptions.DocumentProvider.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); + selectionOptions.GraphProvider.Should().Be(ProjectionReadModelProviderNames.InMemory); } [Fact] @@ -138,7 +138,7 @@ public void AddWorkflowProjectionReadModelProviders_WhenProviderConfiguredUnknow var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:ReadModel:Provider"] = "UnknownProvider", + ["Projection:Document:Provider"] = "UnknownProvider", }) .Build(); @@ -155,16 +155,16 @@ public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRela var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:ReadModel:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, - ["Projection:ReadModel:RelationProvider"] = ProjectionReadModelProviderNames.InMemory, - ["Projection:ReadModel:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", - ["Projection:Policies:DenyInMemoryRelationFactStore"] = "true", + ["Projection:Document:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, + ["Projection:Graph:Provider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + ["Projection:Policies:DenyInMemoryGraphFactStore"] = "true", }) .Build(); Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); act.Should().Throw() - .WithMessage("*InMemory relation provider is not allowed*"); + .WithMessage("*InMemory graph provider is not allowed*"); } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index dbf88f5c4..9f31ae9ba 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -1,5 +1,6 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.InMemory.Stores; +using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Projection; using Aevatar.Workflow.Projection.Orchestration; @@ -90,7 +91,11 @@ await store.UpsertAsync(new WorkflowExecutionReport EndedAt = startedAt.AddMinutes(-6), CompletionStatus = WorkflowExecutionCompletionStatus.Running, }); - var updater = new WorkflowProjectionReadModelUpdater(store, new FixedClock(stoppedAt)); + var updater = new WorkflowProjectionReadModelUpdater( + new ProjectionMaterializationRouter( + store, + new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())), + new FixedClock(stoppedAt)); var context = new WorkflowExecutionProjectionContext { ProjectionId = "projection-1", @@ -162,7 +167,7 @@ await store.UpsertAsync(new WorkflowExecutionReport var reader = new WorkflowProjectionQueryReader( store, new WorkflowExecutionReadModelMapper(), - new InMemoryProjectionRelationStore()); + new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())); var snapshot = await reader.GetActorSnapshotAsync("actor-3"); var timeline = await reader.ListActorTimelineAsync("actor-3", take: 2); From 6da09aa048330a64aa172059ea5f8af17495b60c Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 00:11:29 +0800 Subject: [PATCH 29/46] Enhance Projection Architecture with New Runtime Abstractions and Provider Updates - Added `Aevatar.CQRS.Projection.Runtime.Abstractions` to support new runtime strategies and materialization contracts. - Updated existing projection stores and providers to implement `IDocumentProjectionStore` and `IProjectionGraphStore` interfaces, enhancing document and graph capabilities. - Refactored dependency injection configurations to register new services and ensure compatibility with the updated architecture. - Improved documentation to reflect the architectural changes and provide clearer guidance on the new abstractions and provider capabilities. --- aevatar.slnx | 1 + .../Aevatar.Demos.CaseProjection.csproj | 1 + .../ServiceCollectionExtensions.cs | 2 +- .../GlobalUsings.cs | 1 + .../Orchestration/CaseProjectionService.cs | 4 +- .../Projectors/CaseReadModelProjector.cs | 4 +- .../Stores/InMemoryCaseReadModelStore.cs | 2 +- docs/CQRS_ARCHITECTURE.md | 2 +- ...ng-elasticsearch-readmodel-requirements.md | 2 +- ...readmodel-full-refactor-plan-2026-02-24.md | 467 +++--------------- ...ojection-readmodel-scorecard-2026-02-24.md | 16 +- .../README.md | 2 +- .../ProjectionQueryPortServiceBase.cs | 18 +- src/Aevatar.CQRS.Projection.Core/README.md | 2 +- ....Projection.Providers.Elasticsearch.csproj | 1 + .../ServiceCollectionExtensions.cs | 10 +- .../GlobalUsings.cs | 1 + .../README.md | 6 +- .../ElasticsearchProjectionReadModelStore.cs | 11 +- ....CQRS.Projection.Providers.InMemory.csproj | 1 + .../ServiceCollectionExtensions.cs | 30 +- .../GlobalUsings.cs | 1 + .../README.md | 4 +- ...ore.cs => InMemoryProjectionGraphStore.cs} | 103 ++-- .../InMemoryProjectionReadModelStore.cs | 15 +- ...tar.CQRS.Projection.Providers.Neo4j.csproj | 1 + ...cs => Neo4jProjectionGraphStoreOptions.cs} | 4 +- .../ServiceCollectionExtensions.cs | 34 +- .../GlobalUsings.cs | 1 + .../README.md | 6 +- ...nStore.cs => Neo4jProjectionGraphStore.cs} | 97 ++-- .../Stores/Neo4jProjectionReadModelStore.cs | 15 +- .../DelegateProjectionStoreRegistration.cs | 6 +- .../Core/IProjectionStoreRegistration.cs | 4 +- .../Graphs/IProjectionGraphStoreFactory.cs | 9 + .../IProjectionGraphStoreProviderRegistry.cs | 6 + .../IProjectionGraphStoreProviderSelector.cs | 9 + .../IProjectionDocumentMetadataResolver.cs | 2 +- .../IProjectionDocumentStoreFactory.cs | 10 + ...ProjectionDocumentStoreProviderRegistry.cs | 8 + ...ProjectionDocumentStoreProviderSelector.cs | 10 + .../IProjectionProviderCapabilityValidator.cs | 13 + .../ProjectionDocumentStoreSelector.cs | 23 + .../ReadModels/ProjectionIndexKind.cs | 8 + .../ProjectionProviderCapabilities.cs} | 28 +- ...nProviderCapabilityValidationException.cs} | 14 +- .../ProjectionProviderCapabilityValidator.cs} | 22 +- .../ReadModels/ProjectionProviderNames.cs} | 4 +- .../ReadModels/ProjectionStoreMode.cs | 8 + .../ReadModels/ProjectionStoreRequirements.cs | 42 ++ .../ProjectionStoreRuntimeOptions.cs | 20 + .../ProjectionStoreSelectionOptions.cs} | 4 +- .../Selection/IProjectionGraphMaterializer.cs | 7 + .../IProjectionMaterializationRouter.cs | 2 +- .../IProjectionStoreSelectionPlanner.cs | 4 +- ...IProjectionStoreSelectionRuntimeOptions.cs | 4 +- .../IProjectionStoreStartupValidator.cs | 15 + .../ProjectionProviderSelectionException.cs | 2 +- .../Selection/ProjectionStoreSelectionPlan.cs | 7 + .../Selection/ProjectionStoreSelector.cs | 12 +- ...QRS.Projection.Runtime.Abstractions.csproj | 12 + .../GlobalUsings.cs | 1 + .../README.md | 23 + .../Aevatar.CQRS.Projection.Runtime.csproj | 1 + .../ServiceCollectionExtensions.cs | 16 +- .../GlobalUsings.cs | 1 + src/Aevatar.CQRS.Projection.Runtime/README.md | 9 +- .../ProjectionDocumentMetadataResolver.cs | 2 +- ...y.cs => ProjectionDocumentStoreFactory.cs} | 30 +- ...ProjectionDocumentStoreProviderRegistry.cs | 16 + ...rojectionDocumentStoreProviderSelector.cs} | 40 +- ...pter.cs => ProjectionGraphMaterializer.cs} | 54 +- ...tory.cs => ProjectionGraphStoreFactory.cs} | 24 +- ...> ProjectionGraphStoreProviderRegistry.cs} | 6 +- ...> ProjectionGraphStoreProviderSelector.cs} | 28 +- .../ProjectionMaterializationRouter.cs | 14 +- ...ctionProviderCapabilityValidatorService.cs | 15 + .../ProjectionReadModelBindingResolver.cs | 65 --- ...tionReadModelCapabilityValidatorService.cs | 15 - .../ProjectionReadModelProviderRegistry.cs | 16 - .../ProjectionStoreSelectionPlanner.cs | 50 +- .../ProjectionStoreStartupValidator.cs | 36 +- .../README.md | 2 +- .../Core/IProjectionStoreProviderMetadata.cs | 6 - .../Graphs/IProjectionGraphStore.cs | 18 + .../ProjectionGraphDirection.cs} | 2 +- .../ProjectionGraphEdge.cs} | 4 +- .../ProjectionGraphNode.cs} | 2 +- .../ProjectionGraphQuery.cs} | 6 +- .../Graphs/ProjectionGraphSubgraph.cs | 14 + .../ReadModels/GraphEdgeDescriptor.cs | 2 +- .../ReadModels/IGraphProjectionStore.cs | 15 - ...=> IProjectionDocumentMetadataProvider.cs} | 2 +- .../IProjectionReadModelBindingResolver.cs | 8 - ...IProjectionReadModelCapabilityValidator.cs | 13 - .../IProjectionReadModelProviderRegistry.cs | 8 - .../IProjectionReadModelProviderSelector.cs | 10 - .../ReadModels/IProjectionReadModelStore.cs | 8 - .../IProjectionReadModelStoreFactory.cs | 10 - .../ProjectionReadModelBindingException.cs | 33 -- .../ProjectionReadModelIndexKind.cs | 8 - .../ReadModels/ProjectionReadModelMode.cs | 8 - .../ProjectionReadModelRequirements.cs | 42 -- .../ProjectionReadModelRuntimeOptions.cs | 20 - .../ProjectionReadModelStoreSelector.cs | 23 - .../Relations/IProjectionRelationStore.cs | 18 - .../IProjectionRelationStoreFactory.cs | 9 - ...ProjectionRelationStoreProviderRegistry.cs | 6 - ...ProjectionRelationStoreProviderSelector.cs | 9 - .../Relations/ProjectionRelationSubgraph.cs | 14 - .../IProjectionStoreStartupValidator.cs | 15 - .../Selection/ProjectionStoreSelectionPlan.cs | 7 - .../README.md | 27 +- .../IWorkflowExecutionProjectionQueryPort.cs | 10 +- ...orkflowExecutionQueryApplicationService.cs | 10 +- .../Queries/WorkflowExecutionQueryModels.cs | 22 +- ...orkflowExecutionQueryApplicationService.cs | 16 +- .../CapabilityApi/ChatQueryEndpoints.cs | 50 +- .../Aevatar.Workflow.Projection.csproj | 1 + .../WorkflowExecutionProjectionOptions.cs | 6 +- .../ServiceCollectionExtensions.cs | 35 +- .../GlobalUsings.cs | 1 + ...ExecutionReportDocumentMetadataProvider.cs | 2 +- .../IWorkflowProjectionQueryReader.cs | 10 +- ...WorkflowExecutionProjectionQueryService.cs | 44 +- .../WorkflowProjectionQueryReader.cs | 56 +-- ...ReadModelStartupValidationHostedService.cs | 26 +- .../Aevatar.Workflow.Projection/README.md | 23 +- .../WorkflowExecutionGraphConstants.cs | 18 + .../ReadModels/WorkflowExecutionReadModel.cs | 24 +- .../WorkflowExecutionReadModelMapper.cs | 20 +- .../WorkflowExecutionRelationConstants.cs | 18 - ...Aevatar.Workflow.Extensions.Hosting.csproj | 1 + ...tionProviderServiceCollectionExtensions.cs | 41 +- .../Aevatar.CQRS.Projection.Core.Tests.csproj | 1 + .../GlobalUsings.cs | 1 + .../ProjectionReadModelRuntimeTests.cs | 66 +-- .../ProjectionReadModelStoreSelectorTests.cs | 62 +-- .../ProjectionStoreSelectionPlannerTests.cs | 44 +- .../WorkflowApplicationLayerTests.cs | 48 +- .../WorkflowRunOrchestrationComponentTests.cs | 8 +- .../Aevatar.Workflow.Host.Api.Tests.csproj | 1 + .../ChatEndpointsInternalTests.cs | 68 +-- ...hatWebSocketCoordinatorAndProtocolTests.cs | 16 +- .../GlobalUsings.cs | 1 + ...orkflowCapabilityEndpointsCoverageTests.cs | 14 +- ...lowExecutionProjectionRegistrationTests.cs | 32 +- ...WorkflowExecutionProjectionServiceTests.cs | 28 +- ...orkflowExecutionReadModelProjectorTests.cs | 2 +- .../WorkflowHostingExtensionsCoverageTests.cs | 26 +- ...owProjectionOrchestrationComponentTests.cs | 4 +- tools/ci/architecture_guards.sh | 10 + 152 files changed, 1210 insertions(+), 1667 deletions(-) rename src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/{InMemoryProjectionRelationStore.cs => InMemoryProjectionGraphStore.cs} (66%) rename src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/{Neo4jProjectionRelationStoreOptions.cs => Neo4jProjectionGraphStoreOptions.cs} (80%) rename src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/{Neo4jProjectionRelationStore.cs => Neo4jProjectionGraphStore.cs} (85%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Core/DelegateProjectionStoreRegistration.cs (82%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Core/IProjectionStoreRegistration.cs (65%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs (74%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs} (57%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs} (60%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs} (72%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs} (58%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs} (53%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Selection/IProjectionMaterializationRouter.cs (89%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs (60%) rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs (63%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Selection/ProjectionProviderSelectionException.cs (95%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions => Aevatar.CQRS.Projection.Runtime.Abstractions}/Abstractions/Selection/ProjectionStoreSelector.cs (87%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Aevatar.CQRS.Projection.Runtime.Abstractions.csproj create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/GlobalUsings.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionReadModelStoreFactory.cs => ProjectionDocumentStoreFactory.cs} (56%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionReadModelProviderSelector.cs => ProjectionDocumentStoreProviderSelector.cs} (65%) rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionGraphStoreAdapter.cs => ProjectionGraphMaterializer.cs} (70%) rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionRelationStoreFactory.cs => ProjectionGraphStoreFactory.cs} (66%) rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionRelationStoreProviderRegistry.cs => ProjectionGraphStoreProviderRegistry.cs} (65%) rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionRelationStoreProviderSelector.cs => ProjectionGraphStoreProviderSelector.cs} (56%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/{Relations/ProjectionRelationDirection.cs => Graphs/ProjectionGraphDirection.cs} (73%) rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/{Relations/ProjectionRelationEdge.cs => Graphs/ProjectionGraphEdge.cs} (81%) rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/{Relations/ProjectionRelationNode.cs => Graphs/ProjectionGraphNode.cs} (89%) rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/{Relations/ProjectionRelationQuery.cs => Graphs/ProjectionGraphQuery.cs} (53%) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/{IReadModelDocumentMetadataProvider.cs => IProjectionDocumentMetadataProvider.cs} (68%) delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionGraphConstants.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs diff --git a/aevatar.slnx b/aevatar.slnx index f89055595..2ba1dd45f 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -21,6 +21,7 @@ + diff --git a/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj b/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj index c9b34160d..9327f0e05 100644 --- a/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj +++ b/demos/Aevatar.Demos.CaseProjection/Aevatar.Demos.CaseProjection.csproj @@ -9,6 +9,7 @@ + diff --git a/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs b/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs index 386267d92..3c8f45564 100644 --- a/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs @@ -23,7 +23,7 @@ public static IServiceCollection AddCaseProjectionDemo( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); - services.TryAddSingleton>(sp => + services.TryAddSingleton>(sp => sp.GetRequiredService()); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs b/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs index 103add2eb..e8a1883b0 100644 --- a/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs +++ b/demos/Aevatar.Demos.CaseProjection/GlobalUsings.cs @@ -1,5 +1,6 @@ global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; global using Aevatar.CQRS.Projection.Core.Orchestration; global using Aevatar.CQRS.Projection.Core.Streaming; global using Aevatar.Demos.CaseProjection.Abstractions; diff --git a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs index 7f7365ee0..100c5b5ba 100644 --- a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs +++ b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs @@ -11,7 +11,7 @@ public sealed class CaseProjectionService : ICaseProjectionService { private readonly CaseProjectionOptions _options; private readonly IProjectionLifecycleService> _lifecycle; - private readonly IProjectionReadModelStore _store; + private readonly IDocumentProjectionStore _store; private readonly IProjectionClock _clock; private readonly ICaseProjectionContextFactory _contextFactory; private readonly ConcurrentDictionary _contexts = new(StringComparer.Ordinal); @@ -19,7 +19,7 @@ public sealed class CaseProjectionService : ICaseProjectionService public CaseProjectionService( CaseProjectionOptions options, IProjectionLifecycleService> lifecycle, - IProjectionReadModelStore store, + IDocumentProjectionStore store, IProjectionClock clock, ICaseProjectionContextFactory contextFactory) { diff --git a/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs b/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs index 813745b9f..3a3d06b71 100644 --- a/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs +++ b/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs @@ -5,11 +5,11 @@ namespace Aevatar.Demos.CaseProjection.Projectors; public sealed class CaseReadModelProjector : IProjectionProjector> { - private readonly IProjectionReadModelStore _store; + private readonly IDocumentProjectionStore _store; private readonly IReadOnlyDictionary>> _reducersByType; public CaseReadModelProjector( - IProjectionReadModelStore store, + IDocumentProjectionStore store, IEnumerable> reducers) { _store = store; diff --git a/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs b/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs index 426677fcd..0dff85f88 100644 --- a/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs +++ b/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs @@ -1,6 +1,6 @@ namespace Aevatar.Demos.CaseProjection.Stores; -public sealed class InMemoryCaseReadModelStore : IProjectionReadModelStore +public sealed class InMemoryCaseReadModelStore : IDocumentProjectionStore { private readonly object _gate = new(); private readonly Dictionary _reports = new(StringComparer.Ordinal); diff --git a/docs/CQRS_ARCHITECTURE.md b/docs/CQRS_ARCHITECTURE.md index 5360bb8fb..38014d47b 100644 --- a/docs/CQRS_ARCHITECTURE.md +++ b/docs/CQRS_ARCHITECTURE.md @@ -45,7 +45,7 @@ flowchart LR 3. 未命中 reducer 的事件必须为 no-op。 4. Workflow 投影生命周期通过 lease/session 句柄管理,不允许 `actorId -> context` 反查。 5. 同一 `EventEnvelope` 分发到多个 projector 时采用“一对多全分支尝试”语义:单个 projector 失败不阻断其他 projector 执行,最终以聚合异常统一回传。 -6. `Projection:ReadModel:Bindings` 的 key 必须使用 read model `Type.FullName`,禁止短类名绑定。 +6. 禁止 `Projection:ReadModel:Bindings` 与任何 BindingResolver 路由;投影存储路由必须由 `IDocumentReadModel/IGraphReadModel` 能力自动决策。 7. Host 组合层按配置仅注册所需 provider 组合,不允许无条件并列注册 InMemory/Elasticsearch/Neo4j。 ## 5.1 编排减重落地(2026-02-22) diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index a751555e1..fc9d0a49c 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -123,7 +123,7 @@ - 当前行为: 1. Host 启动阶段执行 read-model provider 选择 + 能力校验 dry-run。 2. provider 缺失或能力不匹配可在启动阶段 fail-fast。 - 3. 可通过 `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup` 控制启用(默认开启)。 + 3. 可通过 `WorkflowExecutionProjection:ValidateDocumentProviderOnStartup` 控制启用(默认开启)。 ## 7. 需求分解与状态矩阵 diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index a9667f370..4ef0b76fa 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -1,422 +1,87 @@ -# Projection ReadModel 全量重构实施计划(v2,按仓库现状细化) - -## 1. 本版更新说明 - -1. 采用你提出的核心规则:**索引 metadata 由 `TReadModel` 泛型 provider 提供**,不挂在 readmodel 实例方法上。 -2. 计划已按当前仓库真实结构重写,覆盖到项目/目录/文件级实施方式。 -3. 继续执行“无兼容重构”:删除旧链路,不保留兼容层和回退开关。 - -### 1.1 已落地实现(2026-02-24) - -1. 已新增 capability-first 抽象:`IProjectionReadModel`、`IDocumentReadModel`、`IGraphReadModel`、`IDocumentProjectionStore`、`IGraphProjectionStore`、`IProjectionMaterializationRouter`。 -2. 已落地泛型 metadata 链路:`IReadModelDocumentMetadataProvider` + `IProjectionDocumentMetadataResolver`。 -3. 已落地 Runtime 双写路由:`ProjectionMaterializationRouter` 与 `ProjectionGraphStoreAdapter`。 -4. 已删除 `WorkflowExecutionRelationProjector`,关系写入改为 `WorkflowExecutionReport` 的 `GraphNodes/GraphEdges` 驱动。 -5. Host 配置已切换为 `Projection:Document:*` / `Projection:Graph:*`,并禁用 Elasticsearch 作为 graph provider。 -6. Workflow Query 已新增 `graph-enriched` 聚合查询与 endpoint:`/actors/{actorId}/graph-enriched`。 - -## 2. 目标与边界 - -1. 目标:开发者只定义 `State -> ReadModel`,并通过 readmodel 能力接口自动决定写入文档库/图库。 -2. 目标:消除 `Bindings[Type.FullName]` 配置路由,改为类型能力路由。 -3. 目标:消除 `ReadModel` 与 `Relation` 双体系心智割裂。 -4. 非目标:不做旧配置兼容,不做灰度并存。 - -## 3. 仓库现状映射(实施基准) - -| 层 | 项目 | 现状职责 | 重构动作 | -|---|---|---|---| -| 抽象 | `src/Aevatar.CQRS.Projection.Stores.Abstractions` | ReadModel/Relation/Selection 抽象 | 重构为 capability-first 抽象中心 | -| 运行时 | `src/Aevatar.CQRS.Projection.Runtime` | provider 选择、binding 解析、factory | 删除 binding 路径,新增 capability router | -| Provider | `src/Aevatar.CQRS.Projection.Providers.InMemory` | InMemory ReadModel + Relation | 拆分 Doc/Graph provider 能力注册 | -| Provider | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` | ES ReadModel + (弱)Relation | 收敛为 Document provider | -| Provider | `src/Aevatar.CQRS.Projection.Providers.Neo4j` | Neo4j ReadModel + Relation | 收敛为 Graph provider(可选保留 Doc) | -| 读侧业务 | `src/workflow/Aevatar.Workflow.Projection` | ReadModel projector + Relation projector | 合并为单 readmodel materialization 主链 | -| 应用层 | `src/workflow/Aevatar.Workflow.Application` | 查询/运行编排 | 查询改为统一 facade/port 聚合 | -| 接口层 | `src/workflow/Aevatar.Workflow.Infrastructure` | endpoints 协议适配 | 新增 graph-enriched endpoint,禁止手工两跳拼装 | -| Host 组合 | `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` | provider 配置与注册 | 改为 Document/Graph provider 独立配置 | -| 门禁 | `tools/ci/architecture_guards.sh` 等 | 架构与路由守卫 | 新增 capability-first 规则守卫 | - -## 4. 目标架构(最终) +# Projection Abstractions 重构落地报告(v6,无兼容) + +> 日期:2026-02-24 + +## 1. 本次重构已完成内容 + +1. 新增 `Aevatar.CQRS.Projection.Runtime.Abstractions`,承接 Runtime 策略/选择/Factory/Materialization 契约。 +2. `Aevatar.CQRS.Projection.Stores.Abstractions` 彻底收敛为纯存储契约: + - 仅保留 `IDocumentProjectionStore<,>`、`IProjectionGraphStore`、ReadModel/Graph 结构描述。 + - 删除 Selection/Core 运行时编排抽象(迁移至 Runtime.Abstractions)。 +3. 删除 Graph 重复抽象: + - 删除 `IGraphProjectionStore`。 + - 新增 `IProjectionGraphMaterializer`。 + - `ProjectionMaterializationRouter` 改为依赖 `IProjectionGraphMaterializer`。 +4. Runtime 实现落地: + - `ProjectionGraphStoreAdapter` 重构为 `ProjectionGraphMaterializer`。 + - DI 改为注册 `IProjectionGraphMaterializer<>`。 +5. Provider 元数据重复抽象清理: + - 删除 `IProjectionStoreProviderMetadata`。 + - Provider 能力统一通过 `IProjectionStoreRegistration.Capabilities` 描述。 +6. 命名语义统一: + - `ProjectionDocumentStoreSelectionOptions` -> `ProjectionStoreSelectionOptions` + - `ProjectionDocumentRequirements` -> `ProjectionStoreRequirements` + +## 2. 当前边界(As-Built) ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - subgraph D["Domain ReadModel"] - D1["WorkflowExecutionReport"] - D2["IDocumentReadModel"] - D3["IGraphReadModel"] - D4["Graph Node/Edges Data"] - end - - subgraph A["Projection Application"] - A1["Reducers"] - A2["ReadModel Projector"] - A3["Materialization Router"] - A4["Projection Query Facade"] - end - - subgraph I["Infrastructure Stores"] - I1["Document Store (ES/InMemory)"] - I2["Graph Store (Neo4j/InMemory)"] - I3["ReadModel Metadata Provider"] - end - - subgraph H["Host/API"] - H1["/actors/{id}"] - H2["/actors/{id}/relation-subgraph"] - H3["/actors/{id}/graph-enriched"] - end - - D1 --> D2 - D1 --> D3 - D3 --> D4 - - A1 --> A2 --> A3 - A3 --> I1 - A3 --> I2 - D2 --> I3 --> I1 - - H1 --> A4 - H2 --> A4 - H3 --> A4 - A4 --> I1 - A4 --> I2 -``` - -## 5. 核心契约(重构后) - -### 5.1 ReadModel 能力接口 - -```csharp -public interface IProjectionReadModel -{ - string Id { get; } -} - -public interface IDocumentReadModel : IProjectionReadModel -{ - string DocumentScope { get; } -} - -public interface IGraphReadModel : IProjectionReadModel -{ - GraphNodeDescriptor GraphNode { get; } - IReadOnlyList GraphEdges { get; } -} - -public sealed record GraphNodeDescriptor( - string NodeId, - string NodeType, - IReadOnlyDictionary Properties); - -public sealed record GraphEdgeDescriptor( - string EdgeId, - string RelationType, - string FromNodeId, - string ToNodeId, - IReadOnlyDictionary Properties); -``` - -### 5.2 索引 metadata(泛型 provider) - -```csharp -public sealed record DocumentIndexMetadata( - string IndexName, - string MappingJson, - IReadOnlyDictionary Settings, - IReadOnlyDictionary Aliases); - -public interface IReadModelDocumentMetadataProvider - where TReadModel : class, IDocumentReadModel -{ - DocumentIndexMetadata Metadata { get; } -} + SA["Aevatar.CQRS.Projection.Stores.Abstractions\nDocument/Graph Store Contracts"] + RA["Aevatar.CQRS.Projection.Runtime.Abstractions\nSelection + Provider Registration + Materialization Contracts"] + CA["Aevatar.CQRS.Projection.Core.Abstractions\nPipeline Protocol"] + RT["Aevatar.CQRS.Projection.Runtime\nRuntime Implementations"] + PR["Providers\nInMemory/Elasticsearch/Neo4j"] + WF["Workflow.Projection"] + + PR --> SA + PR --> RA + RT --> SA + RT --> RA + RT --> CA + WF --> SA + WF --> RA + WF --> CA ``` -约束: - -1. `DocumentIndexMetadata` 必须由 `TReadModel` 对应 provider 给出,禁止从运行时对象反射/动态拼接。 -2. `IReadModelDocumentMetadataProvider` 在 DI 中必须唯一注册。 - -### 5.3 路由与写入契约 +## 3. 关键契约(最终) ```csharp -public interface IDocumentProjectionStore - where TReadModel : class, IDocumentReadModel +public interface IProjectionGraphStore { - Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); - Task GetAsync(TKey key, CancellationToken ct = default); - Task> ListAsync(int take = 50, CancellationToken ct = default); + Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default); + Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default); + Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); + Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default); + Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default); } -public interface IGraphProjectionStore - where TReadModel : class, IGraphReadModel +public interface IProjectionGraphMaterializer + where TReadModel : class { Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); - Task GetSubgraphAsync(string nodeId, int depth, int take, CancellationToken ct = default); -} - -public interface IProjectionMaterializationRouter - where TReadModel : class, IProjectionReadModel -{ - Task MaterializeAsync(TReadModel readModel, TKey key, CancellationToken ct = default); } ``` -## 6. 关键实施策略 - -1. 单一 projector 产出 `ReadModel`,materialization router 根据接口能力执行 Doc/Graph 单写或双写。 -2. Graph 不再由单独 relation projector 维护;关系由 `IGraphReadModel.GraphEdges` 数据驱动。 -3. Graph 写入必须执行边差异收敛(`toAdd/toUpdate/toDelete`),不能只 upsert。 -4. 查询统一由 projection query facade 完成,endpoint 不手工先查图再查文档。 -5. 默认 deduplicator 改为持久化实现,passthrough 只在测试 profile 注入。 -6. 统一时间源到 `IProjectionClock`。 - -## 7. 文件级改造清单(按项目) - -## 7.1 `src/Aevatar.CQRS.Projection.Stores.Abstractions` - -新增: - -1. `Abstractions/ReadModels/IProjectionReadModel.cs` -2. `Abstractions/ReadModels/IDocumentReadModel.cs` -3. `Abstractions/ReadModels/IGraphReadModel.cs` -4. `Abstractions/ReadModels/GraphNodeDescriptor.cs` -5. `Abstractions/ReadModels/GraphEdgeDescriptor.cs` -6. `Abstractions/ReadModels/DocumentIndexMetadata.cs` -7. `Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs` -8. `Abstractions/ReadModels/IDocumentProjectionStore.cs` -9. `Abstractions/ReadModels/IGraphProjectionStore.cs` -10. `Abstractions/Selection/IProjectionMaterializationRouter.cs` - -修改: - -1. `Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs`(移除 `Bindings`) -2. `Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs`(改为 Document/Graph provider 选项) -3. `Abstractions/ReadModels/ProjectionReadModelRequirements.cs`(改为 capability bool 集合) - -删除: - -1. `Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs` -2. `Abstractions/ReadModels/ProjectionReadModelBindingException.cs` -3. `Abstractions/ReadModels/ProjectionReadModelIndexKind.cs` - -## 7.2 `src/Aevatar.CQRS.Projection.Runtime` - -新增: - -1. `Runtime/ProjectionReadModelCapabilityInspector.cs` -2. `Runtime/ProjectionMaterializationRouter.cs` -3. `Runtime/ProjectionDocumentMetadataResolver.cs` -4. `Runtime/ProjectionGraphWritePlanner.cs` - -修改: - -1. `Runtime/ProjectionStoreSelectionPlanner.cs`(基于 capability,不再读取 binding) -2. `Runtime/ProjectionReadModelProviderSelector.cs`(Document provider 选择) -3. `Runtime/ProjectionRelationStoreProviderSelector.cs`(重命名为 Graph provider selector) -4. `DependencyInjection/ServiceCollectionExtensions.cs`(注册新 router/resolver) - -删除: - -1. `Runtime/ProjectionReadModelBindingResolver.cs` - -## 7.3 `src/Aevatar.CQRS.Projection.Providers.InMemory` - -新增: - -1. `Stores/InMemoryDocumentProjectionStore.cs` -2. `Stores/InMemoryGraphProjectionStore.cs` - -修改: +## 4. 目录变化(核心) -1. `DependencyInjection/ServiceCollectionExtensions.cs`(改为注册 `IDocumentProjectionStore` / `IGraphProjectionStore`) +1. 新增项目:`src/Aevatar.CQRS.Projection.Runtime.Abstractions/` +2. 迁移: + - `Stores.Abstractions/Abstractions/Core/*` -> `Runtime.Abstractions/Abstractions/Core/*` + - `Stores.Abstractions/Abstractions/Selection/*` -> `Runtime.Abstractions/Abstractions/Selection/*` + - `Stores.Abstractions` 中 provider factory/selector/options/capabilities 相关抽象 -> `Runtime.Abstractions` +3. 删除: + - `Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs` + - `Runtime.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs` -删除: +## 5. 验证状态 -1. `Stores/InMemoryProjectionReadModelStore.cs` -2. `Stores/InMemoryProjectionRelationStore.cs` +1. `dotnet build aevatar.slnx --nologo`:通过。 -## 7.4 `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` +## 6. 后续可选收敛(非兼容) -新增: - -1. `Stores/ElasticsearchDocumentProjectionStore.cs` -2. `Stores/ElasticsearchIndexMetadataBootstrapper.cs` - -修改: - -1. `DependencyInjection/ServiceCollectionExtensions.cs`(仅注册 document provider) - -删除: - -1. `Stores/ElasticsearchProjectionRelationStore.cs` -2. `Stores/ElasticsearchProjectionReadModelStore.cs` - -## 7.5 `src/Aevatar.CQRS.Projection.Providers.Neo4j` - -新增: - -1. `Stores/Neo4jGraphProjectionStore.cs` -2. `Stores/Neo4jGraphDiffWriter.cs` - -修改: - -1. `DependencyInjection/ServiceCollectionExtensions.cs`(默认注册 graph provider) - -删除: - -1. `Stores/Neo4jProjectionRelationStore.cs` -2. `Stores/Neo4jProjectionReadModelStore.cs`(若不保留图库文档能力) - -## 7.6 `src/workflow/Aevatar.Workflow.Projection` - -新增: - -1. `Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs` -2. `Orchestration/WorkflowProjectionQueryFacade.cs` - -修改: - -1. `ReadModels/WorkflowExecutionReadModel.cs`(实现 `IDocumentReadModel` + `IGraphReadModel`) -2. `Projectors/WorkflowExecutionReadModelProjector.cs`(完成 reduce 后只调用 materialization router) -3. `DependencyInjection/ServiceCollectionExtensions.cs`(移除 relation projector 注册,注册 metadata provider) -4. `Orchestration/WorkflowProjectionQueryReader.cs`(调用 facade,支持 graph-enriched) -5. `Orchestration/WorkflowProjectionReadModelUpdater.cs`(统一 clock 与 materialization) - -删除: - -1. `Projectors/WorkflowExecutionRelationProjector.cs` -2. `ReadModels/WorkflowExecutionRelationConstants.cs`(关系常量下沉到 graph descriptor) - -## 7.7 `src/workflow/Aevatar.Workflow.Application.Abstractions` - -修改: - -1. `Projections/IWorkflowExecutionProjectionQueryPort.cs`(新增 graph-enriched 查询方法) -2. `Queries/WorkflowExecutionQueryModels.cs`(新增 graph-enriched DTO) - -## 7.8 `src/workflow/Aevatar.Workflow.Application` - -修改: - -1. `Queries/WorkflowExecutionQueryApplicationService.cs`(统一走新 query port/facade) - -## 7.9 `src/workflow/Aevatar.Workflow.Infrastructure` - -修改: - -1. `CapabilityApi/ChatQueryEndpoints.cs`(新增 `/actors/{actorId}/graph-enriched`) -2. `DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs`(去掉 relation 分支显式依赖) - -## 7.10 `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` - -修改: - -1. `WorkflowProjectionProviderServiceCollectionExtensions.cs` - 1. 删除 `Projection:ReadModel:Bindings` 路径。 - 2. 新增 `Projection:Document:Provider` 与 `Projection:Graph:Provider`。 - 3. provider 注册改为 capability 对应注册。 - -## 7.11 测试项目 - -修改: - -1. `test/Aevatar.CQRS.Projection.Core.Tests` - 1. 删除 binding resolver 相关测试。 - 2. 新增 metadata provider、capability router、dual-write 路径测试。 -2. `test/Aevatar.Workflow.Host.Api.Tests` - 1. 删除 `WorkflowExecutionRelationProjectorTests.cs`。 - 2. 新增 graph-enriched 查询与统一 projector 行为测试。 - -## 7.12 CI / Guard - -修改: - -1. `tools/ci/architecture_guards.sh` - 1. 新增禁用 `Bindings[` / `Type.FullName` 路由规则。 - 2. 新增 `IReadModelDocumentMetadataProvider` 唯一注册守卫。 -2. `tools/ci/projection_route_mapping_guard.sh` - 1. 保留 TypeUrl 精确路由守卫。 -3. `tools/ci/projection_provider_e2e_smoke.sh` - 1. 增加 dual-write(ES + Neo4j)完整执行断言。 - -## 8. 实施阶段(可执行方式) - -### Phase 0:基线冻结(0.5 天) - -1. 执行并留档: - 1. `bash tools/ci/architecture_guards.sh` - 2. `bash tools/ci/projection_route_mapping_guard.sh` - 3. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` - 4. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -2. 输出 ADR:确认无兼容策略与删除清单。 - -### Phase 1:抽象改造(2 天) - -1. 先改 `Stores.Abstractions` 新接口与 metadata provider 泛型。 -2. 删 binding 抽象与引用。 -3. 修复编译并补最小单测。 - -### Phase 2:Runtime + Provider(3 天) - -1. 落地 capability inspector/router/metadata resolver。 -2. InMemory/ES/Neo4j provider 按 doc/graph 重建。 -3. 补 dual-write 失败上报与重试策略测试。 - -### Phase 3:Workflow Projection 主链收敛(3 天) - -1. `WorkflowExecutionReport` 实现双能力。 -2. 删除 relation projector,统一写入 router。 -3. QueryReader 改为统一 facade/port。 - -### Phase 4:Application + API(2 天) - -1. 应用查询服务与 endpoint 全部接新 query port。 -2. 新增 graph-enriched endpoint。 -3. 删除 endpoint 手工二次拼装逻辑。 - -### Phase 5:门禁与文档收口(1.5 天) - -1. 更新 CI 守卫与 E2E。 -2. 删除遗留文档、遗留配置示例、遗留测试。 -3. 重新执行全量验证命令。 - -## 9. 验证命令(阶段完成即跑) - -1. `dotnet restore aevatar.slnx --nologo` -2. `dotnet build aevatar.slnx --nologo` -3. `dotnet test aevatar.slnx --nologo` -4. `bash tools/ci/architecture_guards.sh` -5. `bash tools/ci/projection_route_mapping_guard.sh` -6. `bash tools/ci/solution_split_guards.sh` -7. `bash tools/ci/solution_split_test_guards.sh` -8. `bash tools/ci/test_stability_guards.sh` - -## 10. 验收标准(DoD) - -1. 新增 readmodel 时不再需要 `Bindings` 配置。 -2. `TReadModel` 的索引 metadata 必须由 `IReadModelDocumentMetadataProvider` 提供且可被 runtime 解析。 -3. 同一事件输入可稳定触发 doc/graph 双写(按能力接口自动路由)。 -4. 图查询调用方不再手写两跳逻辑。 -5. 全量门禁、构建、测试通过。 - -## 11. 不做项(明确删除) - -1. 不保留旧 `Projection:ReadModel:Bindings` 配置。 -2. 不保留旧 relation projector 与新 router 并存。 -3. 不保留 compatibility adapter 或 feature flag 回退。 - -## 12. 阶段执行图 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart TB - P0["Phase 0 基线冻结"] --> P1["Phase 1 抽象改造"] - P1 --> P2["Phase 2 Runtime + Provider"] - P2 --> P3["Phase 3 Workflow Projection 收敛"] - P3 --> P4["Phase 4 Application + API"] - P4 --> P5["Phase 5 门禁与文档收口"] - P5 --> DONE["Build/Test/Guards 全绿"] -``` +1. 进一步收敛 `Core.Abstractions`:评估将 `IProjectionDispatcher/IProjectionCoordinator/IProjectionSubscriptionRegistry` 由对外契约下沉为实现细节。 +2. 在 `architecture_guards.sh` 增补硬约束: + - 禁止 `Stores.Abstractions` 回流 `Selection/*` 契约。 + - 禁止 `IGraphProjectionStore<` 回流。 +3. 文档全仓术语收敛:`Runtime.Abstractions` 引用补齐(README/架构图/审计文档)。 diff --git a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md index 1d33d9d25..c80c2ce36 100644 --- a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md @@ -13,7 +13,7 @@ |---|---|---| | 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | | 路由映射专项 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | -| Projection Core 定向测试 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests\|FullyQualifiedName~ProjectionReadModelStoreSelectorTests\|FullyQualifiedName~ProjectionProviderE2EIntegrationTests" -m:1 -p:UseSharedCompilation=false` | 通过(`10 passed / 0 failed / 2 skipped`) | +| Projection Core 定向测试 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests\|FullyQualifiedName~ProjectionDocumentStoreSelectorTests\|FullyQualifiedName~ProjectionProviderE2EIntegrationTests" -m:1 -p:UseSharedCompilation=false` | 通过(`10 passed / 0 failed / 2 skipped`) | | Workflow Projection 定向测试 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests\|FullyQualifiedName~WorkflowExecutionReadModelProjectorTests\|FullyQualifiedName~WorkflowProjectionOrchestrationComponentTests" -m:1 -p:UseSharedCompilation=false` | 通过(`36 passed / 0 failed / 0 skipped`) | ## 3. 总分与等级 @@ -64,9 +64,9 @@ ## 5. 正向证据(加分项) -1. 统一选择权威入口:Runtime selector 复用抽象层权威 `ProjectionReadModelStoreSelector.Select(...)`。 +1. 统一选择权威入口:Runtime selector 复用抽象层权威 `ProjectionDocumentStoreSelector.Select(...)`。 `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32` - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs:5` + `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs:5` 2. ReadModel/Relation 统一规划:单一 `ProjectionStoreSelectionPlanner` 产出双存储 selection plan。 `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs:12` `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:155` @@ -179,23 +179,23 @@ flowchart LR ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart TB - O1["ProjectionReadModelRuntimeOptions"] --> P1["ProjectionStoreSelectionPlanner.Build"] - P1 --> P2["ReadModelSelectionOptions"] + O1["ProjectionStoreRuntimeOptions"] --> P1["ProjectionStoreSelectionPlanner.Build"] + P1 --> P2["DocumentSelectionOptions"] P1 --> P3["RelationSelectionOptions"] O2["ReadModel Bindings(Type.FullName -> IndexKind)"] --> B1["ProjectionReadModelBindingResolver.Resolve"] --> P1 P2 --> F1["ProjectionReadModelStoreFactory.Create"] F1 --> G1["ProviderRegistry.GetRegistrations"] G1 --> S1["ProjectionReadModelProviderSelector.Select"] - S1 --> X1["ProjectionReadModelStoreSelector.Select(Authority)"] - X1 --> C1["ProjectionReadModelCapabilityValidator.EnsureSupported"] + S1 --> X1["ProjectionDocumentStoreSelector.Select(Authority)"] + X1 --> C1["ProjectionProviderCapabilityValidator.EnsureSupported"] C1 --> RM["IProjectionReadModelStore"] P3 --> F2["ProjectionRelationStoreFactory.Create"] F2 --> G2["RelationProviderRegistry.GetRegistrations"] G2 --> S2["ProjectionRelationStoreProviderSelector.Select"] S2 --> X2["ProjectionStoreSelector.Select(Authority)"] - X2 --> C2["ProjectionReadModelCapabilityValidator.EnsureSupported"] + X2 --> C2["ProjectionProviderCapabilityValidator.EnsureSupported"] C2 --> RS["IProjectionRelationStore"] RM --> V1["WorkflowExecutionReadModelProjector"] diff --git a/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md index 212c8b873..ac3f1dbdc 100644 --- a/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Core.Abstractions/README.md @@ -1,6 +1,6 @@ # Aevatar.CQRS.Projection.Core.Abstractions -`Aevatar.CQRS.Projection.Core.Abstractions` 只包含投影运行时主链路的通用抽象,不包含任何 ReadModel/Relation provider 选择与存储能力。 +`Aevatar.CQRS.Projection.Core.Abstractions` 只包含投影运行时主链路的通用抽象,不包含任何 ReadModel/Graph provider 选择与存储能力。 ## 目录结构 diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs index 73506a514..b29f563e4 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs @@ -3,7 +3,7 @@ namespace Aevatar.CQRS.Projection.Core.Orchestration; /// /// Generic query port base that centralizes query enable-gate behavior. /// -public abstract class ProjectionQueryPortServiceBase +public abstract class ProjectionQueryPortServiceBase { private readonly Func _queryEnabledAccessor; @@ -45,7 +45,7 @@ protected async Task> ListTimelineAsync( return await ReadTimelineCoreAsync(entityId, take, ct); } - protected async Task> GetRelationsAsync( + protected async Task> GetGraphEdgesAsync( string entityId, int take = 200, CancellationToken ct = default) @@ -53,19 +53,19 @@ protected async Task> GetRelationsAsync( if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) return []; - return await ReadRelationsCoreAsync(entityId, take, ct); + return await ReadGraphEdgesCoreAsync(entityId, take, ct); } - protected async Task GetRelationSubgraphAsync( + protected async Task GetGraphSubgraphAsync( string entityId, int depth = 2, int take = 200, CancellationToken ct = default) { if (!QueryEnabledCore || string.IsNullOrWhiteSpace(entityId)) - return CreateEmptyRelationSubgraph(entityId); + return CreateEmptyGraphSubgraph(entityId); - return await ReadRelationSubgraphCoreAsync(entityId, depth, take, ct); + return await ReadGraphSubgraphCoreAsync(entityId, depth, take, ct); } protected abstract Task ReadSnapshotCoreAsync( @@ -81,16 +81,16 @@ protected abstract Task> ReadTimelineCoreAsync( int take, CancellationToken ct); - protected abstract Task> ReadRelationsCoreAsync( + protected abstract Task> ReadGraphEdgesCoreAsync( string entityId, int take, CancellationToken ct); - protected abstract Task ReadRelationSubgraphCoreAsync( + protected abstract Task ReadGraphSubgraphCoreAsync( string entityId, int depth, int take, CancellationToken ct); - protected virtual TRelationSubgraph CreateEmptyRelationSubgraph(string entityId) => default!; + protected virtual TGraphSubgraph CreateEmptyGraphSubgraph(string entityId) => default!; } diff --git a/src/Aevatar.CQRS.Projection.Core/README.md b/src/Aevatar.CQRS.Projection.Core/README.md index f4d658e58..84f68f616 100644 --- a/src/Aevatar.CQRS.Projection.Core/README.md +++ b/src/Aevatar.CQRS.Projection.Core/README.md @@ -13,7 +13,7 @@ - `ProjectionSubscriptionRegistry` - `ProjectionLifecycleService` - `ProjectionLifecyclePortServiceBase` - - `ProjectionQueryPortServiceBase` + - `ProjectionQueryPortServiceBase` - `ActorStreamSubscriptionHub` - `ProjectionAssemblyRegistration` - `SystemProjectionClock` diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj index 3d7dc4c00..c99c14581 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Aevatar.CQRS.Projection.Providers.Elasticsearch.csproj @@ -7,6 +7,7 @@ Aevatar.CQRS.Projection.Providers.Elasticsearch + diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index bace1df7c..d36bcb12f 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,20 +13,20 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration indexScopeFactory, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionReadModelProviderNames.Elasticsearch) + string providerName = ProjectionProviderNames.Elasticsearch) where TReadModel : class { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(indexScopeFactory); ArgumentNullException.ThrowIfNull(keySelector); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, - new ProjectionReadModelProviderCapabilities( + new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], + indexKinds: [ProjectionIndexKind.Document], supportsAliases: false, supportsSchemaValidation: false), provider => new ElasticsearchProjectionReadModelStore( diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs index 16f01928e..7844512e6 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/GlobalUsings.cs @@ -1 +1,2 @@ global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 116ddde54..9bdf6a6ad 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -3,7 +3,7 @@ 通用 Elasticsearch Document ReadModel Provider。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 +- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 - 能力声明:`Document` 索引(不声明 alias/schema validation 能力)。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 - `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 @@ -19,6 +19,6 @@ 关键参数: - `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 -- `indexScopeFactory`:由 `IReadModelDocumentMetadataProvider` 派生索引名。 +- `indexScopeFactory`:由 `IProjectionDocumentMetadataProvider` 派生索引名。 - `keySelector/keyFormatter`:ReadModel 主键映射。 -- `providerName`:默认 `Elasticsearch`(与 `ProjectionReadModelProviderNames.Elasticsearch` 一致)。 +- `providerName`:默认 `Elasticsearch`(与 `ProjectionProviderNames.Elasticsearch` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index 0e9424d68..a11526496 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -9,8 +9,7 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; public sealed class ElasticsearchProjectionReadModelStore - : IProjectionReadModelStore, - IProjectionStoreProviderMetadata, + : IDocumentProjectionStore, IDisposable where TReadModel : class { @@ -39,7 +38,7 @@ public ElasticsearchProjectionReadModelStore( string indexScope, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionReadModelProviderNames.Elasticsearch, + string providerName = ProjectionProviderNames.Elasticsearch, ILogger>? logger = null, HttpMessageHandler? httpMessageHandler = null) { @@ -76,7 +75,7 @@ public ElasticsearchProjectionReadModelStore( ProviderCapabilities = BuildCapabilities(providerName); } - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + public ProjectionProviderCapabilities ProviderCapabilities { get; } public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) => UpsertCoreAsync(readModel, allowCreateIndex: true, ct); @@ -544,11 +543,11 @@ private static string TruncatePayload(string payload) return payload[..maxLength] + "...(truncated)"; } - private static ProjectionReadModelProviderCapabilities BuildCapabilities(string providerName) => + private static ProjectionProviderCapabilities BuildCapabilities(string providerName) => new( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], + indexKinds: [ProjectionIndexKind.Document], supportsAliases: false, supportsSchemaValidation: false); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj index bf038d668..a051f3273 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Aevatar.CQRS.Projection.Providers.InMemory.csproj @@ -7,6 +7,7 @@ Aevatar.CQRS.Projection.Providers.InMemory + diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 010155451..ffcc283b5 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,20 +12,20 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200, - string providerName = ProjectionReadModelProviderNames.InMemory) + string providerName = ProjectionProviderNames.InMemory) where TReadModel : class { ArgumentNullException.ThrowIfNull(keySelector); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, - new ProjectionReadModelProviderCapabilities( + new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], - supportsRelations: false, - supportsRelationTraversal: false), + indexKinds: [ProjectionIndexKind.Document], + supportsGraph: false, + supportsGraphTraversal: false), provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -39,18 +39,18 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>( - new DelegateProjectionStoreRegistration( + services.AddSingleton>( + new DelegateProjectionStoreRegistration( providerName, - new ProjectionReadModelProviderCapabilities( + new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], - supportsRelations: true, - supportsRelationTraversal: true), - _ => new InMemoryProjectionRelationStore(providerName))); + indexKinds: [ProjectionIndexKind.Graph], + supportsGraph: true, + supportsGraphTraversal: true), + _ => new InMemoryProjectionGraphStore(providerName))); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs index 16f01928e..7844512e6 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/GlobalUsings.cs @@ -1 +1,2 @@ global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index 8c0cc060b..7b13de9d8 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -3,7 +3,7 @@ 通用 InMemory Provider(支持 Document/Graph 两类能力)。 - 不依赖业务域模型。 -- 支持按 keySelector 注册任意 `IProjectionReadModelStore`(Document)。 +- 支持按 keySelector 注册任意 `IDocumentProjectionStore`(Document)。 - 支持关系图存储注册(Graph)。 - 默认能力:Document 索引 / Graph 索引(仅用于开发和测试语义)。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 @@ -20,4 +20,4 @@ - `keySelector/keyFormatter`:ReadModel 主键映射。 - `listSortSelector`:`ListAsync` 排序字段(可选)。 - `listTakeMax`:`ListAsync` 硬上限。 -- `providerName`:默认 `InMemory`(与 `ProjectionReadModelProviderNames.InMemory` 一致)。 +- `providerName`:默认 `InMemory`(与 `ProjectionProviderNames.InMemory` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs similarity index 66% rename from src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs rename to src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index 0d74a6ecd..ff4fd350d 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -2,36 +2,35 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; -public sealed class InMemoryProjectionRelationStore - : IProjectionRelationStore, - IProjectionStoreProviderMetadata +public sealed class InMemoryProjectionGraphStore + : IProjectionGraphStore { private readonly object _gate = new(); - private readonly Dictionary _nodes = new(StringComparer.Ordinal); - private readonly Dictionary _edges = new(StringComparer.Ordinal); + private readonly Dictionary _nodes = new(StringComparer.Ordinal); + private readonly Dictionary _edges = new(StringComparer.Ordinal); private readonly JsonSerializerOptions _jsonOptions = new(); - public InMemoryProjectionRelationStore( - string providerName = ProjectionReadModelProviderNames.InMemory) + public InMemoryProjectionGraphStore( + string providerName = ProjectionProviderNames.InMemory) { - ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + ProviderCapabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], - supportsRelations: true, - supportsRelationTraversal: true); + indexKinds: [ProjectionIndexKind.Graph], + supportsGraph: true, + supportsGraphTraversal: true); } - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + public ProjectionProviderCapabilities ProviderCapabilities { get; } - public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(node); ct.ThrowIfCancellationRequested(); var scope = NormalizeToken(node.Scope); var nodeId = NormalizeToken(node.NodeId); if (scope.Length == 0 || nodeId.Length == 0) - throw new InvalidOperationException("Relation node requires non-empty scope and nodeId."); + throw new InvalidOperationException("Graph node requires non-empty scope and nodeId."); var key = BuildNodeKey(scope, nodeId); var clone = CloneNode(node, scope, nodeId); @@ -40,7 +39,7 @@ public Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = return Task.CompletedTask; } - public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) + public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(edge); ct.ThrowIfCancellationRequested(); @@ -48,9 +47,9 @@ public Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = var edgeId = NormalizeToken(edge.EdgeId); var fromNodeId = NormalizeToken(edge.FromNodeId); var toNodeId = NormalizeToken(edge.ToNodeId); - var relationType = NormalizeToken(edge.RelationType); + var relationType = NormalizeToken(edge.EdgeType); if (scope.Length == 0 || edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0 || relationType.Length == 0) - throw new InvalidOperationException("Relation edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); + throw new InvalidOperationException("Graph edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); var key = BuildEdgeKey(scope, edgeId); var clone = CloneEdge(edge, scope, edgeId, fromNodeId, toNodeId, relationType); @@ -72,8 +71,8 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = return Task.CompletedTask; } - public Task> GetNeighborsAsync( - ProjectionRelationQuery query, + public Task> GetNeighborsAsync( + ProjectionGraphQuery query, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(query); @@ -81,16 +80,16 @@ public Task> GetNeighborsAsync( var scope = NormalizeToken(query.Scope); var rootNodeId = NormalizeToken(query.RootNodeId); if (scope.Length == 0 || rootNodeId.Length == 0) - return Task.FromResult>([]); + return Task.FromResult>([]); - var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); var take = Math.Clamp(query.Take, 1, 5000); - List edges; + List edges; lock (_gate) { edges = _edges.Values .Where(x => string.Equals(x.Scope, scope, StringComparison.Ordinal)) - .Where(x => relationTypes.Count == 0 || relationTypes.Contains(x.RelationType)) + .Where(x => edgeTypes.Count == 0 || edgeTypes.Contains(x.EdgeType)) .Where(x => MatchesDirection(x, rootNodeId, query.Direction)) .OrderByDescending(x => x.UpdatedAt) .Take(take) @@ -98,11 +97,11 @@ public Task> GetNeighborsAsync( .ToList(); } - return Task.FromResult>(edges); + return Task.FromResult>(edges); } - public Task GetSubgraphAsync( - ProjectionRelationQuery query, + public Task GetSubgraphAsync( + ProjectionGraphQuery query, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(query); @@ -110,15 +109,15 @@ public Task GetSubgraphAsync( var scope = NormalizeToken(query.Scope); var rootNodeId = NormalizeToken(query.RootNodeId); if (scope.Length == 0 || rootNodeId.Length == 0) - return Task.FromResult(new ProjectionRelationSubgraph()); + return Task.FromResult(new ProjectionGraphSubgraph()); - var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); var depth = Math.Clamp(query.Depth, 1, 8); var take = Math.Clamp(query.Take, 1, 5000); var visitedNodeIds = new HashSet(StringComparer.Ordinal) { rootNodeId }; var frontier = new HashSet(StringComparer.Ordinal) { rootNodeId }; - var collectedEdges = new Dictionary(StringComparer.Ordinal); + var collectedEdges = new Dictionary(StringComparer.Ordinal); for (var currentDepth = 0; currentDepth < depth; currentDepth++) { @@ -129,12 +128,12 @@ public Task GetSubgraphAsync( foreach (var nodeId in frontier) { ct.ThrowIfCancellationRequested(); - IReadOnlyList neighbors; + IReadOnlyList neighbors; lock (_gate) { neighbors = _edges.Values .Where(x => string.Equals(x.Scope, scope, StringComparison.Ordinal)) - .Where(x => relationTypes.Count == 0 || relationTypes.Contains(x.RelationType)) + .Where(x => edgeTypes.Count == 0 || edgeTypes.Contains(x.EdgeType)) .Where(x => MatchesDirection(x, nodeId, query.Direction)) .OrderByDescending(x => x.UpdatedAt) .ToList(); @@ -160,7 +159,7 @@ public Task GetSubgraphAsync( frontier = nextFrontier; } - List nodes; + List nodes; lock (_gate) { nodes = visitedNodeIds @@ -170,7 +169,7 @@ public Task GetSubgraphAsync( if (_nodes.TryGetValue(key, out var existing)) return CloneNode(existing); - return new ProjectionRelationNode + return new ProjectionGraphNode { Scope = scope, NodeId = x, @@ -182,7 +181,7 @@ public Task GetSubgraphAsync( .ToList(); } - var graph = new ProjectionRelationSubgraph + var graph = new ProjectionGraphSubgraph { Nodes = nodes, Edges = collectedEdges.Values.ToList(), @@ -191,20 +190,20 @@ public Task GetSubgraphAsync( } private bool MatchesDirection( - ProjectionRelationEdge edge, + ProjectionGraphEdge edge, string rootNodeId, - ProjectionRelationDirection direction) + ProjectionGraphDirection direction) { return direction switch { - ProjectionRelationDirection.Outbound => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal), - ProjectionRelationDirection.Inbound => string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), + ProjectionGraphDirection.Outbound => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal), + ProjectionGraphDirection.Inbound => string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), _ => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal) || string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), }; } - private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, string nodeId) + private static string ResolveCounterpartNodeId(ProjectionGraphEdge edge, string nodeId) { if (string.Equals(edge.FromNodeId, nodeId, StringComparison.Ordinal)) return edge.ToNodeId; @@ -213,9 +212,9 @@ private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, stri return ""; } - private static HashSet NormalizeRelationTypes(IReadOnlyList relationTypes) + private static HashSet NormalizeEdgeTypes(IReadOnlyList edgeTypes) { - return relationTypes + return edgeTypes .Select(NormalizeToken) .Where(x => x.Length > 0) .ToHashSet(StringComparer.Ordinal); @@ -225,24 +224,24 @@ private static HashSet NormalizeRelationTypes(IReadOnlyList rela private string BuildEdgeKey(string scope, string edgeId) => $"{scope}:{edgeId}"; - private ProjectionRelationNode CloneNode(ProjectionRelationNode source) => + private ProjectionGraphNode CloneNode(ProjectionGraphNode source) => CloneNode(source, source.Scope, source.NodeId); - private ProjectionRelationNode CloneNode(ProjectionRelationNode source, string scope, string nodeId) + private ProjectionGraphNode CloneNode(ProjectionGraphNode source, string scope, string nodeId) { var payload = JsonSerializer.Serialize(source, _jsonOptions); - var clone = JsonSerializer.Deserialize(payload, _jsonOptions) - ?? throw new InvalidOperationException("Failed to clone relation node."); + var clone = JsonSerializer.Deserialize(payload, _jsonOptions) + ?? throw new InvalidOperationException("Failed to clone graph node."); clone.Scope = scope; clone.NodeId = nodeId; return clone; } - private ProjectionRelationEdge CloneEdge(ProjectionRelationEdge source) => - CloneEdge(source, source.Scope, source.EdgeId, source.FromNodeId, source.ToNodeId, source.RelationType); + private ProjectionGraphEdge CloneEdge(ProjectionGraphEdge source) => + CloneEdge(source, source.Scope, source.EdgeId, source.FromNodeId, source.ToNodeId, source.EdgeType); - private ProjectionRelationEdge CloneEdge( - ProjectionRelationEdge source, + private ProjectionGraphEdge CloneEdge( + ProjectionGraphEdge source, string scope, string edgeId, string fromNodeId, @@ -250,13 +249,13 @@ private ProjectionRelationEdge CloneEdge( string relationType) { var payload = JsonSerializer.Serialize(source, _jsonOptions); - var clone = JsonSerializer.Deserialize(payload, _jsonOptions) - ?? throw new InvalidOperationException("Failed to clone relation edge."); + var clone = JsonSerializer.Deserialize(payload, _jsonOptions) + ?? throw new InvalidOperationException("Failed to clone graph edge."); clone.Scope = scope; clone.EdgeId = edgeId; clone.FromNodeId = fromNodeId; clone.ToNodeId = toNodeId; - clone.RelationType = relationType; + clone.EdgeType = relationType; return clone; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index c56ae7e65..6582bf42b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -5,8 +5,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; public sealed class InMemoryProjectionReadModelStore - : IProjectionReadModelStore, - IProjectionStoreProviderMetadata + : IDocumentProjectionStore where TReadModel : class { private readonly object _gate = new(); @@ -23,7 +22,7 @@ public InMemoryProjectionReadModelStore( Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200, - string providerName = ProjectionReadModelProviderNames.InMemory, + string providerName = ProjectionProviderNames.InMemory, ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(keySelector); @@ -32,15 +31,15 @@ public InMemoryProjectionReadModelStore( _listSortSelector = listSortSelector; _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; _logger = logger ?? NullLogger>.Instance; - ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + ProviderCapabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document], - supportsRelations: false, - supportsRelationTraversal: false); + indexKinds: [ProjectionIndexKind.Document], + supportsGraph: false, + supportsGraphTraversal: false); } - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + public ProjectionProviderCapabilities ProviderCapabilities { get; } public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj index b8fbdceef..9da53b567 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Aevatar.CQRS.Projection.Providers.Neo4j.csproj @@ -7,6 +7,7 @@ Aevatar.CQRS.Projection.Providers.Neo4j + diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs similarity index 80% rename from src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs rename to src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs index 2e40e40a9..be08ab712 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionRelationStoreOptions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; -public sealed class Neo4jProjectionRelationStoreOptions +public sealed class Neo4jProjectionGraphStoreOptions { public string Uri { get; set; } = "bolt://localhost:7687"; @@ -14,7 +14,7 @@ public sealed class Neo4jProjectionRelationStoreOptions public bool AutoCreateConstraints { get; set; } = true; - public string NodeLabel { get; set; } = "ProjectionRelationNode"; + public string NodeLabel { get; set; } = "ProjectionGraphNode"; public string EdgeType { get; set; } = "PROJECTION_REL"; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index afe9de1bf..83de8cf34 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,24 +13,24 @@ public static IServiceCollection AddNeo4jDocumentStoreRegistration scopeFactory, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionReadModelProviderNames.Neo4j) + string providerName = ProjectionProviderNames.Neo4j) where TReadModel : class { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); ArgumentNullException.ThrowIfNull(keySelector); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( providerName, - new ProjectionReadModelProviderCapabilities( + new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], + indexKinds: [ProjectionIndexKind.Graph], supportsAliases: false, supportsSchemaValidation: true, - supportsRelations: true, - supportsRelationTraversal: true), + supportsGraph: true, + supportsGraphTraversal: true), provider => new Neo4jProjectionReadModelStore( optionsFactory(provider), scopeFactory(provider), @@ -44,29 +44,29 @@ public static IServiceCollection AddNeo4jDocumentStoreRegistration optionsFactory, + Func optionsFactory, Func scopeFactory, - string providerName = ProjectionReadModelProviderNames.Neo4j) + string providerName = ProjectionProviderNames.Neo4j) { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( + services.AddSingleton>( + new DelegateProjectionStoreRegistration( providerName, - new ProjectionReadModelProviderCapabilities( + new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], + indexKinds: [ProjectionIndexKind.Graph], supportsAliases: false, supportsSchemaValidation: true, - supportsRelations: true, - supportsRelationTraversal: true), - provider => new Neo4jProjectionRelationStore( + supportsGraph: true, + supportsGraphTraversal: true), + provider => new Neo4jProjectionGraphStore( optionsFactory(provider), scopeFactory(provider), providerName, - provider.GetService>()))); + provider.GetService>()))); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs index 16f01928e..7844512e6 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/GlobalUsings.cs @@ -1 +1,2 @@ global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index d07738149..ae4cc8f2b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -3,8 +3,8 @@ 通用 Neo4j Provider(支持 Document/Graph 两类能力)。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成(Document)。 -- 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 +- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成(Document)。 +- 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 - 能力声明:`Document/Graph` 索引、schema validation。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 - 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 @@ -21,4 +21,4 @@ - `optionsFactory`:绑定 `Projection:Document:Providers:Neo4j:*` 或 `Projection:Graph:Providers:Neo4j:*` 配置。 - `scopeFactory`:文档 scope 或 graph scope 提供器。 - `keySelector/keyFormatter`:ReadModel 主键映射。 -- `providerName`:默认 `Neo4j`(与 `ProjectionReadModelProviderNames.Neo4j` 一致)。 +- `providerName`:默认 `Neo4j`(与 `ProjectionProviderNames.Neo4j` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs similarity index 85% rename from src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs rename to src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 22712939e..f5b5e811a 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionRelationStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -6,9 +6,8 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; -public sealed class Neo4jProjectionRelationStore - : IProjectionRelationStore, - IProjectionStoreProviderMetadata, +public sealed class Neo4jProjectionGraphStore + : IProjectionGraphStore, IAsyncDisposable { private readonly IDriver _driver; @@ -18,7 +17,7 @@ public sealed class Neo4jProjectionRelationStore private readonly string _edgeType; private readonly bool _autoCreateConstraints; private readonly int _maxTraversalDepth; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly SemaphoreSlim _schemaLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() { @@ -26,22 +25,22 @@ public sealed class Neo4jProjectionRelationStore }; private bool _schemaInitialized; - public Neo4jProjectionRelationStore( - Neo4jProjectionRelationStoreOptions options, + public Neo4jProjectionGraphStore( + Neo4jProjectionGraphStoreOptions options, string scope, - string providerName = ProjectionReadModelProviderNames.Neo4j, - ILogger? logger = null) + string providerName = ProjectionProviderNames.Neo4j, + ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); ArgumentException.ThrowIfNullOrWhiteSpace(scope); _scope = scope.Trim(); _database = options.Database?.Trim() ?? ""; - _nodeLabel = NormalizeLabel(options.NodeLabel, "ProjectionRelationNode"); + _nodeLabel = NormalizeLabel(options.NodeLabel, "ProjectionGraphNode"); _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); _autoCreateConstraints = options.AutoCreateConstraints; _maxTraversalDepth = Math.Clamp(options.MaxTraversalDepth, 1, 8); - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; var auth = string.IsNullOrWhiteSpace(options.Username) ? AuthTokens.None @@ -49,19 +48,19 @@ public Neo4jProjectionRelationStore( _driver = GraphDatabase.Driver(options.Uri, auth, config => config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); - ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + ProviderCapabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph], + indexKinds: [ProjectionIndexKind.Graph], supportsAliases: false, supportsSchemaValidation: true, - supportsRelations: true, - supportsRelationTraversal: true); + supportsGraph: true, + supportsGraphTraversal: true); } - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + public ProjectionProviderCapabilities ProviderCapabilities { get; } - public async Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default) + public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(node); ct.ThrowIfCancellationRequested(); @@ -69,7 +68,7 @@ public async Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken var scope = NormalizeToken(node.Scope); var nodeId = NormalizeToken(node.NodeId); if (scope.Length == 0 || nodeId.Length == 0) - throw new InvalidOperationException("Relation node requires non-empty scope and nodeId."); + throw new InvalidOperationException("Graph node requires non-empty scope and nodeId."); var nodeType = NormalizeToken(node.NodeType); if (nodeType.Length == 0) @@ -91,7 +90,7 @@ public async Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken await ExecuteWriteAsync(cypher, parameters, ct); } - public async Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default) + public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(edge); ct.ThrowIfCancellationRequested(); @@ -100,10 +99,10 @@ public async Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken var edgeId = NormalizeToken(edge.EdgeId); var fromNodeId = NormalizeToken(edge.FromNodeId); var toNodeId = NormalizeToken(edge.ToNodeId); - var relationType = NormalizeToken(edge.RelationType); + var relationType = NormalizeToken(edge.EdgeType); if (scope.Length == 0 || edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0 || relationType.Length == 0) { - throw new InvalidOperationException("Relation edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); + throw new InvalidOperationException("Graph edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); } var updatedAtEpochMs = NormalizeTimestamp(edge.UpdatedAt); @@ -147,8 +146,8 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken await ExecuteWriteAsync(cypher, parameters, ct); } - public async Task> GetNeighborsAsync( - ProjectionRelationQuery query, + public async Task> GetNeighborsAsync( + ProjectionGraphQuery query, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(query); @@ -160,18 +159,18 @@ public async Task> GetNeighborsAsync( await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(query.Take, 1, 5000); - var relationTypes = NormalizeRelationTypes(query.RelationTypes); + var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); var cypher = BuildNeighborCypher(query.Direction, boundedTake); var parameters = new Dictionary { ["scope"] = scope, ["rootNodeId"] = rootNodeId, - ["relationTypes"] = relationTypes, + ["edgeTypes"] = edgeTypes, ["take"] = boundedTake, }; var rows = await ExecuteReadAsync(cypher, parameters, ct); - var edges = new List(rows.Count); + var edges = new List(rows.Count); foreach (var row in rows) { var edge = BuildEdgeFromRow(scope, row); @@ -182,8 +181,8 @@ public async Task> GetNeighborsAsync( return edges; } - public async Task GetSubgraphAsync( - ProjectionRelationQuery query, + public async Task GetSubgraphAsync( + ProjectionGraphQuery query, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(query); @@ -191,13 +190,13 @@ public async Task GetSubgraphAsync( var scope = NormalizeToken(query.Scope); var rootNodeId = NormalizeToken(query.RootNodeId); if (scope.Length == 0 || rootNodeId.Length == 0) - return new ProjectionRelationSubgraph(); + return new ProjectionGraphSubgraph(); var depth = Math.Clamp(query.Depth, 1, _maxTraversalDepth); var take = Math.Clamp(query.Take, 1, 5000); var visitedNodeIds = new HashSet(StringComparer.Ordinal) { rootNodeId }; var frontier = new HashSet(StringComparer.Ordinal) { rootNodeId }; - var collectedEdges = new Dictionary(StringComparer.Ordinal); + var collectedEdges = new Dictionary(StringComparer.Ordinal); for (var currentDepth = 0; currentDepth < depth; currentDepth++) { @@ -209,12 +208,12 @@ public async Task GetSubgraphAsync( { ct.ThrowIfCancellationRequested(); var neighbors = await GetNeighborsAsync( - new ProjectionRelationQuery + new ProjectionGraphQuery { Scope = scope, RootNodeId = nodeId, Direction = query.Direction, - RelationTypes = query.RelationTypes, + EdgeTypes = query.EdgeTypes, Depth = 1, Take = take - collectedEdges.Count, }, @@ -243,7 +242,7 @@ public async Task GetSubgraphAsync( var nodes = await GetNodesByIdsAsync(scope, visitedNodeIds, ct); if (!nodes.Any(x => string.Equals(x.NodeId, rootNodeId, StringComparison.Ordinal))) { - nodes.Add(new ProjectionRelationNode + nodes.Add(new ProjectionGraphNode { Scope = scope, NodeId = rootNodeId, @@ -253,7 +252,7 @@ public async Task GetSubgraphAsync( }); } - return new ProjectionRelationSubgraph + return new ProjectionGraphSubgraph { Nodes = nodes, Edges = collectedEdges.Values.ToList(), @@ -266,7 +265,7 @@ public async ValueTask DisposeAsync() await _driver.DisposeAsync(); } - private async Task> GetNodesByIdsAsync( + private async Task> GetNodesByIdsAsync( string scope, IReadOnlySet nodeIds, CancellationToken ct) @@ -288,7 +287,7 @@ private async Task> GetNodesByIdsAsync( }; var rows = await ExecuteReadAsync(cypher, parameters, ct); - var nodes = new List(rows.Count); + var nodes = new List(rows.Count); foreach (var row in rows) { if (!row.TryGetValue("nodeId", out var nodeIdValue)) @@ -310,7 +309,7 @@ private async Task> GetNodesByIdsAsync( ? updatedAtEpochMsValue.As() : 0L; - nodes.Add(new ProjectionRelationNode + nodes.Add(new ProjectionGraphNode { Scope = scope, NodeId = nodeId, @@ -323,7 +322,7 @@ private async Task> GetNodesByIdsAsync( return nodes; } - private ProjectionRelationEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) + private ProjectionGraphEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) { if (!row.TryGetValue("edgeId", out var edgeIdValue)) return null; @@ -350,21 +349,21 @@ private async Task> GetNodesByIdsAsync( ? updatedAtEpochMsValue.As() : 0L; - return new ProjectionRelationEdge + return new ProjectionGraphEdge { Scope = scope, EdgeId = edgeId, FromNodeId = fromNodeId, ToNodeId = toNodeId, - RelationType = relationType, + EdgeType = relationType, Properties = DeserializeProperties(propertiesJson), UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), }; } - private string BuildNeighborCypher(ProjectionRelationDirection direction, int take) + private string BuildNeighborCypher(ProjectionGraphDirection direction, int take) { - var filter = "WHERE size($relationTypes) = 0 OR r.relationType IN $relationTypes "; + var filter = "WHERE size($edgeTypes) = 0 OR r.relationType IN $edgeTypes "; var projection = "RETURN r.edgeId AS edgeId, " + "startNode(r).nodeId AS fromNodeId, " + "endNode(r).nodeId AS toNodeId, " + @@ -374,11 +373,11 @@ private string BuildNeighborCypher(ProjectionRelationDirection direction, int ta "ORDER BY updatedAtEpochMs DESC LIMIT $take"; return direction switch { - ProjectionRelationDirection.Outbound => + ProjectionGraphDirection.Outbound => $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]->(:{_nodeLabel} {{scope: $scope}}) " + filter + projection, - ProjectionRelationDirection.Inbound => + ProjectionGraphDirection.Inbound => $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + filter + projection, @@ -400,7 +399,7 @@ private async Task EnsureSchemaAsync(CancellationToken ct) if (_schemaInitialized) return; - var nodeConstraintName = NormalizeConstraintName($"projection_relation_node_scope_id_{_nodeLabel}"); + var nodeConstraintName = NormalizeConstraintName($"projection_graph_node_scope_id_{_nodeLabel}"); var cypher = $"CREATE CONSTRAINT {nodeConstraintName} IF NOT EXISTS " + $"FOR (n:{_nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; await ExecuteWriteAsync(cypher, new Dictionary(), ct); @@ -446,7 +445,7 @@ private IAsyncSession CreateSession(AccessMode accessMode) }); } - private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, string nodeId) + private static string ResolveCounterpartNodeId(ProjectionGraphEdge edge, string nodeId) { if (string.Equals(edge.FromNodeId, nodeId, StringComparison.Ordinal)) return edge.ToNodeId; @@ -455,9 +454,9 @@ private static string ResolveCounterpartNodeId(ProjectionRelationEdge edge, stri return ""; } - private static string[] NormalizeRelationTypes(IReadOnlyList relationTypes) + private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) { - return relationTypes + return edgeTypes .Select(NormalizeToken) .Where(x => x.Length > 0) .Distinct(StringComparer.Ordinal) @@ -486,7 +485,7 @@ private Dictionary DeserializeProperties(string payload) { _logger.LogWarning( ex, - "Failed to deserialize relation properties payload. provider={Provider} scope={Scope}", + "Failed to deserialize graph edge properties payload. provider={Provider} scope={Scope}", ProviderCapabilities.ProviderName, _scope); return new Dictionary(StringComparer.Ordinal); @@ -532,7 +531,7 @@ private static string NormalizeConstraintName(string rawName) .ToArray(); var normalized = new string(chars); if (normalized.Length == 0) - return "projection_relation_constraint"; + return "projection_graph_constraint"; if (char.IsDigit(normalized[0])) normalized = $"c_{normalized}"; return normalized; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index 8f68e14d2..439b69c9c 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -7,8 +7,7 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; public sealed class Neo4jProjectionReadModelStore - : IProjectionReadModelStore, - IProjectionStoreProviderMetadata, + : IDocumentProjectionStore, IAsyncDisposable where TReadModel : class { @@ -33,7 +32,7 @@ public Neo4jProjectionReadModelStore( string scope, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionReadModelProviderNames.Neo4j, + string providerName = ProjectionProviderNames.Neo4j, ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(options); @@ -55,17 +54,17 @@ public Neo4jProjectionReadModelStore( _driver = GraphDatabase.Driver(options.Uri, auth, config => config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); - ProviderCapabilities = new ProjectionReadModelProviderCapabilities( + ProviderCapabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Document, ProjectionReadModelIndexKind.Graph], + indexKinds: [ProjectionIndexKind.Document, ProjectionIndexKind.Graph], supportsAliases: false, supportsSchemaValidation: true, - supportsRelations: true, - supportsRelationTraversal: true); + supportsGraph: true, + supportsGraphTraversal: true); } - public ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } + public ProjectionProviderCapabilities ProviderCapabilities { get; } public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs similarity index 82% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 6a6045656..617dc1e95 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public sealed class DelegateProjectionStoreRegistration : IProjectionStoreRegistration { @@ -6,7 +6,7 @@ public sealed class DelegateProjectionStoreRegistration : IProjectionSto public DelegateProjectionStoreRegistration( string providerName, - ProjectionReadModelProviderCapabilities capabilities, + ProjectionProviderCapabilities capabilities, Func factory) { if (string.IsNullOrWhiteSpace(providerName)) @@ -21,7 +21,7 @@ public DelegateProjectionStoreRegistration( public string ProviderName { get; } - public ProjectionReadModelProviderCapabilities Capabilities { get; } + public ProjectionProviderCapabilities Capabilities { get; } public TStore Create(IServiceProvider serviceProvider) { diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs similarity index 65% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index 3d1048a5e..4ad98a895 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -1,10 +1,10 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionStoreRegistration { string ProviderName { get; } - ProjectionReadModelProviderCapabilities Capabilities { get; } + ProjectionProviderCapabilities Capabilities { get; } } public interface IProjectionStoreRegistration : IProjectionStoreRegistration diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs new file mode 100644 index 000000000..7b8b3ea9e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphStoreFactory +{ + IProjectionGraphStore Create( + IServiceProvider serviceProvider, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs new file mode 100644 index 000000000..41f845942 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphStoreProviderRegistry +{ + IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs new file mode 100644 index 000000000..3f92b8aad --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphStoreProviderSelector +{ + IProjectionStoreRegistration Select( + IReadOnlyList> registrations, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs similarity index 74% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs index 60d4af5df..4cc868952 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionDocumentMetadataResolver { diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs new file mode 100644 index 000000000..da2936f8e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionDocumentStoreFactory +{ + IDocumentProjectionStore Create( + IServiceProvider serviceProvider, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs new file mode 100644 index 000000000..a5ebc861a --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionDocumentStoreProviderRegistry +{ + IReadOnlyList>> GetRegistrations( + IServiceProvider serviceProvider) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs new file mode 100644 index 000000000..fec9eadf5 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionDocumentStoreProviderSelector +{ + IProjectionStoreRegistration> Select( + IReadOnlyList>> registrations, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs new file mode 100644 index 000000000..c556bab59 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs @@ -0,0 +1,13 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionProviderCapabilityValidator +{ + IReadOnlyList Validate( + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities); + + void EnsureSupported( + Type readModelType, + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs new file mode 100644 index 000000000..fe5316146 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs @@ -0,0 +1,23 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public static class ProjectionDocumentStoreSelector +{ + public static IProjectionStoreRegistration> Select( + IEnumerable>> registrations, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements, + IProjectionProviderCapabilityValidator? capabilityValidator = null) + where TReadModel : class + { + return ProjectionStoreSelector.Select< + IProjectionStoreRegistration>>( + registrations, + selectionOptions, + requirements, + typeof(TReadModel), + noRegistrationsReason: "No provider registrations were found.", + multipleRegistrationsReason: "Multiple providers are registered but no explicit provider was requested.", + providerNotRegisteredReason: "Requested provider is not registered.", + capabilityValidator); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs new file mode 100644 index 000000000..edc2ef556 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public enum ProjectionIndexKind +{ + None = 0, + Document = 1, + Graph = 2, +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs similarity index 57% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs index 9f97f6356..b4daaf039 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderCapabilities.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs @@ -1,18 +1,18 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionReadModelProviderCapabilities +public sealed class ProjectionProviderCapabilities { - private static readonly IReadOnlySet EmptyIndexKinds = - new HashSet(); + private static readonly IReadOnlySet EmptyIndexKinds = + new HashSet(); - public ProjectionReadModelProviderCapabilities( + public ProjectionProviderCapabilities( string providerName, bool supportsIndexing, - IEnumerable? indexKinds = null, + IEnumerable? indexKinds = null, bool supportsAliases = false, bool supportsSchemaValidation = false, - bool supportsRelations = false, - bool supportsRelationTraversal = false) + bool supportsGraph = false, + bool supportsGraphTraversal = false) { if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); @@ -21,11 +21,11 @@ public ProjectionReadModelProviderCapabilities( SupportsIndexing = supportsIndexing; SupportsAliases = supportsAliases; SupportsSchemaValidation = supportsSchemaValidation; - SupportsRelations = supportsRelations; - SupportsRelationTraversal = supportsRelationTraversal; + SupportsGraph = supportsGraph; + SupportsGraphTraversal = supportsGraphTraversal; var normalizedIndexKinds = (indexKinds ?? []) - .Where(x => x != ProjectionReadModelIndexKind.None) + .Where(x => x != ProjectionIndexKind.None) .ToHashSet(); if (!supportsIndexing && normalizedIndexKinds.Count > 0) @@ -42,13 +42,13 @@ public ProjectionReadModelProviderCapabilities( public bool SupportsIndexing { get; } - public IReadOnlySet IndexKinds { get; } + public IReadOnlySet IndexKinds { get; } public bool SupportsAliases { get; } public bool SupportsSchemaValidation { get; } - public bool SupportsRelations { get; } + public bool SupportsGraph { get; } - public bool SupportsRelationTraversal { get; } + public bool SupportsGraphTraversal { get; } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs similarity index 60% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs index ec99e02cf..e26a827e2 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidationException.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs @@ -1,11 +1,11 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionReadModelCapabilityValidationException : InvalidOperationException +public sealed class ProjectionProviderCapabilityValidationException : InvalidOperationException { - public ProjectionReadModelCapabilityValidationException( + public ProjectionProviderCapabilityValidationException( Type readModelType, - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities, + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities, IReadOnlyList violations) : base(BuildMessage(readModelType, capabilities.ProviderName, violations)) { @@ -17,9 +17,9 @@ public ProjectionReadModelCapabilityValidationException( public Type ReadModelType { get; } - public ProjectionReadModelRequirements Requirements { get; } + public ProjectionStoreRequirements Requirements { get; } - public ProjectionReadModelProviderCapabilities Capabilities { get; } + public ProjectionProviderCapabilities Capabilities { get; } public IReadOnlyList Violations { get; } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs similarity index 72% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs index 92fdb3d3c..f8242c8a9 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelCapabilityValidator.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs @@ -1,10 +1,10 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public static class ProjectionReadModelCapabilityValidator +public static class ProjectionProviderCapabilityValidator { public static IReadOnlyList Validate( - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities) + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities) { ArgumentNullException.ThrowIfNull(requirements); ArgumentNullException.ThrowIfNull(capabilities); @@ -40,26 +40,26 @@ public static IReadOnlyList Validate( if (requirements.RequiresSchemaValidation && !capabilities.SupportsSchemaValidation) violations.Add("requires schema validation, but provider does not support schema validation"); - if (requirements.RequiresRelations && !capabilities.SupportsRelations) - violations.Add("requires relation storage, but provider does not support relations"); + if (requirements.RequiresGraph && !capabilities.SupportsGraph) + violations.Add("requires graph storage, but provider does not support graph storage"); - if (requirements.RequiresRelationTraversal && !capabilities.SupportsRelationTraversal) - violations.Add("requires relation traversal, but provider does not support relation traversal"); + if (requirements.RequiresGraphTraversal && !capabilities.SupportsGraphTraversal) + violations.Add("requires graph traversal, but provider does not support graph traversal"); return violations; } public static void EnsureSupported( Type readModelType, - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities) + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities) { ArgumentNullException.ThrowIfNull(readModelType); var violations = Validate(requirements, capabilities); if (violations.Count == 0) return; - throw new ProjectionReadModelCapabilityValidationException( + throw new ProjectionProviderCapabilityValidationException( readModelType, requirements, capabilities, diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs similarity index 58% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs index bee736261..85689dd09 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelProviderNames.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs @@ -1,6 +1,6 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public static class ProjectionReadModelProviderNames +public static class ProjectionProviderNames { public const string InMemory = "InMemory"; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs new file mode 100644 index 000000000..6dfacb872 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public enum ProjectionStoreMode +{ + Custom = 0, + Default = 1, + StateOnly = 2, +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs new file mode 100644 index 000000000..57922a2b9 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs @@ -0,0 +1,42 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionStoreRequirements +{ + private static readonly IReadOnlySet EmptyIndexKinds = + new HashSet(); + + public ProjectionStoreRequirements( + bool requiresIndexing = false, + IEnumerable? requiredIndexKinds = null, + bool requiresAliases = false, + bool requiresSchemaValidation = false, + bool requiresGraph = false, + bool requiresGraphTraversal = false) + { + RequiresIndexing = requiresIndexing; + RequiresAliases = requiresAliases; + RequiresSchemaValidation = requiresSchemaValidation; + RequiresGraph = requiresGraph; + RequiresGraphTraversal = requiresGraphTraversal; + + var normalizedIndexKinds = (requiredIndexKinds ?? []) + .Where(x => x != ProjectionIndexKind.None) + .ToHashSet(); + + RequiredIndexKinds = normalizedIndexKinds.Count == 0 + ? EmptyIndexKinds + : normalizedIndexKinds; + } + + public bool RequiresIndexing { get; } + + public IReadOnlySet RequiredIndexKinds { get; } + + public bool RequiresAliases { get; } + + public bool RequiresSchemaValidation { get; } + + public bool RequiresGraph { get; } + + public bool RequiresGraphTraversal { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs new file mode 100644 index 000000000..f31f06075 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs @@ -0,0 +1,20 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionStoreRuntimeOptions : IProjectionStoreSelectionRuntimeOptions +{ + public ProjectionStoreMode Mode { get; set; } = ProjectionStoreMode.Custom; + + public string DocumentProvider { get; set; } = ProjectionProviderNames.InMemory; + + public string GraphProvider { get; set; } = ProjectionProviderNames.InMemory; + + public bool FailOnUnsupportedCapabilities { get; set; } = true; + + string IProjectionStoreSelectionRuntimeOptions.DocumentProvider => DocumentProvider; + + string IProjectionStoreSelectionRuntimeOptions.GraphProvider => GraphProvider; + + bool IProjectionStoreSelectionRuntimeOptions.FailOnUnsupportedCapabilities => FailOnUnsupportedCapabilities; + + ProjectionStoreMode IProjectionStoreSelectionRuntimeOptions.StoreMode => Mode; +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs similarity index 53% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs index 15cb74b61..33f974691 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelectionOptions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs @@ -1,6 +1,6 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionReadModelStoreSelectionOptions +public sealed class ProjectionStoreSelectionOptions { public string RequestedProviderName { get; set; } = ""; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs new file mode 100644 index 000000000..8a8f0cb50 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphMaterializer + where TReadModel : class +{ + Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs similarity index 89% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs index f66d7d1b1..4f503ad2d 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionMaterializationRouter where TReadModel : class, IProjectionReadModel diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs similarity index 60% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs index 6021e7fc1..11f5a16b8 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs @@ -1,9 +1,9 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionStoreSelectionPlanner { ProjectionStoreSelectionPlan Build( IProjectionStoreSelectionRuntimeOptions options, Type readModelType, - ProjectionReadModelRequirements relationRequirements); + ProjectionStoreRequirements graphRequirements); } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs similarity index 63% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs index c3770f599..7edfb023b 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionStoreSelectionRuntimeOptions { @@ -8,5 +8,5 @@ public interface IProjectionStoreSelectionRuntimeOptions bool FailOnUnsupportedCapabilities { get; } - ProjectionReadModelMode ReadModelMode { get; } + ProjectionStoreMode StoreMode { get; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs new file mode 100644 index 000000000..0f49b9178 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionStoreStartupValidator +{ + IProjectionStoreRegistration> ValidateDocumentProvider( + IServiceProvider serviceProvider, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) + where TReadModel : class; + + IProjectionStoreRegistration ValidateGraphProvider( + IServiceProvider serviceProvider, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs similarity index 95% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs index abd131a43..0e567521a 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs @@ -1,4 +1,4 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public sealed class ProjectionProviderSelectionException : InvalidOperationException { diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs new file mode 100644 index 000000000..5fd89fe9b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs @@ -0,0 +1,7 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public readonly record struct ProjectionStoreSelectionPlan( + ProjectionStoreRequirements DocumentRequirements, + ProjectionStoreSelectionOptions DocumentSelectionOptions, + ProjectionStoreRequirements GraphRequirements, + ProjectionStoreSelectionOptions GraphSelectionOptions); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs similarity index 87% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs index e76c8ee8f..5647efbb9 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs @@ -1,16 +1,16 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public static class ProjectionStoreSelector { public static TRegistration Select( IEnumerable registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements, Type logicalModelType, string noRegistrationsReason, string multipleRegistrationsReason, string providerNotRegisteredReason, - IProjectionReadModelCapabilityValidator? capabilityValidator = null) + IProjectionProviderCapabilityValidator? capabilityValidator = null) where TRegistration : IProjectionStoreRegistration { ArgumentNullException.ThrowIfNull(registrations); @@ -36,11 +36,11 @@ public static TRegistration Select( multipleRegistrationsReason, providerNotRegisteredReason); var violations = capabilityValidator == null - ? ProjectionReadModelCapabilityValidator.Validate(requirements, selected.Capabilities) + ? ProjectionProviderCapabilityValidator.Validate(requirements, selected.Capabilities) : capabilityValidator.Validate(requirements, selected.Capabilities); if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) { - throw new ProjectionReadModelCapabilityValidationException( + throw new ProjectionProviderCapabilityValidationException( logicalModelType, requirements, selected.Capabilities, diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Aevatar.CQRS.Projection.Runtime.Abstractions.csproj b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Aevatar.CQRS.Projection.Runtime.Abstractions.csproj new file mode 100644 index 000000000..b9ea244ae --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Aevatar.CQRS.Projection.Runtime.Abstractions.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + Aevatar.CQRS.Projection.Runtime.Abstractions + Aevatar.CQRS.Projection.Runtime.Abstractions + + + + + diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/GlobalUsings.cs new file mode 100644 index 000000000..16f01928e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/GlobalUsings.cs @@ -0,0 +1 @@ +global using Aevatar.CQRS.Projection.Stores.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md new file mode 100644 index 000000000..99fc3fd56 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -0,0 +1,23 @@ +# Aevatar.CQRS.Projection.Runtime.Abstractions + +`Aevatar.CQRS.Projection.Runtime.Abstractions` 承载 Projection Runtime 的策略与编排契约,不承载具体实现。 + +## 目录结构 + +- `Abstractions/Core`:Provider 注册契约(`IProjectionStoreRegistration`) +- `Abstractions/ReadModels`:Document provider 选择、能力模型、runtime options、metadata resolver 契约 +- `Abstractions/Graphs`:Graph provider 选择与 factory 契约 +- `Abstractions/Selection`:统一选择计划、启动校验与 materialization 路由契约 + +## 关键契约 + +- Provider 注册与能力:`IProjectionStoreRegistration`、`ProjectionProviderCapabilities` +- 选择规划:`IProjectionStoreSelectionPlanner`、`ProjectionStoreSelectionPlan` +- 运行时选择参数:`ProjectionStoreSelectionOptions`、`ProjectionStoreRequirements`、`IProjectionStoreSelectionRuntimeOptions` +- Store factory:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` +- Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` + +## 约束 + +1. 仅定义运行时编排协议,不包含具体 provider/store 实现。 +2. 仅依赖抽象层(`Stores.Abstractions`),不依赖业务模块。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj index c75aea117..032e6827c 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj +++ b/src/Aevatar.CQRS.Projection.Runtime/Aevatar.CQRS.Projection.Runtime.csproj @@ -7,6 +7,7 @@ Aevatar.CQRS.Projection.Runtime + diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index dda05d3d8..36f233c92 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,15 +8,15 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(typeof(IGraphProjectionStore<>), typeof(ProjectionGraphStoreAdapter<>)); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(typeof(IProjectionGraphMaterializer<>), typeof(ProjectionGraphMaterializer<>)); services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs index 16f01928e..7844512e6 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/GlobalUsings.cs @@ -1 +1,2 @@ global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index f7dc5829b..d282ace16 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -4,11 +4,12 @@ ## 职责 -- 收敛 Provider 注册查询(`IProjectionReadModelProviderRegistry`)。 -- 执行 Provider 选择策略(`IProjectionReadModelProviderSelector`)。 +- 收敛 Provider 注册查询(`IProjectionDocumentStoreProviderRegistry`)。 +- 执行 Provider 选择策略(`IProjectionDocumentStoreProviderSelector`)。 - 按 `IDocumentReadModel/IGraphReadModel` 能力推导选择需求(`IProjectionStoreSelectionPlanner`)。 -- 统一创建 Store 并输出结构化创建日志(`IProjectionReadModelStoreFactory`)。 -- 提供 `IProjectionMaterializationRouter` 与 `ProjectionGraphStoreAdapter` 双写路由能力。 +- 统一创建 Store 并输出结构化创建日志(`IProjectionDocumentStoreFactory`)。 +- 提供 `IProjectionMaterializationRouter` 与 `ProjectionGraphMaterializer` 双写路由能力。 +- 所有上述契约统一来自 `Aevatar.CQRS.Projection.Runtime.Abstractions`。 ## DI 入口 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs index c63f2d44a..397341711 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs @@ -14,7 +14,7 @@ public ProjectionDocumentMetadataResolver(IServiceProvider serviceProvider) public DocumentIndexMetadata Resolve() where TReadModel : class, IDocumentReadModel { - var provider = _serviceProvider.GetRequiredService>(); + var provider = _serviceProvider.GetRequiredService>(); return provider.Metadata; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs similarity index 56% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs index 303b5c8ae..dc3908c08 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs @@ -3,27 +3,27 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionReadModelStoreFactory - : IProjectionReadModelStoreFactory +public sealed class ProjectionDocumentStoreFactory + : IProjectionDocumentStoreFactory { - private readonly IProjectionReadModelProviderRegistry _providerRegistry; - private readonly IProjectionReadModelProviderSelector _providerSelector; - private readonly ILogger _logger; + private readonly IProjectionDocumentStoreProviderRegistry _providerRegistry; + private readonly IProjectionDocumentStoreProviderSelector _providerSelector; + private readonly ILogger _logger; - public ProjectionReadModelStoreFactory( - IProjectionReadModelProviderRegistry providerRegistry, - IProjectionReadModelProviderSelector providerSelector, - ILogger? logger = null) + public ProjectionDocumentStoreFactory( + IProjectionDocumentStoreProviderRegistry providerRegistry, + IProjectionDocumentStoreProviderSelector providerSelector, + ILogger? logger = null) { _providerRegistry = providerRegistry; _providerSelector = providerSelector; - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; } - public IProjectionReadModelStore Create( + public IDocumentProjectionStore Create( IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) where TReadModel : class { ArgumentNullException.ThrowIfNull(serviceProvider); @@ -39,7 +39,7 @@ public IProjectionReadModelStore Create( var store = selected.Create(serviceProvider); var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( - "Projection read-model store created. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result}", + "Projection document store created. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result}", selected.ProviderName, typeof(TReadModel).FullName, elapsedMs, @@ -51,7 +51,7 @@ public IProjectionReadModelStore Create( var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogError( ex, - "Projection read-model store creation failed. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", + "Projection document store creation failed. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", selected.ProviderName, typeof(TReadModel).FullName, elapsedMs, diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs new file mode 100644 index 000000000..4f21ce069 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionDocumentStoreProviderRegistry : IProjectionDocumentStoreProviderRegistry +{ + public IReadOnlyList>> GetRegistrations( + IServiceProvider serviceProvider) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(serviceProvider); + return serviceProvider + .GetServices>>() + .ToList(); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs similarity index 65% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs index 78db1b275..d92733a1a 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs @@ -3,24 +3,24 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionReadModelProviderSelector - : IProjectionReadModelProviderSelector +public sealed class ProjectionDocumentStoreProviderSelector + : IProjectionDocumentStoreProviderSelector { - private readonly IProjectionReadModelCapabilityValidator _capabilityValidator; - private readonly ILogger _logger; + private readonly IProjectionProviderCapabilityValidator _capabilityValidator; + private readonly ILogger _logger; - public ProjectionReadModelProviderSelector( - IProjectionReadModelCapabilityValidator capabilityValidator, - ILogger? logger = null) + public ProjectionDocumentStoreProviderSelector( + IProjectionProviderCapabilityValidator capabilityValidator, + ILogger? logger = null) { _capabilityValidator = capabilityValidator; - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; } - public IProjectionStoreRegistration> Select( - IReadOnlyList>> registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + public IProjectionStoreRegistration> Select( + IReadOnlyList>> registrations, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) where TReadModel : class { ArgumentNullException.ThrowIfNull(registrations); @@ -29,7 +29,7 @@ public IProjectionStoreRegistration> try { - var selected = ProjectionReadModelStoreSelector.Select( + var selected = ProjectionDocumentStoreSelector.Select( registrations, selectionOptions, requirements, @@ -41,7 +41,7 @@ public IProjectionStoreRegistration> selectionOptions.FailOnUnsupportedCapabilities); return selected; } - catch (ProjectionReadModelCapabilityValidationException ex) + catch (ProjectionProviderCapabilityValidationException ex) { _logger.LogError( "Projection provider capability validation failed. readModel={ReadModel} provider={Provider} requiredCapabilities={RequiredCapabilities} actualCapabilities={ActualCapabilities} violations={Violations}", @@ -66,23 +66,23 @@ public IProjectionStoreRegistration> } } - private static string FormatRequirements(ProjectionReadModelRequirements requirements) + private static string FormatRequirements(ProjectionStoreRequirements requirements) { return $"requiresIndexing={requirements.RequiresIndexing};" + $"requiredIndexKinds=[{string.Join(",", requirements.RequiredIndexKinds)}];" + $"requiresAliases={requirements.RequiresAliases};" + $"requiresSchemaValidation={requirements.RequiresSchemaValidation};" + - $"requiresRelations={requirements.RequiresRelations};" + - $"requiresRelationTraversal={requirements.RequiresRelationTraversal}"; + $"requiresGraph={requirements.RequiresGraph};" + + $"requiresGraphTraversal={requirements.RequiresGraphTraversal}"; } - private static string FormatCapabilities(ProjectionReadModelProviderCapabilities capabilities) + private static string FormatCapabilities(ProjectionProviderCapabilities capabilities) { return $"supportsIndexing={capabilities.SupportsIndexing};" + $"indexKinds=[{string.Join(",", capabilities.IndexKinds)}];" + $"supportsAliases={capabilities.SupportsAliases};" + $"supportsSchemaValidation={capabilities.SupportsSchemaValidation};" + - $"supportsRelations={capabilities.SupportsRelations};" + - $"supportsRelationTraversal={capabilities.SupportsRelationTraversal}"; + $"supportsGraph={capabilities.SupportsGraph};" + + $"supportsGraphTraversal={capabilities.SupportsGraphTraversal}"; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs similarity index 70% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs index 2642fe261..a14140597 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreAdapter.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs @@ -1,16 +1,16 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionGraphStoreAdapter - : IGraphProjectionStore +public sealed class ProjectionGraphMaterializer + : IProjectionGraphMaterializer where TReadModel : class { private const string ManagedMarkerKey = "projectionManaged"; private const string ManagedMarkerValue = "true"; - private readonly IProjectionRelationStore _relationStore; + private readonly IProjectionGraphStore _graphStore; - public ProjectionGraphStoreAdapter(IProjectionRelationStore relationStore) + public ProjectionGraphMaterializer(IProjectionGraphStore graphStore) { - _relationStore = relationStore; + _graphStore = graphStore; } public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default) @@ -29,22 +29,22 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope); foreach (var node in normalizedNodes) - await _relationStore.UpsertNodeAsync(node, ct); + await _graphStore.UpsertNodeAsync(node, ct); var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope); foreach (var edge in normalizedEdges) - await _relationStore.UpsertEdgeAsync(edge, ct); + await _graphStore.UpsertEdgeAsync(edge, ct); var anchorNodeId = ResolveAnchorNodeId(graphReadModel, normalizedNodes, normalizedEdges); if (anchorNodeId.Length == 0) return; - var existing = await _relationStore.GetSubgraphAsync( - new ProjectionRelationQuery + var existing = await _graphStore.GetSubgraphAsync( + new ProjectionGraphQuery { Scope = scope, RootNodeId = anchorNodeId, - Direction = ProjectionRelationDirection.Both, + Direction = ProjectionGraphDirection.Both, Depth = 8, Take = 5000, }, @@ -59,24 +59,14 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = if (targetEdgeIds.Contains(edge.EdgeId)) continue; - await _relationStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); + await _graphStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); } } - public Task> GetNeighborsAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) => - _relationStore.GetNeighborsAsync(query, ct); - - public Task GetSubgraphAsync( - ProjectionRelationQuery query, - CancellationToken ct = default) => - _relationStore.GetSubgraphAsync(query, ct); - private static string ResolveAnchorNodeId( IGraphReadModel readModel, - IReadOnlyList nodes, - IReadOnlyList edges) + IReadOnlyList nodes, + IReadOnlyList edges) { var firstNodeId = nodes.FirstOrDefault()?.NodeId ?? ""; if (firstNodeId.Length > 0) @@ -89,20 +79,20 @@ private static string ResolveAnchorNodeId( return edges.FirstOrDefault()?.FromNodeId ?? ""; } - private static bool IsManagedEdge(ProjectionRelationEdge edge) + private static bool IsManagedEdge(ProjectionGraphEdge edge) { return edge.Properties.TryGetValue(ManagedMarkerKey, out var markerValue) && string.Equals(markerValue, ManagedMarkerValue, StringComparison.Ordinal); } - private static IReadOnlyList NormalizeNodes( + private static IReadOnlyList NormalizeNodes( IReadOnlyList graphNodes, string scope) { if (graphNodes.Count == 0) return []; - var nodesById = new Dictionary(StringComparer.Ordinal); + var nodesById = new Dictionary(StringComparer.Ordinal); foreach (var graphNode in graphNodes) { var nodeId = NormalizeToken(graphNode.NodeId); @@ -113,7 +103,7 @@ private static IReadOnlyList NormalizeNodes( if (nodeType.Length == 0) nodeType = "Unknown"; - nodesById[nodeId] = new ProjectionRelationNode + nodesById[nodeId] = new ProjectionGraphNode { Scope = scope, NodeId = nodeId, @@ -126,18 +116,18 @@ private static IReadOnlyList NormalizeNodes( return nodesById.Values.ToList(); } - private static IReadOnlyList NormalizeEdges( + private static IReadOnlyList NormalizeEdges( IReadOnlyList graphEdges, string scope) { if (graphEdges.Count == 0) return []; - var edgesById = new Dictionary(StringComparer.Ordinal); + var edgesById = new Dictionary(StringComparer.Ordinal); foreach (var graphEdge in graphEdges) { var edgeId = NormalizeToken(graphEdge.EdgeId); - var relationType = NormalizeToken(graphEdge.RelationType); + var relationType = NormalizeToken(graphEdge.EdgeType); var fromNodeId = NormalizeToken(graphEdge.FromNodeId); var toNodeId = NormalizeToken(graphEdge.ToNodeId); if (edgeId.Length == 0 || @@ -153,11 +143,11 @@ private static IReadOnlyList NormalizeEdges( [ManagedMarkerKey] = ManagedMarkerValue, }; - edgesById[edgeId] = new ProjectionRelationEdge + edgesById[edgeId] = new ProjectionGraphEdge { Scope = scope, EdgeId = edgeId, - RelationType = relationType, + EdgeType = relationType, FromNodeId = fromNodeId, ToNodeId = toNodeId, Properties = properties, diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs similarity index 66% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs index 32e2156aa..c5eba2fbc 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs @@ -3,26 +3,26 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionRelationStoreFactory : IProjectionRelationStoreFactory +public sealed class ProjectionGraphStoreFactory : IProjectionGraphStoreFactory { - private readonly IProjectionRelationStoreProviderRegistry _providerRegistry; - private readonly IProjectionRelationStoreProviderSelector _providerSelector; - private readonly ILogger _logger; + private readonly IProjectionGraphStoreProviderRegistry _providerRegistry; + private readonly IProjectionGraphStoreProviderSelector _providerSelector; + private readonly ILogger _logger; - public ProjectionRelationStoreFactory( - IProjectionRelationStoreProviderRegistry providerRegistry, - IProjectionRelationStoreProviderSelector providerSelector, - ILogger? logger = null) + public ProjectionGraphStoreFactory( + IProjectionGraphStoreProviderRegistry providerRegistry, + IProjectionGraphStoreProviderSelector providerSelector, + ILogger? logger = null) { _providerRegistry = providerRegistry; _providerSelector = providerSelector; - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; } - public IProjectionRelationStore Create( + public IProjectionGraphStore Create( IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) { ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(selectionOptions); diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs similarity index 65% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs index 06b0e7497..848439bd5 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderRegistry.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs @@ -2,13 +2,13 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionRelationStoreProviderRegistry : IProjectionRelationStoreProviderRegistry +public sealed class ProjectionGraphStoreProviderRegistry : IProjectionGraphStoreProviderRegistry { - public IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider) + public IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); return serviceProvider - .GetServices>() + .GetServices>() .ToList(); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs similarity index 56% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs index e3012113e..ea72060ca 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionRelationStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs @@ -3,34 +3,34 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionRelationStoreProviderSelector - : IProjectionRelationStoreProviderSelector +public sealed class ProjectionGraphStoreProviderSelector + : IProjectionGraphStoreProviderSelector { - private readonly IProjectionReadModelCapabilityValidator _capabilityValidator; - private readonly ILogger _logger; + private readonly IProjectionProviderCapabilityValidator _capabilityValidator; + private readonly ILogger _logger; - public ProjectionRelationStoreProviderSelector( - IProjectionReadModelCapabilityValidator capabilityValidator, - ILogger? logger = null) + public ProjectionGraphStoreProviderSelector( + IProjectionProviderCapabilityValidator capabilityValidator, + ILogger? logger = null) { _capabilityValidator = capabilityValidator; - _logger = logger ?? NullLogger.Instance; + _logger = logger ?? NullLogger.Instance; } - public IProjectionStoreRegistration Select( - IReadOnlyList> registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + public IProjectionStoreRegistration Select( + IReadOnlyList> registrations, + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) { ArgumentNullException.ThrowIfNull(registrations); ArgumentNullException.ThrowIfNull(selectionOptions); ArgumentNullException.ThrowIfNull(requirements); - var selected = ProjectionStoreSelector.Select>( + var selected = ProjectionStoreSelector.Select>( registrations, selectionOptions, requirements, - typeof(ProjectionRelationNode), + typeof(ProjectionGraphNode), noRegistrationsReason: "No relation store provider registrations were found.", multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", providerNotRegisteredReason: "Requested relation store provider is not registered.", diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs index 64c163880..a67661c81 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs @@ -8,18 +8,18 @@ public sealed class ProjectionMaterializationRouter where TReadModel : class, IProjectionReadModel { private readonly IDocumentProjectionStore? _documentStore; - private readonly IGraphProjectionStore? _graphStore; + private readonly IProjectionGraphMaterializer? _graphMaterializer; private readonly ILogger> _logger; private readonly bool _requiresDocumentStore = typeof(IDocumentReadModel).IsAssignableFrom(typeof(TReadModel)); private readonly bool _requiresGraphStore = typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel)); public ProjectionMaterializationRouter( IDocumentProjectionStore? documentStore = null, - IGraphProjectionStore? graphStore = null, + IProjectionGraphMaterializer? graphMaterializer = null, ILogger>? logger = null) { _documentStore = documentStore; - _graphStore = graphStore; + _graphMaterializer = graphMaterializer; _logger = logger ?? NullLogger>.Instance; } @@ -33,7 +33,7 @@ public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = defau await _documentStore!.UpsertAsync(readModel, ct); if (_requiresGraphStore) - await _graphStore!.UpsertGraphAsync(readModel, ct); + await _graphMaterializer!.UpsertGraphAsync(readModel, ct); } public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) @@ -59,7 +59,7 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT return; } - await _graphStore!.UpsertGraphAsync(updated, ct); + await _graphMaterializer!.UpsertGraphAsync(updated, ct); } public Task GetAsync(TKey key, CancellationToken ct = default) @@ -92,10 +92,10 @@ private void EnsureStoresReady() $"Document capability is required by read model '{typeof(TReadModel).FullName}', but no document projection store is registered."); } - if (_requiresGraphStore && _graphStore == null) + if (_requiresGraphStore && _graphMaterializer == null) { throw new InvalidOperationException( - $"Graph capability is required by read model '{typeof(TReadModel).FullName}', but no graph projection store is registered."); + $"Graph capability is required by read model '{typeof(TReadModel).FullName}', but no graph projection materializer is registered."); } } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs new file mode 100644 index 000000000..ff6810169 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs @@ -0,0 +1,15 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionProviderCapabilityValidatorService : IProjectionProviderCapabilityValidator +{ + public IReadOnlyList Validate( + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities) => + ProjectionProviderCapabilityValidator.Validate(requirements, capabilities); + + public void EnsureSupported( + Type readModelType, + ProjectionStoreRequirements requirements, + ProjectionProviderCapabilities capabilities) => + ProjectionProviderCapabilityValidator.EnsureSupported(readModelType, requirements, capabilities); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs deleted file mode 100644 index 229b6bd24..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelBindingResolver.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionReadModelBindingResolver : IProjectionReadModelBindingResolver -{ - public ProjectionReadModelRequirements Resolve( - IReadOnlyDictionary readModelBindings, - Type readModelType) - { - ArgumentNullException.ThrowIfNull(readModelBindings); - ArgumentNullException.ThrowIfNull(readModelType); - - if (!TryGetBinding(readModelBindings, readModelType, out var bindingKey, out var bindingValue)) - return new ProjectionReadModelRequirements(); - - if (!Enum.TryParse(bindingValue, true, out var indexKind) || - indexKind == ProjectionReadModelIndexKind.None) - { - throw new ProjectionReadModelBindingException( - readModelType, - bindingKey, - bindingValue, - $"Allowed values are {ProjectionReadModelIndexKind.Document} or {ProjectionReadModelIndexKind.Graph}."); - } - - return new ProjectionReadModelRequirements( - requiresIndexing: true, - requiredIndexKinds: [indexKind], - requiresRelations: indexKind == ProjectionReadModelIndexKind.Graph, - requiresRelationTraversal: indexKind == ProjectionReadModelIndexKind.Graph); - } - - private static bool TryGetBinding( - IReadOnlyDictionary readModelBindings, - Type readModelType, - out string bindingKey, - out string bindingValue) - { - if (readModelBindings.Count == 0) - { - bindingKey = ""; - bindingValue = ""; - return false; - } - - var fullName = readModelType.FullName ?? ""; - if (fullName.Length > 0 && readModelBindings.TryGetValue(fullName, out bindingValue!)) - { - bindingKey = fullName; - return true; - } - - if (readModelBindings.TryGetValue(readModelType.Name, out bindingValue!)) - { - throw new ProjectionReadModelBindingException( - readModelType, - readModelType.Name, - bindingValue, - $"Binding key must use full type name '{fullName}'."); - } - - bindingKey = ""; - bindingValue = ""; - return false; - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs deleted file mode 100644 index ffc731b26..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelCapabilityValidatorService.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionReadModelCapabilityValidatorService : IProjectionReadModelCapabilityValidator -{ - public IReadOnlyList Validate( - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities) => - ProjectionReadModelCapabilityValidator.Validate(requirements, capabilities); - - public void EnsureSupported( - Type readModelType, - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities) => - ProjectionReadModelCapabilityValidator.EnsureSupported(readModelType, requirements, capabilities); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs deleted file mode 100644 index 7784b8631..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderRegistry.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionReadModelProviderRegistry : IProjectionReadModelProviderRegistry -{ - public IReadOnlyList>> GetRegistrations( - IServiceProvider serviceProvider) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(serviceProvider); - return serviceProvider - .GetServices>>() - .ToList(); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs index bb1cb8e63..8e79e95fc 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs @@ -5,24 +5,24 @@ public sealed class ProjectionStoreSelectionPlanner : IProjectionStoreSelectionP public ProjectionStoreSelectionPlan Build( IProjectionStoreSelectionRuntimeOptions options, Type readModelType, - ProjectionReadModelRequirements relationRequirements) + ProjectionStoreRequirements graphRequirements) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(readModelType); - ArgumentNullException.ThrowIfNull(relationRequirements); - EnsureReadModelModeSupported(options.ReadModelMode); + ArgumentNullException.ThrowIfNull(graphRequirements); + EnsureStoreModeSupported(options.StoreMode); - var readModelRequirements = BuildReadModelRequirements(readModelType); + var readModelRequirements = BuildDocumentRequirements(readModelType); var readModelRequiresGraph = typeof(IGraphReadModel).IsAssignableFrom(readModelType); var readModelProvider = NormalizeRequiredProviderName(options.DocumentProvider); - var readModelSelectionOptions = new ProjectionReadModelStoreSelectionOptions + var readModelSelectionOptions = new ProjectionStoreSelectionOptions { RequestedProviderName = readModelProvider, FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, }; - var mergedRelationRequirements = MergeRelationRequirements(relationRequirements, readModelRequiresGraph); - var relationSelectionOptions = new ProjectionReadModelStoreSelectionOptions + var mergedGraphRequirements = MergeGraphRequirements(graphRequirements, readModelRequiresGraph); + var graphSelectionOptions = new ProjectionStoreSelectionOptions { RequestedProviderName = NormalizeGraphProviderName( options.GraphProvider, @@ -33,31 +33,31 @@ public ProjectionStoreSelectionPlan Build( return new ProjectionStoreSelectionPlan( readModelRequirements, readModelSelectionOptions, - mergedRelationRequirements, - relationSelectionOptions); + mergedGraphRequirements, + graphSelectionOptions); } - private static ProjectionReadModelRequirements MergeRelationRequirements( - ProjectionReadModelRequirements relationRequirements, + private static ProjectionStoreRequirements MergeGraphRequirements( + ProjectionStoreRequirements graphRequirements, bool readModelRequiresGraph) { - return new ProjectionReadModelRequirements( - requiresIndexing: relationRequirements.RequiresIndexing, - requiredIndexKinds: relationRequirements.RequiredIndexKinds, - requiresAliases: relationRequirements.RequiresAliases, - requiresSchemaValidation: relationRequirements.RequiresSchemaValidation, - requiresRelations: relationRequirements.RequiresRelations || readModelRequiresGraph, - requiresRelationTraversal: relationRequirements.RequiresRelationTraversal || readModelRequiresGraph); + return new ProjectionStoreRequirements( + requiresIndexing: graphRequirements.RequiresIndexing, + requiredIndexKinds: graphRequirements.RequiredIndexKinds, + requiresAliases: graphRequirements.RequiresAliases, + requiresSchemaValidation: graphRequirements.RequiresSchemaValidation, + requiresGraph: graphRequirements.RequiresGraph || readModelRequiresGraph, + requiresGraphTraversal: graphRequirements.RequiresGraphTraversal || readModelRequiresGraph); } - private static ProjectionReadModelRequirements BuildReadModelRequirements(Type readModelType) + private static ProjectionStoreRequirements BuildDocumentRequirements(Type readModelType) { - var requiredIndexKinds = new List(); + var requiredIndexKinds = new List(); if (typeof(IDocumentReadModel).IsAssignableFrom(readModelType)) - requiredIndexKinds.Add(ProjectionReadModelIndexKind.Document); + requiredIndexKinds.Add(ProjectionIndexKind.Document); - return new ProjectionReadModelRequirements( + return new ProjectionStoreRequirements( requiresIndexing: requiredIndexKinds.Count > 0, requiredIndexKinds: requiredIndexKinds); } @@ -83,13 +83,13 @@ private static string NormalizeGraphProviderName( return graphProviderName.Trim(); } - private static void EnsureReadModelModeSupported(ProjectionReadModelMode readModelMode) + private static void EnsureStoreModeSupported(ProjectionStoreMode readModelMode) { - if (readModelMode != ProjectionReadModelMode.StateOnly) + if (readModelMode != ProjectionStoreMode.StateOnly) return; throw new InvalidOperationException( "Projection store selection does not support Projection:Document:Mode=StateOnly. " + - "Use CustomReadModel or DefaultReadModel."); + "Use Custom or Default."); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs index eba8da2ca..ec2c4bda7 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs @@ -2,27 +2,27 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionStoreStartupValidator : IProjectionStoreStartupValidator { - private readonly IProjectionReadModelProviderRegistry _readModelProviderRegistry; - private readonly IProjectionReadModelProviderSelector _readModelProviderSelector; - private readonly IProjectionRelationStoreProviderRegistry _relationProviderRegistry; - private readonly IProjectionRelationStoreProviderSelector _relationProviderSelector; + private readonly IProjectionDocumentStoreProviderRegistry _readModelProviderRegistry; + private readonly IProjectionDocumentStoreProviderSelector _readModelProviderSelector; + private readonly IProjectionGraphStoreProviderRegistry _graphProviderRegistry; + private readonly IProjectionGraphStoreProviderSelector _graphProviderSelector; public ProjectionStoreStartupValidator( - IProjectionReadModelProviderRegistry readModelProviderRegistry, - IProjectionReadModelProviderSelector readModelProviderSelector, - IProjectionRelationStoreProviderRegistry relationProviderRegistry, - IProjectionRelationStoreProviderSelector relationProviderSelector) + IProjectionDocumentStoreProviderRegistry readModelProviderRegistry, + IProjectionDocumentStoreProviderSelector readModelProviderSelector, + IProjectionGraphStoreProviderRegistry graphProviderRegistry, + IProjectionGraphStoreProviderSelector graphProviderSelector) { _readModelProviderRegistry = readModelProviderRegistry; _readModelProviderSelector = readModelProviderSelector; - _relationProviderRegistry = relationProviderRegistry; - _relationProviderSelector = relationProviderSelector; + _graphProviderRegistry = graphProviderRegistry; + _graphProviderSelector = graphProviderSelector; } - public IProjectionStoreRegistration> ValidateReadModelProvider( + public IProjectionStoreRegistration> ValidateDocumentProvider( IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) where TReadModel : class { ArgumentNullException.ThrowIfNull(serviceProvider); @@ -33,16 +33,16 @@ public IProjectionStoreRegistration> return _readModelProviderSelector.Select(registrations, selectionOptions, requirements); } - public IProjectionStoreRegistration ValidateRelationProvider( + public IProjectionStoreRegistration ValidateGraphProvider( IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) + ProjectionStoreSelectionOptions selectionOptions, + ProjectionStoreRequirements requirements) { ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(selectionOptions); ArgumentNullException.ThrowIfNull(requirements); - var registrations = _relationProviderRegistry.GetRegistrations(serviceProvider); - return _relationProviderSelector.Select(registrations, selectionOptions, requirements); + var registrations = _graphProviderRegistry.GetRegistrations(serviceProvider); + return _graphProviderSelector.Select(registrations, selectionOptions, requirements); } } diff --git a/src/Aevatar.CQRS.Projection.StateMirror/README.md b/src/Aevatar.CQRS.Projection.StateMirror/README.md index 368e24c87..fdac1bc0d 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/README.md +++ b/src/Aevatar.CQRS.Projection.StateMirror/README.md @@ -5,4 +5,4 @@ - 默认实现:`JsonStateMirrorProjection`。 - 支持字段忽略:`StateMirrorProjectionOptions.IgnoredFields`。 - 支持字段重命名:`StateMirrorProjectionOptions.RenamedFields`。 -- 可作为 `DefaultReadModel` 模式的基础设施组件复用。 +- 可作为 `Default` 模式的基础设施组件复用。 diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs deleted file mode 100644 index 39230890b..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionStoreProviderMetadata -{ - ProjectionReadModelProviderCapabilities ProviderCapabilities { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs new file mode 100644 index 000000000..d67e8f2bc --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs @@ -0,0 +1,18 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public interface IProjectionGraphStore +{ + Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default); + + Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default); + + Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); + + Task> GetNeighborsAsync( + ProjectionGraphQuery query, + CancellationToken ct = default); + + Task GetSubgraphAsync( + ProjectionGraphQuery query, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs similarity index 73% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs index 7acbeef1a..9da3bcc3b 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationDirection.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public enum ProjectionRelationDirection +public enum ProjectionGraphDirection { Outbound = 0, Inbound = 1, diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs similarity index 81% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs index 13f0b8ca8..9b07546fd 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationEdge.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public sealed class ProjectionRelationEdge +public sealed class ProjectionGraphEdge { public string Scope { get; set; } = ""; @@ -10,7 +10,7 @@ public sealed class ProjectionRelationEdge public string ToNodeId { get; set; } = ""; - public string RelationType { get; set; } = ""; + public string EdgeType { get; set; } = ""; public Dictionary Properties { get; set; } = new(StringComparer.Ordinal); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs similarity index 89% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs index 6d7304aa7..24c9d8d7a 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationNode.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public sealed class ProjectionRelationNode +public sealed class ProjectionGraphNode { public string Scope { get; set; } = ""; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs similarity index 53% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs index 5bbe45c7b..fa4910230 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationQuery.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs @@ -1,14 +1,14 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public sealed class ProjectionRelationQuery +public sealed class ProjectionGraphQuery { public string Scope { get; set; } = ""; public string RootNodeId { get; set; } = ""; - public ProjectionRelationDirection Direction { get; set; } = ProjectionRelationDirection.Both; + public ProjectionGraphDirection Direction { get; set; } = ProjectionGraphDirection.Both; - public IReadOnlyList RelationTypes { get; set; } = []; + public IReadOnlyList EdgeTypes { get; set; } = []; public int Depth { get; set; } = 1; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs new file mode 100644 index 000000000..82b3cc090 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs @@ -0,0 +1,14 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public sealed class ProjectionGraphSubgraph +{ + public ProjectionGraphSubgraph() + { + Nodes = []; + Edges = []; + } + + public IReadOnlyList Nodes { get; set; } + + public IReadOnlyList Edges { get; set; } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs index 02bbd941c..1b580bca8 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs @@ -2,7 +2,7 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed record GraphEdgeDescriptor( string EdgeId, - string RelationType, + string EdgeType, string FromNodeId, string ToNodeId, IReadOnlyDictionary Properties, diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs deleted file mode 100644 index 43aeedcb1..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IGraphProjectionStore - where TReadModel : class -{ - Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); - - Task> GetNeighborsAsync( - ProjectionRelationQuery query, - CancellationToken ct = default); - - Task GetSubgraphAsync( - ProjectionRelationQuery query, - CancellationToken ct = default); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs similarity index 68% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs index 9dc887eb3..8a2d73235 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IReadModelDocumentMetadataProvider.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public interface IReadModelDocumentMetadataProvider +public interface IProjectionDocumentMetadataProvider where TReadModel : class, IDocumentReadModel { DocumentIndexMetadata Metadata { get; } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs deleted file mode 100644 index 0ac47587d..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelBindingResolver.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionReadModelBindingResolver -{ - ProjectionReadModelRequirements Resolve( - IReadOnlyDictionary readModelBindings, - Type readModelType); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs deleted file mode 100644 index 233f9c348..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelCapabilityValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionReadModelCapabilityValidator -{ - IReadOnlyList Validate( - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities); - - void EnsureSupported( - Type readModelType, - ProjectionReadModelRequirements requirements, - ProjectionReadModelProviderCapabilities capabilities); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs deleted file mode 100644 index b127038b4..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderRegistry.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionReadModelProviderRegistry -{ - IReadOnlyList>> GetRegistrations( - IServiceProvider serviceProvider) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs deleted file mode 100644 index c8beba36c..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelProviderSelector.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionReadModelProviderSelector -{ - IProjectionStoreRegistration> Select( - IReadOnlyList>> registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs deleted file mode 100644 index 3f7664892..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStore.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -/// -/// Generic read-model store contract for projection state persistence/query. -/// -public interface IProjectionReadModelStore - : IDocumentProjectionStore - where TReadModel : class; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs deleted file mode 100644 index 21d30fac8..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModelStoreFactory.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionReadModelStoreFactory -{ - IProjectionReadModelStore Create( - IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs deleted file mode 100644 index 9492a0d4b..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelBindingException.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed class ProjectionReadModelBindingException : InvalidOperationException -{ - public ProjectionReadModelBindingException( - Type readModelType, - string bindingKey, - string bindingValue, - string reason) - : base(BuildMessage(readModelType, bindingKey, bindingValue, reason)) - { - ReadModelType = readModelType; - BindingKey = bindingKey; - BindingValue = bindingValue; - Reason = reason; - } - - public Type ReadModelType { get; } - - public string BindingKey { get; } - - public string BindingValue { get; } - - public string Reason { get; } - - private static string BuildMessage( - Type readModelType, - string bindingKey, - string bindingValue, - string reason) => - $"Read-model binding is invalid for '{readModelType.FullName}'. " + - $"key='{bindingKey}', value='{bindingValue}', reason='{reason}'."; -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs deleted file mode 100644 index 8f1c61aef..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelIndexKind.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public enum ProjectionReadModelIndexKind -{ - None = 0, - Document = 1, - Graph = 2, -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs deleted file mode 100644 index 0ed8be4fe..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelMode.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public enum ProjectionReadModelMode -{ - CustomReadModel = 0, - DefaultReadModel = 1, - StateOnly = 2, -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs deleted file mode 100644 index 3a17d6527..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRequirements.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed class ProjectionReadModelRequirements -{ - private static readonly IReadOnlySet EmptyIndexKinds = - new HashSet(); - - public ProjectionReadModelRequirements( - bool requiresIndexing = false, - IEnumerable? requiredIndexKinds = null, - bool requiresAliases = false, - bool requiresSchemaValidation = false, - bool requiresRelations = false, - bool requiresRelationTraversal = false) - { - RequiresIndexing = requiresIndexing; - RequiresAliases = requiresAliases; - RequiresSchemaValidation = requiresSchemaValidation; - RequiresRelations = requiresRelations; - RequiresRelationTraversal = requiresRelationTraversal; - - var normalizedIndexKinds = (requiredIndexKinds ?? []) - .Where(x => x != ProjectionReadModelIndexKind.None) - .ToHashSet(); - - RequiredIndexKinds = normalizedIndexKinds.Count == 0 - ? EmptyIndexKinds - : normalizedIndexKinds; - } - - public bool RequiresIndexing { get; } - - public IReadOnlySet RequiredIndexKinds { get; } - - public bool RequiresAliases { get; } - - public bool RequiresSchemaValidation { get; } - - public bool RequiresRelations { get; } - - public bool RequiresRelationTraversal { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs deleted file mode 100644 index 05704c7ba..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelRuntimeOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed class ProjectionReadModelRuntimeOptions : IProjectionStoreSelectionRuntimeOptions -{ - public ProjectionReadModelMode Mode { get; set; } = ProjectionReadModelMode.CustomReadModel; - - public string DocumentProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - - public string GraphProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; - - public bool FailOnUnsupportedCapabilities { get; set; } = true; - - string IProjectionStoreSelectionRuntimeOptions.DocumentProvider => DocumentProvider; - - string IProjectionStoreSelectionRuntimeOptions.GraphProvider => GraphProvider; - - bool IProjectionStoreSelectionRuntimeOptions.FailOnUnsupportedCapabilities => FailOnUnsupportedCapabilities; - - ProjectionReadModelMode IProjectionStoreSelectionRuntimeOptions.ReadModelMode => Mode; -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs deleted file mode 100644 index 3a1a5c8ff..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionReadModelStoreSelector.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public static class ProjectionReadModelStoreSelector -{ - public static IProjectionStoreRegistration> Select( - IEnumerable>> registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements, - IProjectionReadModelCapabilityValidator? capabilityValidator = null) - where TReadModel : class - { - return ProjectionStoreSelector.Select< - IProjectionStoreRegistration>>( - registrations, - selectionOptions, - requirements, - typeof(TReadModel), - noRegistrationsReason: "No provider registrations were found.", - multipleRegistrationsReason: "Multiple providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested provider is not registered.", - capabilityValidator); - } -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs deleted file mode 100644 index ae32579ca..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStore.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionRelationStore -{ - Task UpsertNodeAsync(ProjectionRelationNode node, CancellationToken ct = default); - - Task UpsertEdgeAsync(ProjectionRelationEdge edge, CancellationToken ct = default); - - Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); - - Task> GetNeighborsAsync( - ProjectionRelationQuery query, - CancellationToken ct = default); - - Task GetSubgraphAsync( - ProjectionRelationQuery query, - CancellationToken ct = default); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs deleted file mode 100644 index ad2169f58..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionRelationStoreFactory -{ - IProjectionRelationStore Create( - IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs deleted file mode 100644 index 38ab2e4f0..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderRegistry.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionRelationStoreProviderRegistry -{ - IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs deleted file mode 100644 index 868c36add..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/IProjectionRelationStoreProviderSelector.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionRelationStoreProviderSelector -{ - IProjectionStoreRegistration Select( - IReadOnlyList> registrations, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs deleted file mode 100644 index d73c16533..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Relations/ProjectionRelationSubgraph.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed class ProjectionRelationSubgraph -{ - public ProjectionRelationSubgraph() - { - Nodes = []; - Edges = []; - } - - public IReadOnlyList Nodes { get; set; } - - public IReadOnlyList Edges { get; set; } -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs deleted file mode 100644 index c7536b985..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IProjectionStoreStartupValidator -{ - IProjectionStoreRegistration> ValidateReadModelProvider( - IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements) - where TReadModel : class; - - IProjectionStoreRegistration ValidateRelationProvider( - IServiceProvider serviceProvider, - ProjectionReadModelStoreSelectionOptions selectionOptions, - ProjectionReadModelRequirements requirements); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs deleted file mode 100644 index 49e3fa131..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public readonly record struct ProjectionStoreSelectionPlan( - ProjectionReadModelRequirements ReadModelRequirements, - ProjectionReadModelStoreSelectionOptions ReadModelSelectionOptions, - ProjectionReadModelRequirements RelationRequirements, - ProjectionReadModelStoreSelectionOptions RelationSelectionOptions); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md index 140de72e9..469d450de 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -1,29 +1,22 @@ # Aevatar.CQRS.Projection.Stores.Abstractions -`Aevatar.CQRS.Projection.Stores.Abstractions` 只包含投影存储、能力声明、provider 选择与启动校验相关抽象。 +`Aevatar.CQRS.Projection.Stores.Abstractions` 只包含投影存储能力契约与读模型结构契约。 ## 目录结构 -- `Abstractions/Core`:provider 元数据与通用注册契约(`IProjectionStoreRegistration`) -- `Abstractions/ReadModels`:ReadModel store/provider 能力、选择与校验抽象 -- `Abstractions/Relations`:Relation store/provider 与图关系模型抽象 -- `Abstractions/Selection`:跨 readmodel/relation 的统一选择计划与启动校验契约 +- `Abstractions/ReadModels`:读模型能力与文档存储契约 +- `Abstractions/Graphs`:图存储契约与图查询模型 ## 包含内容 -- 读模型存储:`IProjectionReadModelStore<,>` -- 关系存储:`IProjectionRelationStore` +- 读模型存储:`IDocumentProjectionStore<,>` +- 图存储:`IProjectionGraphStore` - ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`、`IGraphReadModel` -- 双写能力抽象:`IDocumentProjectionStore<,>`、`IGraphProjectionStore<>`、`IProjectionMaterializationRouter<,>` -- 文档索引元数据抽象:`DocumentIndexMetadata`、`IReadModelDocumentMetadataProvider`、`IProjectionDocumentMetadataResolver` -- Provider 能力建模:`ProjectionReadModelProviderCapabilities`、`IProjectionStoreProviderMetadata` -- 能力校验:`ProjectionReadModelRequirements`、`ProjectionReadModelCapabilityValidator` -- Provider 注册与选择:`IProjectionStoreRegistration`、`DelegateProjectionStoreRegistration` -- Provider Runtime 契约:`IProjectionReadModelProviderRegistry`、`IProjectionReadModelProviderSelector`、`IProjectionReadModelStoreFactory` -- 选择编排:`IProjectionStoreSelectionPlanner`、`IProjectionStoreStartupValidator` +- 文档索引元数据声明:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider` +- 图结构描述:`GraphNodeDescriptor`、`GraphEdgeDescriptor` ## 约束 -1. 不包含投影主链路编排接口(这些在 `Aevatar.CQRS.Projection.Core.Abstractions`)。 -2. 不包含业务模型、DI 装配与具体 provider 实现。 -3. capability 声明必须与 provider 真实实现一致,不允许“声明支持但未实现”。 +1. 不包含 Provider 选择、Factory、Runtime options、Materialization Router 等运行时编排契约。 +2. 不包含投影主链路编排接口(这些在 `Aevatar.CQRS.Projection.Core.Abstractions`)。 +3. 不包含业务模型、DI 装配与具体 provider 实现。 diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs index 3daf8f1e4..9b2714953 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionQueryPort.cs @@ -19,23 +19,23 @@ Task> ListActorTimelineAsync( int take = 200, CancellationToken ct = default); - Task> GetActorRelationsAsync( + Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); - Task GetActorRelationSubgraphAsync( + Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs index 3ba84833a..ce8012746 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/IWorkflowExecutionQueryApplicationService.cs @@ -12,23 +12,23 @@ public interface IWorkflowExecutionQueryApplicationService Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default); - Task> ListActorRelationsAsync( + Task> ListActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); - Task GetActorRelationSubgraphAsync( + Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs index 71a5d7443..b63c936ff 100644 --- a/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs +++ b/src/workflow/Aevatar.Workflow.Application.Abstractions/Queries/WorkflowExecutionQueryModels.cs @@ -34,7 +34,7 @@ public sealed class WorkflowActorTimelineItem public Dictionary Data { get; set; } = []; } -public sealed class WorkflowActorRelationNode +public sealed class WorkflowActorGraphNode { public string NodeId { get; set; } = string.Empty; @@ -45,21 +45,21 @@ public sealed class WorkflowActorRelationNode public Dictionary Properties { get; set; } = []; } -public enum WorkflowActorRelationDirection +public enum WorkflowActorGraphDirection { Outbound = 0, Inbound = 1, Both = 2, } -public sealed class WorkflowActorRelationQueryOptions +public sealed class WorkflowActorGraphQueryOptions { - public WorkflowActorRelationDirection Direction { get; set; } = WorkflowActorRelationDirection.Both; + public WorkflowActorGraphDirection Direction { get; set; } = WorkflowActorGraphDirection.Both; - public IReadOnlyList RelationTypes { get; set; } = []; + public IReadOnlyList EdgeTypes { get; set; } = []; } -public sealed class WorkflowActorRelationItem +public sealed class WorkflowActorGraphEdge { public string EdgeId { get; set; } = string.Empty; @@ -67,27 +67,27 @@ public sealed class WorkflowActorRelationItem public string ToNodeId { get; set; } = string.Empty; - public string RelationType { get; set; } = string.Empty; + public string EdgeType { get; set; } = string.Empty; public DateTimeOffset UpdatedAt { get; set; } public Dictionary Properties { get; set; } = []; } -public sealed class WorkflowActorRelationSubgraph +public sealed class WorkflowActorGraphSubgraph { public string RootNodeId { get; set; } = string.Empty; - public List Nodes { get; set; } = []; + public List Nodes { get; set; } = []; - public List Edges { get; set; } = []; + public List Edges { get; set; } = []; } public sealed class WorkflowActorGraphEnrichedSnapshot { public WorkflowActorSnapshot Snapshot { get; set; } = new(); - public WorkflowActorRelationSubgraph Subgraph { get; set; } = new(); + public WorkflowActorGraphSubgraph Subgraph { get; set; } = new(); } public sealed record WorkflowTopologyEdge(string Parent, string Child); diff --git a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs index d0e0a68bc..a1c6193d9 100644 --- a/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs +++ b/src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs @@ -55,39 +55,39 @@ public async Task> ListActorTimelineAsy return await _projectionPort.ListActorTimelineAsync(actorId, take, ct); } - public async Task> ListActorRelationsAsync( + public async Task> ListActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) return []; - return await _projectionPort.GetActorRelationsAsync(actorId, take, options, ct); + return await _projectionPort.GetActorGraphEdgesAsync(actorId, take, options, ct); } - public async Task GetActorRelationSubgraphAsync( + public async Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) - return new WorkflowActorRelationSubgraph + return new WorkflowActorGraphSubgraph { RootNodeId = actorId ?? string.Empty, }; - return await _projectionPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + return await _projectionPort.GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); } public async Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { if (!ActorQueryEnabled || string.IsNullOrWhiteSpace(actorId)) diff --git a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs index 74a59399f..fe3fa4ccc 100644 --- a/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs +++ b/src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs @@ -22,10 +22,10 @@ public static void Map(RouteGroupBuilder group) group.MapGet("/actors/{actorId}/timeline", ListActorTimeline) .Produces(StatusCodes.Status200OK); - group.MapGet("/actors/{actorId}/relations", ListActorRelations) + group.MapGet("/actors/{actorId}/graph-edges", ListActorGraphEdges) .Produces(StatusCodes.Status200OK); - group.MapGet("/actors/{actorId}/relation-subgraph", GetActorRelationSubgraph) + group.MapGet("/actors/{actorId}/graph-subgraph", GetActorGraphSubgraph) .Produces(StatusCodes.Status200OK); group.MapGet("/actors/{actorId}/graph-enriched", GetActorGraphEnrichedSnapshot) @@ -63,30 +63,30 @@ internal static async Task ListActorTimeline( return Results.Ok(timeline); } - internal static async Task ListActorRelations( + internal static async Task ListActorGraphEdges( string actorId, IWorkflowExecutionQueryApplicationService queryService, int take = 200, string? direction = null, - string[]? relationTypes = null, + string[]? edgeTypes = null, CancellationToken ct = default) { - var relationOptions = BuildRelationQueryOptions(direction, relationTypes); - var relations = await queryService.ListActorRelationsAsync(actorId, take, relationOptions, ct); - return Results.Ok(relations); + var graphOptions = BuildGraphQueryOptions(direction, edgeTypes); + var edges = await queryService.ListActorGraphEdgesAsync(actorId, take, graphOptions, ct); + return Results.Ok(edges); } - internal static async Task GetActorRelationSubgraph( + internal static async Task GetActorGraphSubgraph( string actorId, IWorkflowExecutionQueryApplicationService queryService, int depth = 2, int take = 200, string? direction = null, - string[]? relationTypes = null, + string[]? edgeTypes = null, CancellationToken ct = default) { - var relationOptions = BuildRelationQueryOptions(direction, relationTypes); - var subgraph = await queryService.GetActorRelationSubgraphAsync(actorId, depth, take, relationOptions, ct); + var graphOptions = BuildGraphQueryOptions(direction, edgeTypes); + var subgraph = await queryService.GetActorGraphSubgraphAsync(actorId, depth, take, graphOptions, ct); return Results.Ok(subgraph); } @@ -96,41 +96,41 @@ internal static async Task GetActorGraphEnrichedSnapshot( int depth = 2, int take = 200, string? direction = null, - string[]? relationTypes = null, + string[]? edgeTypes = null, CancellationToken ct = default) { - var relationOptions = BuildRelationQueryOptions(direction, relationTypes); - var graphEnriched = await queryService.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, relationOptions, ct); + var graphOptions = BuildGraphQueryOptions(direction, edgeTypes); + var graphEnriched = await queryService.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, graphOptions, ct); return graphEnriched == null ? Results.NotFound() : Results.Ok(graphEnriched); } - private static WorkflowActorRelationQueryOptions BuildRelationQueryOptions( + private static WorkflowActorGraphQueryOptions BuildGraphQueryOptions( string? direction, - string[]? relationTypes) + string[]? edgeTypes) { - return new WorkflowActorRelationQueryOptions + return new WorkflowActorGraphQueryOptions { Direction = ParseDirection(direction), - RelationTypes = NormalizeRelationTypes(relationTypes), + EdgeTypes = NormalizeEdgeTypes(edgeTypes), }; } - private static WorkflowActorRelationDirection ParseDirection(string? direction) + private static WorkflowActorGraphDirection ParseDirection(string? direction) { if (string.IsNullOrWhiteSpace(direction)) - return WorkflowActorRelationDirection.Both; + return WorkflowActorGraphDirection.Both; - return Enum.TryParse(direction.Trim(), ignoreCase: true, out var parsed) + return Enum.TryParse(direction.Trim(), ignoreCase: true, out var parsed) ? parsed - : WorkflowActorRelationDirection.Both; + : WorkflowActorGraphDirection.Both; } - private static IReadOnlyList NormalizeRelationTypes(IReadOnlyList? relationTypes) + private static IReadOnlyList NormalizeEdgeTypes(IReadOnlyList? edgeTypes) { - if (relationTypes == null || relationTypes.Count == 0) + if (edgeTypes == null || edgeTypes.Count == 0) return []; - return relationTypes + return edgeTypes .Select(x => x?.Trim() ?? "") .Where(x => x.Length > 0) .Distinct(StringComparer.Ordinal) diff --git a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj index 52f16ff40..20b569e21 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj +++ b/src/workflow/Aevatar.Workflow.Projection/Aevatar.Workflow.Projection.csproj @@ -10,6 +10,7 @@ + diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index a9b3d1cc0..e3cd50ed4 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -41,10 +41,10 @@ public bool EnableRunQueryEndpoints /// /// Whether to pre-validate read-model provider selection and capabilities during host startup. /// - public bool ValidateReadModelProviderOnStartup { get; set; } = true; + public bool ValidateDocumentProviderOnStartup { get; set; } = true; /// - /// Whether to pre-validate relation provider selection and capabilities during host startup. + /// Whether to pre-validate graph provider selection and capabilities during host startup. /// - public bool ValidateRelationProviderOnStartup { get; set; } = true; + public bool ValidateGraphProviderOnStartup { get; set; } = true; } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 52e9ce1d7..a8137bac1 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -37,12 +37,12 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.Replace(ServiceDescriptor.Singleton(options)); services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(sp => - sp.GetRequiredService()); + sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); - services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); + services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); RegisterWorkflowDocumentStoreSelector(services); RegisterWorkflowGraphStoreSelector(services); RegisterWorkflowMaterializationRouter(services); @@ -125,37 +125,30 @@ private static void RegisterFromAssembly(IServiceCollection services, Assembly a private static void RegisterWorkflowDocumentStoreSelector(IServiceCollection services) { - services.Replace(ServiceDescriptor.Singleton>(sp => + services.Replace(ServiceDescriptor.Singleton>(sp => { - var storeFactory = sp.GetRequiredService(); + var storeFactory = sp.GetRequiredService(); var selectionPlan = BuildSelectionPlan(sp); return storeFactory.Create( sp, - selectionPlan.ReadModelSelectionOptions, - selectionPlan.ReadModelRequirements); + selectionPlan.DocumentSelectionOptions, + selectionPlan.DocumentRequirements); })); - - services.Replace(ServiceDescriptor.Singleton>(sp => - sp.GetRequiredService>())); } private static void RegisterWorkflowGraphStoreSelector(IServiceCollection services) { - services.Replace(ServiceDescriptor.Singleton(sp => + services.Replace(ServiceDescriptor.Singleton(sp => { - var relationStoreFactory = sp.GetRequiredService(); + var graphStoreFactory = sp.GetRequiredService(); var selectionPlan = BuildSelectionPlan(sp); - return relationStoreFactory.Create( + return graphStoreFactory.Create( sp, - selectionPlan.RelationSelectionOptions, - selectionPlan.RelationRequirements); + selectionPlan.GraphSelectionOptions, + selectionPlan.GraphRequirements); })); - - services.Replace(ServiceDescriptor.Singleton>(sp => - new ProjectionGraphStoreAdapter( - sp.GetRequiredService()))); } private static void RegisterWorkflowMaterializationRouter(IServiceCollection services) @@ -163,7 +156,7 @@ private static void RegisterWorkflowMaterializationRouter(IServiceCollection ser services.Replace(ServiceDescriptor.Singleton>(sp => new ProjectionMaterializationRouter( sp.GetRequiredService>(), - sp.GetRequiredService>()))); + sp.GetRequiredService>()))); } private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider serviceProvider) @@ -173,7 +166,7 @@ private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider return selectionPlanner.Build( runtimeOptions, typeof(WorkflowExecutionReport), - new ProjectionReadModelRequirements()); + new ProjectionStoreRequirements()); } private sealed class PassthroughEventDeduplicator : IEventDeduplicator diff --git a/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs b/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs index 263294b8a..816391d30 100644 --- a/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs +++ b/src/workflow/Aevatar.Workflow.Projection/GlobalUsings.cs @@ -1,5 +1,6 @@ global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; global using Aevatar.Workflow.Projection.ReadModels; global using Aevatar.Foundation.Abstractions; global using Aevatar.Workflow.Abstractions; diff --git a/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs index e61a80954..a63f76d64 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs @@ -3,7 +3,7 @@ namespace Aevatar.Workflow.Projection.Metadata; public sealed class WorkflowExecutionReportDocumentMetadataProvider - : IReadModelDocumentMetadataProvider + : IProjectionDocumentMetadataProvider { public DocumentIndexMetadata Metadata { get; } = new( IndexName: "workflow-execution-reports", diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs index 59f6eae73..507c16ac3 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionQueryReader.cs @@ -17,23 +17,23 @@ Task> ListActorTimelineAsync( int take = 200, CancellationToken ct = default); - Task> GetActorRelationsAsync( + Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); - Task GetActorRelationSubgraphAsync( + Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs index 96e263352..924fbdafc 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs @@ -6,7 +6,7 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowExecutionProjectionQueryService - : ProjectionQueryPortServiceBase, + : ProjectionQueryPortServiceBase, IWorkflowExecutionProjectionQueryPort { private readonly IWorkflowProjectionQueryReader _queryReader; @@ -37,26 +37,26 @@ public Task> ListActorTimelineAsync( CancellationToken ct = default) => ListTimelineAsync(actorId, take, ct); - public Task> GetActorRelationsAsync( + public Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => - GetRelationsInternalAsync(actorId, take, options, ct); + GetGraphEdgesInternalAsync(actorId, take, options, ct); - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => - GetRelationSubgraphInternalAsync(actorId, depth, take, options, ct); + GetGraphSubgraphInternalAsync(actorId, depth, take, options, ct); public Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => GetGraphEnrichedInternalAsync(actorId, depth, take, options, ct); @@ -76,57 +76,57 @@ protected override Task> ReadTimelineCo CancellationToken ct) => _queryReader.ListActorTimelineAsync(entityId, take, ct); - protected override Task> ReadRelationsCoreAsync( + protected override Task> ReadGraphEdgesCoreAsync( string entityId, int take, CancellationToken ct) - => _queryReader.GetActorRelationsAsync(entityId, take, options: null, ct); + => _queryReader.GetActorGraphEdgesAsync(entityId, take, options: null, ct); - protected override Task ReadRelationSubgraphCoreAsync( + protected override Task ReadGraphSubgraphCoreAsync( string entityId, int depth, int take, CancellationToken ct) - => _queryReader.GetActorRelationSubgraphAsync(entityId, depth, take, options: null, ct); + => _queryReader.GetActorGraphSubgraphAsync(entityId, depth, take, options: null, ct); - protected override WorkflowActorRelationSubgraph CreateEmptyRelationSubgraph(string entityId) + protected override WorkflowActorGraphSubgraph CreateEmptyGraphSubgraph(string entityId) { - return new WorkflowActorRelationSubgraph + return new WorkflowActorGraphSubgraph { RootNodeId = entityId ?? string.Empty, }; } - private async Task> GetRelationsInternalAsync( + private async Task> GetGraphEdgesInternalAsync( string actorId, int take, - WorkflowActorRelationQueryOptions? options, + WorkflowActorGraphQueryOptions? options, CancellationToken ct) { if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) return []; - return await _queryReader.GetActorRelationsAsync(actorId, take, options, ct); + return await _queryReader.GetActorGraphEdgesAsync(actorId, take, options, ct); } - private async Task GetRelationSubgraphInternalAsync( + private async Task GetGraphSubgraphInternalAsync( string actorId, int depth, int take, - WorkflowActorRelationQueryOptions? options, + WorkflowActorGraphQueryOptions? options, CancellationToken ct) { if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) - return CreateEmptyRelationSubgraph(actorId); + return CreateEmptyGraphSubgraph(actorId); - return await _queryReader.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + return await _queryReader.GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); } private async Task GetGraphEnrichedInternalAsync( string actorId, int depth, int take, - WorkflowActorRelationQueryOptions? options, + WorkflowActorGraphQueryOptions? options, CancellationToken ct) { if (!QueryEnabledCore || string.IsNullOrWhiteSpace(actorId)) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index d9da2d3c1..bfef488f1 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -6,13 +6,13 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionQueryReader : IWorkflowProjectionQueryReader { private readonly IDocumentProjectionStore _documentStore; - private readonly IGraphProjectionStore _graphStore; + private readonly IProjectionGraphStore _graphStore; private readonly WorkflowExecutionReadModelMapper _mapper; public WorkflowProjectionQueryReader( IDocumentProjectionStore documentStore, WorkflowExecutionReadModelMapper mapper, - IGraphProjectionStore graphStore) + IProjectionGraphStore graphStore) { _documentStore = documentStore; _mapper = mapper; @@ -55,10 +55,10 @@ public async Task> ListActorTimelineAsy .ToList(); } - public async Task> GetActorRelationsAsync( + public async Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { var actorIdValue = actorId?.Trim() ?? ""; @@ -66,62 +66,62 @@ public async Task> GetActorRelationsAsy return []; var boundedTake = Math.Clamp(take, 1, 1000); - var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); - var relationTypes = NormalizeRelationTypes(options?.RelationTypes); + var direction = MapDirection(options?.Direction ?? WorkflowActorGraphDirection.Both); + var edgeTypes = NormalizeEdgeTypes(options?.EdgeTypes); var edges = await _graphStore.GetNeighborsAsync( - new ProjectionRelationQuery + new ProjectionGraphQuery { - Scope = WorkflowExecutionRelationConstants.Scope, + Scope = WorkflowExecutionGraphConstants.Scope, RootNodeId = actorIdValue, Direction = direction, - RelationTypes = relationTypes, + EdgeTypes = edgeTypes, Take = boundedTake, }, ct); - return edges.Select(_mapper.ToActorRelationItem).ToList(); + return edges.Select(_mapper.ToActorGraphEdge).ToList(); } - public async Task GetActorRelationSubgraphAsync( + public async Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { var actorIdValue = actorId?.Trim() ?? ""; if (actorIdValue.Length == 0) - return new WorkflowActorRelationSubgraph(); + return new WorkflowActorGraphSubgraph(); var boundedDepth = Math.Clamp(depth, 1, 8); var boundedTake = Math.Clamp(take, 1, 2000); - var direction = MapDirection(options?.Direction ?? WorkflowActorRelationDirection.Both); - var relationTypes = NormalizeRelationTypes(options?.RelationTypes); + var direction = MapDirection(options?.Direction ?? WorkflowActorGraphDirection.Both); + var edgeTypes = NormalizeEdgeTypes(options?.EdgeTypes); var subgraph = await _graphStore.GetSubgraphAsync( - new ProjectionRelationQuery + new ProjectionGraphQuery { - Scope = WorkflowExecutionRelationConstants.Scope, + Scope = WorkflowExecutionGraphConstants.Scope, RootNodeId = actorIdValue, Direction = direction, - RelationTypes = relationTypes, + EdgeTypes = edgeTypes, Depth = boundedDepth, Take = boundedTake, }, ct); - return _mapper.ToActorRelationSubgraph(actorIdValue, subgraph); + return _mapper.ToActorGraphSubgraph(actorIdValue, subgraph); } public async Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { var snapshot = await GetActorSnapshotAsync(actorId, ct); if (snapshot == null) return null; - var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + var subgraph = await GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); return new WorkflowActorGraphEnrichedSnapshot { Snapshot = snapshot, @@ -129,22 +129,22 @@ public async Task GetActorRelationSubgraphAsync( }; } - private static ProjectionRelationDirection MapDirection(WorkflowActorRelationDirection direction) + private static ProjectionGraphDirection MapDirection(WorkflowActorGraphDirection direction) { return direction switch { - WorkflowActorRelationDirection.Outbound => ProjectionRelationDirection.Outbound, - WorkflowActorRelationDirection.Inbound => ProjectionRelationDirection.Inbound, - _ => ProjectionRelationDirection.Both, + WorkflowActorGraphDirection.Outbound => ProjectionGraphDirection.Outbound, + WorkflowActorGraphDirection.Inbound => ProjectionGraphDirection.Inbound, + _ => ProjectionGraphDirection.Both, }; } - private static IReadOnlyList NormalizeRelationTypes(IReadOnlyList? relationTypes) + private static IReadOnlyList NormalizeEdgeTypes(IReadOnlyList? edgeTypes) { - if (relationTypes == null || relationTypes.Count == 0) + if (edgeTypes == null || edgeTypes.Count == 0) return []; - return relationTypes + return edgeTypes .Select(x => x?.Trim() ?? "") .Where(x => x.Length > 0) .Distinct(StringComparer.Ordinal) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 7e9b26ada..9420fffbe 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -41,30 +41,30 @@ public Task StartAsync(CancellationToken cancellationToken) var selectionPlan = _selectionPlanner.Build( _selectionRuntimeOptions, typeof(WorkflowExecutionReport), - new ProjectionReadModelRequirements()); + new ProjectionStoreRequirements()); - if (_options.ValidateReadModelProviderOnStartup) + if (_options.ValidateDocumentProviderOnStartup) { - var selectedReadModelProvider = _startupValidator.ValidateReadModelProvider( + var selectedDocumentProvider = _startupValidator.ValidateDocumentProvider( _serviceProvider, - selectionPlan.ReadModelSelectionOptions, - selectionPlan.ReadModelRequirements); + selectionPlan.DocumentSelectionOptions, + selectionPlan.DocumentRequirements); _logger.LogInformation( "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", typeof(WorkflowExecutionReport).FullName, - selectedReadModelProvider.ProviderName); + selectedDocumentProvider.ProviderName); } - if (_options.ValidateRelationProviderOnStartup) + if (_options.ValidateGraphProviderOnStartup) { - var selectedRelationProvider = _startupValidator.ValidateRelationProvider( + var selectedGraphProvider = _startupValidator.ValidateGraphProvider( _serviceProvider, - selectionPlan.RelationSelectionOptions, - selectionPlan.RelationRequirements); + selectionPlan.GraphSelectionOptions, + selectionPlan.GraphRequirements); _logger.LogInformation( - "Workflow relation provider startup validation passed. relationType={RelationType} provider={Provider}", - typeof(ProjectionRelationNode).FullName, - selectedRelationProvider.ProviderName); + "Workflow graph provider startup validation passed. graphType={GraphType} provider={Provider}", + typeof(ProjectionGraphNode).FullName, + selectedGraphProvider.ProviderName); } return Task.CompletedTask; } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index f30838fcc..fb71a1151 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -6,7 +6,7 @@ - 应用层投影端口实现: - `IWorkflowExecutionProjectionLifecyclePort`(`Ensure/Attach/Detach/Release`) - - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/Relations/Subgraph/GraphEnriched`) + - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/GraphEdges/GraphSubgraph/GraphEnriched`) - 默认实现分别为 `WorkflowExecutionProjectionLifecycleService` 与 `WorkflowExecutionProjectionQueryService` - 两个实现分别继承 `ProjectionLifecyclePortServiceBase` / `ProjectionQueryPortServiceBase`,通用端口编排已下沉到 `Aevatar.CQRS.Projection.Core` - 编排组件拆分(避免单类过重): @@ -23,13 +23,14 @@ - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` -- Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionReadModelCapabilityValidator` 校验 +- Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionProviderCapabilityValidator` 校验 - ReadModel 选择规则统一:DI store 解析与 Startup validation 均复用 `IProjectionStoreSelectionPlanner + IProjectionStoreSelectionRuntimeOptions`,避免双处规则漂移 本项目依赖: - `Aevatar.CQRS.Projection.Core.Abstractions`(投影管线/端口抽象) -- `Aevatar.CQRS.Projection.Stores.Abstractions`(ReadModel/Relation/选择抽象) +- `Aevatar.CQRS.Projection.Stores.Abstractions`(Document/Graph 存储契约) +- `Aevatar.CQRS.Projection.Runtime.Abstractions`(Provider 选择与 materialization 编排契约) - `Aevatar.CQRS.Projection.Core`(通用生命周期/订阅/协调实现) - `Aevatar.Foundation.Projection`(最小 read model 基类与读侧能力接口) - `Aevatar.Workflow.Extensions.AIProjection`(可选扩展:组合 `Aevatar.AI.Projection` 的通用 reducer/applier) @@ -82,8 +83,8 @@ FAQ: - 实现 `IProjectionProjector>` - 在 DI 中注册 - 扩展 ReadModel Provider(推荐): - - 文档存储注册:`IProjectionStoreRegistration>` - - 图存储注册:`IProjectionStoreRegistration` + - 文档存储注册:`IProjectionStoreRegistration>` + - 图存储注册:`IProjectionStoreRegistration` - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - 通过 `Projection:Document:*` 与 `Projection:Graph:*` 配置选择 Provider @@ -98,12 +99,12 @@ FAQ: - `Projection:Document:Providers:Neo4j:*` - 图 Provider 配置: - `Projection:Graph:Providers:Neo4j:*` -- `WorkflowExecutionProjection:ValidateReadModelProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) -- `WorkflowExecutionProjection:ValidateRelationProviderOnStartup`:启动阶段预校验 graph provider(默认 `true`) -- 关系查询参数: - - `/actors/{actorId}/relations` 支持 `direction` 与 `relationTypes` - - `/actors/{actorId}/relation-subgraph` 支持 `direction` 与 `relationTypes` - - `/actors/{actorId}/graph-enriched` 支持 `direction` 与 `relationTypes` +- `WorkflowExecutionProjection:ValidateDocumentProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) +- `WorkflowExecutionProjection:ValidateGraphProviderOnStartup`:启动阶段预校验 graph provider(默认 `true`) +- 图查询参数: + - `/actors/{actorId}/graph-edges` 支持 `direction` 与 `edgeTypes` + - `/actors/{actorId}/graph-subgraph` 支持 `direction` 与 `edgeTypes` + - `/actors/{actorId}/graph-enriched` 支持 `direction` 与 `edgeTypes` - 扩展 run 输出协议: - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 - 不改 Application 用例编排代码 diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionGraphConstants.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionGraphConstants.cs new file mode 100644 index 000000000..0d15cd44d --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionGraphConstants.cs @@ -0,0 +1,18 @@ +namespace Aevatar.Workflow.Projection.ReadModels; + +public static class WorkflowExecutionGraphConstants +{ + public const string Scope = "workflow-execution-graph"; + + public const string ActorNodeType = "Actor"; + + public const string RunNodeType = "WorkflowRun"; + + public const string StepNodeType = "WorkflowStep"; + + public const string EdgeTypeOwns = "OWNS"; + + public const string EdgeTypeContainsStep = "CONTAINS_STEP"; + + public const string EdgeTypeChildOf = "CHILD_OF"; +} diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index f2da8ddd3..35c97bc30 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -40,7 +40,7 @@ public sealed class WorkflowExecutionReport public string DocumentScope => "workflow-execution-reports"; - public string GraphScope => WorkflowExecutionRelationConstants.Scope; + public string GraphScope => WorkflowExecutionGraphConstants.Scope; public IReadOnlyList GraphNodes => BuildGraphNodes(); @@ -106,7 +106,7 @@ private IReadOnlyList BuildGraphNodes() nodes[rootActorId] = new GraphNodeDescriptor( rootActorId, - WorkflowExecutionRelationConstants.ActorNodeType, + WorkflowExecutionGraphConstants.ActorNodeType, new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", @@ -115,7 +115,7 @@ private IReadOnlyList BuildGraphNodes() nodes[runNodeId] = new GraphNodeDescriptor( runNodeId, - WorkflowExecutionRelationConstants.RunNodeType, + WorkflowExecutionGraphConstants.RunNodeType, new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = rootActorId, @@ -130,7 +130,7 @@ private IReadOnlyList BuildGraphNodes() var stepNodeId = BuildStepNodeId(rootActorId, CommandId, step.StepId); nodes[stepNodeId] = new GraphNodeDescriptor( stepNodeId, - WorkflowExecutionRelationConstants.StepNodeType, + WorkflowExecutionGraphConstants.StepNodeType, new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = rootActorId, @@ -152,7 +152,7 @@ private IReadOnlyList BuildGraphNodes() { nodes[parentId] = new GraphNodeDescriptor( parentId, - WorkflowExecutionRelationConstants.ActorNodeType, + WorkflowExecutionGraphConstants.ActorNodeType, new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", @@ -164,7 +164,7 @@ private IReadOnlyList BuildGraphNodes() { nodes[childId] = new GraphNodeDescriptor( childId, - WorkflowExecutionRelationConstants.ActorNodeType, + WorkflowExecutionGraphConstants.ActorNodeType, new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", @@ -184,7 +184,7 @@ private IReadOnlyList BuildGraphEdges() var edges = new Dictionary(StringComparer.Ordinal); var ownsEdge = CreateEdge( - WorkflowExecutionRelationConstants.RelationOwns, + WorkflowExecutionGraphConstants.EdgeTypeOwns, rootActorId, runNodeId, new Dictionary(StringComparer.Ordinal), @@ -195,7 +195,7 @@ private IReadOnlyList BuildGraphEdges() { var stepNodeId = BuildStepNodeId(rootActorId, CommandId, step.StepId); var containsEdge = CreateEdge( - WorkflowExecutionRelationConstants.RelationContainsStep, + WorkflowExecutionGraphConstants.EdgeTypeContainsStep, runNodeId, stepNodeId, new Dictionary(StringComparer.Ordinal) @@ -215,7 +215,7 @@ private IReadOnlyList BuildGraphEdges() continue; var childOfEdge = CreateEdge( - WorkflowExecutionRelationConstants.RelationChildOf, + WorkflowExecutionGraphConstants.EdgeTypeChildOf, parentId, childId, new Dictionary(StringComparer.Ordinal), @@ -235,11 +235,11 @@ private static GraphEdgeDescriptor CreateEdge( { var normalizedFromNodeId = NormalizeToken(fromNodeId); var normalizedToNodeId = NormalizeToken(toNodeId); - var normalizedRelationType = NormalizeToken(relationType); - var edgeId = BuildEdgeId(normalizedRelationType, normalizedFromNodeId, normalizedToNodeId); + var normalizedEdgeType = NormalizeToken(relationType); + var edgeId = BuildEdgeId(normalizedEdgeType, normalizedFromNodeId, normalizedToNodeId); return new GraphEdgeDescriptor( edgeId, - normalizedRelationType, + normalizedEdgeType, normalizedFromNodeId, normalizedToNodeId, new Dictionary(properties, StringComparer.Ordinal), diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs index 3b5b30dfd..eaa9abb1a 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs @@ -39,9 +39,9 @@ public WorkflowActorTimelineItem ToActorTimelineItem(WorkflowExecutionTimelineEv }; } - public WorkflowActorRelationNode ToActorRelationNode(ProjectionRelationNode source) + public WorkflowActorGraphNode ToActorGraphNode(ProjectionGraphNode source) { - return new WorkflowActorRelationNode + return new WorkflowActorGraphNode { NodeId = source.NodeId, NodeType = source.NodeType, @@ -50,28 +50,28 @@ public WorkflowActorRelationNode ToActorRelationNode(ProjectionRelationNode sour }; } - public WorkflowActorRelationItem ToActorRelationItem(ProjectionRelationEdge source) + public WorkflowActorGraphEdge ToActorGraphEdge(ProjectionGraphEdge source) { - return new WorkflowActorRelationItem + return new WorkflowActorGraphEdge { EdgeId = source.EdgeId, FromNodeId = source.FromNodeId, ToNodeId = source.ToNodeId, - RelationType = source.RelationType, + EdgeType = source.EdgeType, UpdatedAt = source.UpdatedAt, Properties = new Dictionary(source.Properties, StringComparer.Ordinal), }; } - public WorkflowActorRelationSubgraph ToActorRelationSubgraph( + public WorkflowActorGraphSubgraph ToActorGraphSubgraph( string rootNodeId, - ProjectionRelationSubgraph source) + ProjectionGraphSubgraph source) { - return new WorkflowActorRelationSubgraph + return new WorkflowActorGraphSubgraph { RootNodeId = rootNodeId, - Nodes = source.Nodes.Select(ToActorRelationNode).ToList(), - Edges = source.Edges.Select(ToActorRelationItem).ToList(), + Nodes = source.Nodes.Select(ToActorGraphNode).ToList(), + Edges = source.Edges.Select(ToActorGraphEdge).ToList(), }; } } diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs deleted file mode 100644 index be34a7cf7..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionRelationConstants.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Aevatar.Workflow.Projection.ReadModels; - -public static class WorkflowExecutionRelationConstants -{ - public const string Scope = "workflow-execution-relations"; - - public const string ActorNodeType = "Actor"; - - public const string RunNodeType = "WorkflowRun"; - - public const string StepNodeType = "WorkflowStep"; - - public const string RelationOwns = "OWNS"; - - public const string RelationContainsStep = "CONTAINS_STEP"; - - public const string RelationChildOf = "CHILD_OF"; -} diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj index ba1f87406..09ff66b15 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj @@ -7,6 +7,7 @@ Aevatar.Workflow.Extensions.Hosting + diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 3449198fc..8407ba805 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; +using Aevatar.CQRS.Projection.Runtime.Abstractions; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; @@ -27,17 +28,17 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( var providerSelection = ResolveProviderSelection(configuration); EnforceGraphProviderPolicy(configuration, providerSelection.GraphProvider); - var runtimeOptions = new ProjectionReadModelRuntimeOptions + var runtimeOptions = new ProjectionStoreRuntimeOptions { DocumentProvider = providerSelection.DocumentProvider, GraphProvider = providerSelection.GraphProvider, FailOnUnsupportedCapabilities = true, - Mode = ProjectionReadModelMode.CustomReadModel, + Mode = ProjectionStoreMode.Custom, }; services.Replace(ServiceDescriptor.Singleton(runtimeOptions)); services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); + sp.GetRequiredService())); RegisterDocumentProvider(services, configuration, providerSelection.DocumentProvider); RegisterGraphProvider(services, configuration, providerSelection.GraphProvider); @@ -49,7 +50,7 @@ private static ProviderSelection ResolveProviderSelection(IConfiguration configu { var documentProvider = NormalizeOrDefaultProvider( configuration["Projection:Document:Provider"], - ProjectionReadModelProviderNames.InMemory, + ProjectionProviderNames.InMemory, "Projection:Document:Provider"); var graphProvider = NormalizeOrDefaultProvider( @@ -72,7 +73,7 @@ private static void EnforceGraphProviderPolicy( if ((denyInMemoryGraphProvider || production) && string.Equals( graphProviderName, - ProjectionReadModelProviderNames.InMemory, + ProjectionProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException( @@ -113,16 +114,16 @@ private static string NormalizeOrDefaultProvider( ? fallbackValue : configuredValue.Trim(); - if (string.Equals(candidate, ProjectionReadModelProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) - return ProjectionReadModelProviderNames.InMemory; - if (string.Equals(candidate, ProjectionReadModelProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) - return ProjectionReadModelProviderNames.Elasticsearch; - if (string.Equals(candidate, ProjectionReadModelProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) - return ProjectionReadModelProviderNames.Neo4j; + if (string.Equals(candidate, ProjectionProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) + return ProjectionProviderNames.InMemory; + if (string.Equals(candidate, ProjectionProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) + return ProjectionProviderNames.Elasticsearch; + if (string.Equals(candidate, ProjectionProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) + return ProjectionProviderNames.Neo4j; throw new InvalidOperationException( $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + - $"Allowed values: {ProjectionReadModelProviderNames.InMemory}, {ProjectionReadModelProviderNames.Elasticsearch}, {ProjectionReadModelProviderNames.Neo4j}."); + $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Elasticsearch}, {ProjectionProviderNames.Neo4j}."); } private static void RegisterDocumentProvider( @@ -132,14 +133,14 @@ private static void RegisterDocumentProvider( { switch (providerName) { - case ProjectionReadModelProviderNames.InMemory: + case ProjectionProviderNames.InMemory: services.AddInMemoryDocumentStoreRegistration( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); break; - case ProjectionReadModelProviderNames.Elasticsearch: + case ProjectionProviderNames.Elasticsearch: services.AddElasticsearchDocumentStoreRegistration( optionsFactory: _ => { @@ -155,7 +156,7 @@ private static void RegisterDocumentProvider( keySelector: report => report.RootActorId, keyFormatter: key => key); break; - case ProjectionReadModelProviderNames.Neo4j: + case ProjectionProviderNames.Neo4j: services.AddNeo4jDocumentStoreRegistration( optionsFactory: _ => { @@ -183,21 +184,21 @@ private static void RegisterGraphProvider( { switch (providerName) { - case ProjectionReadModelProviderNames.InMemory: + case ProjectionProviderNames.InMemory: services.AddInMemoryGraphStoreRegistration(); break; - case ProjectionReadModelProviderNames.Elasticsearch: + case ProjectionProviderNames.Elasticsearch: throw new InvalidOperationException( "Elasticsearch cannot be used as graph provider. Use InMemory (dev/test) or Neo4j."); - case ProjectionReadModelProviderNames.Neo4j: + case ProjectionProviderNames.Neo4j: services.AddNeo4jGraphStoreRegistration( optionsFactory: _ => { - var providerOptions = new Neo4jProjectionRelationStoreOptions(); + var providerOptions = new Neo4jProjectionGraphStoreOptions(); configuration.GetSection("Projection:Graph:Providers:Neo4j").Bind(providerOptions); return providerOptions; }, - scopeFactory: _ => WorkflowExecutionRelationConstants.Scope); + scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); break; default: throw new InvalidOperationException($"Unsupported graph provider '{providerName}'."); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj index 541238305..83fd3aec2 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj +++ b/test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs b/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs index 0c3254ec5..593848c52 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/GlobalUsings.cs @@ -2,3 +2,4 @@ global using Aevatar.Foundation.Abstractions; global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index b9cadd4db..4199a8f19 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -5,44 +5,12 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { - [Fact] - public void BindingResolver_ShouldResolveRequirement_ByReadModelFullName() - { - var resolver = new ProjectionReadModelBindingResolver(); - var bindingKey = typeof(TestReadModel).FullName!; - var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [bindingKey] = ProjectionReadModelIndexKind.Graph.ToString(), - }; - - var requirements = resolver.Resolve(bindings, typeof(TestReadModel)); - - requirements.RequiresIndexing.Should().BeTrue(); - requirements.RequiredIndexKinds.Should().ContainSingle() - .Which.Should().Be(ProjectionReadModelIndexKind.Graph); - } - - [Fact] - public void BindingResolver_WhenBindingUsesShortTypeName_ShouldThrow() - { - var resolver = new ProjectionReadModelBindingResolver(); - var bindings = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [nameof(TestReadModel)] = ProjectionReadModelIndexKind.Document.ToString(), - }; - - Action act = () => resolver.Resolve(bindings, typeof(TestReadModel)); - - act.Should().Throw() - .WithMessage("*must use full type name*"); - } - [Fact] public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() { - var selector = new ProjectionReadModelProviderSelector( - new ProjectionReadModelCapabilityValidatorService()); - var registrations = new List>> + var selector = new ProjectionDocumentStoreProviderSelector( + new ProjectionProviderCapabilityValidatorService()); + var registrations = new List>> { CreateRegistration( "InMemory", @@ -52,14 +20,14 @@ public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() var selected = selector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions + new ProjectionStoreSelectionOptions { RequestedProviderName = "InMemory", FailOnUnsupportedCapabilities = false, }, - new ProjectionReadModelRequirements( + new ProjectionStoreRequirements( requiresIndexing: true, - requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + requiredIndexKinds: [ProjectionIndexKind.Document])); selected.ProviderName.Should().Be("InMemory"); } @@ -67,34 +35,34 @@ public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() [Fact] public void ProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() { - var selector = new ProjectionReadModelProviderSelector( - new ProjectionReadModelCapabilityValidatorService()); - var registrations = new List>> + var selector = new ProjectionDocumentStoreProviderSelector( + new ProjectionProviderCapabilityValidatorService()); + var registrations = new List>> { CreateRegistration("InMemory", supportsIndexing: false, indexKinds: []), - CreateRegistration("Elasticsearch", supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document]), + CreateRegistration("Elasticsearch", supportsIndexing: true, indexKinds: [ProjectionIndexKind.Document]), }; Action act = () => selector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions(), - new ProjectionReadModelRequirements()); + new ProjectionStoreSelectionOptions(), + new ProjectionStoreRequirements()); act.Should().Throw() .Where(ex => ex.ReadModelType == typeof(TestReadModel)); } - private static IProjectionStoreRegistration> CreateRegistration( + private static IProjectionStoreRegistration> CreateRegistration( string providerName, bool supportsIndexing, - IReadOnlyList indexKinds) + IReadOnlyList indexKinds) { - var capabilities = new ProjectionReadModelProviderCapabilities( + var capabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing, indexKinds); - return new DelegateProjectionStoreRegistration>( + return new DelegateProjectionStoreRegistration>( providerName, capabilities, _ => new NoopStore()); @@ -105,7 +73,7 @@ public sealed class TestReadModel public string Id { get; set; } = ""; } - private sealed class NoopStore : IProjectionReadModelStore + private sealed class NoopStore : IDocumentProjectionStore { public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 6612e3d48..085eafe3c 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -2,7 +2,7 @@ namespace Aevatar.CQRS.Projection.Core.Tests; -public class ProjectionReadModelStoreSelectorTests +public class ProjectionDocumentStoreSelectorTests { [Fact] public void Select_WhenSingleProviderRegistered_ShouldReturnSingleProvider() @@ -12,10 +12,10 @@ public void Select_WhenSingleProviderRegistered_ShouldReturnSingleProvider() CreateRegistration("inmemory", supportsIndexing: false), }; - var selected = ProjectionReadModelStoreSelector.Select( + var selected = ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions(), - new ProjectionReadModelRequirements()); + new ProjectionStoreSelectionOptions(), + new ProjectionStoreRequirements()); selected.ProviderName.Should().Be("inmemory"); } @@ -26,13 +26,13 @@ public void Select_WhenMultipleProvidersAndNoRequestedProvider_ShouldThrow() var registrations = new[] { CreateRegistration("inmemory", supportsIndexing: false), - CreateRegistration("elasticsearch", supportsIndexing: true, indexKinds: [ProjectionReadModelIndexKind.Document]), + CreateRegistration("elasticsearch", supportsIndexing: true, indexKinds: [ProjectionIndexKind.Document]), }; - Action act = () => ProjectionReadModelStoreSelector.Select( + Action act = () => ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions(), - new ProjectionReadModelRequirements()); + new ProjectionStoreSelectionOptions(), + new ProjectionStoreRequirements()); act.Should().Throw() .Where(ex => ex.Reason.Contains("Multiple providers are registered", StringComparison.Ordinal)); @@ -46,13 +46,13 @@ public void Select_WhenRequestedProviderMissing_ShouldThrow() CreateRegistration("inmemory", supportsIndexing: false), }; - Action act = () => ProjectionReadModelStoreSelector.Select( + Action act = () => ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions + new ProjectionStoreSelectionOptions { RequestedProviderName = "elasticsearch", }, - new ProjectionReadModelRequirements()); + new ProjectionStoreRequirements()); act.Should().Throw() .Where(ex => ex.Reason.Contains("Requested provider is not registered", StringComparison.Ordinal)); @@ -66,18 +66,18 @@ public void Select_WhenCapabilitiesUnsupportedAndFailFastEnabled_ShouldThrow() CreateRegistration("inmemory", supportsIndexing: false), }; - Action act = () => ProjectionReadModelStoreSelector.Select( + Action act = () => ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions + new ProjectionStoreSelectionOptions { RequestedProviderName = "inmemory", FailOnUnsupportedCapabilities = true, }, - new ProjectionReadModelRequirements( + new ProjectionStoreRequirements( requiresIndexing: true, - requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + requiredIndexKinds: [ProjectionIndexKind.Document])); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -88,21 +88,21 @@ public void Select_WhenRequiredIndexKindsAreNotFullySupported_ShouldThrow() CreateRegistration( "neo4j", supportsIndexing: true, - indexKinds: [ProjectionReadModelIndexKind.Graph]), + indexKinds: [ProjectionIndexKind.Graph]), }; - Action act = () => ProjectionReadModelStoreSelector.Select( + Action act = () => ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions + new ProjectionStoreSelectionOptions { RequestedProviderName = "neo4j", FailOnUnsupportedCapabilities = true, }, - new ProjectionReadModelRequirements( + new ProjectionStoreRequirements( requiresIndexing: true, - requiredIndexKinds: [ProjectionReadModelIndexKind.Document, ProjectionReadModelIndexKind.Graph])); + requiredIndexKinds: [ProjectionIndexKind.Document, ProjectionIndexKind.Graph])); - act.Should().Throw() + act.Should().Throw() .WithMessage("*not fully supported*"); } @@ -114,37 +114,37 @@ public void Select_WhenCapabilitiesUnsupportedAndFailFastDisabled_ShouldReturnPr CreateRegistration("inmemory", supportsIndexing: false), }; - var selected = ProjectionReadModelStoreSelector.Select( + var selected = ProjectionDocumentStoreSelector.Select( registrations, - new ProjectionReadModelStoreSelectionOptions + new ProjectionStoreSelectionOptions { RequestedProviderName = "inmemory", FailOnUnsupportedCapabilities = false, }, - new ProjectionReadModelRequirements( + new ProjectionStoreRequirements( requiresIndexing: true, - requiredIndexKinds: [ProjectionReadModelIndexKind.Document])); + requiredIndexKinds: [ProjectionIndexKind.Document])); selected.ProviderName.Should().Be("inmemory"); } - private static IProjectionStoreRegistration> CreateRegistration( + private static IProjectionStoreRegistration> CreateRegistration( string providerName, bool supportsIndexing, - IEnumerable? indexKinds = null) + IEnumerable? indexKinds = null) { - var capabilities = new ProjectionReadModelProviderCapabilities( + var capabilities = new ProjectionProviderCapabilities( providerName, supportsIndexing, indexKinds); - return new DelegateProjectionStoreRegistration>( + return new DelegateProjectionStoreRegistration>( providerName, capabilities, _ => new NoopStore()); } - private sealed class NoopStore : IProjectionReadModelStore + private sealed class NoopStore : IDocumentProjectionStore { public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) => Task.CompletedTask; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs index 032ae3ec2..f8c027860 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs @@ -16,7 +16,7 @@ public void Build_WhenReadModelProviderIsEmpty_ShouldThrow() DocumentProvider = " ", }; - Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements()); + Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements()); act.Should().Throw() .WithMessage("*read-model provider is required*"); @@ -27,37 +27,37 @@ public void Build_WhenRelationProviderMissing_ShouldFallbackToReadModelProvider( { var options = new FakeOptions { - DocumentProvider = ProjectionReadModelProviderNames.Neo4j, + DocumentProvider = ProjectionProviderNames.Neo4j, GraphProvider = " ", }; - var plan = _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements( - requiresRelations: true, - requiresRelationTraversal: true)); + var plan = _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements( + requiresGraph: true, + requiresGraphTraversal: true)); - plan.ReadModelSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); - plan.RelationSelectionOptions.RequestedProviderName.Should().Be(ProjectionReadModelProviderNames.Neo4j); + plan.DocumentSelectionOptions.RequestedProviderName.Should().Be(ProjectionProviderNames.Neo4j); + plan.GraphSelectionOptions.RequestedProviderName.Should().Be(ProjectionProviderNames.Neo4j); } [Fact] - public void Build_ShouldMergeRelationRequirementsWithReadModelAliasAndSchemaRequirements() + public void Build_ShouldMergeGraphRequirementsWithReadModelAliasAndSchemaRequirements() { var options = new FakeOptions { - DocumentProvider = ProjectionReadModelProviderNames.Neo4j, + DocumentProvider = ProjectionProviderNames.Neo4j, }; - var relationRequirements = new ProjectionReadModelRequirements( - requiresRelations: true, - requiresRelationTraversal: true, + var graphRequirements = new ProjectionStoreRequirements( + requiresGraph: true, + requiresGraphTraversal: true, requiresAliases: false, requiresSchemaValidation: false); - var plan = _planner.Build(options, typeof(TestGraphReadModel), relationRequirements); + var plan = _planner.Build(options, typeof(TestGraphReadModel), graphRequirements); - plan.RelationRequirements.RequiresRelations.Should().BeTrue(); - plan.RelationRequirements.RequiresRelationTraversal.Should().BeTrue(); - plan.RelationRequirements.RequiresAliases.Should().BeFalse(); - plan.RelationRequirements.RequiresSchemaValidation.Should().BeFalse(); + plan.GraphRequirements.RequiresGraph.Should().BeTrue(); + plan.GraphRequirements.RequiresGraphTraversal.Should().BeTrue(); + plan.GraphRequirements.RequiresAliases.Should().BeFalse(); + plan.GraphRequirements.RequiresSchemaValidation.Should().BeFalse(); } [Fact] @@ -65,11 +65,11 @@ public void Build_WhenStateOnlyModeConfigured_ShouldThrow() { var options = new FakeOptions { - DocumentProvider = ProjectionReadModelProviderNames.InMemory, - ReadModelMode = ProjectionReadModelMode.StateOnly, + DocumentProvider = ProjectionProviderNames.InMemory, + StoreMode = ProjectionStoreMode.StateOnly, }; - Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionReadModelRequirements()); + Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements()); act.Should().Throw() .WithMessage("*does not support*StateOnly*"); @@ -77,13 +77,13 @@ public void Build_WhenStateOnlyModeConfigured_ShouldThrow() private sealed class FakeOptions : IProjectionStoreSelectionRuntimeOptions { - public string DocumentProvider { get; set; } = ProjectionReadModelProviderNames.InMemory; + public string DocumentProvider { get; set; } = ProjectionProviderNames.InMemory; public string GraphProvider { get; set; } = ""; public bool FailOnUnsupportedCapabilities { get; set; } = true; - public ProjectionReadModelMode ReadModelMode { get; set; } = ProjectionReadModelMode.CustomReadModel; + public ProjectionStoreMode StoreMode { get; set; } = ProjectionStoreMode.Custom; } private sealed class TestReadModel; diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs index ab439a4c7..9917105f8 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowApplicationLayerTests.cs @@ -294,20 +294,20 @@ public async Task GetActorSnapshotAsync_ShouldReturnProjectionPortResult() } [Fact] - public async Task ListActorRelationsAsync_ShouldReturnProjectionPortResult() + public async Task ListActorGraphEdgesAsync_ShouldReturnProjectionPortResult() { - var relation = new WorkflowActorRelationItem + var relation = new WorkflowActorGraphEdge { EdgeId = "edge-1", FromNodeId = "actor-1", ToNodeId = "actor-2", - RelationType = "CHILD_OF", + EdgeType = "CHILD_OF", UpdatedAt = DateTimeOffset.UtcNow, }; var projection = new FakeProjectionService { EnableActorQueryEndpointsValue = true, - RelationsByActorId = new Dictionary>(StringComparer.Ordinal) + RelationsByActorId = new Dictionary>(StringComparer.Ordinal) { ["actor-1"] = [relation], }, @@ -316,27 +316,27 @@ public async Task ListActorRelationsAsync_ShouldReturnProjectionPortResult() new WorkflowDefinitionRegistry(), projection); - var items = await queryService.ListActorRelationsAsync("actor-1", ct: CancellationToken.None); + var items = await queryService.ListActorGraphEdgesAsync("actor-1", ct: CancellationToken.None); items.Should().ContainSingle(); items[0].EdgeId.Should().Be("edge-1"); - items[0].RelationType.Should().Be("CHILD_OF"); + items[0].EdgeType.Should().Be("CHILD_OF"); } [Fact] - public async Task GetActorRelationSubgraphAsync_ShouldReturnProjectionPortResult() + public async Task GetActorGraphSubgraphAsync_ShouldReturnProjectionPortResult() { - var subgraph = new WorkflowActorRelationSubgraph + var subgraph = new WorkflowActorGraphSubgraph { RootNodeId = "actor-1", Nodes = [ - new WorkflowActorRelationNode + new WorkflowActorGraphNode { NodeId = "actor-1", NodeType = "Actor", }, - new WorkflowActorRelationNode + new WorkflowActorGraphNode { NodeId = "actor-2", NodeType = "Actor", @@ -344,19 +344,19 @@ public async Task GetActorRelationSubgraphAsync_ShouldReturnProjectionPortResult ], Edges = [ - new WorkflowActorRelationItem + new WorkflowActorGraphEdge { EdgeId = "edge-1", FromNodeId = "actor-1", ToNodeId = "actor-2", - RelationType = "CHILD_OF", + EdgeType = "CHILD_OF", }, ], }; var projection = new FakeProjectionService { EnableActorQueryEndpointsValue = true, - SubgraphByActorId = new Dictionary(StringComparer.Ordinal) + SubgraphByActorId = new Dictionary(StringComparer.Ordinal) { ["actor-1"] = subgraph, }, @@ -365,7 +365,7 @@ public async Task GetActorRelationSubgraphAsync_ShouldReturnProjectionPortResult new WorkflowDefinitionRegistry(), projection); - var item = await queryService.GetActorRelationSubgraphAsync("actor-1", ct: CancellationToken.None); + var item = await queryService.GetActorGraphSubgraphAsync("actor-1", ct: CancellationToken.None); item.RootNodeId.Should().Be("actor-1"); item.Nodes.Should().HaveCount(2); @@ -408,8 +408,8 @@ internal sealed class FakeProjectionService : public IWorkflowExecutionProjectionLease? LastLease { get; private set; } public Dictionary SnapshotByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary> TimelineByActorId { get; set; } = new(StringComparer.Ordinal); - public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); - public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); public IReadOnlyList SnapshotList { get; set; } = []; public bool EnableActorQueryEndpoints => EnableActorQueryEndpointsValue; @@ -472,10 +472,10 @@ public Task> ListActorTimelineAsync( return Task.FromResult>(timeline.Take(Math.Max(1, take)).ToList()); } - public Task> GetActorRelationsAsync( + public Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { _ = options; @@ -483,14 +483,14 @@ public Task> GetActorRelationsAsync( if (!RelationsByActorId.TryGetValue(actorId, out var relations)) relations = []; - return Task.FromResult>(relations.Take(Math.Max(1, take)).ToList()); + return Task.FromResult>(relations.Take(Math.Max(1, take)).ToList()); } - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { _ = depth; @@ -499,7 +499,7 @@ public Task GetActorRelationSubgraphAsync( _ = ct; if (!SubgraphByActorId.TryGetValue(actorId, out var subgraph)) { - subgraph = new WorkflowActorRelationSubgraph + subgraph = new WorkflowActorGraphSubgraph { RootNodeId = actorId, }; @@ -512,14 +512,14 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { var snapshot = await GetActorSnapshotAsync(actorId, ct); if (snapshot == null) return null; - var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + var subgraph = await GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); return new WorkflowActorGraphEnrichedSnapshot { Snapshot = snapshot, diff --git a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs index 4d84cad87..464917cb1 100644 --- a/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Application.Tests/WorkflowRunOrchestrationComponentTests.cs @@ -358,7 +358,7 @@ public Task> ListActorTimelineAsync(str return Task.FromResult>([]); } - public Task> GetActorRelationsAsync( + public Task> GetActorGraphEdgesAsync( string actorId, int take = 200, CancellationToken ct = default) @@ -366,10 +366,10 @@ public Task> GetActorRelationsAsync( _ = actorId; _ = take; ct.ThrowIfCancellationRequested(); - return Task.FromResult>([]); + return Task.FromResult>([]); } - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, @@ -378,7 +378,7 @@ public Task GetActorRelationSubgraphAsync( _ = depth; _ = take; ct.ThrowIfCancellationRequested(); - return Task.FromResult(new WorkflowActorRelationSubgraph + return Task.FromResult(new WorkflowActorGraphSubgraph { RootNodeId = actorId, }); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj b/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj index 990d69098..8d57ef957 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj +++ b/test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs index 9f00d65d6..ecd4b9584 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatEndpointsInternalTests.cs @@ -39,8 +39,8 @@ public void MapWorkflowCapabilityEndpoints_ShouldRegisterCoreRoutes() routePatterns.Should().Contain("/api/ws/chat"); routePatterns.Should().Contain("/api/actors/{actorId}"); routePatterns.Should().Contain("/api/actors/{actorId}/timeline"); - routePatterns.Should().Contain("/api/actors/{actorId}/relations"); - routePatterns.Should().Contain("/api/actors/{actorId}/relation-subgraph"); + routePatterns.Should().Contain("/api/actors/{actorId}/graph-edges"); + routePatterns.Should().Contain("/api/actors/{actorId}/graph-subgraph"); } [Fact] @@ -271,27 +271,27 @@ public async Task ListActorTimeline_ShouldReturnTimelineItems() } [Fact] - public async Task ListActorRelations_ShouldReturnRelationItems() + public async Task ListActorGraphEdges_ShouldReturnRelationItems() { var queryService = new FakeQueryService { ActorQueryEnabledValue = true, - RelationsByActorId = new Dictionary>(StringComparer.Ordinal) + RelationsByActorId = new Dictionary>(StringComparer.Ordinal) { ["actor-1"] = [ - new WorkflowActorRelationItem + new WorkflowActorGraphEdge { EdgeId = "edge-1", FromNodeId = "actor-1", ToNodeId = "actor-2", - RelationType = "CHILD_OF", + EdgeType = "CHILD_OF", }, ], }, }; - var result = await ChatQueryEndpoints.ListActorRelations("actor-1", queryService, 50, ct: CancellationToken.None); + var result = await ChatQueryEndpoints.ListActorGraphEdges("actor-1", queryService, 50, ct: CancellationToken.None); var (statusCode, body) = await ExecuteResultAsync(result); using var doc = JsonDocument.Parse(body); @@ -301,47 +301,47 @@ public async Task ListActorRelations_ShouldReturnRelationItems() } [Fact] - public async Task ListActorRelations_WhenDirectionAndRelationTypesProvided_ShouldForwardQueryOptions() + public async Task ListActorGraphEdges_WhenDirectionAndEdgeTypesProvided_ShouldForwardQueryOptions() { var queryService = new FakeQueryService { ActorQueryEnabledValue = true, }; - var result = await ChatQueryEndpoints.ListActorRelations( + var result = await ChatQueryEndpoints.ListActorGraphEdges( "actor-1", queryService, 50, direction: "Outbound", - relationTypes: ["CHILD_OF", "OWNS"], + edgeTypes: ["CHILD_OF", "OWNS"], ct: CancellationToken.None); var (statusCode, _) = await ExecuteResultAsync(result); statusCode.Should().Be(StatusCodes.Status200OK); - queryService.LastRelationQueryOptions.Should().NotBeNull(); - queryService.LastRelationQueryOptions!.Direction.Should().Be(WorkflowActorRelationDirection.Outbound); - queryService.LastRelationQueryOptions.RelationTypes.Should().BeEquivalentTo(["CHILD_OF", "OWNS"]); + queryService.LastGraphQueryOptions.Should().NotBeNull(); + queryService.LastGraphQueryOptions!.Direction.Should().Be(WorkflowActorGraphDirection.Outbound); + queryService.LastGraphQueryOptions.EdgeTypes.Should().BeEquivalentTo(["CHILD_OF", "OWNS"]); } [Fact] - public async Task GetActorRelationSubgraph_ShouldReturnSubgraph() + public async Task GetActorGraphSubgraph_ShouldReturnSubgraph() { var queryService = new FakeQueryService { ActorQueryEnabledValue = true, - SubgraphByActorId = new Dictionary(StringComparer.Ordinal) + SubgraphByActorId = new Dictionary(StringComparer.Ordinal) { - ["actor-1"] = new WorkflowActorRelationSubgraph + ["actor-1"] = new WorkflowActorGraphSubgraph { RootNodeId = "actor-1", Nodes = [ - new WorkflowActorRelationNode + new WorkflowActorGraphNode { NodeId = "actor-1", NodeType = "Actor", }, - new WorkflowActorRelationNode + new WorkflowActorGraphNode { NodeId = "actor-2", NodeType = "Actor", @@ -349,19 +349,19 @@ public async Task GetActorRelationSubgraph_ShouldReturnSubgraph() ], Edges = [ - new WorkflowActorRelationItem + new WorkflowActorGraphEdge { EdgeId = "edge-1", FromNodeId = "actor-1", ToNodeId = "actor-2", - RelationType = "CHILD_OF", + EdgeType = "CHILD_OF", }, ], }, }, }; - var result = await ChatQueryEndpoints.GetActorRelationSubgraph("actor-1", queryService, 2, 50, ct: CancellationToken.None); + var result = await ChatQueryEndpoints.GetActorGraphSubgraph("actor-1", queryService, 2, 50, ct: CancellationToken.None); var (statusCode, body) = await ExecuteResultAsync(result); using var doc = JsonDocument.Parse(body); @@ -435,9 +435,9 @@ private sealed class FakeQueryService : public IReadOnlyList Workflows { get; set; } = []; public Dictionary SnapshotByActorId { get; set; } = new(StringComparer.Ordinal); public Dictionary> TimelineByActorId { get; set; } = new(StringComparer.Ordinal); - public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); - public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); - public WorkflowActorRelationQueryOptions? LastRelationQueryOptions { get; private set; } + public Dictionary> RelationsByActorId { get; set; } = new(StringComparer.Ordinal); + public Dictionary SubgraphByActorId { get; set; } = new(StringComparer.Ordinal); + public WorkflowActorGraphQueryOptions? LastGraphQueryOptions { get; private set; } public bool ActorQueryEnabled => ActorQueryEnabledValue; @@ -460,34 +460,34 @@ public Task> ListActorTimelineAsync(str return Task.FromResult>(items.Take(Math.Max(1, take)).ToList()); } - public Task> ListActorRelationsAsync( + public Task> ListActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { - LastRelationQueryOptions = options; + LastGraphQueryOptions = options; _ = options; if (!RelationsByActorId.TryGetValue(actorId, out var items)) items = []; - return Task.FromResult>(items.Take(Math.Max(1, take)).ToList()); + return Task.FromResult>(items.Take(Math.Max(1, take)).ToList()); } - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { - LastRelationQueryOptions = options; + LastGraphQueryOptions = options; _ = depth; _ = take; _ = options; if (!SubgraphByActorId.TryGetValue(actorId, out var item)) { - item = new WorkflowActorRelationSubgraph + item = new WorkflowActorGraphSubgraph { RootNodeId = actorId, }; @@ -500,14 +500,14 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { var snapshot = await GetActorSnapshotAsync(actorId, ct); if (snapshot == null) return null; - var subgraph = await GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + var subgraph = await GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); return new WorkflowActorGraphEnrichedSnapshot { Snapshot = snapshot, diff --git a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs index 5de50f0ea..e2a0a83e5 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/ChatWebSocketCoordinatorAndProtocolTests.cs @@ -189,18 +189,18 @@ private sealed class FakeQueryService : IWorkflowExecutionQueryApplicationServic return Task.FromResult(Snapshot); } public Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); - public Task> ListActorRelationsAsync( + public Task> ListActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, - CancellationToken ct = default) => Task.FromResult>([]); - public Task GetActorRelationSubgraphAsync( + WorkflowActorGraphQueryOptions? options = null, + CancellationToken ct = default) => Task.FromResult>([]); + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => - Task.FromResult(new WorkflowActorRelationSubgraph + Task.FromResult(new WorkflowActorGraphSubgraph { RootNodeId = actorId, }); @@ -209,7 +209,7 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) { if (Snapshot == null) @@ -218,7 +218,7 @@ public Task GetActorRelationSubgraphAsync( return Task.FromResult(new WorkflowActorGraphEnrichedSnapshot { Snapshot = Snapshot, - Subgraph = new WorkflowActorRelationSubgraph + Subgraph = new WorkflowActorGraphSubgraph { RootNodeId = actorId, }, diff --git a/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs b/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs index cef2ea8fb..41314584d 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/GlobalUsings.cs @@ -1,6 +1,7 @@ global using Aevatar.AI.Abstractions; global using Aevatar.CQRS.Projection.Core.Abstractions; global using Aevatar.CQRS.Projection.Stores.Abstractions; +global using Aevatar.CQRS.Projection.Runtime.Abstractions; global using Aevatar.Foundation.Abstractions; global using Aevatar.Workflow.Abstractions; global using Xunit; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs index b6695c22a..aff922f38 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowCapabilityEndpointsCoverageTests.cs @@ -245,20 +245,20 @@ public Task> ListAgentsAsync(CancellationTok public Task> ListActorTimelineAsync(string actorId, int take = 200, CancellationToken ct = default) => Task.FromResult>([]); - public Task> ListActorRelationsAsync( + public Task> ListActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => - Task.FromResult>([]); + Task.FromResult>([]); - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => - Task.FromResult(new WorkflowActorRelationSubgraph + Task.FromResult(new WorkflowActorGraphSubgraph { RootNodeId = actorId, }); @@ -267,7 +267,7 @@ public Task GetActorRelationSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => Task.FromResult(null); } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 8dcf7d9c0..db1ede0f0 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -39,15 +39,15 @@ public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveInMemoryDocume await using var provider = services.BuildServiceProvider(); var documentStore = provider.GetRequiredService>(); - var readModelStore = provider.GetRequiredService>(); - var relationStore = provider.GetRequiredService(); - var graphStore = provider.GetRequiredService>(); + var readModelStore = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); + var graphStore = provider.GetRequiredService>(); var router = provider.GetRequiredService>(); documentStore.Should().BeOfType>(); readModelStore.Should().BeOfType>(); - relationStore.Should().BeOfType(); - graphStore.Should().BeOfType>(); + relationStore.Should().BeOfType(); + graphStore.Should().BeOfType>(); router.Should().NotBeNull(); Func act = () => StartHostedServicesAsync(provider); @@ -62,17 +62,17 @@ public void AddWorkflowExecutionProjectionCQRS_WhenDocumentElasticsearchAndGraph RegisterElasticsearchDocumentProvider(services); ConfigureStoreSelectionOptions(services, options => { - options.DocumentProvider = ProjectionReadModelProviderNames.Elasticsearch; - options.GraphProvider = ProjectionReadModelProviderNames.InMemory; + options.DocumentProvider = ProjectionProviderNames.Elasticsearch; + options.GraphProvider = ProjectionProviderNames.InMemory; }); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); - var readModelStore = provider.GetRequiredService>(); - var relationStore = provider.GetRequiredService(); + var readModelStore = provider.GetRequiredService>(); + var relationStore = provider.GetRequiredService(); readModelStore.Should().BeOfType>(); - relationStore.Should().BeOfType(); + relationStore.Should().BeOfType(); } [Fact] @@ -82,13 +82,13 @@ public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldTh RegisterElasticsearchDocumentProvider(services); ConfigureStoreSelectionOptions(services, options => { - options.DocumentProvider = ProjectionReadModelProviderNames.Elasticsearch; - options.GraphProvider = ProjectionReadModelProviderNames.Elasticsearch; + options.DocumentProvider = ProjectionProviderNames.Elasticsearch; + options.GraphProvider = ProjectionProviderNames.Elasticsearch; }); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); - Action act = () => provider.GetRequiredService(); + Action act = () => provider.GetRequiredService(); act.Should().Throw() .WithMessage("*No relation store provider registrations were found*"); @@ -122,13 +122,13 @@ private static void RegisterElasticsearchDocumentProvider(IServiceCollection ser private static void ConfigureStoreSelectionOptions( IServiceCollection services, - Action configure) + Action configure) { - var options = new ProjectionReadModelRuntimeOptions(); + var options = new ProjectionStoreRuntimeOptions(); configure(options); services.Replace(ServiceDescriptor.Singleton(options)); services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); + sp.GetRequiredService())); } private static async Task StartHostedServicesAsync(IServiceProvider provider) diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 977470bbd..9edd8701a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -540,8 +540,8 @@ private static ProjectionPortsHarness CreateService( var subscriptionHub = new ActorStreamSubscriptionHub(streams); store = new ObservableWorkflowExecutionReadModelStore(); var resolvedClock = clock ?? new SystemProjectionClock(); - var relationStore = new InMemoryProjectionRelationStore(); - var graphStore = new ProjectionGraphStoreAdapter(relationStore); + var relationStore = new InMemoryProjectionGraphStore(); + var graphStore = new ProjectionGraphMaterializer(relationStore); var materializationRouter = new ProjectionMaterializationRouter( store, graphStore); @@ -589,7 +589,7 @@ private static ProjectionPortsHarness CreateService( var queryReader = new WorkflowProjectionQueryReader( store, mapper, - graphStore); + relationStore); var activationService = new WorkflowProjectionActivationService( lifecycle, resolvedClock, @@ -621,8 +621,8 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( { var store = CreateStore(); var clock = new SystemProjectionClock(); - var relationStore = new InMemoryProjectionRelationStore(); - var graphStore = new ProjectionGraphStoreAdapter(relationStore); + var relationStore = new InMemoryProjectionGraphStore(); + var graphStore = new ProjectionGraphMaterializer(relationStore); var materializationRouter = new ProjectionMaterializationRouter( store, graphStore); @@ -635,7 +635,7 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( var queryReader = new WorkflowProjectionQueryReader( store, mapper, - graphStore); + relationStore); var activationService = new WorkflowProjectionActivationService( lifecycle, clock, @@ -800,32 +800,32 @@ public Task> ListActorTimelineAsync( CancellationToken ct = default) => _queryPort.ListActorTimelineAsync(actorId, take, ct); - public Task> GetActorRelationsAsync( + public Task> GetActorGraphEdgesAsync( string actorId, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) - => _queryPort.GetActorRelationsAsync(actorId, take, options, ct); + => _queryPort.GetActorGraphEdgesAsync(actorId, take, options, ct); - public Task GetActorRelationSubgraphAsync( + public Task GetActorGraphSubgraphAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) - => _queryPort.GetActorRelationSubgraphAsync(actorId, depth, take, options, ct); + => _queryPort.GetActorGraphSubgraphAsync(actorId, depth, take, options, ct); public Task GetActorGraphEnrichedSnapshotAsync( string actorId, int depth = 2, int take = 200, - WorkflowActorRelationQueryOptions? options = null, + WorkflowActorGraphQueryOptions? options = null, CancellationToken ct = default) => _queryPort.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, options, ct); } private sealed class ObservableWorkflowExecutionReadModelStore - : IProjectionReadModelStore + : IDocumentProjectionStore { private readonly InMemoryProjectionReadModelStore _inner = CreateStore(); private readonly object _gate = new(); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index aee77fa68..844eba5f4 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -29,7 +29,7 @@ private static IProjectionMaterializationRouter InMemoryProjectionReadModelStore store) => new ProjectionMaterializationRouter( store, - new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())); + new ProjectionGraphMaterializer(new InMemoryProjectionGraphStore())); private static IReadOnlyList> BuildReducers() => [ diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index c3da3857b..508e1bf72 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -45,7 +45,7 @@ public async Task AddWorkflowCapabilityWithAIDefaults_ShouldRegisterWorkflowAndA builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunRequestExecutor)).Should().BeTrue(); builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunActorPort)).Should().BeTrue(); - builder.Services.Any(x => x.ServiceType == typeof(IProjectionReadModelStore)).Should().BeTrue(); + builder.Services.Any(x => x.ServiceType == typeof(IDocumentProjectionStore)).Should().BeTrue(); await using var provider = builder.Services.BuildServiceProvider(); provider.GetService().Should().NotBeNull(); @@ -66,12 +66,12 @@ public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProviders builder.AddWorkflowCapabilityWithAIDefaults(); var providerRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); providerRegistrations.Should().HaveCount(1); var relationRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); relationRegistrations.Should().HaveCount(1); } @@ -86,12 +86,12 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo services.AddWorkflowProjectionReadModelProviders(configuration); var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); providerRegistrations.Should().HaveCount(1); var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); relationRegistrations.Should().HaveCount(1); } @@ -103,8 +103,8 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:Document:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, - ["Projection:Graph:Provider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:Document:Provider"] = ProjectionProviderNames.Elasticsearch, + ["Projection:Graph:Provider"] = ProjectionProviderNames.InMemory, ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", }) .Build(); @@ -112,10 +112,10 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S services.AddWorkflowProjectionReadModelProviders(configuration); var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) .ToList(); var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); var selectionOptionsRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionStoreSelectionRuntimeOptions)) @@ -127,8 +127,8 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S using var provider = services.BuildServiceProvider(); var selectionOptions = provider.GetRequiredService(); - selectionOptions.DocumentProvider.Should().Be(ProjectionReadModelProviderNames.Elasticsearch); - selectionOptions.GraphProvider.Should().Be(ProjectionReadModelProviderNames.InMemory); + selectionOptions.DocumentProvider.Should().Be(ProjectionProviderNames.Elasticsearch); + selectionOptions.GraphProvider.Should().Be(ProjectionProviderNames.InMemory); } [Fact] @@ -155,8 +155,8 @@ public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRela var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:Document:Provider"] = ProjectionReadModelProviderNames.Elasticsearch, - ["Projection:Graph:Provider"] = ProjectionReadModelProviderNames.InMemory, + ["Projection:Document:Provider"] = ProjectionProviderNames.Elasticsearch, + ["Projection:Graph:Provider"] = ProjectionProviderNames.InMemory, ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", ["Projection:Policies:DenyInMemoryGraphFactStore"] = "true", }) diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index 9f31ae9ba..e02c802c1 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -94,7 +94,7 @@ await store.UpsertAsync(new WorkflowExecutionReport var updater = new WorkflowProjectionReadModelUpdater( new ProjectionMaterializationRouter( store, - new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())), + new ProjectionGraphMaterializer(new InMemoryProjectionGraphStore())), new FixedClock(stoppedAt)); var context = new WorkflowExecutionProjectionContext { @@ -167,7 +167,7 @@ await store.UpsertAsync(new WorkflowExecutionReport var reader = new WorkflowProjectionQueryReader( store, new WorkflowExecutionReadModelMapper(), - new ProjectionGraphStoreAdapter(new InMemoryProjectionRelationStore())); + new InMemoryProjectionGraphStore()); var snapshot = await reader.GetActorSnapshotAsync("actor-3"); var timeline = await reader.ListActorTimelineAsync("actor-3", take: 2); diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 985a20930..af334e5a9 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -52,6 +52,16 @@ if rg -n "GetAwaiter\(\)\.GetResult\(\)" src; then exit 1 fi +if rg -n "IProjectionReadModelBindingResolver|ProjectionReadModelBindingResolver|ProjectionReadModelBindingException" src test; then + echo "BindingResolver-based projection routing is forbidden. Use capability-based Document/Graph routing." + exit 1 +fi + +if rg -n "Projection:ReadModel:Bindings" src test; then + echo "Projection:ReadModel:Bindings is forbidden. Use Projection:Document:* and Projection:Graph:* options." + exit 1 +fi + if [ -f "src/Aevatar.Foundation.Core/EventSourcing/DefaultAutoPersistedStateEventFactory.cs" ]; then echo "DefaultAutoPersistedStateEventFactory is forbidden. EventStore must persist domain events, not snapshot-state events." exit 1 From 70a5682b15a8d29a0a83e020d543891cba18b72f Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 00:39:05 +0800 Subject: [PATCH 30/46] Refactor Projection ReadModel and Provider Architecture for Enhanced Clarity and Functionality - Updated the Projection ReadModel documentation to reflect the latest architectural changes, including the removal of the capabilities model and the introduction of explicit provider selection. - Refactored the Elasticsearch, InMemory, and Neo4j providers to eliminate deprecated capabilities and streamline provider registration. - Introduced new startup validators for document and graph providers to ensure robust validation during initialization. - Enhanced dependency injection configurations to support the updated architecture and improve service registration clarity. - Improved overall documentation to provide clearer guidance on the new abstractions and provider functionalities. --- ...readmodel-full-refactor-plan-2026-02-24.md | 345 ++++++++++++++---- .../ServiceCollectionExtensions.cs | 6 - .../ElasticsearchProjectionReadModelStore.cs | 23 +- .../ServiceCollectionExtensions.cs | 12 - .../Stores/InMemoryProjectionGraphStore.cs | 9 +- .../InMemoryProjectionReadModelStore.cs | 20 +- .../ServiceCollectionExtensions.cs | 16 - .../Stores/Neo4jProjectionGraphStore.cs | 17 +- .../Stores/Neo4jProjectionReadModelStore.cs | 19 +- .../DelegateProjectionStoreRegistration.cs | 5 - .../Core/IProjectionStoreRegistration.cs | 2 - .../IProjectionDocumentRuntimeOptions.cs | 8 + .../IProjectionDocumentStartupValidator.cs | 9 + .../ProjectionDocumentRuntimeOptions.cs | 8 + .../ProjectionDocumentSelectionOptions.cs} | 4 +- .../Graphs/IProjectionGraphRuntimeOptions.cs | 8 + .../IProjectionGraphStartupValidator.cs | 8 + .../Graphs/IProjectionGraphStoreFactory.cs | 3 +- .../IProjectionGraphStoreProviderSelector.cs | 3 +- .../Graphs/ProjectionGraphRuntimeOptions.cs | 8 + .../Graphs/ProjectionGraphSelectionOptions.cs | 6 + .../IProjectionDocumentStoreFactory.cs | 3 +- ...ProjectionDocumentStoreProviderSelector.cs | 3 +- .../IProjectionProviderCapabilityValidator.cs | 13 - .../ProjectionDocumentStoreSelector.cs | 23 -- .../ReadModels/ProjectionIndexKind.cs | 8 - .../ProjectionProviderCapabilities.cs | 54 --- ...onProviderCapabilityValidationException.cs | 32 -- .../ProjectionProviderCapabilityValidator.cs | 68 ---- .../ReadModels/ProjectionStoreMode.cs | 8 - .../ReadModels/ProjectionStoreRequirements.cs | 42 --- .../ProjectionStoreRuntimeOptions.cs | 20 - .../IProjectionStoreSelectionPlanner.cs | 9 - ...IProjectionStoreSelectionRuntimeOptions.cs | 12 - .../IProjectionStoreStartupValidator.cs | 15 - .../Selection/ProjectionStoreSelectionPlan.cs | 7 - .../Selection/ProjectionStoreSelector.cs | 87 ----- .../README.md | 21 +- .../ServiceCollectionExtensions.cs | 5 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 18 +- .../ProjectionDocumentStartupValidator.cs | 27 ++ .../Runtime/ProjectionDocumentStoreFactory.cs | 6 +- ...ProjectionDocumentStoreProviderSelector.cs | 109 +++--- .../ProjectionGraphStartupValidator.cs | 26 ++ .../Runtime/ProjectionGraphStoreFactory.cs | 6 +- .../ProjectionGraphStoreProviderSelector.cs | 63 +++- ...ctionProviderCapabilityValidatorService.cs | 15 - .../ProjectionStoreSelectionPlanner.cs | 95 ----- .../ProjectionStoreStartupValidator.cs | 48 --- .../ServiceCollectionExtensions.cs | 41 ++- ...ReadModelStartupValidationHostedService.cs | 46 +-- .../Aevatar.Workflow.Projection/README.md | 6 +- ...tionProviderServiceCollectionExtensions.cs | 22 +- ...chProjectionReadModelStoreBehaviorTests.cs | 15 - .../ProjectionReadModelRuntimeTests.cs | 43 +-- .../ProjectionReadModelStoreSelectorTests.cs | 152 +++----- .../ProjectionStoreSelectionPlannerTests.cs | 101 ----- ...lowExecutionProjectionRegistrationTests.cs | 45 ++- .../WorkflowHostingExtensionsCoverageTests.cs | 17 +- 59 files changed, 723 insertions(+), 1147 deletions(-) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs rename src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/{ReadModels/ProjectionStoreSelectionOptions.cs => Documents/ProjectionDocumentSelectionOptions.cs} (50%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs delete mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 4ef0b76fa..fe2e50db7 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -1,87 +1,298 @@ -# Projection Abstractions 重构落地报告(v6,无兼容) - -> 日期:2026-02-24 - -## 1. 本次重构已完成内容 - -1. 新增 `Aevatar.CQRS.Projection.Runtime.Abstractions`,承接 Runtime 策略/选择/Factory/Materialization 契约。 -2. `Aevatar.CQRS.Projection.Stores.Abstractions` 彻底收敛为纯存储契约: - - 仅保留 `IDocumentProjectionStore<,>`、`IProjectionGraphStore`、ReadModel/Graph 结构描述。 - - 删除 Selection/Core 运行时编排抽象(迁移至 Runtime.Abstractions)。 -3. 删除 Graph 重复抽象: - - 删除 `IGraphProjectionStore`。 - - 新增 `IProjectionGraphMaterializer`。 - - `ProjectionMaterializationRouter` 改为依赖 `IProjectionGraphMaterializer`。 -4. Runtime 实现落地: - - `ProjectionGraphStoreAdapter` 重构为 `ProjectionGraphMaterializer`。 - - DI 改为注册 `IProjectionGraphMaterializer<>`。 -5. Provider 元数据重复抽象清理: - - 删除 `IProjectionStoreProviderMetadata`。 - - Provider 能力统一通过 `IProjectionStoreRegistration.Capabilities` 描述。 -6. 命名语义统一: - - `ProjectionDocumentStoreSelectionOptions` -> `ProjectionStoreSelectionOptions` - - `ProjectionDocumentRequirements` -> `ProjectionStoreRequirements` - -## 2. 当前边界(As-Built) +# Projection ReadModel 全量重构实施文档(v9,无兼容,无能力模型) + +> 日期:2026-02-24 +> 范围:`Aevatar.CQRS.Projection.Stores.Abstractions`、`Aevatar.CQRS.Projection.Core.Abstractions`、`Aevatar.CQRS.Projection.Runtime.Abstractions`、`Aevatar.CQRS.Projection.Runtime`、`Aevatar.Workflow.Projection` + +## 1. 结论先行 + +核心关系定义:`ReadModel` 与 `ProjectionTarget` 是标准 `1:N` 关系。 + +当前已实现 `N=2`: + +1. `DocumentTarget`(Document Provider 链路) +2. `GraphTarget`(Graph Provider 链路) + +关键决策(本版): + +1. 删除能力模型(`Capabilities/Requirements/CapabilityValidator`)整层。 +2. Provider 选择改为“显式配置 + 启动失败即终止(fail-fast)”,不做能力协商与自动降级。 +3. 索引与关系语义全部保留: + - 索引:Document 链路(ES 等) + - 关系:Graph 链路(Neo4j 等) +4. 写入编排保持单次路由、多目标分发(`1:N`)。 + +## 2. 当前实现诊断(As-Is) + +### 2.1 已经对齐的部分 + +1. `Stores.Abstractions` 已基本收敛为纯存储契约(`IDocumentProjectionStore<,>` + `IProjectionGraphStore`)。 +2. `IGraphProjectionStore` 已删除,改用 `IProjectionGraphMaterializer` 作为图写入适配层。 +3. Workflow 查询已直接读取 `IProjectionGraphStore`,不再从 Materializer 读图。 + +### 2.2 需要继续删除的旧语义 + +1. 统一计划模型(Doc+Graph 绑定): + - `ProjectionStoreSelectionPlan` + - `IProjectionStoreSelectionPlanner` + - `ProjectionStoreSelectionPlanner` +2. 统一能力模型: + - `ProjectionProviderCapabilities` + - `ProjectionStoreRequirements` + - `IProjectionProviderCapabilityValidator` + - `ProjectionProviderCapabilityValidator` +3. 统一启动校验接口: + - `IProjectionStoreStartupValidator` + - `ProjectionStoreStartupValidator` +4. Workflow DI 仍使用统一 `BuildSelectionPlan`。 + +## 3. 目标架构(To-Be) + +### 3.1 主链路 + 一对多目标分发 ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - SA["Aevatar.CQRS.Projection.Stores.Abstractions\nDocument/Graph Store Contracts"] - RA["Aevatar.CQRS.Projection.Runtime.Abstractions\nSelection + Provider Registration + Materialization Contracts"] - CA["Aevatar.CQRS.Projection.Core.Abstractions\nPipeline Protocol"] - RT["Aevatar.CQRS.Projection.Runtime\nRuntime Implementations"] - PR["Providers\nInMemory/Elasticsearch/Neo4j"] - WF["Workflow.Projection"] - - PR --> SA - PR --> RA - RT --> SA - RT --> RA - RT --> CA - WF --> SA - WF --> RA - WF --> CA + EVT["Event Stream"] --> RED["Reducer/Projector"] + RED --> RM["ReadModel(T)"] + RM --> ROUTER["IProjectionMaterializationRouter"] + + ROUTER --> TARGETS["Projection Targets (1..N)"] + + TARGETS --> DOC_ROUTE["Document Target\n(If T : IDocumentReadModel)"] + TARGETS --> GRA_ROUTE["Graph Target\n(If T : IGraphReadModel)"] + TARGETS --> EXT_ROUTE["Future Target\n(Extensible)"] + + DOC_ROUTE --> DOC_FACTORY["IProjectionDocumentStoreFactory"] + DOC_FACTORY --> DOC_STORE["IDocumentProjectionStore"] + + GRA_ROUTE --> GRA_MAT["IProjectionGraphMaterializer"] + GRA_MAT --> GRA_STORE["IProjectionGraphStore"] ``` -## 3. 关键契约(最终) +当前落地状态:`Projection Targets` 已实现 `Document + Graph` 两类目标。 + +### 3.2 Provider 选择(无能力协商) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TB + DOC_CFG["Projection:Document:Provider"] --> DOC_SEL["DocumentProviderSelector"] + DOC_REG["DocumentProviderRegistry"] --> DOC_SEL + DOC_SEL --> DOC_FACTORY["DocumentStoreFactory"] + + GRA_CFG["Projection:Graph:Provider"] --> GRA_SEL["GraphProviderSelector"] + GRA_REG["GraphProviderRegistry"] --> GRA_SEL + GRA_SEL --> GRA_FACTORY["GraphStoreFactory"] +``` + +选择规则: + +1. 配置必须明确指定 provider(或使用明确默认值)。 +2. 选择器仅做 provider name 匹配与唯一性判断。 +3. 启动时仅做注册存在性 + 基础连接健康检查;失败立即报错退出。 + +### 3.3 查询组合门面(降低开发负担) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + API["Query API"] --> FACADE["ProjectionQueryFacade"] + FACADE --> GQ["GraphQueryReader"] + FACADE --> DQ["DocumentQueryReader"] + GQ --> NEO["Graph Provider (e.g. Neo4j)"] + DQ --> ES["Document Provider (e.g. ES)"] + FACADE --> DTO["Enriched Graph DTO"] +``` + +说明:业务层只调用 Facade,不再手工双查(先 Neo4j 再 ES)。 + +## 4. 契约重构(无兼容,直接替换) + +### 4.1 保留的核心语义 + +1. `IDocumentProjectionStore<,>`:文档快照与索引写入。 +2. `IProjectionGraphStore`:图节点/边关系写入与遍历查询。 +3. `IProjectionMaterializationRouter<,>`:单次写入流程的多目标分发。 +4. `IProjectionGraphMaterializer`:从 ReadModel 派生图节点/边并写入 Graph。 + +### 4.2 删除的抽象层 + +删除整层能力协商模型: + +1. `ProjectionProviderCapabilities` +2. `ProjectionStoreRequirements` +3. `IProjectionProviderCapabilityValidator` +4. `ProjectionProviderCapabilityValidator` +5. `ProjectionProviderCapabilityValidationException` + +删除统一计划模型: + +1. `ProjectionStoreSelectionPlan` +2. `IProjectionStoreSelectionPlanner` +3. `ProjectionStoreSelectionPlanner` +4. `IProjectionStoreSelectionRuntimeOptions` +5. `ProjectionStoreRuntimeOptions` + +### 4.3 新的运行时配置模型 + +拆为两条独立配置契约: + +1. `IProjectionDocumentRuntimeOptions` +2. `IProjectionGraphRuntimeOptions` + +建议最小字段: + +1. `ProviderName` +2. `FailFastOnStartup` + +不再包含:`Requirements`、`Capabilities`、`FailOnUnsupportedCapabilities` 等协商语义。 + +### 4.4 启动校验模型 + +统一启动校验器拆分为: + +1. `IProjectionDocumentStartupValidator` +2. `IProjectionGraphStartupValidator` + +校验范围: + +1. 目标 provider 是否注册。 +2. 目标 provider 是否可创建。 +3. 基础连接健康检查(如 ES ping、Neo4j session)。 + +不再校验:抽象能力矩阵。 + +## 5. ReadModel 声明模型(保留索引/关系) + +目标:开发者只定义 `State + ReadModel`,由 ReadModel 声明决定投影目标。 + +建议契约(目标态): ```csharp -public interface IProjectionGraphStore +public interface IProjectionReadModel {} + +public interface IDocumentReadModel : IProjectionReadModel + where TDocumentMetadataProvider : IProjectionDocumentMetadataProvider, new() { - Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default); - Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default); - Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); - Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default); - Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default); } -public interface IProjectionGraphMaterializer - where TReadModel : class +public interface IGraphReadModel : IProjectionReadModel + where TGraphDescriptorProvider : IProjectionGraphDescriptorProvider, new() { - Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); } ``` -## 4. 目录变化(核心) +语义结果: + +1. `T : IDocumentReadModel<...>` -> 进入 DocumentTarget,保留索引能力。 +2. `T : IGraphReadModel<...>` -> 进入 GraphTarget,保留关系能力。 +3. 同时实现两者 -> 同一次流程扇出到两个目标(Doc + Graph)。 +4. 该模型本质是 `1:N`,不是二选一。 + +## 6. 与项目现状对齐的实施计划 -1. 新增项目:`src/Aevatar.CQRS.Projection.Runtime.Abstractions/` -2. 迁移: - - `Stores.Abstractions/Abstractions/Core/*` -> `Runtime.Abstractions/Abstractions/Core/*` - - `Stores.Abstractions/Abstractions/Selection/*` -> `Runtime.Abstractions/Abstractions/Selection/*` - - `Stores.Abstractions` 中 provider factory/selector/options/capabilities 相关抽象 -> `Runtime.Abstractions` -3. 删除: - - `Stores.Abstractions/Abstractions/ReadModels/IGraphProjectionStore.cs` - - `Runtime.Abstractions/Abstractions/Core/IProjectionStoreProviderMetadata.cs` +### Phase 1:删除统一计划与能力模型(Runtime.Abstractions) -## 5. 验证状态 +删除文件(按现有路径): -1. `dotnet build aevatar.slnx --nologo`:通过。 +1. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs` +2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs` +3. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs` +4. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs` +5. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs` +6. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs` +7. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs` +8. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs` +9. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs` +10. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs` +11. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs` +12. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs` + +新增文件(示例命名): + +1. `Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs` +2. `Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs` +3. `Abstractions/Documents/ProjectionDocumentSelectionOptions.cs` +4. `Abstractions/Documents/IProjectionDocumentStartupValidator.cs` +5. `Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs` +6. `Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs` +7. `Abstractions/Graphs/ProjectionGraphSelectionOptions.cs` +8. `Abstractions/Graphs/IProjectionGraphStartupValidator.cs` + +### Phase 2:Runtime 实现替换(无能力协商) + +1. 删除 `Runtime/ProjectionStoreSelectionPlanner.cs`。 +2. 删除 `Runtime/ProjectionStoreStartupValidator.cs`,拆分实现: + - `Runtime/ProjectionDocumentStartupValidator.cs` + - `Runtime/ProjectionGraphStartupValidator.cs` +3. 删除 `Runtime/ProjectionProviderCapabilityValidatorService.cs`。 +4. `ProjectionDocumentStoreFactory` 与 `ProjectionGraphStoreFactory` 改为仅接收对应 `SelectionOptions`。 +5. `ProjectionDocumentStoreProviderSelector` 与 `ProjectionGraphStoreProviderSelector` 去掉 `requirements/capabilityValidator` 参数。 + +### Phase 3:Workflow 依赖替换 + +改造文件: + +1. `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` + - 删除 `BuildSelectionPlan`。 + - 文档与图分别读取各自 runtime options。 +2. `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` + - 注入拆分后的 Document/Graph StartupValidator。 +3. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` + - 分别注册 `ProjectionDocumentRuntimeOptions` 与 `ProjectionGraphRuntimeOptions`。 + +### Phase 4:ReadModel 泛型声明落地(索引与关系显式化) + +1. `Stores.Abstractions` 将 `IDocumentReadModel`、`IGraphReadModel` 升级为泛型声明。 +2. Document metadata 由 ReadModel 泛型 provider 提供(索引名、mapping、alias)。 +3. Graph descriptor 由 ReadModel 泛型 provider 提供(节点与边关系定义)。 + +### Phase 5:查询门面落地 + +1. 在 `Workflow.Projection` 增加 `ProjectionQueryFacade`。 +2. 提供单入口:输入 rootId,返回文档快照 + 图子图(可选深度)。 +3. API 仅依赖 Facade,不直接双查存储。 + +### Phase 6:测试与门禁 + +1. 删除或重写依赖 `ProjectionStoreSelectionPlan`、`ProjectionStoreRequirements`、`ProjectionProviderCapabilities` 的测试。 +2. 新增测试: + - Document provider selection(name match/unique/fail-fast) + - Graph provider selection(name match/unique/fail-fast) + - Router `1:N` 扇出写入 + - Query facade 组合查询 +3. `tools/ci/architecture_guards.sh` 增加规则: + - 禁止 `ProjectionStoreSelectionPlan` 回流。 + - 禁止 `ProjectionStoreRequirements` 回流。 + - 禁止 `ProjectionProviderCapabilities` 回流。 + - 禁止 `CapabilityValidator` 回流。 + +## 7. 验收标准 + +全部满足即重构完成: + +1. `Runtime.Abstractions` 与 `Runtime` 中不再存在能力协商模型(Capabilities/Requirements/Validator)。 +2. `MaterializationRouter` 明确采用 `ReadModel -> Targets(1:N)` 扇出模型,当前 `N=2`(Document、Graph)。 +3. ReadModel 同时声明文档与图能力时,单流程双写可用。 +4. Document 索引语义保留并可验证(metadata 生效)。 +5. Graph 关系语义保留并可验证(节点/边写入与遍历查询可用)。 +6. Workflow 查询提供单门面,业务方无需手工双查。 + +## 8. 验证命令 + +```bash +dotnet restore aevatar.slnx --nologo +dotnet build aevatar.slnx --nologo +dotnet test aevatar.slnx --nologo +bash tools/ci/architecture_guards.sh +bash tools/ci/projection_route_mapping_guard.sh +bash tools/ci/solution_split_guards.sh +bash tools/ci/solution_split_test_guards.sh +bash tools/ci/test_stability_guards.sh +``` -## 6. 后续可选收敛(非兼容) +## 9. 执行策略 -1. 进一步收敛 `Core.Abstractions`:评估将 `IProjectionDispatcher/IProjectionCoordinator/IProjectionSubscriptionRegistry` 由对外契约下沉为实现细节。 -2. 在 `architecture_guards.sh` 增补硬约束: - - 禁止 `Stores.Abstractions` 回流 `Selection/*` 契约。 - - 禁止 `IGraphProjectionStore<` 回流。 -3. 文档全仓术语收敛:`Runtime.Abstractions` 引用补齐(README/架构图/审计文档)。 +1. 删除优先,不保留兼容层与转发壳。 +2. 所有旧命名(`ProjectionStore*` 统一计划/能力模型)直接移除。 +3. 编译失败点逐项修复到新语义。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index d36bcb12f..910a0b83f 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -23,12 +23,6 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( providerName, - new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Document], - supportsAliases: false, - supportsSchemaValidation: false), provider => new ElasticsearchProjectionReadModelStore( optionsFactory(provider), indexScopeFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index a11526496..327716c4b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -25,6 +25,7 @@ public sealed class ElasticsearchProjectionReadModelStore private readonly string _listSortField; private readonly ElasticsearchMissingIndexBehavior _missingIndexBehavior; private readonly int _mutateMaxRetryCount; + private readonly string _providerName; private readonly ILogger> _logger; private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -68,15 +69,15 @@ public ElasticsearchProjectionReadModelStore( _autoCreateIndex = options.AutoCreateIndex; _missingIndexBehavior = options.MissingIndexBehavior; _mutateMaxRetryCount = Math.Clamp(options.MutateMaxRetryCount, 0, 20); + _providerName = string.IsNullOrWhiteSpace(providerName) + ? ProjectionProviderNames.Elasticsearch + : providerName.Trim(); _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortField = options.ListSortField?.Trim() ?? ""; _logger = logger ?? NullLogger>.Instance; - ProviderCapabilities = BuildCapabilities(providerName); } - public ProjectionProviderCapabilities ProviderCapabilities { get; } - public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) => UpsertCoreAsync(readModel, allowCreateIndex: true, ct); @@ -127,7 +128,7 @@ await UpsertCoreAsync( _logger.LogWarning( ex, "Projection read-model optimistic concurrency conflict. provider={Provider} readModelType={ReadModelType} key={Key} attempt={Attempt}/{MaxAttempts}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, keyValue, attempt + 1, @@ -282,7 +283,7 @@ private async Task UpsertCoreAsync( var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -318,7 +319,7 @@ private bool TryHandleMissingIndexForRead(string operation, string payload) _logger.LogWarning( "Projection read-model index is missing. provider={Provider} readModelType={ReadModelType} index={Index} operation={Operation} behavior={Behavior}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, _indexName, operation, @@ -348,7 +349,7 @@ private void LogWriteFailure( _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -543,14 +544,6 @@ private static string TruncatePayload(string payload) return payload[..maxLength] + "...(truncated)"; } - private static ProjectionProviderCapabilities BuildCapabilities(string providerName) => - new( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Document], - supportsAliases: false, - supportsSchemaValidation: false); - public void Dispose() { _httpClient.Dispose(); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index ffcc283b5..b170a5131 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -20,12 +20,6 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( providerName, - new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Document], - supportsGraph: false, - supportsGraphTraversal: false), provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -44,12 +38,6 @@ public static IServiceCollection AddInMemoryGraphStoreRegistration( services.AddSingleton>( new DelegateProjectionStoreRegistration( providerName, - new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph], - supportsGraph: true, - supportsGraphTraversal: true), _ => new InMemoryProjectionGraphStore(providerName))); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index ff4fd350d..8e12a4f4e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -13,16 +13,9 @@ public sealed class InMemoryProjectionGraphStore public InMemoryProjectionGraphStore( string providerName = ProjectionProviderNames.InMemory) { - ProviderCapabilities = new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph], - supportsGraph: true, - supportsGraphTraversal: true); + _ = providerName; } - public ProjectionProviderCapabilities ProviderCapabilities { get; } - public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(node); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index 6582bf42b..617a1db1a 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -14,6 +14,7 @@ public sealed class InMemoryProjectionReadModelStore private readonly Func _keyFormatter; private readonly Func? _listSortSelector; private readonly int _listTakeMax; + private readonly string _providerName; private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonOptions = new(); @@ -30,17 +31,12 @@ public InMemoryProjectionReadModelStore( _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortSelector = listSortSelector; _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; + _providerName = string.IsNullOrWhiteSpace(providerName) + ? ProjectionProviderNames.InMemory + : providerName.Trim(); _logger = logger ?? NullLogger>.Instance; - ProviderCapabilities = new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Document], - supportsGraph: false, - supportsGraphTraversal: false); } - public ProjectionProviderCapabilities ProviderCapabilities { get; } - public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(readModel); @@ -57,7 +53,7 @@ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, key, elapsedMs, @@ -70,7 +66,7 @@ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, key, elapsedMs, @@ -101,7 +97,7 @@ public Task MutateAsync(TKey key, Action mutate, CancellationToken c var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -114,7 +110,7 @@ public Task MutateAsync(TKey key, Action mutate, CancellationToken c _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, keyValue, elapsedMs, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 83de8cf34..6b762b6a8 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -23,14 +23,6 @@ public static IServiceCollection AddNeo4jDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( providerName, - new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph], - supportsAliases: false, - supportsSchemaValidation: true, - supportsGraph: true, - supportsGraphTraversal: true), provider => new Neo4jProjectionReadModelStore( optionsFactory(provider), scopeFactory(provider), @@ -54,14 +46,6 @@ public static IServiceCollection AddNeo4jGraphStoreRegistration( services.AddSingleton>( new DelegateProjectionStoreRegistration( providerName, - new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph], - supportsAliases: false, - supportsSchemaValidation: true, - supportsGraph: true, - supportsGraphTraversal: true), provider => new Neo4jProjectionGraphStore( optionsFactory(provider), scopeFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index f5b5e811a..45de9a5bb 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -17,6 +17,7 @@ public sealed class Neo4jProjectionGraphStore private readonly string _edgeType; private readonly bool _autoCreateConstraints; private readonly int _maxTraversalDepth; + private readonly string _providerName; private readonly ILogger _logger; private readonly SemaphoreSlim _schemaLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -40,6 +41,9 @@ public Neo4jProjectionGraphStore( _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); _autoCreateConstraints = options.AutoCreateConstraints; _maxTraversalDepth = Math.Clamp(options.MaxTraversalDepth, 1, 8); + _providerName = string.IsNullOrWhiteSpace(providerName) + ? ProjectionProviderNames.Neo4j + : providerName.Trim(); _logger = logger ?? NullLogger.Instance; var auth = string.IsNullOrWhiteSpace(options.Username) @@ -47,19 +51,8 @@ public Neo4jProjectionGraphStore( : AuthTokens.Basic(options.Username.Trim(), options.Password ?? ""); _driver = GraphDatabase.Driver(options.Uri, auth, config => config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); - - ProviderCapabilities = new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph], - supportsAliases: false, - supportsSchemaValidation: true, - supportsGraph: true, - supportsGraphTraversal: true); } - public ProjectionProviderCapabilities ProviderCapabilities { get; } - public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(node); @@ -486,7 +479,7 @@ private Dictionary DeserializeProperties(string payload) _logger.LogWarning( ex, "Failed to deserialize graph edge properties payload. provider={Provider} scope={Scope}", - ProviderCapabilities.ProviderName, + _providerName, _scope); return new Dictionary(StringComparer.Ordinal); } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs index 439b69c9c..7f02d8356 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs @@ -19,6 +19,7 @@ public sealed class Neo4jProjectionReadModelStore private readonly int _listTakeMax; private readonly string _label; private readonly bool _autoCreateConstraints; + private readonly string _providerName; private readonly ILogger> _logger; private readonly SemaphoreSlim _schemaLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -44,6 +45,9 @@ public Neo4jProjectionReadModelStore( _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; _label = NormalizeLabel(options.NodeLabel); _autoCreateConstraints = options.AutoCreateConstraints; + _providerName = string.IsNullOrWhiteSpace(providerName) + ? ProjectionProviderNames.Neo4j + : providerName.Trim(); _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _logger = logger ?? NullLogger>.Instance; @@ -53,19 +57,8 @@ public Neo4jProjectionReadModelStore( : AuthTokens.Basic(options.Username.Trim(), options.Password ?? ""); _driver = GraphDatabase.Driver(options.Uri, auth, config => config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); - - ProviderCapabilities = new ProjectionProviderCapabilities( - providerName, - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Document, ProjectionIndexKind.Graph], - supportsAliases: false, - supportsSchemaValidation: true, - supportsGraph: true, - supportsGraphTraversal: true); } - public ProjectionProviderCapabilities ProviderCapabilities { get; } - public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(readModel); @@ -93,7 +86,7 @@ public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = defau var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, key, elapsedMs, @@ -273,7 +266,7 @@ private void LogWriteFailure( _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - ProviderCapabilities.ProviderName, + _providerName, typeof(TReadModel).FullName, key, elapsedMs, diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 617dc1e95..58c36550f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -6,23 +6,18 @@ public sealed class DelegateProjectionStoreRegistration : IProjectionSto public DelegateProjectionStoreRegistration( string providerName, - ProjectionProviderCapabilities capabilities, Func factory) { if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); - ArgumentNullException.ThrowIfNull(capabilities); ArgumentNullException.ThrowIfNull(factory); ProviderName = providerName.Trim(); - Capabilities = capabilities; _factory = factory; } public string ProviderName { get; } - public ProjectionProviderCapabilities Capabilities { get; } - public TStore Create(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index 4ad98a895..99837a996 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -3,8 +3,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionStoreRegistration { string ProviderName { get; } - - ProjectionProviderCapabilities Capabilities { get; } } public interface IProjectionStoreRegistration : IProjectionStoreRegistration diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs new file mode 100644 index 000000000..241d97d2f --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionDocumentRuntimeOptions +{ + string ProviderName { get; } + + bool FailFastOnStartup { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs new file mode 100644 index 000000000..91f4b42f6 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionDocumentStartupValidator +{ + IProjectionStoreRegistration> ValidateProvider( + IServiceProvider serviceProvider, + ProjectionDocumentSelectionOptions selectionOptions) + where TReadModel : class; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs new file mode 100644 index 000000000..92eb4b762 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionDocumentRuntimeOptions : IProjectionDocumentRuntimeOptions +{ + public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; + + public bool FailFastOnStartup { get; set; } = true; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs similarity index 50% rename from src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs index 33f974691..0269884c4 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs @@ -1,8 +1,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionStoreSelectionOptions +public sealed class ProjectionDocumentSelectionOptions { public string RequestedProviderName { get; set; } = ""; - - public bool FailOnUnsupportedCapabilities { get; set; } = true; } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs new file mode 100644 index 000000000..a8f2662b3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphRuntimeOptions +{ + string ProviderName { get; } + + bool FailFastOnStartup { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs new file mode 100644 index 000000000..27eaec634 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionGraphStartupValidator +{ + IProjectionStoreRegistration ValidateProvider( + IServiceProvider serviceProvider, + ProjectionGraphSelectionOptions selectionOptions); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs index 7b8b3ea9e..51882ed82 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs @@ -4,6 +4,5 @@ public interface IProjectionGraphStoreFactory { IProjectionGraphStore Create( IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements); + ProjectionGraphSelectionOptions selectionOptions); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs index 3f92b8aad..2e4969e55 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs @@ -4,6 +4,5 @@ public interface IProjectionGraphStoreProviderSelector { IProjectionStoreRegistration Select( IReadOnlyList> registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements); + ProjectionGraphSelectionOptions selectionOptions); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs new file mode 100644 index 000000000..536470faf --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs @@ -0,0 +1,8 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionGraphRuntimeOptions : IProjectionGraphRuntimeOptions +{ + public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; + + public bool FailFastOnStartup { get; set; } = true; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs new file mode 100644 index 000000000..67e3c2a9a --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionGraphSelectionOptions +{ + public string RequestedProviderName { get; set; } = ""; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs index da2936f8e..1b9eff90d 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs @@ -4,7 +4,6 @@ public interface IProjectionDocumentStoreFactory { IDocumentProjectionStore Create( IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionDocumentSelectionOptions selectionOptions) where TReadModel : class; } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs index fec9eadf5..bfa343c78 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs @@ -4,7 +4,6 @@ public interface IProjectionDocumentStoreProviderSelector { IProjectionStoreRegistration> Select( IReadOnlyList>> registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionDocumentSelectionOptions selectionOptions) where TReadModel : class; } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs deleted file mode 100644 index c556bab59..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionProviderCapabilityValidator -{ - IReadOnlyList Validate( - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities); - - void EnsureSupported( - Type readModelType, - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs deleted file mode 100644 index fe5316146..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public static class ProjectionDocumentStoreSelector -{ - public static IProjectionStoreRegistration> Select( - IEnumerable>> registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements, - IProjectionProviderCapabilityValidator? capabilityValidator = null) - where TReadModel : class - { - return ProjectionStoreSelector.Select< - IProjectionStoreRegistration>>( - registrations, - selectionOptions, - requirements, - typeof(TReadModel), - noRegistrationsReason: "No provider registrations were found.", - multipleRegistrationsReason: "Multiple providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested provider is not registered.", - capabilityValidator); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs deleted file mode 100644 index edc2ef556..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionIndexKind.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public enum ProjectionIndexKind -{ - None = 0, - Document = 1, - Graph = 2, -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs deleted file mode 100644 index b4daaf039..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionProviderCapabilities -{ - private static readonly IReadOnlySet EmptyIndexKinds = - new HashSet(); - - public ProjectionProviderCapabilities( - string providerName, - bool supportsIndexing, - IEnumerable? indexKinds = null, - bool supportsAliases = false, - bool supportsSchemaValidation = false, - bool supportsGraph = false, - bool supportsGraphTraversal = false) - { - if (string.IsNullOrWhiteSpace(providerName)) - throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); - - ProviderName = providerName.Trim(); - SupportsIndexing = supportsIndexing; - SupportsAliases = supportsAliases; - SupportsSchemaValidation = supportsSchemaValidation; - SupportsGraph = supportsGraph; - SupportsGraphTraversal = supportsGraphTraversal; - - var normalizedIndexKinds = (indexKinds ?? []) - .Where(x => x != ProjectionIndexKind.None) - .ToHashSet(); - - if (!supportsIndexing && normalizedIndexKinds.Count > 0) - throw new ArgumentException( - "Index kinds cannot be declared when supportsIndexing is false.", - nameof(indexKinds)); - - IndexKinds = normalizedIndexKinds.Count == 0 - ? EmptyIndexKinds - : normalizedIndexKinds; - } - - public string ProviderName { get; } - - public bool SupportsIndexing { get; } - - public IReadOnlySet IndexKinds { get; } - - public bool SupportsAliases { get; } - - public bool SupportsSchemaValidation { get; } - - public bool SupportsGraph { get; } - - public bool SupportsGraphTraversal { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs deleted file mode 100644 index e26a827e2..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionProviderCapabilityValidationException : InvalidOperationException -{ - public ProjectionProviderCapabilityValidationException( - Type readModelType, - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities, - IReadOnlyList violations) - : base(BuildMessage(readModelType, capabilities.ProviderName, violations)) - { - ReadModelType = readModelType; - Requirements = requirements; - Capabilities = capabilities; - Violations = violations; - } - - public Type ReadModelType { get; } - - public ProjectionStoreRequirements Requirements { get; } - - public ProjectionProviderCapabilities Capabilities { get; } - - public IReadOnlyList Violations { get; } - - private static string BuildMessage( - Type readModelType, - string providerName, - IReadOnlyList violations) => - $"ReadModel '{readModelType.FullName}' is not supported by provider '{providerName}'. " + - $"Violations: {string.Join("; ", violations)}"; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs deleted file mode 100644 index f8242c8a9..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs +++ /dev/null @@ -1,68 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public static class ProjectionProviderCapabilityValidator -{ - public static IReadOnlyList Validate( - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities) - { - ArgumentNullException.ThrowIfNull(requirements); - ArgumentNullException.ThrowIfNull(capabilities); - - var violations = new List(); - - if (requirements.RequiresIndexing && !capabilities.SupportsIndexing) - violations.Add("requires indexing, but provider does not support indexing"); - - if (requirements.RequiredIndexKinds.Count > 0) - { - if (!capabilities.SupportsIndexing) - { - violations.Add( - $"requires index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}], but provider indexing is disabled"); - } - else - { - var missingKinds = requirements.RequiredIndexKinds - .Where(kind => !capabilities.IndexKinds.Contains(kind)) - .ToList(); - if (missingKinds.Count > 0) - { - violations.Add( - $"required index kinds [{string.Join(", ", requirements.RequiredIndexKinds)}] are not fully supported by provider kinds [{string.Join(", ", capabilities.IndexKinds)}]; missing kinds [{string.Join(", ", missingKinds)}]"); - } - } - } - - if (requirements.RequiresAliases && !capabilities.SupportsAliases) - violations.Add("requires alias support, but provider does not support aliases"); - - if (requirements.RequiresSchemaValidation && !capabilities.SupportsSchemaValidation) - violations.Add("requires schema validation, but provider does not support schema validation"); - - if (requirements.RequiresGraph && !capabilities.SupportsGraph) - violations.Add("requires graph storage, but provider does not support graph storage"); - - if (requirements.RequiresGraphTraversal && !capabilities.SupportsGraphTraversal) - violations.Add("requires graph traversal, but provider does not support graph traversal"); - - return violations; - } - - public static void EnsureSupported( - Type readModelType, - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities) - { - ArgumentNullException.ThrowIfNull(readModelType); - var violations = Validate(requirements, capabilities); - if (violations.Count == 0) - return; - - throw new ProjectionProviderCapabilityValidationException( - readModelType, - requirements, - capabilities, - violations); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs deleted file mode 100644 index 6dfacb872..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreMode.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public enum ProjectionStoreMode -{ - Custom = 0, - Default = 1, - StateOnly = 2, -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs deleted file mode 100644 index 57922a2b9..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs +++ /dev/null @@ -1,42 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionStoreRequirements -{ - private static readonly IReadOnlySet EmptyIndexKinds = - new HashSet(); - - public ProjectionStoreRequirements( - bool requiresIndexing = false, - IEnumerable? requiredIndexKinds = null, - bool requiresAliases = false, - bool requiresSchemaValidation = false, - bool requiresGraph = false, - bool requiresGraphTraversal = false) - { - RequiresIndexing = requiresIndexing; - RequiresAliases = requiresAliases; - RequiresSchemaValidation = requiresSchemaValidation; - RequiresGraph = requiresGraph; - RequiresGraphTraversal = requiresGraphTraversal; - - var normalizedIndexKinds = (requiredIndexKinds ?? []) - .Where(x => x != ProjectionIndexKind.None) - .ToHashSet(); - - RequiredIndexKinds = normalizedIndexKinds.Count == 0 - ? EmptyIndexKinds - : normalizedIndexKinds; - } - - public bool RequiresIndexing { get; } - - public IReadOnlySet RequiredIndexKinds { get; } - - public bool RequiresAliases { get; } - - public bool RequiresSchemaValidation { get; } - - public bool RequiresGraph { get; } - - public bool RequiresGraphTraversal { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs deleted file mode 100644 index f31f06075..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionStoreRuntimeOptions : IProjectionStoreSelectionRuntimeOptions -{ - public ProjectionStoreMode Mode { get; set; } = ProjectionStoreMode.Custom; - - public string DocumentProvider { get; set; } = ProjectionProviderNames.InMemory; - - public string GraphProvider { get; set; } = ProjectionProviderNames.InMemory; - - public bool FailOnUnsupportedCapabilities { get; set; } = true; - - string IProjectionStoreSelectionRuntimeOptions.DocumentProvider => DocumentProvider; - - string IProjectionStoreSelectionRuntimeOptions.GraphProvider => GraphProvider; - - bool IProjectionStoreSelectionRuntimeOptions.FailOnUnsupportedCapabilities => FailOnUnsupportedCapabilities; - - ProjectionStoreMode IProjectionStoreSelectionRuntimeOptions.StoreMode => Mode; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs deleted file mode 100644 index 11f5a16b8..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionStoreSelectionPlanner -{ - ProjectionStoreSelectionPlan Build( - IProjectionStoreSelectionRuntimeOptions options, - Type readModelType, - ProjectionStoreRequirements graphRequirements); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs deleted file mode 100644 index 7edfb023b..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionStoreSelectionRuntimeOptions -{ - string DocumentProvider { get; } - - string GraphProvider { get; } - - bool FailOnUnsupportedCapabilities { get; } - - ProjectionStoreMode StoreMode { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs deleted file mode 100644 index 0f49b9178..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionStoreStartupValidator -{ - IProjectionStoreRegistration> ValidateDocumentProvider( - IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) - where TReadModel : class; - - IProjectionStoreRegistration ValidateGraphProvider( - IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs deleted file mode 100644 index 5fd89fe9b..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public readonly record struct ProjectionStoreSelectionPlan( - ProjectionStoreRequirements DocumentRequirements, - ProjectionStoreSelectionOptions DocumentSelectionOptions, - ProjectionStoreRequirements GraphRequirements, - ProjectionStoreSelectionOptions GraphSelectionOptions); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs deleted file mode 100644 index 5647efbb9..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs +++ /dev/null @@ -1,87 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public static class ProjectionStoreSelector -{ - public static TRegistration Select( - IEnumerable registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements, - Type logicalModelType, - string noRegistrationsReason, - string multipleRegistrationsReason, - string providerNotRegisteredReason, - IProjectionProviderCapabilityValidator? capabilityValidator = null) - where TRegistration : IProjectionStoreRegistration - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); - ArgumentNullException.ThrowIfNull(logicalModelType); - - var candidates = registrations.ToList(); - var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; - if (candidates.Count == 0) - { - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - [], - noRegistrationsReason); - } - - var selected = ResolveRegistration( - candidates, - requestedProviderName, - logicalModelType, - multipleRegistrationsReason, - providerNotRegisteredReason); - var violations = capabilityValidator == null - ? ProjectionProviderCapabilityValidator.Validate(requirements, selected.Capabilities) - : capabilityValidator.Validate(requirements, selected.Capabilities); - if (violations.Count > 0 && selectionOptions.FailOnUnsupportedCapabilities) - { - throw new ProjectionProviderCapabilityValidationException( - logicalModelType, - requirements, - selected.Capabilities, - violations); - } - - return selected; - } - - private static TRegistration ResolveRegistration( - IReadOnlyList registrations, - string requestedProviderName, - Type logicalModelType, - string multipleRegistrationsReason, - string providerNotRegisteredReason) - where TRegistration : IProjectionStoreRegistration - { - if (requestedProviderName.Length == 0) - { - if (registrations.Count == 1) - return registrations[0]; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - multipleRegistrationsReason); - } - - var matched = registrations - .FirstOrDefault(x => string.Equals( - x.ProviderName, - requestedProviderName, - StringComparison.OrdinalIgnoreCase)); - if (matched != null) - return matched; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - providerNotRegisteredReason); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 99fc3fd56..7310ffd1e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -1,23 +1,26 @@ # Aevatar.CQRS.Projection.Runtime.Abstractions -`Aevatar.CQRS.Projection.Runtime.Abstractions` 承载 Projection Runtime 的策略与编排契约,不承载具体实现。 +`Aevatar.CQRS.Projection.Runtime.Abstractions` 承载 Projection Runtime 的编排契约,不承载任何具体 Provider 实现。 ## 目录结构 - `Abstractions/Core`:Provider 注册契约(`IProjectionStoreRegistration`) -- `Abstractions/ReadModels`:Document provider 选择、能力模型、runtime options、metadata resolver 契约 -- `Abstractions/Graphs`:Graph provider 选择与 factory 契约 -- `Abstractions/Selection`:统一选择计划、启动校验与 materialization 路由契约 +- `Abstractions/Documents`:Document runtime options、selection options、startup validator 契约 +- `Abstractions/ReadModels`:Document store registry/factory/selector、metadata resolver 契约 +- `Abstractions/Graphs`:Graph runtime options、selection options、startup validator、store registry/factory/selector 契约 +- `Abstractions/Selection`:Materialization 路由与 graph materializer 契约 ## 关键契约 -- Provider 注册与能力:`IProjectionStoreRegistration`、`ProjectionProviderCapabilities` -- 选择规划:`IProjectionStoreSelectionPlanner`、`ProjectionStoreSelectionPlan` -- 运行时选择参数:`ProjectionStoreSelectionOptions`、`ProjectionStoreRequirements`、`IProjectionStoreSelectionRuntimeOptions` +- Provider 注册:`IProjectionStoreRegistration` +- Document 运行时:`IProjectionDocumentRuntimeOptions`、`ProjectionDocumentSelectionOptions` +- Graph 运行时:`IProjectionGraphRuntimeOptions`、`ProjectionGraphSelectionOptions` - Store factory:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` +- Startup validation:`IProjectionDocumentStartupValidator`、`IProjectionGraphStartupValidator` - Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` ## 约束 -1. 仅定义运行时编排协议,不包含具体 provider/store 实现。 -2. 仅依赖抽象层(`Stores.Abstractions`),不依赖业务模块。 +1. 不包含能力协商模型(Capabilities/Requirements/CapabilityValidator)。 +2. Provider 选择语义为显式 providerName + fail-fast,不做自动降级。 +3. 仅依赖 `Aevatar.CQRS.Projection.Stores.Abstractions`。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 36f233c92..49b135e55 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,10 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -19,7 +17,8 @@ public static IServiceCollection AddProjectionReadModelRuntime(this IServiceColl services.TryAddSingleton(typeof(IProjectionGraphMaterializer<>), typeof(ProjectionGraphMaterializer<>)); services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index d282ace16..9a388df3b 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -1,15 +1,14 @@ # Aevatar.CQRS.Projection.Runtime -通用 ReadModel Provider Runtime 组装层。 +通用 ReadModel Runtime 组装层。 ## 职责 -- 收敛 Provider 注册查询(`IProjectionDocumentStoreProviderRegistry`)。 -- 执行 Provider 选择策略(`IProjectionDocumentStoreProviderSelector`)。 -- 按 `IDocumentReadModel/IGraphReadModel` 能力推导选择需求(`IProjectionStoreSelectionPlanner`)。 -- 统一创建 Store 并输出结构化创建日志(`IProjectionDocumentStoreFactory`)。 -- 提供 `IProjectionMaterializationRouter` 与 `ProjectionGraphMaterializer` 双写路由能力。 -- 所有上述契约统一来自 `Aevatar.CQRS.Projection.Runtime.Abstractions`。 +- Document/Graph Provider 注册查询:`IProjectionDocumentStoreProviderRegistry`、`IProjectionGraphStoreProviderRegistry` +- Document/Graph Provider 显式选择:`IProjectionDocumentStoreProviderSelector`、`IProjectionGraphStoreProviderSelector` +- Store 创建与创建日志:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` +- 启动期 fail-fast 校验:`IProjectionDocumentStartupValidator`、`IProjectionGraphStartupValidator` +- Materialization 路由:`IProjectionMaterializationRouter`、`ProjectionGraphMaterializer` ## DI 入口 @@ -17,5 +16,6 @@ ## 设计约束 -- 不承载业务 ReadModel 类型,不引用 Workflow/AI 等业务模块。 -- 仅依赖抽象与 DI,具体 Provider 由上层模块按需注册。 +1. 不承载业务 ReadModel 类型。 +2. 不实现能力协商,不依赖 Capabilities/Requirements 模型。 +3. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs new file mode 100644 index 000000000..d7e4998c4 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs @@ -0,0 +1,27 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionDocumentStartupValidator : IProjectionDocumentStartupValidator +{ + private readonly IProjectionDocumentStoreProviderRegistry _providerRegistry; + private readonly IProjectionDocumentStoreProviderSelector _providerSelector; + + public ProjectionDocumentStartupValidator( + IProjectionDocumentStoreProviderRegistry providerRegistry, + IProjectionDocumentStoreProviderSelector providerSelector) + { + _providerRegistry = providerRegistry; + _providerSelector = providerSelector; + } + + public IProjectionStoreRegistration> ValidateProvider( + IServiceProvider serviceProvider, + ProjectionDocumentSelectionOptions selectionOptions) + where TReadModel : class + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + + var registrations = _providerRegistry.GetRegistrations(serviceProvider); + return _providerSelector.Select(registrations, selectionOptions); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs index dc3908c08..e4e915876 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs @@ -22,16 +22,14 @@ public ProjectionDocumentStoreFactory( public IDocumentProjectionStore Create( IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionDocumentSelectionOptions selectionOptions) where TReadModel : class { ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); var registrations = _providerRegistry.GetRegistrations(serviceProvider); - var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + var selected = _providerSelector.Select(registrations, selectionOptions); var startedAt = DateTimeOffset.UtcNow; try diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs index d92733a1a..67c9bf816 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs @@ -6,83 +6,78 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionDocumentStoreProviderSelector : IProjectionDocumentStoreProviderSelector { - private readonly IProjectionProviderCapabilityValidator _capabilityValidator; private readonly ILogger _logger; public ProjectionDocumentStoreProviderSelector( - IProjectionProviderCapabilityValidator capabilityValidator, ILogger? logger = null) { - _capabilityValidator = capabilityValidator; _logger = logger ?? NullLogger.Instance; } public IProjectionStoreRegistration> Select( IReadOnlyList>> registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionDocumentSelectionOptions selectionOptions) where TReadModel : class { ArgumentNullException.ThrowIfNull(registrations); ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); + var selected = SelectRegistration( + registrations, + selectionOptions, + typeof(TReadModel), + "No document store provider registrations were found.", + "Multiple document store providers are registered but no explicit provider was requested.", + "Requested document store provider is not registered."); + _logger.LogInformation( + "Projection document provider selected. readModel={ReadModel} provider={Provider}", + typeof(TReadModel).FullName, + selected.ProviderName); + return selected; + } - try - { - var selected = ProjectionDocumentStoreSelector.Select( - registrations, - selectionOptions, - requirements, - _capabilityValidator); - _logger.LogInformation( - "Projection provider selected. readModel={ReadModel} provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", - typeof(TReadModel).FullName, - selected.ProviderName, - selectionOptions.FailOnUnsupportedCapabilities); - return selected; - } - catch (ProjectionProviderCapabilityValidationException ex) + private static IProjectionStoreRegistration> SelectRegistration( + IReadOnlyList>> registrations, + ProjectionDocumentSelectionOptions selectionOptions, + Type logicalModelType, + string noRegistrationsReason, + string multipleRegistrationsReason, + string providerNotRegisteredReason) + where TReadModel : class + { + var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; + if (registrations.Count == 0) { - _logger.LogError( - "Projection provider capability validation failed. readModel={ReadModel} provider={Provider} requiredCapabilities={RequiredCapabilities} actualCapabilities={ActualCapabilities} violations={Violations}", - typeof(TReadModel).FullName, - ex.Capabilities.ProviderName, - FormatRequirements(ex.Requirements), - FormatCapabilities(ex.Capabilities), - string.Join("; ", ex.Violations)); - throw; + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + [], + noRegistrationsReason); } - catch (ProjectionProviderSelectionException ex) + + if (requestedProviderName.Length == 0) { - var requestedProvider = ex.RequestedProviderName.Length == 0 ? "" : ex.RequestedProviderName; - var availableProviders = ex.AvailableProviders.Count == 0 ? "" : string.Join(", ", ex.AvailableProviders); - _logger.LogError( - "Projection provider selection failed. readModel={ReadModel} requestedProvider={RequestedProvider} availableProviders={AvailableProviders} reason={Reason}", - typeof(TReadModel).FullName, - requestedProvider, - availableProviders, - ex.Reason); - throw; + if (registrations.Count == 1) + return registrations[0]; + + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + multipleRegistrationsReason); } - } - private static string FormatRequirements(ProjectionStoreRequirements requirements) - { - return $"requiresIndexing={requirements.RequiresIndexing};" + - $"requiredIndexKinds=[{string.Join(",", requirements.RequiredIndexKinds)}];" + - $"requiresAliases={requirements.RequiresAliases};" + - $"requiresSchemaValidation={requirements.RequiresSchemaValidation};" + - $"requiresGraph={requirements.RequiresGraph};" + - $"requiresGraphTraversal={requirements.RequiresGraphTraversal}"; - } + var matched = registrations + .FirstOrDefault(x => string.Equals( + x.ProviderName, + requestedProviderName, + StringComparison.OrdinalIgnoreCase)); + if (matched != null) + return matched; - private static string FormatCapabilities(ProjectionProviderCapabilities capabilities) - { - return $"supportsIndexing={capabilities.SupportsIndexing};" + - $"indexKinds=[{string.Join(",", capabilities.IndexKinds)}];" + - $"supportsAliases={capabilities.SupportsAliases};" + - $"supportsSchemaValidation={capabilities.SupportsSchemaValidation};" + - $"supportsGraph={capabilities.SupportsGraph};" + - $"supportsGraphTraversal={capabilities.SupportsGraphTraversal}"; + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + providerNotRegisteredReason); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs new file mode 100644 index 000000000..81f525ffa --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs @@ -0,0 +1,26 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionGraphStartupValidator : IProjectionGraphStartupValidator +{ + private readonly IProjectionGraphStoreProviderRegistry _providerRegistry; + private readonly IProjectionGraphStoreProviderSelector _providerSelector; + + public ProjectionGraphStartupValidator( + IProjectionGraphStoreProviderRegistry providerRegistry, + IProjectionGraphStoreProviderSelector providerSelector) + { + _providerRegistry = providerRegistry; + _providerSelector = providerSelector; + } + + public IProjectionStoreRegistration ValidateProvider( + IServiceProvider serviceProvider, + ProjectionGraphSelectionOptions selectionOptions) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(selectionOptions); + + var registrations = _providerRegistry.GetRegistrations(serviceProvider); + return _providerSelector.Select(registrations, selectionOptions); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs index c5eba2fbc..a0ab8d2c5 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs @@ -21,15 +21,13 @@ public ProjectionGraphStoreFactory( public IProjectionGraphStore Create( IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionGraphSelectionOptions selectionOptions) { ArgumentNullException.ThrowIfNull(serviceProvider); ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); var registrations = _providerRegistry.GetRegistrations(serviceProvider); - var selected = _providerSelector.Select(registrations, selectionOptions, requirements); + var selected = _providerSelector.Select(registrations, selectionOptions); var startedAt = DateTimeOffset.UtcNow; try diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs index ea72060ca..2e13e697c 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs @@ -6,41 +6,78 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionGraphStoreProviderSelector : IProjectionGraphStoreProviderSelector { - private readonly IProjectionProviderCapabilityValidator _capabilityValidator; private readonly ILogger _logger; public ProjectionGraphStoreProviderSelector( - IProjectionProviderCapabilityValidator capabilityValidator, ILogger? logger = null) { - _capabilityValidator = capabilityValidator; _logger = logger ?? NullLogger.Instance; } public IProjectionStoreRegistration Select( IReadOnlyList> registrations, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) + ProjectionGraphSelectionOptions selectionOptions) { ArgumentNullException.ThrowIfNull(registrations); ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); - var selected = ProjectionStoreSelector.Select>( + var selected = SelectRegistration( registrations, selectionOptions, - requirements, typeof(ProjectionGraphNode), noRegistrationsReason: "No relation store provider registrations were found.", multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested relation store provider is not registered.", - _capabilityValidator); + providerNotRegisteredReason: "Requested relation store provider is not registered."); _logger.LogInformation( - "Projection relation provider selected. provider={Provider} failOnUnsupportedCapabilities={FailOnUnsupportedCapabilities}", - selected.ProviderName, - selectionOptions.FailOnUnsupportedCapabilities); + "Projection relation provider selected. provider={Provider}", + selected.ProviderName); return selected; } + + private static IProjectionStoreRegistration SelectRegistration( + IReadOnlyList> registrations, + ProjectionGraphSelectionOptions selectionOptions, + Type logicalModelType, + string noRegistrationsReason, + string multipleRegistrationsReason, + string providerNotRegisteredReason) + { + var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; + if (registrations.Count == 0) + { + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + [], + noRegistrationsReason); + } + + if (requestedProviderName.Length == 0) + { + if (registrations.Count == 1) + return registrations[0]; + + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + multipleRegistrationsReason); + } + + var matched = registrations + .FirstOrDefault(x => string.Equals( + x.ProviderName, + requestedProviderName, + StringComparison.OrdinalIgnoreCase)); + if (matched != null) + return matched; + + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedProviderName, + registrations.Select(x => x.ProviderName).ToList(), + providerNotRegisteredReason); + } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs deleted file mode 100644 index ff6810169..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionProviderCapabilityValidatorService.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionProviderCapabilityValidatorService : IProjectionProviderCapabilityValidator -{ - public IReadOnlyList Validate( - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities) => - ProjectionProviderCapabilityValidator.Validate(requirements, capabilities); - - public void EnsureSupported( - Type readModelType, - ProjectionStoreRequirements requirements, - ProjectionProviderCapabilities capabilities) => - ProjectionProviderCapabilityValidator.EnsureSupported(readModelType, requirements, capabilities); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs deleted file mode 100644 index 8e79e95fc..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs +++ /dev/null @@ -1,95 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionStoreSelectionPlanner : IProjectionStoreSelectionPlanner -{ - public ProjectionStoreSelectionPlan Build( - IProjectionStoreSelectionRuntimeOptions options, - Type readModelType, - ProjectionStoreRequirements graphRequirements) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(readModelType); - ArgumentNullException.ThrowIfNull(graphRequirements); - EnsureStoreModeSupported(options.StoreMode); - - var readModelRequirements = BuildDocumentRequirements(readModelType); - var readModelRequiresGraph = typeof(IGraphReadModel).IsAssignableFrom(readModelType); - var readModelProvider = NormalizeRequiredProviderName(options.DocumentProvider); - var readModelSelectionOptions = new ProjectionStoreSelectionOptions - { - RequestedProviderName = readModelProvider, - FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, - }; - - var mergedGraphRequirements = MergeGraphRequirements(graphRequirements, readModelRequiresGraph); - var graphSelectionOptions = new ProjectionStoreSelectionOptions - { - RequestedProviderName = NormalizeGraphProviderName( - options.GraphProvider, - readModelProvider), - FailOnUnsupportedCapabilities = options.FailOnUnsupportedCapabilities, - }; - - return new ProjectionStoreSelectionPlan( - readModelRequirements, - readModelSelectionOptions, - mergedGraphRequirements, - graphSelectionOptions); - } - - private static ProjectionStoreRequirements MergeGraphRequirements( - ProjectionStoreRequirements graphRequirements, - bool readModelRequiresGraph) - { - return new ProjectionStoreRequirements( - requiresIndexing: graphRequirements.RequiresIndexing, - requiredIndexKinds: graphRequirements.RequiredIndexKinds, - requiresAliases: graphRequirements.RequiresAliases, - requiresSchemaValidation: graphRequirements.RequiresSchemaValidation, - requiresGraph: graphRequirements.RequiresGraph || readModelRequiresGraph, - requiresGraphTraversal: graphRequirements.RequiresGraphTraversal || readModelRequiresGraph); - } - - private static ProjectionStoreRequirements BuildDocumentRequirements(Type readModelType) - { - var requiredIndexKinds = new List(); - - if (typeof(IDocumentReadModel).IsAssignableFrom(readModelType)) - requiredIndexKinds.Add(ProjectionIndexKind.Document); - - return new ProjectionStoreRequirements( - requiresIndexing: requiredIndexKinds.Count > 0, - requiredIndexKinds: requiredIndexKinds); - } - - private static string NormalizeRequiredProviderName(string providerName) - { - if (string.IsNullOrWhiteSpace(providerName)) - { - throw new InvalidOperationException( - "Projection read-model provider is required and cannot be empty."); - } - - return providerName.Trim(); - } - - private static string NormalizeGraphProviderName( - string graphProviderName, - string fallbackProviderName) - { - if (string.IsNullOrWhiteSpace(graphProviderName)) - return fallbackProviderName; - - return graphProviderName.Trim(); - } - - private static void EnsureStoreModeSupported(ProjectionStoreMode readModelMode) - { - if (readModelMode != ProjectionStoreMode.StateOnly) - return; - - throw new InvalidOperationException( - "Projection store selection does not support Projection:Document:Mode=StateOnly. " + - "Use Custom or Default."); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs deleted file mode 100644 index ec2c4bda7..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreStartupValidator.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionStoreStartupValidator : IProjectionStoreStartupValidator -{ - private readonly IProjectionDocumentStoreProviderRegistry _readModelProviderRegistry; - private readonly IProjectionDocumentStoreProviderSelector _readModelProviderSelector; - private readonly IProjectionGraphStoreProviderRegistry _graphProviderRegistry; - private readonly IProjectionGraphStoreProviderSelector _graphProviderSelector; - - public ProjectionStoreStartupValidator( - IProjectionDocumentStoreProviderRegistry readModelProviderRegistry, - IProjectionDocumentStoreProviderSelector readModelProviderSelector, - IProjectionGraphStoreProviderRegistry graphProviderRegistry, - IProjectionGraphStoreProviderSelector graphProviderSelector) - { - _readModelProviderRegistry = readModelProviderRegistry; - _readModelProviderSelector = readModelProviderSelector; - _graphProviderRegistry = graphProviderRegistry; - _graphProviderSelector = graphProviderSelector; - } - - public IProjectionStoreRegistration> ValidateDocumentProvider( - IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); - - var registrations = _readModelProviderRegistry.GetRegistrations(serviceProvider); - return _readModelProviderSelector.Select(registrations, selectionOptions, requirements); - } - - public IProjectionStoreRegistration ValidateGraphProvider( - IServiceProvider serviceProvider, - ProjectionStoreSelectionOptions selectionOptions, - ProjectionStoreRequirements requirements) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - ArgumentNullException.ThrowIfNull(requirements); - - var registrations = _graphProviderRegistry.GetRegistrations(serviceProvider); - return _graphProviderSelector.Select(registrations, selectionOptions, requirements); - } -} diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index a8137bac1..df20672fc 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -37,9 +37,12 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.Replace(ServiceDescriptor.Singleton(options)); services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(); - services.TryAddSingleton(sp => - sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + sp.GetRequiredService()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => + sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); @@ -128,12 +131,11 @@ private static void RegisterWorkflowDocumentStoreSelector(IServiceCollection ser services.Replace(ServiceDescriptor.Singleton>(sp => { var storeFactory = sp.GetRequiredService(); - var selectionPlan = BuildSelectionPlan(sp); + var selectionOptions = BuildDocumentSelectionOptions(sp); return storeFactory.Create( sp, - selectionPlan.DocumentSelectionOptions, - selectionPlan.DocumentRequirements); + selectionOptions); })); } @@ -142,12 +144,11 @@ private static void RegisterWorkflowGraphStoreSelector(IServiceCollection servic services.Replace(ServiceDescriptor.Singleton(sp => { var graphStoreFactory = sp.GetRequiredService(); - var selectionPlan = BuildSelectionPlan(sp); + var selectionOptions = BuildGraphSelectionOptions(sp); return graphStoreFactory.Create( sp, - selectionPlan.GraphSelectionOptions, - selectionPlan.GraphRequirements); + selectionOptions); })); } @@ -159,14 +160,22 @@ private static void RegisterWorkflowMaterializationRouter(IServiceCollection ser sp.GetRequiredService>()))); } - private static ProjectionStoreSelectionPlan BuildSelectionPlan(IServiceProvider serviceProvider) + private static ProjectionDocumentSelectionOptions BuildDocumentSelectionOptions(IServiceProvider serviceProvider) { - var selectionPlanner = serviceProvider.GetRequiredService(); - var runtimeOptions = serviceProvider.GetRequiredService(); - return selectionPlanner.Build( - runtimeOptions, - typeof(WorkflowExecutionReport), - new ProjectionStoreRequirements()); + var runtimeOptions = serviceProvider.GetRequiredService(); + return new ProjectionDocumentSelectionOptions + { + RequestedProviderName = runtimeOptions.ProviderName, + }; + } + + private static ProjectionGraphSelectionOptions BuildGraphSelectionOptions(IServiceProvider serviceProvider) + { + var runtimeOptions = serviceProvider.GetRequiredService(); + return new ProjectionGraphSelectionOptions + { + RequestedProviderName = runtimeOptions.ProviderName, + }; } private sealed class PassthroughEventDeduplicator : IEventDeduplicator diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 9420fffbe..270a5beef 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -11,24 +11,27 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS { private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; - private readonly IProjectionStoreSelectionPlanner _selectionPlanner; - private readonly IProjectionStoreSelectionRuntimeOptions _selectionRuntimeOptions; - private readonly IProjectionStoreStartupValidator _startupValidator; + private readonly IProjectionDocumentRuntimeOptions _documentRuntimeOptions; + private readonly IProjectionGraphRuntimeOptions _graphRuntimeOptions; + private readonly IProjectionDocumentStartupValidator _documentStartupValidator; + private readonly IProjectionGraphStartupValidator _graphStartupValidator; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, - IProjectionStoreSelectionPlanner selectionPlanner, - IProjectionStoreSelectionRuntimeOptions selectionRuntimeOptions, - IProjectionStoreStartupValidator startupValidator, + IProjectionDocumentRuntimeOptions documentRuntimeOptions, + IProjectionGraphRuntimeOptions graphRuntimeOptions, + IProjectionDocumentStartupValidator documentStartupValidator, + IProjectionGraphStartupValidator graphStartupValidator, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; - _selectionPlanner = selectionPlanner; - _selectionRuntimeOptions = selectionRuntimeOptions; - _startupValidator = startupValidator; + _documentRuntimeOptions = documentRuntimeOptions; + _graphRuntimeOptions = graphRuntimeOptions; + _documentStartupValidator = documentStartupValidator; + _graphStartupValidator = graphStartupValidator; _logger = logger ?? NullLogger.Instance; } @@ -38,29 +41,28 @@ public Task StartAsync(CancellationToken cancellationToken) if (!_options.Enabled) return Task.CompletedTask; - var selectionPlan = _selectionPlanner.Build( - _selectionRuntimeOptions, - typeof(WorkflowExecutionReport), - new ProjectionStoreRequirements()); - - if (_options.ValidateDocumentProviderOnStartup) + if (_options.ValidateDocumentProviderOnStartup && _documentRuntimeOptions.FailFastOnStartup) { - var selectedDocumentProvider = _startupValidator.ValidateDocumentProvider( + var selectedDocumentProvider = _documentStartupValidator.ValidateProvider( _serviceProvider, - selectionPlan.DocumentSelectionOptions, - selectionPlan.DocumentRequirements); + new ProjectionDocumentSelectionOptions + { + RequestedProviderName = _documentRuntimeOptions.ProviderName, + }); _logger.LogInformation( "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", typeof(WorkflowExecutionReport).FullName, selectedDocumentProvider.ProviderName); } - if (_options.ValidateGraphProviderOnStartup) + if (_options.ValidateGraphProviderOnStartup && _graphRuntimeOptions.FailFastOnStartup) { - var selectedGraphProvider = _startupValidator.ValidateGraphProvider( + var selectedGraphProvider = _graphStartupValidator.ValidateProvider( _serviceProvider, - selectionPlan.GraphSelectionOptions, - selectionPlan.GraphRequirements); + new ProjectionGraphSelectionOptions + { + RequestedProviderName = _graphRuntimeOptions.ProviderName, + }); _logger.LogInformation( "Workflow graph provider startup validation passed. graphType={GraphType} provider={Provider}", typeof(ProjectionGraphNode).FullName, diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index fb71a1151..1d225c1df 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -18,13 +18,13 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) - - Store 选择统一由 `IProjectionStoreSelectionPlanner` 执行(基于 `IDocumentReadModel/IGraphReadModel` 能力自动决策) + - Store 选择采用显式 providerName(Document/Graph 分离) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` -- Provider 能力校验:启动期由 `WorkflowReadModelStartupValidationHostedService` 预校验,运行时选择阶段继续按 `ProjectionProviderCapabilityValidator` 校验 -- ReadModel 选择规则统一:DI store 解析与 Startup validation 均复用 `IProjectionStoreSelectionPlanner + IProjectionStoreSelectionRuntimeOptions`,避免双处规则漂移 +- Provider 启动校验:由 `WorkflowReadModelStartupValidationHostedService` 执行 Document/Graph 两条独立 fail-fast 校验 +- ReadModel 选择规则:DI store 解析与 Startup validation 统一复用 Document/Graph runtime options(`IProjectionDocumentRuntimeOptions`、`IProjectionGraphRuntimeOptions`) 本项目依赖: diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 8407ba805..a7c7c39e0 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -28,17 +28,23 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( var providerSelection = ResolveProviderSelection(configuration); EnforceGraphProviderPolicy(configuration, providerSelection.GraphProvider); - var runtimeOptions = new ProjectionStoreRuntimeOptions + var documentRuntimeOptions = new ProjectionDocumentRuntimeOptions { - DocumentProvider = providerSelection.DocumentProvider, - GraphProvider = providerSelection.GraphProvider, - FailOnUnsupportedCapabilities = true, - Mode = ProjectionStoreMode.Custom, + ProviderName = providerSelection.DocumentProvider, + FailFastOnStartup = true, + }; + var graphRuntimeOptions = new ProjectionGraphRuntimeOptions + { + ProviderName = providerSelection.GraphProvider, + FailFastOnStartup = true, }; - services.Replace(ServiceDescriptor.Singleton(runtimeOptions)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(documentRuntimeOptions)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(graphRuntimeOptions)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); RegisterDocumentProvider(services, configuration, providerSelection.DocumentProvider); RegisterGraphProvider(services, configuration, providerSelection.GraphProvider); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs index 2c0a5de43..f7dcfd8f7 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs @@ -8,21 +8,6 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public sealed class ElasticsearchProjectionReadModelStoreBehaviorTests { - [Fact] - public void ProviderCapabilities_ShouldNotClaimAliasOrSchemaValidationSupport() - { - using var store = CreateStore( - new ElasticsearchProjectionReadModelStoreOptions - { - AutoCreateIndex = false, - }, - new ScriptedHttpMessageHandler()); - - var capabilities = store.ProviderCapabilities; - capabilities.SupportsAliases.Should().BeFalse(); - capabilities.SupportsSchemaValidation.Should().BeFalse(); - } - [Fact] public async Task GetAsync_WhenIndexMissingAndAutoCreateDisabled_ShouldThrowByDefault() { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index 4199a8f19..eddbf9322 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -6,65 +6,48 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { [Fact] - public void ProviderSelector_WhenFailFastDisabled_ShouldReturnRegistration() + public void DocumentProviderSelector_WhenRequestedProviderMatched_ShouldReturnRegistration() { - var selector = new ProjectionDocumentStoreProviderSelector( - new ProjectionProviderCapabilityValidatorService()); + var selector = new ProjectionDocumentStoreProviderSelector(); var registrations = new List>> { - CreateRegistration( - "InMemory", - supportsIndexing: false, - indexKinds: []), + CreateRegistration("InMemory"), + CreateRegistration("Elasticsearch"), }; var selected = selector.Select( registrations, - new ProjectionStoreSelectionOptions + new ProjectionDocumentSelectionOptions { - RequestedProviderName = "InMemory", - FailOnUnsupportedCapabilities = false, - }, - new ProjectionStoreRequirements( - requiresIndexing: true, - requiredIndexKinds: [ProjectionIndexKind.Document])); + RequestedProviderName = "inmemory", + }); selected.ProviderName.Should().Be("InMemory"); } [Fact] - public void ProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() + public void DocumentProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() { - var selector = new ProjectionDocumentStoreProviderSelector( - new ProjectionProviderCapabilityValidatorService()); + var selector = new ProjectionDocumentStoreProviderSelector(); var registrations = new List>> { - CreateRegistration("InMemory", supportsIndexing: false, indexKinds: []), - CreateRegistration("Elasticsearch", supportsIndexing: true, indexKinds: [ProjectionIndexKind.Document]), + CreateRegistration("InMemory"), + CreateRegistration("Elasticsearch"), }; Action act = () => selector.Select( registrations, - new ProjectionStoreSelectionOptions(), - new ProjectionStoreRequirements()); + new ProjectionDocumentSelectionOptions()); act.Should().Throw() .Where(ex => ex.ReadModelType == typeof(TestReadModel)); } private static IProjectionStoreRegistration> CreateRegistration( - string providerName, - bool supportsIndexing, - IReadOnlyList indexKinds) + string providerName) { - var capabilities = new ProjectionProviderCapabilities( - providerName, - supportsIndexing, - indexKinds); - return new DelegateProjectionStoreRegistration>( providerName, - capabilities, _ => new NoopStore()); } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 085eafe3c..0e3d5b7e9 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -1,150 +1,97 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; using FluentAssertions; namespace Aevatar.CQRS.Projection.Core.Tests; -public class ProjectionDocumentStoreSelectorTests +public class ProjectionReadModelStoreSelectorTests { [Fact] - public void Select_WhenSingleProviderRegistered_ShouldReturnSingleProvider() + public void DocumentSelector_WhenSingleProviderRegistered_ShouldReturnSingleProvider() { + var selector = new ProjectionDocumentStoreProviderSelector(); var registrations = new[] { - CreateRegistration("inmemory", supportsIndexing: false), + CreateDocumentRegistration("inmemory"), }; - var selected = ProjectionDocumentStoreSelector.Select( + var selected = selector.Select( registrations, - new ProjectionStoreSelectionOptions(), - new ProjectionStoreRequirements()); + new ProjectionDocumentSelectionOptions()); selected.ProviderName.Should().Be("inmemory"); } [Fact] - public void Select_WhenMultipleProvidersAndNoRequestedProvider_ShouldThrow() + public void DocumentSelector_WhenRequestedProviderMissing_ShouldThrow() { + var selector = new ProjectionDocumentStoreProviderSelector(); var registrations = new[] { - CreateRegistration("inmemory", supportsIndexing: false), - CreateRegistration("elasticsearch", supportsIndexing: true, indexKinds: [ProjectionIndexKind.Document]), + CreateDocumentRegistration("inmemory"), }; - Action act = () => ProjectionDocumentStoreSelector.Select( + Action act = () => selector.Select( registrations, - new ProjectionStoreSelectionOptions(), - new ProjectionStoreRequirements()); - - act.Should().Throw() - .Where(ex => ex.Reason.Contains("Multiple providers are registered", StringComparison.Ordinal)); - } - - [Fact] - public void Select_WhenRequestedProviderMissing_ShouldThrow() - { - var registrations = new[] - { - CreateRegistration("inmemory", supportsIndexing: false), - }; - - Action act = () => ProjectionDocumentStoreSelector.Select( - registrations, - new ProjectionStoreSelectionOptions + new ProjectionDocumentSelectionOptions { RequestedProviderName = "elasticsearch", - }, - new ProjectionStoreRequirements()); + }); act.Should().Throw() - .Where(ex => ex.Reason.Contains("Requested provider is not registered", StringComparison.Ordinal)); + .Where(ex => ex.Reason.Contains("Requested document store provider is not registered", StringComparison.Ordinal)); } [Fact] - public void Select_WhenCapabilitiesUnsupportedAndFailFastEnabled_ShouldThrow() + public void GraphSelector_WhenRequestedProviderMatched_ShouldReturnProvider() { + var selector = new ProjectionGraphStoreProviderSelector(); var registrations = new[] { - CreateRegistration("inmemory", supportsIndexing: false), + CreateGraphRegistration("inmemory"), + CreateGraphRegistration("neo4j"), }; - Action act = () => ProjectionDocumentStoreSelector.Select( + var selected = selector.Select( registrations, - new ProjectionStoreSelectionOptions + new ProjectionGraphSelectionOptions { - RequestedProviderName = "inmemory", - FailOnUnsupportedCapabilities = true, - }, - new ProjectionStoreRequirements( - requiresIndexing: true, - requiredIndexKinds: [ProjectionIndexKind.Document])); - - act.Should().Throw(); - } + RequestedProviderName = "Neo4J", + }); - [Fact] - public void Select_WhenRequiredIndexKindsAreNotFullySupported_ShouldThrow() - { - var registrations = new[] - { - CreateRegistration( - "neo4j", - supportsIndexing: true, - indexKinds: [ProjectionIndexKind.Graph]), - }; - - Action act = () => ProjectionDocumentStoreSelector.Select( - registrations, - new ProjectionStoreSelectionOptions - { - RequestedProviderName = "neo4j", - FailOnUnsupportedCapabilities = true, - }, - new ProjectionStoreRequirements( - requiresIndexing: true, - requiredIndexKinds: [ProjectionIndexKind.Document, ProjectionIndexKind.Graph])); - - act.Should().Throw() - .WithMessage("*not fully supported*"); + selected.ProviderName.Should().Be("neo4j"); } [Fact] - public void Select_WhenCapabilitiesUnsupportedAndFailFastDisabled_ShouldReturnProvider() + public void GraphSelector_WhenNoProviderRegistered_ShouldThrow() { - var registrations = new[] - { - CreateRegistration("inmemory", supportsIndexing: false), - }; - - var selected = ProjectionDocumentStoreSelector.Select( - registrations, - new ProjectionStoreSelectionOptions + var selector = new ProjectionGraphStoreProviderSelector(); + Action act = () => selector.Select( + [], + new ProjectionGraphSelectionOptions { - RequestedProviderName = "inmemory", - FailOnUnsupportedCapabilities = false, - }, - new ProjectionStoreRequirements( - requiresIndexing: true, - requiredIndexKinds: [ProjectionIndexKind.Document])); + RequestedProviderName = ProjectionProviderNames.InMemory, + }); - selected.ProviderName.Should().Be("inmemory"); + act.Should().Throw() + .Where(ex => ex.Reason.Contains("No relation store provider registrations", StringComparison.Ordinal)); } - private static IProjectionStoreRegistration> CreateRegistration( - string providerName, - bool supportsIndexing, - IEnumerable? indexKinds = null) + private static IProjectionStoreRegistration> CreateDocumentRegistration( + string providerName) { - var capabilities = new ProjectionProviderCapabilities( + return new DelegateProjectionStoreRegistration>( providerName, - supportsIndexing, - indexKinds); + _ => new NoopDocumentStore()); + } - return new DelegateProjectionStoreRegistration>( + private static IProjectionStoreRegistration CreateGraphRegistration(string providerName) + { + return new DelegateProjectionStoreRegistration( providerName, - capabilities, - _ => new NoopStore()); + _ => new NoopGraphStore()); } - private sealed class NoopStore : IDocumentProjectionStore + private sealed class NoopDocumentStore : IDocumentProjectionStore { public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) => Task.CompletedTask; @@ -156,5 +103,20 @@ public Task> ListAsync(int take = 50, CancellationT Task.FromResult>([]); } + private sealed class NoopGraphStore : IProjectionGraphStore + { + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) => Task.CompletedTask; + + public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) => Task.CompletedTask; + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) => Task.CompletedTask; + + public Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) => + Task.FromResult(new ProjectionGraphSubgraph()); + } + private sealed class TestReadModel; } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs deleted file mode 100644 index f8c027860..000000000 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreSelectionPlannerTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Aevatar.CQRS.Projection.Runtime.Runtime; -using FluentAssertions; - -namespace Aevatar.CQRS.Projection.Core.Tests; - -public sealed class ProjectionStoreSelectionPlannerTests -{ - private readonly ProjectionStoreSelectionPlanner _planner = - new(); - - [Fact] - public void Build_WhenReadModelProviderIsEmpty_ShouldThrow() - { - var options = new FakeOptions - { - DocumentProvider = " ", - }; - - Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements()); - - act.Should().Throw() - .WithMessage("*read-model provider is required*"); - } - - [Fact] - public void Build_WhenRelationProviderMissing_ShouldFallbackToReadModelProvider() - { - var options = new FakeOptions - { - DocumentProvider = ProjectionProviderNames.Neo4j, - GraphProvider = " ", - }; - - var plan = _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements( - requiresGraph: true, - requiresGraphTraversal: true)); - - plan.DocumentSelectionOptions.RequestedProviderName.Should().Be(ProjectionProviderNames.Neo4j); - plan.GraphSelectionOptions.RequestedProviderName.Should().Be(ProjectionProviderNames.Neo4j); - } - - [Fact] - public void Build_ShouldMergeGraphRequirementsWithReadModelAliasAndSchemaRequirements() - { - var options = new FakeOptions - { - DocumentProvider = ProjectionProviderNames.Neo4j, - }; - var graphRequirements = new ProjectionStoreRequirements( - requiresGraph: true, - requiresGraphTraversal: true, - requiresAliases: false, - requiresSchemaValidation: false); - - var plan = _planner.Build(options, typeof(TestGraphReadModel), graphRequirements); - - plan.GraphRequirements.RequiresGraph.Should().BeTrue(); - plan.GraphRequirements.RequiresGraphTraversal.Should().BeTrue(); - plan.GraphRequirements.RequiresAliases.Should().BeFalse(); - plan.GraphRequirements.RequiresSchemaValidation.Should().BeFalse(); - } - - [Fact] - public void Build_WhenStateOnlyModeConfigured_ShouldThrow() - { - var options = new FakeOptions - { - DocumentProvider = ProjectionProviderNames.InMemory, - StoreMode = ProjectionStoreMode.StateOnly, - }; - - Action act = () => _planner.Build(options, typeof(TestReadModel), new ProjectionStoreRequirements()); - - act.Should().Throw() - .WithMessage("*does not support*StateOnly*"); - } - - private sealed class FakeOptions : IProjectionStoreSelectionRuntimeOptions - { - public string DocumentProvider { get; set; } = ProjectionProviderNames.InMemory; - - public string GraphProvider { get; set; } = ""; - - public bool FailOnUnsupportedCapabilities { get; set; } = true; - - public ProjectionStoreMode StoreMode { get; set; } = ProjectionStoreMode.Custom; - } - - private sealed class TestReadModel; - - private sealed class TestGraphReadModel : IGraphReadModel - { - public string Id => "test"; - - public string GraphScope => "test"; - - public IReadOnlyList GraphNodes => []; - - public IReadOnlyList GraphEdges => []; - } -} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index db1ede0f0..ef8d3e5be 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -27,7 +27,7 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNoProvidersRegistered_S Func act = () => StartHostedServicesAsync(provider); await act.Should().ThrowAsync() - .WithMessage("*No provider registrations were found*"); + .WithMessage("*No document store provider registrations were found*"); } [Fact] @@ -60,11 +60,10 @@ public void AddWorkflowExecutionProjectionCQRS_WhenDocumentElasticsearchAndGraph var services = new ServiceCollection(); RegisterInMemoryProviders(services); RegisterElasticsearchDocumentProvider(services); - ConfigureStoreSelectionOptions(services, options => - { - options.DocumentProvider = ProjectionProviderNames.Elasticsearch; - options.GraphProvider = ProjectionProviderNames.InMemory; - }); + ConfigureStoreSelectionOptions( + services, + documentProvider: ProjectionProviderNames.Elasticsearch, + graphProvider: ProjectionProviderNames.InMemory); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); @@ -80,11 +79,10 @@ public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldTh { var services = new ServiceCollection(); RegisterElasticsearchDocumentProvider(services); - ConfigureStoreSelectionOptions(services, options => - { - options.DocumentProvider = ProjectionProviderNames.Elasticsearch; - options.GraphProvider = ProjectionProviderNames.Elasticsearch; - }); + ConfigureStoreSelectionOptions( + services, + documentProvider: ProjectionProviderNames.Elasticsearch, + graphProvider: ProjectionProviderNames.Elasticsearch); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); @@ -122,13 +120,26 @@ private static void RegisterElasticsearchDocumentProvider(IServiceCollection ser private static void ConfigureStoreSelectionOptions( IServiceCollection services, - Action configure) + string documentProvider, + string graphProvider) { - var options = new ProjectionStoreRuntimeOptions(); - configure(options); - services.Replace(ServiceDescriptor.Singleton(options)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); + var documentOptions = new ProjectionDocumentRuntimeOptions + { + ProviderName = documentProvider, + FailFastOnStartup = true, + }; + var graphOptions = new ProjectionGraphRuntimeOptions + { + ProviderName = graphProvider, + FailFastOnStartup = true, + }; + + services.Replace(ServiceDescriptor.Singleton(documentOptions)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); + services.Replace(ServiceDescriptor.Singleton(graphOptions)); + services.Replace(ServiceDescriptor.Singleton(sp => + sp.GetRequiredService())); } private static async Task StartHostedServicesAsync(IServiceProvider provider) diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 508e1bf72..256d857f0 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -117,18 +117,23 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S var relationRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); - var selectionOptionsRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreSelectionRuntimeOptions)) + var documentRuntimeOptionRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionDocumentRuntimeOptions)) + .ToList(); + var graphRuntimeOptionRegistrations = services + .Where(x => x.ServiceType == typeof(IProjectionGraphRuntimeOptions)) .ToList(); providerRegistrations.Should().HaveCount(1); relationRegistrations.Should().HaveCount(1); - selectionOptionsRegistrations.Should().HaveCount(1); + documentRuntimeOptionRegistrations.Should().HaveCount(1); + graphRuntimeOptionRegistrations.Should().HaveCount(1); using var provider = services.BuildServiceProvider(); - var selectionOptions = provider.GetRequiredService(); - selectionOptions.DocumentProvider.Should().Be(ProjectionProviderNames.Elasticsearch); - selectionOptions.GraphProvider.Should().Be(ProjectionProviderNames.InMemory); + var documentOptions = provider.GetRequiredService(); + var graphOptions = provider.GetRequiredService(); + documentOptions.ProviderName.Should().Be(ProjectionProviderNames.Elasticsearch); + graphOptions.ProviderName.Should().Be(ProjectionProviderNames.InMemory); } [Fact] From b61134b791f0505ac8225c387bce78d35b589f1a Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 00:48:44 +0800 Subject: [PATCH 31/46] Refactor Projection ReadModel Architecture and Streamline Provider Selection - Updated the Projection ReadModel documentation to reflect the latest architectural changes, including the removal of the capabilities model and the introduction of explicit provider selection. - Refactored dependency injection configurations to consolidate provider selection logic within factories, enhancing clarity and reducing complexity. - Removed deprecated startup validators and provider registries, streamlining the initialization process for document and graph stores. - Introduced new runtime options classes to replace previous selection options, ensuring a more straightforward configuration approach. - Improved overall documentation to provide clearer guidance on the new abstractions and provider functionalities. --- ...readmodel-full-refactor-plan-2026-02-24.md | 344 ++++++------------ .../Core/IProjectionStoreRegistration.cs | 5 +- .../IProjectionDocumentRuntimeOptions.cs | 8 - .../IProjectionDocumentStartupValidator.cs | 9 - .../ProjectionDocumentRuntimeOptions.cs | 2 +- .../ProjectionDocumentSelectionOptions.cs | 6 - .../Graphs/IProjectionGraphRuntimeOptions.cs | 8 - .../IProjectionGraphStartupValidator.cs | 8 - .../Graphs/IProjectionGraphStoreFactory.cs | 2 +- .../IProjectionGraphStoreProviderRegistry.cs | 6 - .../IProjectionGraphStoreProviderSelector.cs | 8 - .../Graphs/ProjectionGraphRuntimeOptions.cs | 2 +- .../Graphs/ProjectionGraphSelectionOptions.cs | 6 - .../IProjectionDocumentStoreFactory.cs | 2 +- ...ProjectionDocumentStoreProviderRegistry.cs | 8 - ...ProjectionDocumentStoreProviderSelector.cs | 9 - .../README.md | 11 +- .../ServiceCollectionExtensions.cs | 6 - src/Aevatar.CQRS.Projection.Runtime/README.md | 9 +- .../ProjectionDocumentStartupValidator.cs | 27 -- .../Runtime/ProjectionDocumentStoreFactory.cs | 22 +- ...ProjectionDocumentStoreProviderRegistry.cs | 16 - ...ProjectionDocumentStoreProviderSelector.cs | 83 ----- .../ProjectionGraphStartupValidator.cs | 26 -- .../Runtime/ProjectionGraphStoreFactory.cs | 22 +- .../ProjectionGraphStoreProviderRegistry.cs | 14 - .../ProjectionGraphStoreProviderSelector.cs | 83 ----- .../ProjectionStoreRegistrationSelector.cs | 52 +++ .../ReadModels/IDocumentReadModel.cs | 5 +- .../README.md | 2 +- .../ServiceCollectionExtensions.cs | 32 +- ...ReadModelStartupValidationHostedService.cs | 38 +- .../Aevatar.Workflow.Projection/README.md | 2 +- .../ReadModels/WorkflowExecutionReadModel.cs | 2 - ...tionProviderServiceCollectionExtensions.cs | 4 - .../ProjectionReadModelRuntimeTests.cs | 71 ++-- .../ProjectionReadModelStoreSelectorTests.cs | 120 ++---- ...lowExecutionProjectionRegistrationTests.cs | 4 - .../WorkflowHostingExtensionsCoverageTests.cs | 8 +- 39 files changed, 301 insertions(+), 791 deletions(-) delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index fe2e50db7..312d05694 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -1,53 +1,31 @@ -# Projection ReadModel 全量重构实施文档(v9,无兼容,无能力模型) +# Projection ReadModel 全量重构实施文档(v10,无兼容,极简抽象) > 日期:2026-02-24 > 范围:`Aevatar.CQRS.Projection.Stores.Abstractions`、`Aevatar.CQRS.Projection.Core.Abstractions`、`Aevatar.CQRS.Projection.Runtime.Abstractions`、`Aevatar.CQRS.Projection.Runtime`、`Aevatar.Workflow.Projection` ## 1. 结论先行 -核心关系定义:`ReadModel` 与 `ProjectionTarget` 是标准 `1:N` 关系。 +核心关系定义:`ReadModel -> ProjectionTarget` 是标准 `1:N`。 -当前已实现 `N=2`: +当前固定实现 `N=2`: -1. `DocumentTarget`(Document Provider 链路) -2. `GraphTarget`(Graph Provider 链路) +1. `DocumentTarget`(`IDocumentProjectionStore<,>`) +2. `GraphTarget`(`IProjectionGraphStore`) -关键决策(本版): +本版最终架构(已落地): -1. 删除能力模型(`Capabilities/Requirements/CapabilityValidator`)整层。 -2. Provider 选择改为“显式配置 + 启动失败即终止(fail-fast)”,不做能力协商与自动降级。 -3. 索引与关系语义全部保留: - - 索引:Document 链路(ES 等) - - 关系:Graph 链路(Neo4j 等) -4. 写入编排保持单次路由、多目标分发(`1:N`)。 +1. 不保留能力模型(Capabilities/Requirements/Validator)。 +2. 不保留运行时薄封装层(Provider Registry / Provider Selector / Startup Validator)。 +3. Provider 选择逻辑内聚到两个 Factory: + - `ProjectionDocumentStoreFactory` + - `ProjectionGraphStoreFactory` +4. Startup fail-fast 直接通过 Factory 触发真实 store 创建校验,不再多一层 validator。 +5. Document metadata 继续保留,且由 `IProjectionDocumentMetadataProvider`(ReadModel 泛型)提供。 +6. `IDocumentReadModel` 收敛为 marker,删除无效 `DocumentScope` 字段。 -## 2. 当前实现诊断(As-Is) +## 2. 当前架构(v10) -### 2.1 已经对齐的部分 - -1. `Stores.Abstractions` 已基本收敛为纯存储契约(`IDocumentProjectionStore<,>` + `IProjectionGraphStore`)。 -2. `IGraphProjectionStore` 已删除,改用 `IProjectionGraphMaterializer` 作为图写入适配层。 -3. Workflow 查询已直接读取 `IProjectionGraphStore`,不再从 Materializer 读图。 - -### 2.2 需要继续删除的旧语义 - -1. 统一计划模型(Doc+Graph 绑定): - - `ProjectionStoreSelectionPlan` - - `IProjectionStoreSelectionPlanner` - - `ProjectionStoreSelectionPlanner` -2. 统一能力模型: - - `ProjectionProviderCapabilities` - - `ProjectionStoreRequirements` - - `IProjectionProviderCapabilityValidator` - - `ProjectionProviderCapabilityValidator` -3. 统一启动校验接口: - - `IProjectionStoreStartupValidator` - - `ProjectionStoreStartupValidator` -4. Workflow DI 仍使用统一 `BuildSelectionPlan`。 - -## 3. 目标架构(To-Be) - -### 3.1 主链路 + 一对多目标分发 +### 2.1 运行主链路 ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% @@ -56,243 +34,133 @@ flowchart LR RED --> RM["ReadModel(T)"] RM --> ROUTER["IProjectionMaterializationRouter"] - ROUTER --> TARGETS["Projection Targets (1..N)"] - - TARGETS --> DOC_ROUTE["Document Target\n(If T : IDocumentReadModel)"] - TARGETS --> GRA_ROUTE["Graph Target\n(If T : IGraphReadModel)"] - TARGETS --> EXT_ROUTE["Future Target\n(Extensible)"] + ROUTER --> DOC_GATE["if T : IDocumentReadModel"] + ROUTER --> GRA_GATE["if T : IGraphReadModel"] - DOC_ROUTE --> DOC_FACTORY["IProjectionDocumentStoreFactory"] - DOC_FACTORY --> DOC_STORE["IDocumentProjectionStore"] - - GRA_ROUTE --> GRA_MAT["IProjectionGraphMaterializer"] + DOC_GATE --> DOC_STORE["IDocumentProjectionStore"] + GRA_GATE --> GRA_MAT["IProjectionGraphMaterializer"] GRA_MAT --> GRA_STORE["IProjectionGraphStore"] ``` -当前落地状态:`Projection Targets` 已实现 `Document + Graph` 两类目标。 - -### 3.2 Provider 选择(无能力协商) +### 2.2 Provider 选择与启动校验(极简) ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart TB - DOC_CFG["Projection:Document:Provider"] --> DOC_SEL["DocumentProviderSelector"] - DOC_REG["DocumentProviderRegistry"] --> DOC_SEL - DOC_SEL --> DOC_FACTORY["DocumentStoreFactory"] + CFG_DOC["ProjectionDocumentRuntimeOptions.ProviderName"] --> DOC_FACTORY["ProjectionDocumentStoreFactory"] + CFG_GRA["ProjectionGraphRuntimeOptions.ProviderName"] --> GRA_FACTORY["ProjectionGraphStoreFactory"] - GRA_CFG["Projection:Graph:Provider"] --> GRA_SEL["GraphProviderSelector"] - GRA_REG["GraphProviderRegistry"] --> GRA_SEL - GRA_SEL --> GRA_FACTORY["GraphStoreFactory"] -``` - -选择规则: + REG_DOC["IProjectionStoreRegistration>[]"] --> DOC_FACTORY + REG_GRA["IProjectionStoreRegistration[]"] --> GRA_FACTORY -1. 配置必须明确指定 provider(或使用明确默认值)。 -2. 选择器仅做 provider name 匹配与唯一性判断。 -3. 启动时仅做注册存在性 + 基础连接健康检查;失败立即报错退出。 + DOC_FACTORY --> DOC_CREATED["Create Document Store"] + GRA_FACTORY --> GRA_CREATED["Create Graph Store"] -### 3.3 查询组合门面(降低开发负担) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - API["Query API"] --> FACADE["ProjectionQueryFacade"] - FACADE --> GQ["GraphQueryReader"] - FACADE --> DQ["DocumentQueryReader"] - GQ --> NEO["Graph Provider (e.g. Neo4j)"] - DQ --> ES["Document Provider (e.g. ES)"] - FACADE --> DTO["Enriched Graph DTO"] + STARTUP["WorkflowReadModelStartupValidationHostedService"] --> DOC_FACTORY + STARTUP --> GRA_FACTORY ``` -说明:业务层只调用 Facade,不再手工双查(先 Neo4j 再 ES)。 - -## 4. 契约重构(无兼容,直接替换) +选择规则(统一): -### 4.1 保留的核心语义 +1. 没有注册 provider -> 失败。 +2. 有多个注册但未指定 providerName -> 失败。 +3. 指定 providerName 但未命中 -> 失败。 +4. 命中后创建失败 -> 失败。 -1. `IDocumentProjectionStore<,>`:文档快照与索引写入。 -2. `IProjectionGraphStore`:图节点/边关系写入与遍历查询。 -3. `IProjectionMaterializationRouter<,>`:单次写入流程的多目标分发。 -4. `IProjectionGraphMaterializer`:从 ReadModel 派生图节点/边并写入 Graph。 +## 3. 冗余删除清单(第二轮彻底去层) -### 4.2 删除的抽象层 +### 3.1 Runtime.Abstractions 删除 -删除整层能力协商模型: - -1. `ProjectionProviderCapabilities` -2. `ProjectionStoreRequirements` -3. `IProjectionProviderCapabilityValidator` -4. `ProjectionProviderCapabilityValidator` -5. `ProjectionProviderCapabilityValidationException` - -删除统一计划模型: - -1. `ProjectionStoreSelectionPlan` -2. `IProjectionStoreSelectionPlanner` -3. `ProjectionStoreSelectionPlanner` -4. `IProjectionStoreSelectionRuntimeOptions` -5. `ProjectionStoreRuntimeOptions` - -### 4.3 新的运行时配置模型 - -拆为两条独立配置契约: - -1. `IProjectionDocumentRuntimeOptions` -2. `IProjectionGraphRuntimeOptions` - -建议最小字段: - -1. `ProviderName` -2. `FailFastOnStartup` +1. `Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs` +2. `Abstractions/Documents/IProjectionDocumentStartupValidator.cs` +3. `Abstractions/Documents/ProjectionDocumentSelectionOptions.cs` +4. `Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs` +5. `Abstractions/Graphs/IProjectionGraphStartupValidator.cs` +6. `Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs` +7. `Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs` +8. `Abstractions/Graphs/ProjectionGraphSelectionOptions.cs` +9. `Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs` +10. `Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs` -不再包含:`Requirements`、`Capabilities`、`FailOnUnsupportedCapabilities` 等协商语义。 +### 3.2 Runtime 删除 -### 4.4 启动校验模型 +1. `Runtime/ProjectionDocumentStoreProviderRegistry.cs` +2. `Runtime/ProjectionDocumentStoreProviderSelector.cs` +3. `Runtime/ProjectionDocumentStartupValidator.cs` +4. `Runtime/ProjectionGraphStoreProviderRegistry.cs` +5. `Runtime/ProjectionGraphStoreProviderSelector.cs` +6. `Runtime/ProjectionGraphStartupValidator.cs` -统一启动校验器拆分为: +### 3.3 Stores.Abstractions 收敛 -1. `IProjectionDocumentStartupValidator` -2. `IProjectionGraphStartupValidator` +1. `IDocumentReadModel` 从带字段接口改为 marker。 +2. 删除 `WorkflowExecutionReport.DocumentScope` 冗余实现。 -校验范围: +## 4. 保留并强化的核心抽象 -1. 目标 provider 是否注册。 -2. 目标 provider 是否可创建。 -3. 基础连接健康检查(如 ES ping、Neo4j session)。 +1. `IProjectionStoreRegistration`:Provider 注册单一契约。 +2. `IProjectionDocumentStoreFactory`:Document provider 选择 + 实例创建。 +3. `IProjectionGraphStoreFactory`:Graph provider 选择 + 实例创建。 +4. `ProjectionProviderSelectionException`:统一 fail-fast 错误模型。 +5. `ProjectionDocumentRuntimeOptions` / `ProjectionGraphRuntimeOptions`:最小配置模型。 -不再校验:抽象能力矩阵。 +## 5. 与项目实际结构的落地映射 -## 5. ReadModel 声明模型(保留索引/关系) +### 5.1 Runtime 层 -目标:开发者只定义 `State + ReadModel`,由 ReadModel 声明决定投影目标。 +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs` + - 新增统一选择算法。 +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs` + - 直接从 `IServiceProvider.GetServices>()` 拉注册并选择。 +- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs` + - 同上。 +- `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` + - 仅注册 Factory / Materializer / Router / MetadataResolver。 -建议契约(目标态): +### 5.2 Workflow 组装层 -```csharp -public interface IProjectionReadModel {} +- `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` + - 直接注入并使用 `ProjectionDocumentRuntimeOptions` / `ProjectionGraphRuntimeOptions`。 + - Store 解析直接调用 factory + providerName。 +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` + - 启动校验改为直接调用 factory `Create(...)`。 +- `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` + - 只注册 concrete options,不再注册 interface 转发。 -public interface IDocumentReadModel : IProjectionReadModel - where TDocumentMetadataProvider : IProjectionDocumentMetadataProvider, new() -{ -} +### 5.3 测试层 -public interface IGraphReadModel : IProjectionReadModel - where TGraphDescriptorProvider : IProjectionGraphDescriptorProvider, new() -{ -} -``` +- `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs` + - 改为测试 DocumentFactory 选择行为。 +- `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs` + - 改为测试 GraphFactory 选择行为。 +- `test/Aevatar.Workflow.Host.Api.Tests/*` + - 运行时 options 断言切换为 concrete 类型。 -语义结果: +## 6. 开发者体验结果 -1. `T : IDocumentReadModel<...>` -> 进入 DocumentTarget,保留索引能力。 -2. `T : IGraphReadModel<...>` -> 进入 GraphTarget,保留关系能力。 -3. 同时实现两者 -> 同一次流程扇出到两个目标(Doc + Graph)。 -4. 该模型本质是 `1:N`,不是二选一。 +开发者当前只需要关心三件事: -## 6. 与项目现状对齐的实施计划 +1. 定义 ReadModel(实现 `IDocumentReadModel` / `IGraphReadModel` 或同时实现)。 +2. 注册对应 Provider(Document 与 Graph 各自独立注册)。 +3. 配置 `ProjectionDocumentRuntimeOptions.ProviderName` 与 `ProjectionGraphRuntimeOptions.ProviderName`。 -### Phase 1:删除统一计划与能力模型(Runtime.Abstractions) +系统会在: -删除文件(按现有路径): +1. 运行时写入阶段自动执行 `1:N` 分发。 +2. 启动阶段通过真实 store 创建做 fail-fast。 -1. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelectionPlan.cs` -2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionPlanner.cs` -3. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreSelectionRuntimeOptions.cs` -4. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionStoreStartupValidator.cs` -5. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionStoreSelector.cs` -6. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRuntimeOptions.cs` -7. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreRequirements.cs` -8. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionStoreSelectionOptions.cs` -9. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilities.cs` -10. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionProviderCapabilityValidator.cs` -11. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidator.cs` -12. `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderCapabilityValidationException.cs` +## 7. 验收标准(v10) -新增文件(示例命名): +全部满足即视为“彻底重构完成”: -1. `Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs` -2. `Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs` -3. `Abstractions/Documents/ProjectionDocumentSelectionOptions.cs` -4. `Abstractions/Documents/IProjectionDocumentStartupValidator.cs` -5. `Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs` -6. `Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs` -7. `Abstractions/Graphs/ProjectionGraphSelectionOptions.cs` -8. `Abstractions/Graphs/IProjectionGraphStartupValidator.cs` - -### Phase 2:Runtime 实现替换(无能力协商) - -1. 删除 `Runtime/ProjectionStoreSelectionPlanner.cs`。 -2. 删除 `Runtime/ProjectionStoreStartupValidator.cs`,拆分实现: - - `Runtime/ProjectionDocumentStartupValidator.cs` - - `Runtime/ProjectionGraphStartupValidator.cs` -3. 删除 `Runtime/ProjectionProviderCapabilityValidatorService.cs`。 -4. `ProjectionDocumentStoreFactory` 与 `ProjectionGraphStoreFactory` 改为仅接收对应 `SelectionOptions`。 -5. `ProjectionDocumentStoreProviderSelector` 与 `ProjectionGraphStoreProviderSelector` 去掉 `requirements/capabilityValidator` 参数。 - -### Phase 3:Workflow 依赖替换 - -改造文件: - -1. `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` - - 删除 `BuildSelectionPlan`。 - - 文档与图分别读取各自 runtime options。 -2. `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` - - 注入拆分后的 Document/Graph StartupValidator。 -3. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` - - 分别注册 `ProjectionDocumentRuntimeOptions` 与 `ProjectionGraphRuntimeOptions`。 - -### Phase 4:ReadModel 泛型声明落地(索引与关系显式化) - -1. `Stores.Abstractions` 将 `IDocumentReadModel`、`IGraphReadModel` 升级为泛型声明。 -2. Document metadata 由 ReadModel 泛型 provider 提供(索引名、mapping、alias)。 -3. Graph descriptor 由 ReadModel 泛型 provider 提供(节点与边关系定义)。 - -### Phase 5:查询门面落地 - -1. 在 `Workflow.Projection` 增加 `ProjectionQueryFacade`。 -2. 提供单入口:输入 rootId,返回文档快照 + 图子图(可选深度)。 -3. API 仅依赖 Facade,不直接双查存储。 - -### Phase 6:测试与门禁 - -1. 删除或重写依赖 `ProjectionStoreSelectionPlan`、`ProjectionStoreRequirements`、`ProjectionProviderCapabilities` 的测试。 -2. 新增测试: - - Document provider selection(name match/unique/fail-fast) - - Graph provider selection(name match/unique/fail-fast) - - Router `1:N` 扇出写入 - - Query facade 组合查询 -3. `tools/ci/architecture_guards.sh` 增加规则: - - 禁止 `ProjectionStoreSelectionPlan` 回流。 - - 禁止 `ProjectionStoreRequirements` 回流。 - - 禁止 `ProjectionProviderCapabilities` 回流。 - - 禁止 `CapabilityValidator` 回流。 - -## 7. 验收标准 - -全部满足即重构完成: - -1. `Runtime.Abstractions` 与 `Runtime` 中不再存在能力协商模型(Capabilities/Requirements/Validator)。 -2. `MaterializationRouter` 明确采用 `ReadModel -> Targets(1:N)` 扇出模型,当前 `N=2`(Document、Graph)。 -3. ReadModel 同时声明文档与图能力时,单流程双写可用。 -4. Document 索引语义保留并可验证(metadata 生效)。 -5. Graph 关系语义保留并可验证(节点/边写入与遍历查询可用)。 -6. Workflow 查询提供单门面,业务方无需手工双查。 - -## 8. 验证命令 - -```bash -dotnet restore aevatar.slnx --nologo -dotnet build aevatar.slnx --nologo -dotnet test aevatar.slnx --nologo -bash tools/ci/architecture_guards.sh -bash tools/ci/projection_route_mapping_guard.sh -bash tools/ci/solution_split_guards.sh -bash tools/ci/solution_split_test_guards.sh -bash tools/ci/test_stability_guards.sh -``` +1. Runtime 不存在 Provider Registry/Selector/StartupValidator 三类薄层。 +2. Runtime.Abstractions 不存在对应接口与 SelectionOptions 接口模型。 +3. Store 选择只通过 factory + providerName 执行。 +4. Document metadata 与 Graph relation 语义都保留并可运行。 +5. `ReadModel -> Targets` 明确为 `1:N`,当前 `N=2`。 +6. `dotnet build` 与相关测试通过。 -## 9. 执行策略 +## 8. 后续可选继续收敛(不影响当前完成态) -1. 删除优先,不保留兼容层与转发壳。 -2. 所有旧命名(`ProjectionStore*` 统一计划/能力模型)直接移除。 -3. 编译失败点逐项修复到新语义。 +1. 若继续极简,可把 `IProjectionDocumentStoreFactory` / `IProjectionGraphStoreFactory` 也收敛为 concrete 注入。 +2. 若继续减少类型数量,可评估 `GraphNodeDescriptor/GraphEdgeDescriptor` 与 `ProjectionGraphNode/ProjectionGraphEdge` 的边界合并。 diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index 99837a996..d43210ba0 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -1,11 +1,8 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public interface IProjectionStoreRegistration +public interface IProjectionStoreRegistration { string ProviderName { get; } -} -public interface IProjectionStoreRegistration : IProjectionStoreRegistration -{ TStore Create(IServiceProvider serviceProvider); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs deleted file mode 100644 index 241d97d2f..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionDocumentRuntimeOptions -{ - string ProviderName { get; } - - bool FailFastOnStartup { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs deleted file mode 100644 index 91f4b42f6..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/IProjectionDocumentStartupValidator.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionDocumentStartupValidator -{ - IProjectionStoreRegistration> ValidateProvider( - IServiceProvider serviceProvider, - ProjectionDocumentSelectionOptions selectionOptions) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs index 92eb4b762..798f093be 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionDocumentRuntimeOptions : IProjectionDocumentRuntimeOptions +public sealed class ProjectionDocumentRuntimeOptions { public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs deleted file mode 100644 index 0269884c4..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentSelectionOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionDocumentSelectionOptions -{ - public string RequestedProviderName { get; set; } = ""; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs deleted file mode 100644 index a8f2662b3..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphRuntimeOptions -{ - string ProviderName { get; } - - bool FailFastOnStartup { get; } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs deleted file mode 100644 index 27eaec634..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStartupValidator.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphStartupValidator -{ - IProjectionStoreRegistration ValidateProvider( - IServiceProvider serviceProvider, - ProjectionGraphSelectionOptions selectionOptions); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs index 51882ed82..3bf71bf3d 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs @@ -4,5 +4,5 @@ public interface IProjectionGraphStoreFactory { IProjectionGraphStore Create( IServiceProvider serviceProvider, - ProjectionGraphSelectionOptions selectionOptions); + string? requestedProviderName = null); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs deleted file mode 100644 index 41f845942..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphStoreProviderRegistry -{ - IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs deleted file mode 100644 index 2e4969e55..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphStoreProviderSelector -{ - IProjectionStoreRegistration Select( - IReadOnlyList> registrations, - ProjectionGraphSelectionOptions selectionOptions); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs index 536470faf..f2be02012 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public sealed class ProjectionGraphRuntimeOptions : IProjectionGraphRuntimeOptions +public sealed class ProjectionGraphRuntimeOptions { public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs deleted file mode 100644 index 67e3c2a9a..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphSelectionOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionGraphSelectionOptions -{ - public string RequestedProviderName { get; set; } = ""; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs index 1b9eff90d..72c4d0b4e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs @@ -4,6 +4,6 @@ public interface IProjectionDocumentStoreFactory { IDocumentProjectionStore Create( IServiceProvider serviceProvider, - ProjectionDocumentSelectionOptions selectionOptions) + string? requestedProviderName = null) where TReadModel : class; } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs deleted file mode 100644 index a5ebc861a..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionDocumentStoreProviderRegistry -{ - IReadOnlyList>> GetRegistrations( - IServiceProvider serviceProvider) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs deleted file mode 100644 index bfa343c78..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionDocumentStoreProviderSelector -{ - IProjectionStoreRegistration> Select( - IReadOnlyList>> registrations, - ProjectionDocumentSelectionOptions selectionOptions) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 7310ffd1e..68f38c3d9 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -5,18 +5,17 @@ ## 目录结构 - `Abstractions/Core`:Provider 注册契约(`IProjectionStoreRegistration`) -- `Abstractions/Documents`:Document runtime options、selection options、startup validator 契约 -- `Abstractions/ReadModels`:Document store registry/factory/selector、metadata resolver 契约 -- `Abstractions/Graphs`:Graph runtime options、selection options、startup validator、store registry/factory/selector 契约 +- `Abstractions/Documents`:Document runtime options +- `Abstractions/ReadModels`:Document store factory、metadata resolver、provider names +- `Abstractions/Graphs`:Graph runtime options、graph store factory 契约 - `Abstractions/Selection`:Materialization 路由与 graph materializer 契约 ## 关键契约 - Provider 注册:`IProjectionStoreRegistration` -- Document 运行时:`IProjectionDocumentRuntimeOptions`、`ProjectionDocumentSelectionOptions` -- Graph 运行时:`IProjectionGraphRuntimeOptions`、`ProjectionGraphSelectionOptions` +- Document 运行时:`ProjectionDocumentRuntimeOptions` +- Graph 运行时:`ProjectionGraphRuntimeOptions` - Store factory:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` -- Startup validation:`IProjectionDocumentStartupValidator`、`IProjectionGraphStartupValidator` - Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` ## 约束 diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 49b135e55..d556dd65f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,17 +8,11 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(typeof(IProjectionGraphMaterializer<>), typeof(ProjectionGraphMaterializer<>)); services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); return services; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index 9a388df3b..29c57c22a 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -4,10 +4,8 @@ ## 职责 -- Document/Graph Provider 注册查询:`IProjectionDocumentStoreProviderRegistry`、`IProjectionGraphStoreProviderRegistry` -- Document/Graph Provider 显式选择:`IProjectionDocumentStoreProviderSelector`、`IProjectionGraphStoreProviderSelector` -- Store 创建与创建日志:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` -- 启动期 fail-fast 校验:`IProjectionDocumentStartupValidator`、`IProjectionGraphStartupValidator` +- Store 创建与 provider 选择(内聚在 factory):`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` +- Store 创建日志与 fail-fast 错误:`ProjectionProviderSelectionException` - Materialization 路由:`IProjectionMaterializationRouter`、`ProjectionGraphMaterializer` ## DI 入口 @@ -18,4 +16,5 @@ 1. 不承载业务 ReadModel 类型。 2. 不实现能力协商,不依赖 Capabilities/Requirements 模型。 -3. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 +3. Provider 选择规则:`providerName` 精确匹配;无注册、多注册无明确 provider、provider 不存在都立即失败。 +4. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs deleted file mode 100644 index d7e4998c4..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStartupValidator.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionDocumentStartupValidator : IProjectionDocumentStartupValidator -{ - private readonly IProjectionDocumentStoreProviderRegistry _providerRegistry; - private readonly IProjectionDocumentStoreProviderSelector _providerSelector; - - public ProjectionDocumentStartupValidator( - IProjectionDocumentStoreProviderRegistry providerRegistry, - IProjectionDocumentStoreProviderSelector providerSelector) - { - _providerRegistry = providerRegistry; - _providerSelector = providerSelector; - } - - public IProjectionStoreRegistration> ValidateProvider( - IServiceProvider serviceProvider, - ProjectionDocumentSelectionOptions selectionOptions) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - - var registrations = _providerRegistry.GetRegistrations(serviceProvider); - return _providerSelector.Select(registrations, selectionOptions); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs index e4e915876..00a68af04 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -6,30 +7,31 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionDocumentStoreFactory : IProjectionDocumentStoreFactory { - private readonly IProjectionDocumentStoreProviderRegistry _providerRegistry; - private readonly IProjectionDocumentStoreProviderSelector _providerSelector; private readonly ILogger _logger; public ProjectionDocumentStoreFactory( - IProjectionDocumentStoreProviderRegistry providerRegistry, - IProjectionDocumentStoreProviderSelector providerSelector, ILogger? logger = null) { - _providerRegistry = providerRegistry; - _providerSelector = providerSelector; _logger = logger ?? NullLogger.Instance; } public IDocumentProjectionStore Create( IServiceProvider serviceProvider, - ProjectionDocumentSelectionOptions selectionOptions) + string? requestedProviderName = null) where TReadModel : class { ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - var registrations = _providerRegistry.GetRegistrations(serviceProvider); - var selected = _providerSelector.Select(registrations, selectionOptions); + var registrations = serviceProvider + .GetServices>>() + .ToList(); + var selected = ProjectionStoreRegistrationSelector.Select( + registrations, + requestedProviderName, + typeof(TReadModel), + noRegistrationsReason: "No document store provider registrations were found.", + multipleRegistrationsReason: "Multiple document store providers are registered but no explicit provider was requested.", + providerNotRegisteredReason: "Requested document store provider is not registered."); var startedAt = DateTimeOffset.UtcNow; try diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs deleted file mode 100644 index 4f21ce069..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderRegistry.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionDocumentStoreProviderRegistry : IProjectionDocumentStoreProviderRegistry -{ - public IReadOnlyList>> GetRegistrations( - IServiceProvider serviceProvider) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(serviceProvider); - return serviceProvider - .GetServices>>() - .ToList(); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs deleted file mode 100644 index 67c9bf816..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreProviderSelector.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionDocumentStoreProviderSelector - : IProjectionDocumentStoreProviderSelector -{ - private readonly ILogger _logger; - - public ProjectionDocumentStoreProviderSelector( - ILogger? logger = null) - { - _logger = logger ?? NullLogger.Instance; - } - - public IProjectionStoreRegistration> Select( - IReadOnlyList>> registrations, - ProjectionDocumentSelectionOptions selectionOptions) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(selectionOptions); - var selected = SelectRegistration( - registrations, - selectionOptions, - typeof(TReadModel), - "No document store provider registrations were found.", - "Multiple document store providers are registered but no explicit provider was requested.", - "Requested document store provider is not registered."); - _logger.LogInformation( - "Projection document provider selected. readModel={ReadModel} provider={Provider}", - typeof(TReadModel).FullName, - selected.ProviderName); - return selected; - } - - private static IProjectionStoreRegistration> SelectRegistration( - IReadOnlyList>> registrations, - ProjectionDocumentSelectionOptions selectionOptions, - Type logicalModelType, - string noRegistrationsReason, - string multipleRegistrationsReason, - string providerNotRegisteredReason) - where TReadModel : class - { - var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; - if (registrations.Count == 0) - { - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - [], - noRegistrationsReason); - } - - if (requestedProviderName.Length == 0) - { - if (registrations.Count == 1) - return registrations[0]; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - multipleRegistrationsReason); - } - - var matched = registrations - .FirstOrDefault(x => string.Equals( - x.ProviderName, - requestedProviderName, - StringComparison.OrdinalIgnoreCase)); - if (matched != null) - return matched; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - providerNotRegisteredReason); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs deleted file mode 100644 index 81f525ffa..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStartupValidator.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionGraphStartupValidator : IProjectionGraphStartupValidator -{ - private readonly IProjectionGraphStoreProviderRegistry _providerRegistry; - private readonly IProjectionGraphStoreProviderSelector _providerSelector; - - public ProjectionGraphStartupValidator( - IProjectionGraphStoreProviderRegistry providerRegistry, - IProjectionGraphStoreProviderSelector providerSelector) - { - _providerRegistry = providerRegistry; - _providerSelector = providerSelector; - } - - public IProjectionStoreRegistration ValidateProvider( - IServiceProvider serviceProvider, - ProjectionGraphSelectionOptions selectionOptions) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - - var registrations = _providerRegistry.GetRegistrations(serviceProvider); - return _providerSelector.Select(registrations, selectionOptions); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs index a0ab8d2c5..855ac6481 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -5,29 +6,30 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionGraphStoreFactory : IProjectionGraphStoreFactory { - private readonly IProjectionGraphStoreProviderRegistry _providerRegistry; - private readonly IProjectionGraphStoreProviderSelector _providerSelector; private readonly ILogger _logger; public ProjectionGraphStoreFactory( - IProjectionGraphStoreProviderRegistry providerRegistry, - IProjectionGraphStoreProviderSelector providerSelector, ILogger? logger = null) { - _providerRegistry = providerRegistry; - _providerSelector = providerSelector; _logger = logger ?? NullLogger.Instance; } public IProjectionGraphStore Create( IServiceProvider serviceProvider, - ProjectionGraphSelectionOptions selectionOptions) + string? requestedProviderName = null) { ArgumentNullException.ThrowIfNull(serviceProvider); - ArgumentNullException.ThrowIfNull(selectionOptions); - var registrations = _providerRegistry.GetRegistrations(serviceProvider); - var selected = _providerSelector.Select(registrations, selectionOptions); + var registrations = serviceProvider + .GetServices>() + .ToList(); + var selected = ProjectionStoreRegistrationSelector.Select( + registrations, + requestedProviderName, + typeof(ProjectionGraphNode), + noRegistrationsReason: "No relation store provider registrations were found.", + multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", + providerNotRegisteredReason: "Requested relation store provider is not registered."); var startedAt = DateTimeOffset.UtcNow; try diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs deleted file mode 100644 index 848439bd5..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderRegistry.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionGraphStoreProviderRegistry : IProjectionGraphStoreProviderRegistry -{ - public IReadOnlyList> GetRegistrations(IServiceProvider serviceProvider) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - return serviceProvider - .GetServices>() - .ToList(); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs deleted file mode 100644 index 2e13e697c..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreProviderSelector.cs +++ /dev/null @@ -1,83 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionGraphStoreProviderSelector - : IProjectionGraphStoreProviderSelector -{ - private readonly ILogger _logger; - - public ProjectionGraphStoreProviderSelector( - ILogger? logger = null) - { - _logger = logger ?? NullLogger.Instance; - } - - public IProjectionStoreRegistration Select( - IReadOnlyList> registrations, - ProjectionGraphSelectionOptions selectionOptions) - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(selectionOptions); - - var selected = SelectRegistration( - registrations, - selectionOptions, - typeof(ProjectionGraphNode), - noRegistrationsReason: "No relation store provider registrations were found.", - multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested relation store provider is not registered."); - - _logger.LogInformation( - "Projection relation provider selected. provider={Provider}", - selected.ProviderName); - - return selected; - } - - private static IProjectionStoreRegistration SelectRegistration( - IReadOnlyList> registrations, - ProjectionGraphSelectionOptions selectionOptions, - Type logicalModelType, - string noRegistrationsReason, - string multipleRegistrationsReason, - string providerNotRegisteredReason) - { - var requestedProviderName = selectionOptions.RequestedProviderName?.Trim() ?? ""; - if (registrations.Count == 0) - { - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - [], - noRegistrationsReason); - } - - if (requestedProviderName.Length == 0) - { - if (registrations.Count == 1) - return registrations[0]; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - multipleRegistrationsReason); - } - - var matched = registrations - .FirstOrDefault(x => string.Equals( - x.ProviderName, - requestedProviderName, - StringComparison.OrdinalIgnoreCase)); - if (matched != null) - return matched; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedProviderName, - registrations.Select(x => x.ProviderName).ToList(), - providerNotRegisteredReason); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs new file mode 100644 index 000000000..e765e127e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs @@ -0,0 +1,52 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +internal static class ProjectionStoreRegistrationSelector +{ + public static IProjectionStoreRegistration Select( + IReadOnlyList> registrations, + string? requestedProviderName, + Type logicalModelType, + string noRegistrationsReason, + string multipleRegistrationsReason, + string providerNotRegisteredReason) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(logicalModelType); + + var requestedName = requestedProviderName?.Trim() ?? ""; + if (registrations.Count == 0) + { + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedName, + [], + noRegistrationsReason); + } + + if (requestedName.Length == 0) + { + if (registrations.Count == 1) + return registrations[0]; + + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedName, + registrations.Select(x => x.ProviderName).ToList(), + multipleRegistrationsReason); + } + + var matched = registrations + .FirstOrDefault(x => string.Equals( + x.ProviderName, + requestedName, + StringComparison.OrdinalIgnoreCase)); + if (matched != null) + return matched; + + throw new ProjectionProviderSelectionException( + logicalModelType, + requestedName, + registrations.Select(x => x.ProviderName).ToList(), + providerNotRegisteredReason); + } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs index 311541a99..093084136 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs @@ -1,6 +1,3 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public interface IDocumentReadModel : IProjectionReadModel -{ - string DocumentScope { get; } -} +public interface IDocumentReadModel : IProjectionReadModel; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md index 469d450de..c0e66fb06 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -11,7 +11,7 @@ - 读模型存储:`IDocumentProjectionStore<,>` - 图存储:`IProjectionGraphStore` -- ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`、`IGraphReadModel` +- ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`(marker)、`IGraphReadModel` - 文档索引元数据声明:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider` - 图结构描述:`GraphNodeDescriptor`、`GraphEdgeDescriptor` diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index df20672fc..d8610d2f9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -38,11 +38,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); - services.TryAddSingleton(sp => - sp.GetRequiredService()); services.TryAddSingleton(); - services.TryAddSingleton(sp => - sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); @@ -131,11 +127,10 @@ private static void RegisterWorkflowDocumentStoreSelector(IServiceCollection ser services.Replace(ServiceDescriptor.Singleton>(sp => { var storeFactory = sp.GetRequiredService(); - var selectionOptions = BuildDocumentSelectionOptions(sp); - + var runtimeOptions = sp.GetRequiredService(); return storeFactory.Create( sp, - selectionOptions); + runtimeOptions.ProviderName); })); } @@ -144,11 +139,10 @@ private static void RegisterWorkflowGraphStoreSelector(IServiceCollection servic services.Replace(ServiceDescriptor.Singleton(sp => { var graphStoreFactory = sp.GetRequiredService(); - var selectionOptions = BuildGraphSelectionOptions(sp); - + var runtimeOptions = sp.GetRequiredService(); return graphStoreFactory.Create( sp, - selectionOptions); + runtimeOptions.ProviderName); })); } @@ -160,24 +154,6 @@ private static void RegisterWorkflowMaterializationRouter(IServiceCollection ser sp.GetRequiredService>()))); } - private static ProjectionDocumentSelectionOptions BuildDocumentSelectionOptions(IServiceProvider serviceProvider) - { - var runtimeOptions = serviceProvider.GetRequiredService(); - return new ProjectionDocumentSelectionOptions - { - RequestedProviderName = runtimeOptions.ProviderName, - }; - } - - private static ProjectionGraphSelectionOptions BuildGraphSelectionOptions(IServiceProvider serviceProvider) - { - var runtimeOptions = serviceProvider.GetRequiredService(); - return new ProjectionGraphSelectionOptions - { - RequestedProviderName = runtimeOptions.ProviderName, - }; - } - private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 270a5beef..305f09d50 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -11,27 +11,27 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS { private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; - private readonly IProjectionDocumentRuntimeOptions _documentRuntimeOptions; - private readonly IProjectionGraphRuntimeOptions _graphRuntimeOptions; - private readonly IProjectionDocumentStartupValidator _documentStartupValidator; - private readonly IProjectionGraphStartupValidator _graphStartupValidator; + private readonly ProjectionDocumentRuntimeOptions _documentRuntimeOptions; + private readonly ProjectionGraphRuntimeOptions _graphRuntimeOptions; + private readonly IProjectionDocumentStoreFactory _documentStoreFactory; + private readonly IProjectionGraphStoreFactory _graphStoreFactory; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, - IProjectionDocumentRuntimeOptions documentRuntimeOptions, - IProjectionGraphRuntimeOptions graphRuntimeOptions, - IProjectionDocumentStartupValidator documentStartupValidator, - IProjectionGraphStartupValidator graphStartupValidator, + ProjectionDocumentRuntimeOptions documentRuntimeOptions, + ProjectionGraphRuntimeOptions graphRuntimeOptions, + IProjectionDocumentStoreFactory documentStoreFactory, + IProjectionGraphStoreFactory graphStoreFactory, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; _documentRuntimeOptions = documentRuntimeOptions; _graphRuntimeOptions = graphRuntimeOptions; - _documentStartupValidator = documentStartupValidator; - _graphStartupValidator = graphStartupValidator; + _documentStoreFactory = documentStoreFactory; + _graphStoreFactory = graphStoreFactory; _logger = logger ?? NullLogger.Instance; } @@ -43,30 +43,24 @@ public Task StartAsync(CancellationToken cancellationToken) if (_options.ValidateDocumentProviderOnStartup && _documentRuntimeOptions.FailFastOnStartup) { - var selectedDocumentProvider = _documentStartupValidator.ValidateProvider( + _documentStoreFactory.Create( _serviceProvider, - new ProjectionDocumentSelectionOptions - { - RequestedProviderName = _documentRuntimeOptions.ProviderName, - }); + _documentRuntimeOptions.ProviderName); _logger.LogInformation( "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", typeof(WorkflowExecutionReport).FullName, - selectedDocumentProvider.ProviderName); + _documentRuntimeOptions.ProviderName); } if (_options.ValidateGraphProviderOnStartup && _graphRuntimeOptions.FailFastOnStartup) { - var selectedGraphProvider = _graphStartupValidator.ValidateProvider( + _graphStoreFactory.Create( _serviceProvider, - new ProjectionGraphSelectionOptions - { - RequestedProviderName = _graphRuntimeOptions.ProviderName, - }); + _graphRuntimeOptions.ProviderName); _logger.LogInformation( "Workflow graph provider startup validation passed. graphType={GraphType} provider={Provider}", typeof(ProjectionGraphNode).FullName, - selectedGraphProvider.ProviderName); + _graphRuntimeOptions.ProviderName); } return Task.CompletedTask; } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 1d225c1df..32b11e1bf 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -24,7 +24,7 @@ - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` - Provider 启动校验:由 `WorkflowReadModelStartupValidationHostedService` 执行 Document/Graph 两条独立 fail-fast 校验 -- ReadModel 选择规则:DI store 解析与 Startup validation 统一复用 Document/Graph runtime options(`IProjectionDocumentRuntimeOptions`、`IProjectionGraphRuntimeOptions`) +- ReadModel 选择规则:DI store 解析与 Startup validation 统一复用 Document/Graph runtime options(`ProjectionDocumentRuntimeOptions`、`ProjectionGraphRuntimeOptions`) 本项目依赖: diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index 35c97bc30..d3c5fca21 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -38,8 +38,6 @@ public sealed class WorkflowExecutionReport { private const string UnknownToken = "unknown"; - public string DocumentScope => "workflow-execution-reports"; - public string GraphScope => WorkflowExecutionGraphConstants.Scope; public IReadOnlyList GraphNodes => BuildGraphNodes(); diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index a7c7c39e0..37ab284fd 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -40,11 +40,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( }; services.Replace(ServiceDescriptor.Singleton(documentRuntimeOptions)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); services.Replace(ServiceDescriptor.Singleton(graphRuntimeOptions)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); RegisterDocumentProvider(services, configuration, providerSelection.DocumentProvider); RegisterGraphProvider(services, configuration, providerSelection.GraphProvider); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index eddbf9322..4fb8477be 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -1,63 +1,68 @@ using Aevatar.CQRS.Projection.Runtime.Runtime; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { [Fact] - public void DocumentProviderSelector_WhenRequestedProviderMatched_ShouldReturnRegistration() + public void DocumentStoreFactory_WhenRequestedProviderMatched_ShouldCreateRequestedProviderStore() { - var selector = new ProjectionDocumentStoreProviderSelector(); - var registrations = new List>> - { - CreateRegistration("InMemory"), - CreateRegistration("Elasticsearch"), - }; + var services = new ServiceCollection(); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "InMemory", + _ => new NamedDocumentStore("InMemory"))); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "Elasticsearch", + _ => new NamedDocumentStore("Elasticsearch"))); - var selected = selector.Select( - registrations, - new ProjectionDocumentSelectionOptions - { - RequestedProviderName = "inmemory", - }); + using var serviceProvider = services.BuildServiceProvider(); + var factory = new ProjectionDocumentStoreFactory(); - selected.ProviderName.Should().Be("InMemory"); + var selected = factory.Create(serviceProvider, "inmemory"); + var typed = selected.Should().BeOfType().Subject; + typed.ProviderName.Should().Be("InMemory"); } [Fact] - public void DocumentProviderSelector_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() + public void DocumentStoreFactory_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() { - var selector = new ProjectionDocumentStoreProviderSelector(); - var registrations = new List>> - { - CreateRegistration("InMemory"), - CreateRegistration("Elasticsearch"), - }; + var services = new ServiceCollection(); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "InMemory", + _ => new NamedDocumentStore("InMemory"))); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "Elasticsearch", + _ => new NamedDocumentStore("Elasticsearch"))); + + using var serviceProvider = services.BuildServiceProvider(); + var factory = new ProjectionDocumentStoreFactory(); - Action act = () => selector.Select( - registrations, - new ProjectionDocumentSelectionOptions()); + Action act = () => factory.Create(serviceProvider); act.Should().Throw() .Where(ex => ex.ReadModelType == typeof(TestReadModel)); } - private static IProjectionStoreRegistration> CreateRegistration( - string providerName) - { - return new DelegateProjectionStoreRegistration>( - providerName, - _ => new NoopStore()); - } - public sealed class TestReadModel { public string Id { get; set; } = ""; } - private sealed class NoopStore : IDocumentProjectionStore + private sealed class NamedDocumentStore : IDocumentProjectionStore { + public NamedDocumentStore(string providerName) + { + ProviderName = providerName; + } + + public string ProviderName { get; } + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) { _ = readModel; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 0e3d5b7e9..91958c6ba 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -1,110 +1,72 @@ using Aevatar.CQRS.Projection.Runtime.Runtime; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelStoreSelectorTests { [Fact] - public void DocumentSelector_WhenSingleProviderRegistered_ShouldReturnSingleProvider() + public void GraphStoreFactory_WhenRequestedProviderMatched_ShouldCreateRequestedProviderStore() { - var selector = new ProjectionDocumentStoreProviderSelector(); - var registrations = new[] - { - CreateDocumentRegistration("inmemory"), - }; - - var selected = selector.Select( - registrations, - new ProjectionDocumentSelectionOptions()); - - selected.ProviderName.Should().Be("inmemory"); + var services = new ServiceCollection(); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "inmemory", + _ => new NamedGraphStore("inmemory"))); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "neo4j", + _ => new NamedGraphStore("neo4j"))); + + using var serviceProvider = services.BuildServiceProvider(); + var factory = new ProjectionGraphStoreFactory(); + + var selected = factory.Create(serviceProvider, "Neo4J"); + var typed = selected.Should().BeOfType().Subject; + typed.ProviderName.Should().Be("neo4j"); } [Fact] - public void DocumentSelector_WhenRequestedProviderMissing_ShouldThrow() + public void GraphStoreFactory_WhenRequestedProviderMissing_ShouldThrow() { - var selector = new ProjectionDocumentStoreProviderSelector(); - var registrations = new[] - { - CreateDocumentRegistration("inmemory"), - }; + var services = new ServiceCollection(); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "inmemory", + _ => new NamedGraphStore("inmemory"))); + + using var serviceProvider = services.BuildServiceProvider(); + var factory = new ProjectionGraphStoreFactory(); - Action act = () => selector.Select( - registrations, - new ProjectionDocumentSelectionOptions - { - RequestedProviderName = "elasticsearch", - }); + Action act = () => factory.Create(serviceProvider, "elasticsearch"); act.Should().Throw() - .Where(ex => ex.Reason.Contains("Requested document store provider is not registered", StringComparison.Ordinal)); + .Where(ex => ex.Reason.Contains("Requested relation store provider is not registered", StringComparison.Ordinal)); } [Fact] - public void GraphSelector_WhenRequestedProviderMatched_ShouldReturnProvider() + public void GraphStoreFactory_WhenNoProviderRegistered_ShouldThrow() { - var selector = new ProjectionGraphStoreProviderSelector(); - var registrations = new[] - { - CreateGraphRegistration("inmemory"), - CreateGraphRegistration("neo4j"), - }; - - var selected = selector.Select( - registrations, - new ProjectionGraphSelectionOptions - { - RequestedProviderName = "Neo4J", - }); - - selected.ProviderName.Should().Be("neo4j"); - } + var services = new ServiceCollection(); + using var serviceProvider = services.BuildServiceProvider(); + var factory = new ProjectionGraphStoreFactory(); - [Fact] - public void GraphSelector_WhenNoProviderRegistered_ShouldThrow() - { - var selector = new ProjectionGraphStoreProviderSelector(); - Action act = () => selector.Select( - [], - new ProjectionGraphSelectionOptions - { - RequestedProviderName = ProjectionProviderNames.InMemory, - }); + Action act = () => factory.Create(serviceProvider, ProjectionProviderNames.InMemory); act.Should().Throw() .Where(ex => ex.Reason.Contains("No relation store provider registrations", StringComparison.Ordinal)); } - private static IProjectionStoreRegistration> CreateDocumentRegistration( - string providerName) + private sealed class NamedGraphStore : IProjectionGraphStore { - return new DelegateProjectionStoreRegistration>( - providerName, - _ => new NoopDocumentStore()); - } - - private static IProjectionStoreRegistration CreateGraphRegistration(string providerName) - { - return new DelegateProjectionStoreRegistration( - providerName, - _ => new NoopGraphStore()); - } - - private sealed class NoopDocumentStore : IDocumentProjectionStore - { - public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) => Task.CompletedTask; - - public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) => Task.CompletedTask; - - public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(null); + public NamedGraphStore(string providerName) + { + ProviderName = providerName; + } - public Task> ListAsync(int take = 50, CancellationToken ct = default) => - Task.FromResult>([]); - } + public string ProviderName { get; } - private sealed class NoopGraphStore : IProjectionGraphStore - { public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) => Task.CompletedTask; public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) => Task.CompletedTask; @@ -117,6 +79,4 @@ public Task> GetNeighborsAsync(ProjectionGrap public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) => Task.FromResult(new ProjectionGraphSubgraph()); } - - private sealed class TestReadModel; } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index ef8d3e5be..6623f931c 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -135,11 +135,7 @@ private static void ConfigureStoreSelectionOptions( }; services.Replace(ServiceDescriptor.Singleton(documentOptions)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); services.Replace(ServiceDescriptor.Singleton(graphOptions)); - services.Replace(ServiceDescriptor.Singleton(sp => - sp.GetRequiredService())); } private static async Task StartHostedServicesAsync(IServiceProvider provider) diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 256d857f0..df33037ac 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -118,10 +118,10 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); var documentRuntimeOptionRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionDocumentRuntimeOptions)) + .Where(x => x.ServiceType == typeof(ProjectionDocumentRuntimeOptions)) .ToList(); var graphRuntimeOptionRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionGraphRuntimeOptions)) + .Where(x => x.ServiceType == typeof(ProjectionGraphRuntimeOptions)) .ToList(); providerRegistrations.Should().HaveCount(1); @@ -130,8 +130,8 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S graphRuntimeOptionRegistrations.Should().HaveCount(1); using var provider = services.BuildServiceProvider(); - var documentOptions = provider.GetRequiredService(); - var graphOptions = provider.GetRequiredService(); + var documentOptions = provider.GetRequiredService(); + var graphOptions = provider.GetRequiredService(); documentOptions.ProviderName.Should().Be(ProjectionProviderNames.Elasticsearch); graphOptions.ProviderName.Should().Be(ProjectionProviderNames.InMemory); } From 651a1b1616f8a4c3d0585801546727e8234840d2 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 00:59:55 +0800 Subject: [PATCH 32/46] Refactor Neo4j Provider and Update Projection ReadModel Documentation - Updated the Neo4j provider to focus solely on graph capabilities, removing document-related functionalities and associated configurations. - Deleted deprecated classes and methods related to document handling in the Neo4j provider, streamlining the codebase. - Enhanced the documentation for the Projection ReadModel to reflect the latest architectural changes, including the removal of the document provider from Neo4j and clarifications on provider selection. - Improved error handling in the provider selection logic to prevent misconfigurations involving Neo4j as a document provider. - Updated tests to ensure compliance with the new provider architecture and validate the changes made to the Neo4j integration. --- ...readmodel-full-refactor-plan-2026-02-24.md | 219 ++++++------ .../Neo4jProjectionReadModelStoreOptions.cs | 20 -- .../ServiceCollectionExtensions.cs | 27 -- .../README.md | 13 +- .../Stores/Neo4jProjectionReadModelStore.cs | 311 ------------------ .../Aevatar.Workflow.Projection/README.md | 3 +- ...tionProviderServiceCollectionExtensions.cs | 55 ++-- .../ProjectionProviderE2EIntegrationTests.cs | 48 --- .../WorkflowHostingExtensionsCoverageTests.cs | 18 + tools/ci/architecture_guards.sh | 1 - 10 files changed, 163 insertions(+), 552 deletions(-) delete mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 312d05694..ddc1abc39 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -1,166 +1,157 @@ -# Projection ReadModel 全量重构实施文档(v10,无兼容,极简抽象) +# Projection ReadModel 全量重构实施文档(v12,已完成,无兼容) > 日期:2026-02-24 -> 范围:`Aevatar.CQRS.Projection.Stores.Abstractions`、`Aevatar.CQRS.Projection.Core.Abstractions`、`Aevatar.CQRS.Projection.Runtime.Abstractions`、`Aevatar.CQRS.Projection.Runtime`、`Aevatar.Workflow.Projection` +> 范围:`Aevatar.CQRS.Projection.Stores.Abstractions`、`Aevatar.CQRS.Projection.Core.Abstractions`、`Aevatar.CQRS.Projection.Runtime.Abstractions`、`Aevatar.CQRS.Projection.Runtime`、`Aevatar.Workflow.Projection`、`Aevatar.Workflow.Extensions.Hosting`、`Aevatar.CQRS.Projection.Providers.*` -## 1. 结论先行 +## 1. 最终结论 -核心关系定义:`ReadModel -> ProjectionTarget` 是标准 `1:N`。 +Workflow Projection 已彻底收敛为单链路 `1:N` 扇出模型,并固定生产职责分工: -当前固定实现 `N=2`: +1. `Document Target = Elasticsearch`(索引、检索、快照/列表查询) +2. `Graph Target = Neo4j`(关系、邻居、子图遍历) -1. `DocumentTarget`(`IDocumentProjectionStore<,>`) -2. `GraphTarget`(`IProjectionGraphStore`) +同一 `ReadModel`(`WorkflowExecutionReport`)在一次投影中并行写入 `Document + Graph`,不是二选一。 -本版最终架构(已落地): +## 2. 最终架构图 -1. 不保留能力模型(Capabilities/Requirements/Validator)。 -2. 不保留运行时薄封装层(Provider Registry / Provider Selector / Startup Validator)。 -3. Provider 选择逻辑内聚到两个 Factory: - - `ProjectionDocumentStoreFactory` - - `ProjectionGraphStoreFactory` -4. Startup fail-fast 直接通过 Factory 触发真实 store 创建校验,不再多一层 validator。 -5. Document metadata 继续保留,且由 `IProjectionDocumentMetadataProvider`(ReadModel 泛型)提供。 -6. `IDocumentReadModel` 收敛为 marker,删除无效 `DocumentScope` 字段。 - -## 2. 当前架构(v10) - -### 2.1 运行主链路 +### 2.1 写入链路(单链路双写) ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - EVT["Event Stream"] --> RED["Reducer/Projector"] - RED --> RM["ReadModel(T)"] - RM --> ROUTER["IProjectionMaterializationRouter"] + A["Event Stream"] --> B["Reducer / Projector"] + B --> C["ReadModel(T)"] + C --> D["IProjectionMaterializationRouter"] + + D --> E["T : IDocumentReadModel ?"] + D --> F["T : IGraphReadModel ?"] - ROUTER --> DOC_GATE["if T : IDocumentReadModel"] - ROUTER --> GRA_GATE["if T : IGraphReadModel"] + E --> G["IDocumentProjectionStore"] + F --> H["IProjectionGraphMaterializer"] + H --> I["IProjectionGraphStore"] - DOC_GATE --> DOC_STORE["IDocumentProjectionStore"] - GRA_GATE --> GRA_MAT["IProjectionGraphMaterializer"] - GRA_MAT --> GRA_STORE["IProjectionGraphStore"] + G --> J["Elasticsearch"] + I --> K["Neo4j"] ``` -### 2.2 Provider 选择与启动校验(极简) +### 2.2 查询链路(索引与遍历并存) ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart TB - CFG_DOC["ProjectionDocumentRuntimeOptions.ProviderName"] --> DOC_FACTORY["ProjectionDocumentStoreFactory"] - CFG_GRA["ProjectionGraphRuntimeOptions.ProviderName"] --> GRA_FACTORY["ProjectionGraphStoreFactory"] + A["Query API / Application Service"] --> B["WorkflowExecutionProjectionQueryService"] - REG_DOC["IProjectionStoreRegistration>[]"] --> DOC_FACTORY - REG_GRA["IProjectionStoreRegistration[]"] --> GRA_FACTORY + B --> C["Document Query"] + B --> D["Graph Query"] - DOC_FACTORY --> DOC_CREATED["Create Document Store"] - GRA_FACTORY --> GRA_CREATED["Create Graph Store"] + C --> E["IDocumentProjectionStore"] + D --> F["IProjectionGraphStore"] - STARTUP["WorkflowReadModelStartupValidationHostedService"] --> DOC_FACTORY - STARTUP --> GRA_FACTORY + E --> G["Elasticsearch"] + F --> H["Neo4j"] ``` -选择规则(统一): +### 2.3 Provider 选择与启动校验(极简) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TB + A["ProjectionDocumentRuntimeOptions.ProviderName"] --> C["ProjectionDocumentStoreFactory"] + B["ProjectionGraphRuntimeOptions.ProviderName"] --> D["ProjectionGraphStoreFactory"] -1. 没有注册 provider -> 失败。 -2. 有多个注册但未指定 providerName -> 失败。 -3. 指定 providerName 但未命中 -> 失败。 -4. 命中后创建失败 -> 失败。 + E["IProjectionStoreRegistration>[]"] --> C + F["IProjectionStoreRegistration[]"] --> D -## 3. 冗余删除清单(第二轮彻底去层) + C --> G["Create Document Store or Fail"] + D --> H["Create Graph Store or Fail"] -### 3.1 Runtime.Abstractions 删除 + I["WorkflowReadModelStartupValidationHostedService"] --> C + I --> D +``` -1. `Abstractions/Documents/IProjectionDocumentRuntimeOptions.cs` -2. `Abstractions/Documents/IProjectionDocumentStartupValidator.cs` -3. `Abstractions/Documents/ProjectionDocumentSelectionOptions.cs` -4. `Abstractions/Graphs/IProjectionGraphRuntimeOptions.cs` -5. `Abstractions/Graphs/IProjectionGraphStartupValidator.cs` -6. `Abstractions/Graphs/IProjectionGraphStoreProviderRegistry.cs` -7. `Abstractions/Graphs/IProjectionGraphStoreProviderSelector.cs` -8. `Abstractions/Graphs/ProjectionGraphSelectionOptions.cs` -9. `Abstractions/ReadModels/IProjectionDocumentStoreProviderRegistry.cs` -10. `Abstractions/ReadModels/IProjectionDocumentStoreProviderSelector.cs` +## 3. 已完成的彻底重构项 -### 3.2 Runtime 删除 +### 3.1 Runtime 抽象与实现去层 -1. `Runtime/ProjectionDocumentStoreProviderRegistry.cs` -2. `Runtime/ProjectionDocumentStoreProviderSelector.cs` -3. `Runtime/ProjectionDocumentStartupValidator.cs` -4. `Runtime/ProjectionGraphStoreProviderRegistry.cs` -5. `Runtime/ProjectionGraphStoreProviderSelector.cs` -6. `Runtime/ProjectionGraphStartupValidator.cs` +1. 删除能力协商模型(Capabilities/Requirements/Validator)整层。 +2. 删除薄封装中间层(Provider Registry / Provider Selector / Startup Validator)。 +3. Provider 选择逻辑内聚到: + - `ProjectionDocumentStoreFactory` + - `ProjectionGraphStoreFactory` +4. 启动校验改为 HostedService 直接调用 Factory 进行真实创建 fail-fast。 -### 3.3 Stores.Abstractions 收敛 +### 3.2 ReadModel 抽象收敛 -1. `IDocumentReadModel` 从带字段接口改为 marker。 -2. 删除 `WorkflowExecutionReport.DocumentScope` 冗余实现。 +1. `IDocumentReadModel` 收敛为 marker。 +2. 删除 `WorkflowExecutionReport.DocumentScope` 冗余字段。 +3. 保留 `IProjectionDocumentMetadataProvider` 作为索引 metadata 来源。 -## 4. 保留并强化的核心抽象 +### 3.3 Neo4j Provider 职责收敛(仅 Graph) -1. `IProjectionStoreRegistration`:Provider 注册单一契约。 -2. `IProjectionDocumentStoreFactory`:Document provider 选择 + 实例创建。 -3. `IProjectionGraphStoreFactory`:Graph provider 选择 + 实例创建。 -4. `ProjectionProviderSelectionException`:统一 fail-fast 错误模型。 -5. `ProjectionDocumentRuntimeOptions` / `ProjectionGraphRuntimeOptions`:最小配置模型。 +已删除: -## 5. 与项目实际结构的落地映射 +1. `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs` +2. `src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs` -### 5.1 Runtime 层 +已修改: -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs` - - 新增统一选择算法。 -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs` - - 直接从 `IServiceProvider.GetServices>()` 拉注册并选择。 -- `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs` - - 同上。 -- `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` - - 仅注册 Factory / Materializer / Router / MetadataResolver。 +1. `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs` + - 删除 `AddNeo4jDocumentStoreRegistration(...)` + - 仅保留 `AddNeo4jGraphStoreRegistration(...)` +2. `src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md` + - 改为 Graph-only 文档。 -### 5.2 Workflow 组装层 +### 3.4 Workflow Hosting Provider 矩阵收敛 -- `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` - - 直接注入并使用 `ProjectionDocumentRuntimeOptions` / `ProjectionGraphRuntimeOptions`。 - - Store 解析直接调用 factory + providerName。 -- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` - - 启动校验改为直接调用 factory `Create(...)`。 -- `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` - - 只注册 concrete options,不再注册 interface 转发。 +已修改: -### 5.3 测试层 +1. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` + - `Projection:Document:Provider` 只允许 `InMemory | Elasticsearch` + - `Projection:Graph:Provider` 只允许 `InMemory | Neo4j` + - 删除 Document 分支中的 Neo4j 注册 + - 明确抛错:`Neo4j cannot be used as document provider` +2. `src/workflow/Aevatar.Workflow.Projection/README.md` + - 删除 `Projection:Document:Providers:Neo4j:*` 相关说明 -- `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs` - - 改为测试 DocumentFactory 选择行为。 -- `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs` - - 改为测试 GraphFactory 选择行为。 -- `test/Aevatar.Workflow.Host.Api.Tests/*` - - 运行时 options 断言切换为 concrete 类型。 +### 3.5 测试与门禁同步 -## 6. 开发者体验结果 +已修改: -开发者当前只需要关心三件事: +1. `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs` + - 删除 Neo4j Document Store E2E 场景 +2. `test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs` + - 新增断言:`Projection:Document:Provider=Neo4j` 必须抛错 +3. `tools/ci/architecture_guards.sh` + - 移除对 `Neo4jProjectionReadModelStore.cs` 必须存在的检查 -1. 定义 ReadModel(实现 `IDocumentReadModel` / `IGraphReadModel` 或同时实现)。 -2. 注册对应 Provider(Document 与 Graph 各自独立注册)。 -3. 配置 `ProjectionDocumentRuntimeOptions.ProviderName` 与 `ProjectionGraphRuntimeOptions.ProviderName`。 +## 4. 开发者使用模型(当前标准) -系统会在: +1. 定义 ReadModel:可同时实现 `IDocumentReadModel + IGraphReadModel`。 +2. 注册 Provider:Document 与 Graph 各自注册,互不混用职责。 +3. 配置 Provider: + - `Projection:Document:Provider=Elasticsearch`(生产) + - `Projection:Graph:Provider=Neo4j`(生产) -1. 运行时写入阶段自动执行 `1:N` 分发。 -2. 启动阶段通过真实 store 创建做 fail-fast。 +系统自动完成: -## 7. 验收标准(v10) +1. 单次流程双写(Document + Graph)。 +2. 启动期双链路 fail-fast。 +3. 查询期索引查询与图遍历并存。 -全部满足即视为“彻底重构完成”: +## 5. 验收结果(本次执行) -1. Runtime 不存在 Provider Registry/Selector/StartupValidator 三类薄层。 -2. Runtime.Abstractions 不存在对应接口与 SelectionOptions 接口模型。 -3. Store 选择只通过 factory + providerName 执行。 -4. Document metadata 与 Graph relation 语义都保留并可运行。 -5. `ReadModel -> Targets` 明确为 `1:N`,当前 `N=2`。 -6. `dotnet build` 与相关测试通过。 +1. `dotnet build aevatar.slnx --nologo`:通过 +2. `dotnet test aevatar.slnx --nologo`:通过 +3. `bash tools/ci/architecture_guards.sh`:通过 +4. `bash tools/ci/projection_route_mapping_guard.sh`:通过 +5. `bash tools/ci/solution_split_guards.sh`:通过 +6. `bash tools/ci/solution_split_test_guards.sh`:通过 +7. `bash tools/ci/test_stability_guards.sh`:通过 -## 8. 后续可选继续收敛(不影响当前完成态) +## 6. 最终验收标准(全部满足) -1. 若继续极简,可把 `IProjectionDocumentStoreFactory` / `IProjectionGraphStoreFactory` 也收敛为 concrete 注入。 -2. 若继续减少类型数量,可评估 `GraphNodeDescriptor/GraphEdgeDescriptor` 与 `ProjectionGraphNode/ProjectionGraphEdge` 的边界合并。 +1. Workflow 生产路径固定为 `ES(Document) + Neo4j(Graph)`。 +2. Workflow 不再支持 `Projection:Document:Provider=Neo4j`。 +3. Neo4j Provider 仅承载 Graph Store。 +4. Projection Router 双写语义保持不变。 +5. 编译、测试、架构门禁全部通过。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs deleted file mode 100644 index 7c1feb453..000000000 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; - -public sealed class Neo4jProjectionReadModelStoreOptions -{ - public string Uri { get; set; } = "bolt://localhost:7687"; - - public string Username { get; set; } = "neo4j"; - - public string Password { get; set; } = ""; - - public string Database { get; set; } = ""; - - public int RequestTimeoutMs { get; set; } = 5000; - - public int ListTakeMax { get; set; } = 200; - - public bool AutoCreateConstraints { get; set; } = true; - - public string NodeLabel { get; set; } = "ProjectionReadModel"; -} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 6b762b6a8..7df415610 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,33 +7,6 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddNeo4jDocumentStoreRegistration( - this IServiceCollection services, - Func optionsFactory, - Func scopeFactory, - Func keySelector, - Func? keyFormatter = null, - string providerName = ProjectionProviderNames.Neo4j) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(keySelector); - - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - providerName, - provider => new Neo4jProjectionReadModelStore( - optionsFactory(provider), - scopeFactory(provider), - keySelector, - keyFormatter, - providerName, - provider.GetService>>()))); - - return services; - } - public static IServiceCollection AddNeo4jGraphStoreRegistration( this IServiceCollection services, Func optionsFactory, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index ae4cc8f2b..32ba7b540 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -1,24 +1,21 @@ # Aevatar.CQRS.Projection.Providers.Neo4j -通用 Neo4j Provider(支持 Document/Graph 两类能力)。 +通用 Neo4j Provider(仅 Graph 能力)。 - 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成(Document)。 - 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 -- 能力声明:`Document/Graph` 索引、schema validation。 +- 能力声明:Graph schema validation。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 -- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 +- 写入路径输出结构化日志:`provider/scope/nodeId-or-edgeId/elapsedMs/result/errorType`。 ## DI 注册 使用扩展方法: -- `AddNeo4jDocumentStoreRegistration(...)` - `AddNeo4jGraphStoreRegistration(...)` 关键参数: -- `optionsFactory`:绑定 `Projection:Document:Providers:Neo4j:*` 或 `Projection:Graph:Providers:Neo4j:*` 配置。 -- `scopeFactory`:文档 scope 或 graph scope 提供器。 -- `keySelector/keyFormatter`:ReadModel 主键映射。 +- `optionsFactory`:绑定 `Projection:Graph:Providers:Neo4j:*` 配置。 +- `scopeFactory`:graph scope 提供器。 - `providerName`:默认 `Neo4j`(与 `ProjectionProviderNames.Neo4j` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs deleted file mode 100644 index 7f02d8356..000000000 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs +++ /dev/null @@ -1,311 +0,0 @@ -using System.Text.Json; -using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Neo4j.Driver; - -namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; - -public sealed class Neo4jProjectionReadModelStore - : IDocumentProjectionStore, - IAsyncDisposable - where TReadModel : class -{ - private readonly IDriver _driver; - private readonly Func _keySelector; - private readonly Func _keyFormatter; - private readonly string _scope; - private readonly string _database; - private readonly int _listTakeMax; - private readonly string _label; - private readonly bool _autoCreateConstraints; - private readonly string _providerName; - private readonly ILogger> _logger; - private readonly SemaphoreSlim _schemaLock = new(1, 1); - private readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - private bool _schemaInitialized; - - public Neo4jProjectionReadModelStore( - Neo4jProjectionReadModelStoreOptions options, - string scope, - Func keySelector, - Func? keyFormatter = null, - string providerName = ProjectionProviderNames.Neo4j, - ILogger>? logger = null) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentException.ThrowIfNullOrWhiteSpace(scope); - ArgumentNullException.ThrowIfNull(keySelector); - - _scope = scope.Trim(); - _database = options.Database?.Trim() ?? ""; - _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; - _label = NormalizeLabel(options.NodeLabel); - _autoCreateConstraints = options.AutoCreateConstraints; - _providerName = string.IsNullOrWhiteSpace(providerName) - ? ProjectionProviderNames.Neo4j - : providerName.Trim(); - _keySelector = keySelector; - _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); - _logger = logger ?? NullLogger>.Instance; - - var auth = string.IsNullOrWhiteSpace(options.Username) - ? AuthTokens.None - : AuthTokens.Basic(options.Username.Trim(), options.Password ?? ""); - _driver = GraphDatabase.Driver(options.Uri, auth, config => - config.WithConnectionTimeout(TimeSpan.FromMilliseconds(Math.Max(1000, options.RequestTimeoutMs)))); - } - - public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(readModel); - var startedAt = DateTimeOffset.UtcNow; - var key = ""; - try - { - await EnsureSchemaAsync(ct); - - key = ResolveReadModelKey(readModel); - var payload = JsonSerializer.Serialize(readModel, _jsonOptions); - var updatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - var cypher = $"MERGE (n:{_label} {{scope: $scope, id: $id}}) " + - "SET n.payload = $payload, n.updatedAtEpochMs = $updatedAtEpochMs"; - var parameters = new Dictionary - { - ["scope"] = _scope, - ["id"] = key, - ["payload"] = payload, - ["updatedAtEpochMs"] = updatedAtEpochMs, - }; - - await ExecuteWriteAsync(cypher, parameters, ct); - - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogInformation( - "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - _providerName, - typeof(TReadModel).FullName, - key, - elapsedMs, - "ok"); - } - catch (Exception ex) - { - LogWriteFailure(key, startedAt, ex); - throw; - } - } - - public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(mutate); - ct.ThrowIfCancellationRequested(); - - var keyValue = FormatKey(key); - var startedAt = DateTimeOffset.UtcNow; - var existing = await GetAsync(key, ct); - if (existing == null) - { - var notFound = new InvalidOperationException( - $"ReadModel '{typeof(TReadModel).FullName}' with key '{keyValue}' was not found."); - LogWriteFailure(keyValue, startedAt, notFound); - throw notFound; - } - - try - { - mutate(existing); - } - catch (Exception ex) - { - LogWriteFailure(keyValue, startedAt, ex); - throw; - } - - await UpsertAsync(existing, ct); - } - - public async Task GetAsync(TKey key, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - await EnsureSchemaAsync(ct); - - var keyValue = FormatKey(key); - if (keyValue.Length == 0) - return null; - - var cypher = $"MATCH (n:{_label} {{scope: $scope, id: $id}}) " + - "RETURN n.payload AS payload LIMIT 1"; - var parameters = new Dictionary - { - ["scope"] = _scope, - ["id"] = keyValue, - }; - var rows = await ExecuteReadAsync(cypher, parameters, ct); - if (rows.Count == 0) - return null; - - if (!rows[0].TryGetValue("payload", out var payloadValue)) - return null; - - var payload = payloadValue.As(); - return Deserialize(payload); - } - - public async Task> ListAsync(int take = 50, CancellationToken ct = default) - { - ct.ThrowIfCancellationRequested(); - await EnsureSchemaAsync(ct); - var boundedTake = Math.Clamp(take, 1, _listTakeMax); - var cypher = $"MATCH (n:{_label} {{scope: $scope}}) " + - "RETURN n.payload AS payload ORDER BY n.updatedAtEpochMs DESC LIMIT $take"; - var parameters = new Dictionary - { - ["scope"] = _scope, - ["take"] = boundedTake, - }; - - var rows = await ExecuteReadAsync(cypher, parameters, ct); - var readModels = new List(rows.Count); - foreach (var row in rows) - { - if (!row.TryGetValue("payload", out var payloadValue)) - continue; - - var item = Deserialize(payloadValue.As()); - if (item != null) - readModels.Add(item); - } - - return readModels; - } - - public async ValueTask DisposeAsync() - { - _schemaLock.Dispose(); - await _driver.DisposeAsync(); - } - - private async Task EnsureSchemaAsync(CancellationToken ct) - { - if (!_autoCreateConstraints || _schemaInitialized) - return; - - await _schemaLock.WaitAsync(ct); - try - { - if (_schemaInitialized) - return; - - var constraintName = NormalizeConstraintName($"projection_readmodel_scope_id_{_label}"); - var cypher = $"CREATE CONSTRAINT {constraintName} IF NOT EXISTS " + - $"FOR (n:{_label}) REQUIRE (n.scope, n.id) IS UNIQUE"; - await ExecuteWriteAsync(cypher, new Dictionary(), ct); - _schemaInitialized = true; - } - finally - { - _schemaLock.Release(); - } - } - - private async Task ExecuteWriteAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Write); - var cursor = await session.RunAsync(cypher, parameters); - await cursor.ConsumeAsync(); - ct.ThrowIfCancellationRequested(); - } - - private async Task>> ExecuteReadAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Read); - var cursor = await session.RunAsync(cypher, parameters); - var rows = await cursor.ToListAsync(); - ct.ThrowIfCancellationRequested(); - return rows; - } - - private IAsyncSession CreateSession(AccessMode accessMode) - { - return _driver.AsyncSession(options => - { - options.WithDefaultAccessMode(accessMode); - if (_database.Length > 0) - options.WithDatabase(_database); - }); - } - - private string ResolveReadModelKey(TReadModel readModel) - { - var key = _keySelector(readModel); - var keyValue = FormatKey(key); - if (keyValue.Length == 0) - throw new InvalidOperationException( - $"ReadModel '{typeof(TReadModel).FullName}' resolved an empty key for Neo4j persistence."); - return keyValue; - } - - private string FormatKey(TKey key) => _keyFormatter(key)?.Trim() ?? ""; - - private void LogWriteFailure( - string key, - DateTimeOffset startedAt, - Exception ex) - { - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogError( - ex, - "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - _providerName, - typeof(TReadModel).FullName, - key, - elapsedMs, - "failed", - ex.GetType().Name); - } - - private TReadModel? Deserialize(string payload) - { - var value = JsonSerializer.Deserialize(payload, _jsonOptions); - if (value == null) - return null; - - var copied = JsonSerializer.Serialize(value, _jsonOptions); - return JsonSerializer.Deserialize(copied, _jsonOptions); - } - - private static string NormalizeLabel(string rawLabel) - { - var label = (rawLabel ?? "").Trim(); - if (label.Length == 0) - label = "ProjectionReadModel"; - - var chars = label - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') - .ToArray(); - return new string(chars); - } - - private static string NormalizeConstraintName(string rawName) - { - var chars = rawName - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') - .ToArray(); - var normalized = new string(chars); - if (normalized.Length == 0) - return "projection_constraint"; - if (char.IsDigit(normalized[0])) - normalized = $"c_{normalized}"; - return normalized; - } -} diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 32b11e1bf..96fa2f5ac 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -91,12 +91,11 @@ FAQ: ## Provider 配置 - Provider 选择统一配置入口: - - `Projection:Document:Provider`:`InMemory`(默认)/`Elasticsearch`/`Neo4j` + - `Projection:Document:Provider`:`InMemory`(默认)/`Elasticsearch` - `Projection:Graph:Provider`:`InMemory`(默认)/`Neo4j` - `Projection:Policies:DenyInMemoryGraphFactStore`:禁用 InMemory graph 作为事实源(生产建议开启) - 文档 Provider 配置: - `Projection:Document:Providers:Elasticsearch:*` - - `Projection:Document:Providers:Neo4j:*` - 图 Provider 配置: - `Projection:Graph:Providers:Neo4j:*` - `WorkflowExecutionProjection:ValidateDocumentProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 37ab284fd..67ed08d2c 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -50,14 +50,14 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( private static ProviderSelection ResolveProviderSelection(IConfiguration configuration) { - var documentProvider = NormalizeOrDefaultProvider( + var documentProvider = NormalizeDocumentProvider( configuration["Projection:Document:Provider"], ProjectionProviderNames.InMemory, "Projection:Document:Provider"); - var graphProvider = NormalizeOrDefaultProvider( + var graphProvider = NormalizeGraphProvider( configuration["Projection:Graph:Provider"], - documentProvider, + ProjectionProviderNames.InMemory, "Projection:Graph:Provider"); return new ProviderSelection(documentProvider, graphProvider); @@ -107,7 +107,7 @@ private static bool ParseBool(string? value) return bool.TryParse(value, out var parsed) && parsed; } - private static string NormalizeOrDefaultProvider( + private static string NormalizeDocumentProvider( string? configuredValue, string fallbackValue, string optionPath) @@ -120,12 +120,41 @@ private static string NormalizeOrDefaultProvider( return ProjectionProviderNames.InMemory; if (string.Equals(candidate, ProjectionProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) return ProjectionProviderNames.Elasticsearch; + if (string.Equals(candidate, ProjectionProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Neo4j cannot be used as document provider. " + + "Use Elasticsearch for document indexing and Neo4j for graph traversal."); + } + + throw new InvalidOperationException( + $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + + $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Elasticsearch}."); + } + + private static string NormalizeGraphProvider( + string? configuredValue, + string fallbackValue, + string optionPath) + { + var candidate = string.IsNullOrWhiteSpace(configuredValue) + ? fallbackValue + : configuredValue.Trim(); + + if (string.Equals(candidate, ProjectionProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) + return ProjectionProviderNames.InMemory; if (string.Equals(candidate, ProjectionProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) return ProjectionProviderNames.Neo4j; + if (string.Equals(candidate, ProjectionProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Elasticsearch cannot be used as graph provider. " + + "Use InMemory (dev/test) or Neo4j."); + } throw new InvalidOperationException( $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + - $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Elasticsearch}, {ProjectionProviderNames.Neo4j}."); + $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Neo4j}."); } private static void RegisterDocumentProvider( @@ -158,22 +187,6 @@ private static void RegisterDocumentProvider( keySelector: report => report.RootActorId, keyFormatter: key => key); break; - case ProjectionProviderNames.Neo4j: - services.AddNeo4jDocumentStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new Neo4jProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:Document:Providers:Neo4j").Bind(providerOptions); - return providerOptions; - }, - scopeFactory: sp => - { - var metadataResolver = sp.GetRequiredService(); - return metadataResolver.Resolve().IndexName; - }, - keySelector: report => report.RootActorId, - keyFormatter: key => key); - break; default: throw new InvalidOperationException($"Unsupported document provider '{providerName}'."); } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs index 37a5de16b..9d14be7de 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -1,7 +1,5 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; -using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; -using Aevatar.CQRS.Projection.Providers.Neo4j.Stores; using FluentAssertions; namespace Aevatar.CQRS.Projection.Core.Tests; @@ -48,52 +46,6 @@ await store.MutateAsync(readModel.Id, model => mutated!.Value.Should().Be("v2"); } - [Neo4jIntegrationFact] - public async Task Neo4jStore_ShouldRoundtripUpsertMutateAndList() - { - var uri = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_URI"); - var username = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_USERNAME"); - var password = GetRequiredEnvironmentVariable("AEVATAR_TEST_NEO4J_PASSWORD"); - var options = new Neo4jProjectionReadModelStoreOptions - { - Uri = uri, - Username = username, - Password = password, - NodeLabel = "ProjectionReadModelE2E", - AutoCreateConstraints = true, - RequestTimeoutMs = 5000, - }; - var scope = "projection-provider-e2e-" + Guid.NewGuid().ToString("N"); - await using var store = new Neo4jProjectionReadModelStore( - options, - scope, - model => model.Id); - - var readModel = new ProviderStoreSmokeReadModel - { - Id = Guid.NewGuid().ToString("N"), - Value = "v1", - UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - }; - - await store.UpsertAsync(readModel); - var fetched = await store.GetAsync(readModel.Id); - fetched.Should().NotBeNull(); - fetched!.Value.Should().Be("v1"); - - await store.MutateAsync(readModel.Id, model => - { - model.Value = "v2"; - model.UpdatedAtEpochMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - }); - var mutated = await store.GetAsync(readModel.Id); - mutated.Should().NotBeNull(); - mutated!.Value.Should().Be("v2"); - - var listed = await store.ListAsync(20); - listed.Select(model => model.Id).Should().Contain(readModel.Id); - } - private static string GetRequiredEnvironmentVariable(string name) { var value = Environment.GetEnvironmentVariable(name); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index df33037ac..f0348fd81 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -153,6 +153,24 @@ public void AddWorkflowProjectionReadModelProviders_WhenProviderConfiguredUnknow .WithMessage("*Unsupported projection provider*"); } + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenDocumentProviderConfiguredAsNeo4j_ShouldThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:Document:Provider"] = ProjectionProviderNames.Neo4j, + ["Projection:Graph:Provider"] = ProjectionProviderNames.Neo4j, + }) + .Build(); + + Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); + + act.Should().Throw() + .WithMessage("*Neo4j cannot be used as document provider*"); + } + [Fact] public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRelationFactStore_ShouldThrow() { diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index af334e5a9..2aafdd383 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -659,7 +659,6 @@ fi projection_provider_store_files=( "src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs" "src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs" - "src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs" ) for provider_store_file in "${projection_provider_store_files[@]}"; do From ff4c8e237682d217a68bf44bfa92988073c52d1a Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 01:24:54 +0800 Subject: [PATCH 33/46] Refactor Projection ReadModel and Update Audit Scorecard Documentation - Completed the full refactor of the Projection ReadModel architecture, consolidating document and graph capabilities into distinct fan-out models. - Removed the outdated audit scorecard for the Projection ReadModel and introduced a new scorecard reflecting the updated architecture and validation results. - Enhanced documentation to clarify the new structure, including the removal of deprecated classes and the introduction of streamlined provider registration. - Improved overall clarity and maintainability of the codebase by eliminating redundant abstractions and updating dependency injection configurations. - Ensured that all changes align with the latest architectural objectives and enhance the overall functionality of the Projection ReadModel. --- ...readmodel-full-refactor-plan-2026-02-24.md | 216 +++++++------- ...ojection-readmodel-scorecard-2026-02-24.md | 218 -------------- ...on-store-readmodel-scorecard-2026-02-24.md | 78 +++++ .../ServiceCollectionExtensions.cs | 12 +- .../README.md | 10 +- .../ElasticsearchProjectionReadModelStore.cs | 101 +++++-- .../ServiceCollectionExtensions.cs | 13 +- .../README.md | 10 +- .../Stores/InMemoryProjectionGraphStore.cs | 4 +- .../InMemoryProjectionReadModelStore.cs | 14 +- .../ServiceCollectionExtensions.cs | 6 +- .../README.md | 6 +- .../Stores/Neo4jProjectionGraphStore.cs | 8 +- .../ProjectionDocumentRuntimeOptions.cs | 8 - .../Graphs/IProjectionGraphStoreFactory.cs | 8 - .../Graphs/ProjectionGraphRuntimeOptions.cs | 8 - .../IProjectionDocumentStoreFactory.cs | 9 - .../ReadModels/ProjectionProviderNames.cs | 10 - .../ProjectionProviderSelectionException.cs | 37 --- .../README.md | 16 +- .../ServiceCollectionExtensions.cs | 4 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 18 +- .../Runtime/ProjectionDocumentStoreFactory.cs | 63 ---- .../Runtime/ProjectionDocumentStoreFanout.cs | 85 ++++++ .../Runtime/ProjectionGraphStoreFactory.cs | 59 ---- .../Runtime/ProjectionGraphStoreFanout.cs | 83 ++++++ .../ProjectionStoreRegistrationSelector.cs | 52 ---- .../ServiceCollectionExtensions.cs | 38 --- ...ReadModelStartupValidationHostedService.cs | 35 +-- .../Aevatar.Workflow.Projection/README.md | 21 +- ...tionProviderServiceCollectionExtensions.cs | 279 ++++++++---------- ...chProjectionReadModelStoreBehaviorTests.cs | 6 +- .../ProjectionProviderE2EIntegrationTests.cs | 6 +- .../ProjectionReadModelRuntimeTests.cs | 83 +++--- .../ProjectionReadModelStoreSelectorTests.cs | 118 +++++--- ...lowExecutionProjectionRegistrationTests.cs | 67 +---- .../WorkflowHostingExtensionsCoverageTests.cs | 54 +--- 37 files changed, 791 insertions(+), 1072 deletions(-) delete mode 100644 docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md create mode 100644 docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index ddc1abc39..135909c27 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -1,157 +1,149 @@ -# Projection ReadModel 全量重构实施文档(v12,已完成,无兼容) +# Projection Store/ReadModel Full Refactor Plan (No Compatibility) -> 日期:2026-02-24 -> 范围:`Aevatar.CQRS.Projection.Stores.Abstractions`、`Aevatar.CQRS.Projection.Core.Abstractions`、`Aevatar.CQRS.Projection.Runtime.Abstractions`、`Aevatar.CQRS.Projection.Runtime`、`Aevatar.Workflow.Projection`、`Aevatar.Workflow.Extensions.Hosting`、`Aevatar.CQRS.Projection.Providers.*` +- Date: 2026-02-24 +- Status: Completed +- Scope: `Aevatar.CQRS.Projection.*` + `Aevatar.Workflow.Projection` + `Aevatar.Workflow.Extensions.Hosting` -## 1. 最终结论 +## 1. Refactor Goals -Workflow Projection 已彻底收敛为单链路 `1:N` 扇出模型,并固定生产职责分工: +1. Remove single-provider selection (`providerName + factory + runtime options`) and switch to one-to-many fan-out. +2. Keep Document/Graph as independent provider categories. +3. Make read model routing capability-driven: + - `IDocumentReadModel` -> document store fan-out + - `IGraphReadModel` -> graph store fan-out + - both interfaces -> both paths +4. Preserve index metadata and graph relation semantics: + - document: `DocumentIndexMetadata` + - graph: `IGraphReadModel.GraphNodes/GraphEdges` +5. Delete redundant abstraction layers and dead code without compatibility shims. -1. `Document Target = Elasticsearch`(索引、检索、快照/列表查询) -2. `Graph Target = Neo4j`(关系、邻居、子图遍历) +## 2. Target Architecture -同一 `ReadModel`(`WorkflowExecutionReport`)在一次投影中并行写入 `Document + Graph`,不是二选一。 - -## 2. 最终架构图 - -### 2.1 写入链路(单链路双写) +### 2.1 Write Pipeline ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - A["Event Stream"] --> B["Reducer / Projector"] - B --> C["ReadModel(T)"] - C --> D["IProjectionMaterializationRouter"] + A["Domain Event Stream"] --> B["Projection Coordinator / Dispatcher"] + B --> C["Workflow ReadModel Reducers + Projectors"] + C --> D["IProjectionMaterializationRouter"] - D --> E["T : IDocumentReadModel ?"] - D --> F["T : IGraphReadModel ?"] + D --> E["ProjectionDocumentStoreFanout"] + D --> F["ProjectionGraphMaterializer"] - E --> G["IDocumentProjectionStore"] - F --> H["IProjectionGraphMaterializer"] - H --> I["IProjectionGraphStore"] + E --> E1["Document Provider #1 (InMemory)"] + E --> E2["Document Provider #2 (Elasticsearch)"] - G --> J["Elasticsearch"] - I --> K["Neo4j"] + F --> G["ProjectionGraphStoreFanout"] + G --> G1["Graph Provider #1 (InMemory)"] + G --> G2["Graph Provider #2 (Neo4j)"] ``` -### 2.2 查询链路(索引与遍历并存) +### 2.2 Query Pipeline ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart TB - A["Query API / Application Service"] --> B["WorkflowExecutionProjectionQueryService"] - - B --> C["Document Query"] - B --> D["Graph Query"] +flowchart LR + Q1["WorkflowProjectionQueryReader"] --> Q2["IDocumentProjectionStore"] + Q1 --> Q3["IProjectionGraphStore"] - C --> E["IDocumentProjectionStore"] - D --> F["IProjectionGraphStore"] + Q2 --> Q4["ProjectionDocumentStoreFanout (primary read store + fan-out write)"] + Q3 --> Q5["ProjectionGraphStoreFanout (primary read store + fan-out write)"] - E --> G["Elasticsearch"] - F --> H["Neo4j"] + Q4 --> Q6["Document Primary Provider"] + Q5 --> Q7["Graph Primary Provider"] ``` -### 2.3 Provider 选择与启动校验(极简) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart TB - A["ProjectionDocumentRuntimeOptions.ProviderName"] --> C["ProjectionDocumentStoreFactory"] - B["ProjectionGraphRuntimeOptions.ProviderName"] --> D["ProjectionGraphStoreFactory"] - - E["IProjectionStoreRegistration>[]"] --> C - F["IProjectionStoreRegistration[]"] --> D - - C --> G["Create Document Store or Fail"] - D --> H["Create Graph Store or Fail"] - - I["WorkflowReadModelStartupValidationHostedService"] --> C - I --> D -``` +## 3. Major Structural Changes -## 3. 已完成的彻底重构项 +## 3.1 Removed (hard delete) -### 3.1 Runtime 抽象与实现去层 +- `ProjectionDocumentRuntimeOptions` +- `ProjectionGraphRuntimeOptions` +- `ProjectionProviderNames` +- `IProjectionDocumentStoreFactory` +- `IProjectionGraphStoreFactory` +- `ProjectionProviderSelectionException` +- `ProjectionDocumentStoreFactory` +- `ProjectionGraphStoreFactory` +- `ProjectionStoreRegistrationSelector` -1. 删除能力协商模型(Capabilities/Requirements/Validator)整层。 -2. 删除薄封装中间层(Provider Registry / Provider Selector / Startup Validator)。 -3. Provider 选择逻辑内聚到: - - `ProjectionDocumentStoreFactory` - - `ProjectionGraphStoreFactory` -4. 启动校验改为 HostedService 直接调用 Factory 进行真实创建 fail-fast。 +## 3.2 Added -### 3.2 ReadModel 抽象收敛 +- `ProjectionDocumentStoreFanout` +- `ProjectionGraphStoreFanout` -1. `IDocumentReadModel` 收敛为 marker。 -2. 删除 `WorkflowExecutionReport.DocumentScope` 冗余字段。 -3. 保留 `IProjectionDocumentMetadataProvider` 作为索引 metadata 来源。 +## 3.3 Runtime DI Model -### 3.3 Neo4j Provider 职责收敛(仅 Graph) +`AddProjectionReadModelRuntime()` now registers: -已删除: +- `IDocumentProjectionStore<,>` -> `ProjectionDocumentStoreFanout<,>` +- `IProjectionGraphStore` -> `ProjectionGraphStoreFanout` +- `IProjectionGraphMaterializer<>` +- `IProjectionMaterializationRouter<,>` +- `IProjectionDocumentMetadataResolver` -1. `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionReadModelStore.cs` -2. `src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionReadModelStoreOptions.cs` +## 3.4 Workflow Provider Registration Model -已修改: +`AddWorkflowProjectionReadModelProviders(configuration)` now uses enable flags: -1. `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs` - - 删除 `AddNeo4jDocumentStoreRegistration(...)` - - 仅保留 `AddNeo4jGraphStoreRegistration(...)` -2. `src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md` - - 改为 Graph-only 文档。 +- `Projection:Document:Providers:InMemory:Enabled` +- `Projection:Document:Providers:Elasticsearch:Enabled` +- `Projection:Graph:Providers:InMemory:Enabled` +- `Projection:Graph:Providers:Neo4j:Enabled` -### 3.4 Workflow Hosting Provider 矩阵收敛 +Rules: -已修改: +1. Legacy single-select keys (`Projection:Document:Provider`, `Projection:Graph:Provider`) are rejected. +2. InMemory providers are fallback defaults when durable providers are not enabled. +3. `Projection:Policies:DenyInMemoryGraphFactStore=true` forbids in-memory graph fact store. -1. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` - - `Projection:Document:Provider` 只允许 `InMemory | Elasticsearch` - - `Projection:Graph:Provider` 只允许 `InMemory | Neo4j` - - 删除 Document 分支中的 Neo4j 注册 - - 明确抛错:`Neo4j cannot be used as document provider` -2. `src/workflow/Aevatar.Workflow.Projection/README.md` - - 删除 `Projection:Document:Providers:Neo4j:*` 相关说明 +## 3.5 Elasticsearch Metadata Behavior -### 3.5 测试与门禁同步 +`ElasticsearchProjectionReadModelStore` now consumes full `DocumentIndexMetadata`: -已修改: +- `IndexName` as logical scope input +- `MappingJson` as index mappings object +- `Settings` as index settings object +- `Aliases` as index alias object -1. `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs` - - 删除 Neo4j Document Store E2E 场景 -2. `test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs` - - 新增断言:`Projection:Document:Provider=Neo4j` 必须抛错 -3. `tools/ci/architecture_guards.sh` - - 移除对 `Neo4jProjectionReadModelStore.cs` 必须存在的检查 +Index bootstrap now uses metadata payload instead of fixed `{"mappings":{"dynamic":true}}`. -## 4. 开发者使用模型(当前标准) +## 4. Project-Level Responsibility Split (Post-Refactor) -1. 定义 ReadModel:可同时实现 `IDocumentReadModel + IGraphReadModel`。 -2. 注册 Provider:Document 与 Graph 各自注册,互不混用职责。 -3. 配置 Provider: - - `Projection:Document:Provider=Elasticsearch`(生产) - - `Projection:Graph:Provider=Neo4j`(生产) +- `Aevatar.CQRS.Projection.Stores.Abstractions` + - pure read model/store contracts and metadata contracts +- `Aevatar.CQRS.Projection.Runtime.Abstractions` + - store registration + materialization contracts only +- `Aevatar.CQRS.Projection.Runtime` + - fan-out composition + graph materialization + metadata resolver +- `Aevatar.CQRS.Projection.Providers.*` + - concrete provider implementations only +- `Aevatar.Workflow.Projection` + - workflow reducers/projectors/read model/query services +- `Aevatar.Workflow.Extensions.Hosting` + - host-layer provider enablement and policy binding -系统自动完成: +## 5. Implementation Status by Module -1. 单次流程双写(Document + Graph)。 -2. 启动期双链路 fail-fast。 -3. 查询期索引查询与图遍历并存。 +- Runtime: completed +- Runtime.Abstractions: completed +- Provider extensions: completed +- Workflow projection composition: completed +- Workflow hosting provider extension: completed +- Tests: completed and updated to fan-out semantics +- Readme/docs sync: completed -## 5. 验收结果(本次执行) +## 6. Verification -1. `dotnet build aevatar.slnx --nologo`:通过 -2. `dotnet test aevatar.slnx --nologo`:通过 -3. `bash tools/ci/architecture_guards.sh`:通过 -4. `bash tools/ci/projection_route_mapping_guard.sh`:通过 -5. `bash tools/ci/solution_split_guards.sh`:通过 -6. `bash tools/ci/solution_split_test_guards.sh`:通过 -7. `bash tools/ci/test_stability_guards.sh`:通过 +Commands executed: -## 6. 最终验收标准(全部满足) +1. `dotnet build aevatar.slnx --nologo` +2. `dotnet test aevatar.slnx --nologo` +3. `bash tools/ci/architecture_guards.sh` +4. `bash tools/ci/projection_route_mapping_guard.sh` +5. `bash tools/ci/solution_split_guards.sh` +6. `bash tools/ci/solution_split_test_guards.sh` +7. `bash tools/ci/test_stability_guards.sh` -1. Workflow 生产路径固定为 `ES(Document) + Neo4j(Graph)`。 -2. Workflow 不再支持 `Projection:Document:Provider=Neo4j`。 -3. Neo4j Provider 仅承载 Graph Store。 -4. Projection Router 双写语义保持不变。 -5. 编译、测试、架构门禁全部通过。 +Result: all passed. diff --git a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md deleted file mode 100644 index c80c2ce36..000000000 --- a/docs/audit-scorecard/projection-readmodel-scorecard-2026-02-24.md +++ /dev/null @@ -1,218 +0,0 @@ -# Projection ReadModel 架构评分卡(2026-02-24,严格详细版) - -## 1. 审计范围与方法 - -1. 审计对象:`Projection ReadModel` 端到端链路(Host 接线、Application 端口、Projection Core 编排、Runtime 选型、Provider 存储、AGUI 同链路分支、CI 门禁与测试)。 -2. 评分规范:`docs/audit-scorecard/README.md`(100 分,6 维度)。 -3. 评分原则:按“先满分后扣分”,仅对已落地且可复现的问题扣分;每项扣分绑定代码证据(文件+行号)或命令结果。 -4. 审计基线:遵循评分规范第 2 节(InMemory/Local Actor/ProjectReference 不作为扣分项)。 - -## 2. 关键验证结果(本次实跑) - -| 检查项 | 命令 | 结果 | -|---|---|---| -| 架构门禁(含 route mapping) | `bash tools/ci/architecture_guards.sh` | 通过(`Architecture guards passed.`) | -| 路由映射专项 | `bash tools/ci/projection_route_mapping_guard.sh` | 通过(`Projection route-mapping guard passed.`) | -| Projection Core 定向测试 | `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionReadModelRuntimeTests\|FullyQualifiedName~ProjectionDocumentStoreSelectorTests\|FullyQualifiedName~ProjectionProviderE2EIntegrationTests" -m:1 -p:UseSharedCompilation=false` | 通过(`10 passed / 0 failed / 2 skipped`) | -| Workflow Projection 定向测试 | `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo --filter "FullyQualifiedName~WorkflowExecutionProjectionRegistrationTests\|FullyQualifiedName~WorkflowExecutionReadModelProjectorTests\|FullyQualifiedName~WorkflowProjectionOrchestrationComponentTests" -m:1 -p:UseSharedCompilation=false` | 通过(`36 passed / 0 failed / 0 skipped`) | - -## 3. 总分与等级 - -**总分:95 / 100(A+)** - -| 维度 | 权重 | 得分 | 扣分说明 | -|---|---:|---:|---| -| 分层与依赖反转 | 20 | 20 | 未发现跨层反向依赖与宿主承载核心业务编排问题。 | -| CQRS 与统一投影链路 | 20 | 19 | 默认去重器为透传实现,降低 at-least-once 场景下 readmodel 幂等鲁棒性。 | -| Projection 编排与状态约束 | 20 | 17 | live sink 订阅关系保存在进程内 lease 对象,未 actor/distributed 化。 | -| 读写分离与会话语义 | 15 | 14 | 完成阶段时间源使用 `DateTimeOffset.UtcNow`,未统一走 `IProjectionClock`。 | -| 命名语义与冗余清理 | 10 | 10 | 命名、职责边界与扩展点语义一致,未见重复空壳层。 | -| 可验证性(门禁/构建/测试) | 15 | 15 | 架构门禁 + 路由门禁 + 相关定向测试均通过。 | - -## 4. 详细扣分项(严格) - -### 4.1 P2-1:Live Sink 订阅运行态仍为进程内事实(-3) - -1. 证据:`WorkflowExecutionRuntimeLease` 内维护 `_liveSinkSubscriptions` 列表,并提供 attach/detach/count 逻辑。 - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:8` - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:31` - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionRuntimeLease.cs:60` -2. 证据:`ReleaseIfIdleAsync` 依赖该本地计数决定是否释放投影 ownership。 - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs:31` -3. 影响:在多节点/连接迁移场景,sink 订阅事实与 ownership 释放决策可能出现节点本地视角偏差。 -4. 结论:不属于“actorId->context 字典”硬违规,但与“投影会话/订阅运行态 actor 化”目标相比仍有架构欠账。 - -### 4.2 P2-2:默认去重策略为透传(-1) - -1. 证据:默认注入 `PassthroughEventDeduplicator`。 - `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:46` -2. 证据:透传实现始终返回 `true`,不记录任何去重状态。 - `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:165` -3. 证据:projector 去重逻辑依赖该接口。 - `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs:61` -4. 影响:在重复投递或重放噪声下,timeline/summary 可能重复累计,影响 readmodel 一致性鲁棒性。 - -### 4.3 P3-1:时间源未完全统一(-1) - -1. 证据:`CompleteAsync` 直接使用 `DateTimeOffset.UtcNow`。 - `src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs:88` -2. 对比:同链路其他组件已使用 `IProjectionClock`(activation/updater/failure reporter)。 - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs:39` - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs:24` - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchFailureReporter.cs:42` -3. 影响:测试可控性与跨组件时间语义一致性略受影响。 - -## 5. 正向证据(加分项) - -1. 统一选择权威入口:Runtime selector 复用抽象层权威 `ProjectionDocumentStoreSelector.Select(...)`。 - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs:32` - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/ProjectionDocumentStoreSelector.cs:5` -2. ReadModel/Relation 统一规划:单一 `ProjectionStoreSelectionPlanner` 产出双存储 selection plan。 - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreSelectionPlanner.cs:12` - `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs:155` -3. startup 校验与运行时选择同源:`WorkflowReadModelStartupValidationHostedService` 复用同一 selection plan。 - `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs:47` -4. 生命周期显式 lease/session:Lifecycle port 不暴露 `actorId` 反查,Attach/Detach/Release 都基于 lease。 - `src/workflow/Aevatar.Workflow.Application.Abstractions/Projections/IWorkflowExecutionProjectionLifecyclePort.cs:16` -5. ownership actor 化:acquire/release 通过 `ActorProjectionOwnershipCoordinator -> ProjectionOwnershipCoordinatorGAgent`。 - `src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs:23` - `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs:25` -6. 统一入口一对多分发:同一 `EventEnvelope` 在 coordinator 中分发到多个 projector 分支。 - `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionCoordinator.cs:19` -7. AGUI 与 ReadModel 共用同链路输入:AGUI projector 作为同一 projector 分支发布 run event。 - `src/workflow/Aevatar.Workflow.Infrastructure/DependencyInjection/WorkflowCapabilityServiceCollectionExtensions.cs:22` - `src/workflow/Aevatar.Workflow.Presentation.AGUIAdapter/WorkflowExecutionAGUIEventProjector.cs:15` -8. Route mapping 守卫要求 `TypeUrl` 派生 + 精确键匹配 + `TryGetValue` 命中。 - `tools/ci/projection_route_mapping_guard.sh:11` - `tools/ci/projection_route_mapping_guard.sh:71` -9. 中间层 ID 映射字典门禁已落地(full-scan)。 - `tools/ci/architecture_guards.sh:548` - `tools/ci/architecture_guards.sh:590` - -## 6. 分模块评分 - -| 模块 | 得分 | 结论 | -|---|---:|---| -| Projection Core(编排/订阅/ownership) | 95 | 主链清晰,ownership actor 化到位;runtime sink 运行态仍有本地化残留。 | -| Runtime(provider registry/selector/factory/planner) | 100 | 单一权威选择逻辑 + 统一规划,结构化日志与失败语义完整。 | -| Workflow Projection(port/orchestration/projector) | 92 | 端口分离、链路完整;默认去重透传与完成时间源不统一需收敛。 | -| Provider(InMemory/ES/Neo4j + relation) | 96 | 能力声明与写路径日志规范较完整;可观测性和一致性策略仍可增强。 | -| Host/API 组合层 | 96 | API 主要负责协议适配与组合;未下沉到 projection 内核细节。 | -| Guards + Tests | 98 | 守卫覆盖关键架构约束,相关定向测试通过,验证闭环较完整。 | - -## 7. 详细架构图 - -### 7.1 Projection ReadModel 主链路(命令驱动 + 同链路 AGUI) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - subgraph H["Host / API"] - H1["/api/chat /api/ws/chat"] - H2["ICommandExecutionService"] - H3["IWorkflowExecutionQueryApplicationService"] - end - - subgraph A["Application Layer"] - A1["WorkflowRunContextFactory"] - A2["IWorkflowExecutionProjectionLifecyclePort"] - A3["WorkflowRunExecutionEngine"] - A4["WorkflowRunResourceFinalizer"] - A5["IWorkflowExecutionProjectionQueryPort"] - end - - subgraph P["Projection Ports + Orchestration"] - P1["WorkflowExecutionProjectionLifecycleService"] - P2["WorkflowProjectionActivationService"] - P3["WorkflowProjectionLeaseManager"] - P4["ActorProjectionOwnershipCoordinator"] - P5["ProjectionOwnershipCoordinatorGAgent(State)"] - P6["ProjectionLifecycleService"] - P7["ProjectionSubscriptionRegistry"] - P8["ActorStreamSubscriptionHub"] - end - - subgraph B["Projector Branches (Same Envelope)"] - B1["WorkflowExecutionReadModelProjector"] - B2["WorkflowExecutionRelationProjector"] - B3["WorkflowExecutionAGUIEventProjector"] - end - - subgraph R["Runtime Selection + Stores"] - R1["ProjectionStoreSelectionPlanner"] - R2["ProjectionReadModelBindingResolver"] - R3["ProjectionReadModelStoreFactory"] - R4["ProjectionRelationStoreFactory"] - R5["InMemory / Elasticsearch / Neo4j"] - end - - subgraph S["Session Stream + Live Sink"] - S1["ProjectionSessionEventHub"] - S2["WorkflowRunEventChannel / Sink"] - end - - H1 --> H2 - H2 --> A1 - A1 --> A2 - A2 --> P1 - P1 --> P2 - P2 --> P3 --> P4 --> P5 - P2 --> P6 --> P7 --> P8 - P8 --> B1 - P8 --> B2 - P8 --> B3 - - R1 --> R2 - P2 --> R1 - B1 --> R3 --> R5 - B2 --> R4 --> R5 - B3 --> S1 --> S2 - A3 --> A4 --> A2 - - H3 --> A5 - A5 --> R3 - A5 --> R4 -``` - -### 7.2 Store 选型与能力校验链(ReadModel + Relation) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart TB - O1["ProjectionStoreRuntimeOptions"] --> P1["ProjectionStoreSelectionPlanner.Build"] - P1 --> P2["DocumentSelectionOptions"] - P1 --> P3["RelationSelectionOptions"] - O2["ReadModel Bindings(Type.FullName -> IndexKind)"] --> B1["ProjectionReadModelBindingResolver.Resolve"] --> P1 - - P2 --> F1["ProjectionReadModelStoreFactory.Create"] - F1 --> G1["ProviderRegistry.GetRegistrations"] - G1 --> S1["ProjectionReadModelProviderSelector.Select"] - S1 --> X1["ProjectionDocumentStoreSelector.Select(Authority)"] - X1 --> C1["ProjectionProviderCapabilityValidator.EnsureSupported"] - C1 --> RM["IProjectionReadModelStore"] - - P3 --> F2["ProjectionRelationStoreFactory.Create"] - F2 --> G2["RelationProviderRegistry.GetRegistrations"] - G2 --> S2["ProjectionRelationStoreProviderSelector.Select"] - S2 --> X2["ProjectionStoreSelector.Select(Authority)"] - X2 --> C2["ProjectionProviderCapabilityValidator.EnsureSupported"] - C2 --> RS["IProjectionRelationStore"] - - RM --> V1["WorkflowExecutionReadModelProjector"] - RS --> V2["WorkflowExecutionRelationProjector"] -``` - -## 8. 结论与优先级建议 - -### P1 - -1. 无。 - -### P2 - -1. 将 live sink 订阅计数/绑定关系从 `WorkflowExecutionRuntimeLease` 迁移到 actor 持久态或抽象化分布式状态,避免多节点本地视角偏差。 -2. 用可替换的持久化 `IEventDeduplicator` 作为默认实现(至少按 `rootActorId + envelopeId` 做幂等记录),透传实现仅用于 dev/test。 - -### P3 - -1. 将 `WorkflowExecutionReadModelProjector.CompleteAsync` 的时间源统一到 `IProjectionClock`,消除链路内时间语义分叉。 diff --git a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md new file mode 100644 index 000000000..868eaa7a8 --- /dev/null +++ b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md @@ -0,0 +1,78 @@ +# Projection Store / ReadModel 严格评分卡(2026-02-24,重构后) + +## 1. 审计范围 + +1. `src/Aevatar.CQRS.Projection.Stores.Abstractions` +2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions` +3. `src/Aevatar.CQRS.Projection.Runtime` +4. `src/Aevatar.CQRS.Projection.Providers.InMemory` +5. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` +6. `src/Aevatar.CQRS.Projection.Providers.Neo4j` +7. `src/workflow/Aevatar.Workflow.Projection` +8. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` +9. `test/Aevatar.CQRS.Projection.Core.Tests`、`test/Aevatar.Workflow.Host.Api.Tests` + +## 2. 验证基线 + +已执行并通过: + +1. `dotnet build aevatar.slnx --nologo` +2. `dotnet test aevatar.slnx --nologo` +3. `bash tools/ci/architecture_guards.sh` +4. `bash tools/ci/projection_route_mapping_guard.sh` +5. `bash tools/ci/solution_split_guards.sh` +6. `bash tools/ci/solution_split_test_guards.sh` +7. `bash tools/ci/test_stability_guards.sh` + +## 3. 总分结论 + +- **总分:90 / 100** +- **等级:A-(严格口径)** +- **结论:架构主干已清晰收敛为“Document/Graph 分离 + 各自一对多 Fan-out + ReadModel 接口驱动”。主要剩余风险集中在 Graph 清理窗口与跨存储一致性策略。** + +## 4. 维度评分(严格扣分) + +| 维度 | 权重 | 得分 | 说明 | +|---|---:|---:|---| +| 架构边界与抽象收敛 | 15 | 15 | 已删除 `Factory/Selector/RuntimeOptions/ProviderNames` 冗余层;Runtime 仅保留 fan-out 与 materialization。 | +| Provider 模型清晰度 | 15 | 15 | 从单选改为一对多广播:`ProjectionDocumentStoreFanout<,>`、`ProjectionGraphStoreFanout`。 | +| Document 索引语义完整性 | 20 | 18 | Elasticsearch 已使用 `DocumentIndexMetadata` 的 `MappingJson/Settings/Aliases` 初始化索引。 | +| Graph 关系语义与正确性 | 15 | 12 | `IGraphReadModel` 声明式节点/边清晰;但图清理仍依赖固定窗口扫描。 | +| 一致性与失败语义 | 10 | 8 | 双写为顺序 fan-out,非事务;故障语义仍是“部分成功即失败抛出”。 | +| Provider 实现质量与性能 | 10 | 8 | ES OCC 完整;Neo4j 子图遍历仍存在逐层多次查询放大风险。 | +| 可观测性 | 5 | 4 | 文档写路径日志较完整;图路径仍可补充统一成功日志与关键指标。 | +| 测试与治理门禁 | 10 | 10 | Core/Workflow 测试已对 fan-out 语义更新,build/test/guards 全通过。 | + +## 5. 关键发现 + +### 高优先级 + +1. **Graph 清理窗口固定值风险** + - `ProjectionGraphMaterializer` 仍使用固定 `Depth/Take` 子图扫描清理旧边。 + +### 中优先级 + +1. **跨 Provider 双写非事务** + - 当前策略是顺序写入,失败后由上层重试/补偿,不保证原子。 + +2. **Neo4j 子图查询存在潜在放大** + - 深度遍历为循环邻接查询模式,大图场景需继续压测与优化。 + +## 6. 严格整改建议 + +### P1(必须) + +1. 为 Graph 清理增加可配置窗口、分批策略与 owner/version 标记。 + +### P2(应做) + +1. 增加跨 Provider 写失败重试/补偿策略文档与可观测指标。 +2. 为 Neo4j 子图查询补充压力测试与查询计划基准。 + +### P3(可选) + +1. 引入图写入/清理统计指标(吞吐、延迟、清理命中率)。 + +## 7. 最终判定 + +重构后已达到“结构正确、边界清晰、可验证”的高质量状态;在严格口径下为 **90/100(A-)**。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 910a0b83f..a6aee7094 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -10,25 +10,23 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddElasticsearchDocumentStoreRegistration( this IServiceCollection services, Func optionsFactory, - Func indexScopeFactory, + Func metadataFactory, Func keySelector, - Func? keyFormatter = null, - string providerName = ProjectionProviderNames.Elasticsearch) + Func? keyFormatter = null) where TReadModel : class { ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentNullException.ThrowIfNull(indexScopeFactory); + ArgumentNullException.ThrowIfNull(metadataFactory); ArgumentNullException.ThrowIfNull(keySelector); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - providerName, + "Elasticsearch", provider => new ElasticsearchProjectionReadModelStore( optionsFactory(provider), - indexScopeFactory(provider), + metadataFactory(provider), keySelector, keyFormatter, - providerName, provider.GetService>>()))); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 9bdf6a6ad..9c9591361 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -4,21 +4,17 @@ - 不依赖任何业务域 read model。 - 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 -- 能力声明:`Document` 索引(不声明 alias/schema validation 能力)。 -- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 - `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 - `AutoCreateIndex=false` 时可通过 `MissingIndexBehavior` 控制索引缺失行为(默认抛错)。 -- `ListSortField` 为空时默认按 `CreatedAt desc -> _id desc` 排序,优先按创建时间倒序并保证稳定性。 +- `ListSortField` 为空时默认按 `CreatedAt desc -> _id desc` 排序。 +- 索引初始化支持 `DocumentIndexMetadata`:`MappingJson`、`Settings`、`Aliases`。 ## DI 注册 -使用扩展方法: - - `AddElasticsearchDocumentStoreRegistration(...)` 关键参数: - `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 -- `indexScopeFactory`:由 `IProjectionDocumentMetadataProvider` 派生索引名。 +- `metadataFactory`:通常由 `IProjectionDocumentMetadataResolver` 解析 `IProjectionDocumentMetadataProvider`。 - `keySelector/keyFormatter`:ReadModel 主键映射。 -- `providerName`:默认 `Elasticsearch`(与 `ProjectionProviderNames.Elasticsearch` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index 327716c4b..b97052df4 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -13,6 +13,7 @@ public sealed class ElasticsearchProjectionReadModelStore IDisposable where TReadModel : class { + private const string ProviderName = "Elasticsearch"; private const string DefaultListPrimarySortField = "CreatedAt"; private const string DefaultListTiebreakSortField = "_id"; @@ -25,7 +26,7 @@ public sealed class ElasticsearchProjectionReadModelStore private readonly string _listSortField; private readonly ElasticsearchMissingIndexBehavior _missingIndexBehavior; private readonly int _mutateMaxRetryCount; - private readonly string _providerName; + private readonly DocumentIndexMetadata _indexMetadata; private readonly ILogger> _logger; private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -36,10 +37,9 @@ public sealed class ElasticsearchProjectionReadModelStore public ElasticsearchProjectionReadModelStore( ElasticsearchProjectionReadModelStoreOptions options, - string indexScope, + DocumentIndexMetadata indexMetadata, Func keySelector, Func? keyFormatter = null, - string providerName = ProjectionProviderNames.Elasticsearch, ILogger>? logger = null, HttpMessageHandler? httpMessageHandler = null) { @@ -60,18 +60,16 @@ public ElasticsearchProjectionReadModelStore( _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token); } - var normalizedScope = NormalizeToken(indexScope); + var normalizedMetadata = NormalizeMetadata(indexMetadata); + var normalizedScope = NormalizeToken(normalizedMetadata.IndexName); if (normalizedScope.Length == 0) normalizedScope = "readmodel"; - _indexName = BuildIndexName(options.IndexPrefix, normalizedScope); _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; _autoCreateIndex = options.AutoCreateIndex; _missingIndexBehavior = options.MissingIndexBehavior; _mutateMaxRetryCount = Math.Clamp(options.MutateMaxRetryCount, 0, 20); - _providerName = string.IsNullOrWhiteSpace(providerName) - ? ProjectionProviderNames.Elasticsearch - : providerName.Trim(); + _indexMetadata = normalizedMetadata with { IndexName = _indexName }; _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortField = options.ListSortField?.Trim() ?? ""; @@ -127,8 +125,8 @@ await UpsertCoreAsync( { _logger.LogWarning( ex, - "Projection read-model optimistic concurrency conflict. provider={Provider} readModelType={ReadModelType} key={Key} attempt={Attempt}/{MaxAttempts}", - _providerName, + "Projection read-model optimistic concurrency conflict. provider={Provider} readModelType={ReadModelType} key={Key} attempt={Attempt}/{MaxAttempts}", + ProviderName, typeof(TReadModel).FullName, keyValue, attempt + 1, @@ -283,7 +281,7 @@ private async Task UpsertCoreAsync( var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - _providerName, + ProviderName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -319,7 +317,7 @@ private bool TryHandleMissingIndexForRead(string operation, string payload) _logger.LogWarning( "Projection read-model index is missing. provider={Provider} readModelType={ReadModelType} index={Index} operation={Operation} behavior={Behavior}", - _providerName, + ProviderName, typeof(TReadModel).FullName, _indexName, operation, @@ -349,7 +347,7 @@ private void LogWriteFailure( _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - _providerName, + ProviderName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -456,9 +454,10 @@ private async Task EnsureIndexAsync(CancellationToken ct) if (_indexInitialized) return; + var payload = BuildIndexInitializationPayload(); using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) { - Content = new StringContent("{\"mappings\":{\"dynamic\":true}}", Encoding.UTF8, "application/json"), + Content = new StringContent(payload, Encoding.UTF8, "application/json"), }; using var response = await _httpClient.SendAsync(request, ct); if (response.IsSuccessStatusCode) @@ -467,16 +466,16 @@ private async Task EnsureIndexAsync(CancellationToken ct) return; } - var payload = await response.Content.ReadAsStringAsync(ct); + var responsePayload = await response.Content.ReadAsStringAsync(ct); if (response.StatusCode == HttpStatusCode.BadRequest && - payload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) + responsePayload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) { _indexInitialized = true; return; } throw new InvalidOperationException( - $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); + $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={responsePayload}"); } finally { @@ -484,6 +483,74 @@ private async Task EnsureIndexAsync(CancellationToken ct) } } + private string BuildIndexInitializationPayload() + { + object mappings = new { dynamic = true }; + if (!string.IsNullOrWhiteSpace(_indexMetadata.MappingJson)) + mappings = ParseJsonObject(_indexMetadata.MappingJson, "DocumentIndexMetadata.MappingJson"); + + var root = new Dictionary + { + ["mappings"] = mappings, + }; + + if (_indexMetadata.Settings.Count > 0) + root["settings"] = _indexMetadata.Settings; + if (_indexMetadata.Aliases.Count > 0) + root["aliases"] = BuildAliasPayload(_indexMetadata.Aliases); + + return JsonSerializer.Serialize(root, _jsonOptions); + } + + private static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + return new DocumentIndexMetadata( + metadata.IndexName?.Trim() ?? "", + metadata.MappingJson?.Trim() ?? "{}", + new Dictionary(metadata.Settings, StringComparer.Ordinal), + new Dictionary(metadata.Aliases, StringComparer.Ordinal)); + } + + private static IReadOnlyDictionary BuildAliasPayload( + IReadOnlyDictionary aliases) + { + var payload = new Dictionary(StringComparer.Ordinal); + foreach (var alias in aliases) + { + var aliasName = alias.Key?.Trim() ?? ""; + if (aliasName.Length == 0) + continue; + + var aliasConfig = alias.Value?.Trim() ?? ""; + payload[aliasName] = aliasConfig.Length == 0 + ? new Dictionary(StringComparer.Ordinal) + : ParseJsonObject(aliasConfig, $"DocumentIndexMetadata.Aliases['{aliasName}']"); + } + + return payload; + } + + private static object ParseJsonObject(string payload, string context) + { + try + { + using var json = JsonDocument.Parse(payload); + if (json.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException( + $"{context} must be a JSON object. actualKind={json.RootElement.ValueKind}"); + } + + return JsonSerializer.Deserialize>( + json.RootElement.GetRawText()) ?? new Dictionary(StringComparer.Ordinal); + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + throw new InvalidOperationException($"{context} is invalid JSON object: {ex.Message}", ex); + } + } + private static async Task EnsureSuccessAsync( HttpResponseMessage response, string operation, diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index b170a5131..90ce52acb 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -11,34 +11,31 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration keySelector, Func? keyFormatter = null, Func? listSortSelector = null, - int listTakeMax = 200, - string providerName = ProjectionProviderNames.InMemory) + int listTakeMax = 200) where TReadModel : class { ArgumentNullException.ThrowIfNull(keySelector); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - providerName, + "InMemory", provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, listSortSelector, listTakeMax, - providerName, provider.GetService>>()))); return services; } public static IServiceCollection AddInMemoryGraphStoreRegistration( - this IServiceCollection services, - string providerName = ProjectionProviderNames.InMemory) + this IServiceCollection services) { services.AddSingleton>( new DelegateProjectionStoreRegistration( - providerName, - _ => new InMemoryProjectionGraphStore(providerName))); + "InMemory", + _ => new InMemoryProjectionGraphStore())); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index 7b13de9d8..c1705238d 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -4,20 +4,16 @@ - 不依赖业务域模型。 - 支持按 keySelector 注册任意 `IDocumentProjectionStore`(Document)。 -- 支持关系图存储注册(Graph)。 -- 默认能力:Document 索引 / Graph 索引(仅用于开发和测试语义)。 -- 写入路径输出结构化日志:`provider/readModelType/key/elapsedMs/result/errorType`。 +- 支持图存储注册(Graph)。 +- 仅用于开发和测试语义,不作为生产事实源。 ## DI 注册 -使用扩展方法: - - `AddInMemoryDocumentStoreRegistration(...)` -- `AddInMemoryGraphStoreRegistration(...)` +- `AddInMemoryGraphStoreRegistration()` 关键参数: - `keySelector/keyFormatter`:ReadModel 主键映射。 - `listSortSelector`:`ListAsync` 排序字段(可选)。 - `listTakeMax`:`ListAsync` 硬上限。 -- `providerName`:默认 `InMemory`(与 `ProjectionProviderNames.InMemory` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index 8e12a4f4e..bb05510f4 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -10,10 +10,8 @@ public sealed class InMemoryProjectionGraphStore private readonly Dictionary _edges = new(StringComparer.Ordinal); private readonly JsonSerializerOptions _jsonOptions = new(); - public InMemoryProjectionGraphStore( - string providerName = ProjectionProviderNames.InMemory) + public InMemoryProjectionGraphStore() { - _ = providerName; } public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs index 617a1db1a..0e8e9ae79 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs @@ -8,13 +8,13 @@ public sealed class InMemoryProjectionReadModelStore : IDocumentProjectionStore where TReadModel : class { + private const string ProviderName = "InMemory"; private readonly object _gate = new(); private readonly Dictionary _itemsByKey = new(StringComparer.Ordinal); private readonly Func _keySelector; private readonly Func _keyFormatter; private readonly Func? _listSortSelector; private readonly int _listTakeMax; - private readonly string _providerName; private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonOptions = new(); @@ -23,7 +23,6 @@ public InMemoryProjectionReadModelStore( Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200, - string providerName = ProjectionProviderNames.InMemory, ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(keySelector); @@ -31,9 +30,6 @@ public InMemoryProjectionReadModelStore( _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortSelector = listSortSelector; _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; - _providerName = string.IsNullOrWhiteSpace(providerName) - ? ProjectionProviderNames.InMemory - : providerName.Trim(); _logger = logger ?? NullLogger>.Instance; } @@ -53,7 +49,7 @@ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - _providerName, + ProviderName, typeof(TReadModel).FullName, key, elapsedMs, @@ -66,7 +62,7 @@ public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - _providerName, + ProviderName, typeof(TReadModel).FullName, key, elapsedMs, @@ -97,7 +93,7 @@ public Task MutateAsync(TKey key, Action mutate, CancellationToken c var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( "Projection read-model write completed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result}", - _providerName, + ProviderName, typeof(TReadModel).FullName, keyValue, elapsedMs, @@ -110,7 +106,7 @@ public Task MutateAsync(TKey key, Action mutate, CancellationToken c _logger.LogError( ex, "Projection read-model write failed. provider={Provider} readModelType={ReadModelType} key={Key} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - _providerName, + ProviderName, typeof(TReadModel).FullName, keyValue, elapsedMs, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 7df415610..2ffa1cfa9 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -10,19 +10,17 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddNeo4jGraphStoreRegistration( this IServiceCollection services, Func optionsFactory, - Func scopeFactory, - string providerName = ProjectionProviderNames.Neo4j) + Func scopeFactory) { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); services.AddSingleton>( new DelegateProjectionStoreRegistration( - providerName, + "Neo4j", provider => new Neo4jProjectionGraphStore( optionsFactory(provider), scopeFactory(provider), - providerName, provider.GetService>()))); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index 32ba7b540..14969cdb2 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -4,18 +4,14 @@ - 不依赖任何业务域 read model。 - 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 -- 能力声明:Graph schema validation。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 -- 写入路径输出结构化日志:`provider/scope/nodeId-or-edgeId/elapsedMs/result/errorType`。 +- 支持 schema 约束初始化、邻居查询、子图遍历。 ## DI 注册 -使用扩展方法: - - `AddNeo4jGraphStoreRegistration(...)` 关键参数: - `optionsFactory`:绑定 `Projection:Graph:Providers:Neo4j:*` 配置。 - `scopeFactory`:graph scope 提供器。 -- `providerName`:默认 `Neo4j`(与 `ProjectionProviderNames.Neo4j` 一致)。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 45de9a5bb..ec84ea055 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -10,6 +10,7 @@ public sealed class Neo4jProjectionGraphStore : IProjectionGraphStore, IAsyncDisposable { + private const string ProviderName = "Neo4j"; private readonly IDriver _driver; private readonly string _scope; private readonly string _database; @@ -17,7 +18,6 @@ public sealed class Neo4jProjectionGraphStore private readonly string _edgeType; private readonly bool _autoCreateConstraints; private readonly int _maxTraversalDepth; - private readonly string _providerName; private readonly ILogger _logger; private readonly SemaphoreSlim _schemaLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() @@ -29,7 +29,6 @@ public sealed class Neo4jProjectionGraphStore public Neo4jProjectionGraphStore( Neo4jProjectionGraphStoreOptions options, string scope, - string providerName = ProjectionProviderNames.Neo4j, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); @@ -41,9 +40,6 @@ public Neo4jProjectionGraphStore( _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); _autoCreateConstraints = options.AutoCreateConstraints; _maxTraversalDepth = Math.Clamp(options.MaxTraversalDepth, 1, 8); - _providerName = string.IsNullOrWhiteSpace(providerName) - ? ProjectionProviderNames.Neo4j - : providerName.Trim(); _logger = logger ?? NullLogger.Instance; var auth = string.IsNullOrWhiteSpace(options.Username) @@ -479,7 +475,7 @@ private Dictionary DeserializeProperties(string payload) _logger.LogWarning( ex, "Failed to deserialize graph edge properties payload. provider={Provider} scope={Scope}", - _providerName, + ProviderName, _scope); return new Dictionary(StringComparer.Ordinal); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs deleted file mode 100644 index 798f093be..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Documents/ProjectionDocumentRuntimeOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionDocumentRuntimeOptions -{ - public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; - - public bool FailFastOnStartup { get; set; } = true; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs deleted file mode 100644 index 3bf71bf3d..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/IProjectionGraphStoreFactory.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphStoreFactory -{ - IProjectionGraphStore Create( - IServiceProvider serviceProvider, - string? requestedProviderName = null); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs deleted file mode 100644 index f2be02012..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphRuntimeOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionGraphRuntimeOptions -{ - public string ProviderName { get; set; } = ProjectionProviderNames.InMemory; - - public bool FailFastOnStartup { get; set; } = true; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs deleted file mode 100644 index 72c4d0b4e..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentStoreFactory.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionDocumentStoreFactory -{ - IDocumentProjectionStore Create( - IServiceProvider serviceProvider, - string? requestedProviderName = null) - where TReadModel : class; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs deleted file mode 100644 index 85689dd09..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/ProjectionProviderNames.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public static class ProjectionProviderNames -{ - public const string InMemory = "InMemory"; - - public const string Elasticsearch = "Elasticsearch"; - - public const string Neo4j = "Neo4j"; -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs deleted file mode 100644 index 0e567521a..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/ProjectionProviderSelectionException.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class ProjectionProviderSelectionException : InvalidOperationException -{ - public ProjectionProviderSelectionException( - Type readModelType, - string requestedProviderName, - IReadOnlyList availableProviders, - string reason) - : base(BuildMessage(readModelType, requestedProviderName, availableProviders, reason)) - { - ReadModelType = readModelType; - RequestedProviderName = requestedProviderName; - AvailableProviders = availableProviders; - Reason = reason; - } - - public Type ReadModelType { get; } - - public string RequestedProviderName { get; } - - public IReadOnlyList AvailableProviders { get; } - - public string Reason { get; } - - private static string BuildMessage( - Type readModelType, - string requestedProviderName, - IReadOnlyList availableProviders, - string reason) - { - var requested = requestedProviderName.Length == 0 ? "" : requestedProviderName; - var available = availableProviders.Count == 0 ? "" : string.Join(", ", availableProviders); - return $"Provider selection failed for read-model '{readModelType.FullName}'. " + - $"requested={requested}; available={available}; reason={reason}."; - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 68f38c3d9..3c681b8be 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -4,22 +4,18 @@ ## 目录结构 -- `Abstractions/Core`:Provider 注册契约(`IProjectionStoreRegistration`) -- `Abstractions/Documents`:Document runtime options -- `Abstractions/ReadModels`:Document store factory、metadata resolver、provider names -- `Abstractions/Graphs`:Graph runtime options、graph store factory 契约 +- `Abstractions/Core`:Store 注册契约(`IProjectionStoreRegistration`) +- `Abstractions/ReadModels`:Document metadata resolver - `Abstractions/Selection`:Materialization 路由与 graph materializer 契约 ## 关键契约 -- Provider 注册:`IProjectionStoreRegistration` -- Document 运行时:`ProjectionDocumentRuntimeOptions` -- Graph 运行时:`ProjectionGraphRuntimeOptions` -- Store factory:`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` +- Store 注册:`IProjectionStoreRegistration` - Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` +- Metadata:`IProjectionDocumentMetadataResolver` ## 约束 -1. 不包含能力协商模型(Capabilities/Requirements/CapabilityValidator)。 -2. Provider 选择语义为显式 providerName + fail-fast,不做自动降级。 +1. 不包含 ProviderName 选择与 RuntimeOptions。 +2. 不包含能力协商模型(Capabilities/Requirements/CapabilityValidator)。 3. 仅依赖 `Aevatar.CQRS.Projection.Stores.Abstractions`。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index d556dd65f..909929674 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,8 +8,8 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton(typeof(IDocumentProjectionStore<,>), typeof(ProjectionDocumentStoreFanout<,>)); + services.TryAddSingleton(); services.TryAddSingleton(typeof(IProjectionGraphMaterializer<>), typeof(ProjectionGraphMaterializer<>)); services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); services.TryAddSingleton(); diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index 29c57c22a..12e228d3f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -4,17 +4,27 @@ ## 职责 -- Store 创建与 provider 选择(内聚在 factory):`IProjectionDocumentStoreFactory`、`IProjectionGraphStoreFactory` -- Store 创建日志与 fail-fast 错误:`ProjectionProviderSelectionException` +- Store Fan-out 组合: + - `ProjectionDocumentStoreFanout` + - `ProjectionGraphStoreFanout` - Materialization 路由:`IProjectionMaterializationRouter`、`ProjectionGraphMaterializer` +- Document metadata 解析:`IProjectionDocumentMetadataResolver` ## DI 入口 - `services.AddProjectionReadModelRuntime()` +默认注册: + +- `IDocumentProjectionStore<,>` -> `ProjectionDocumentStoreFanout<,>` +- `IProjectionGraphStore` -> `ProjectionGraphStoreFanout` +- `IProjectionGraphMaterializer<>` +- `IProjectionMaterializationRouter<,>` +- `IProjectionDocumentMetadataResolver` + ## 设计约束 1. 不承载业务 ReadModel 类型。 -2. 不实现能力协商,不依赖 Capabilities/Requirements 模型。 -3. Provider 选择规则:`providerName` 精确匹配;无注册、多注册无明确 provider、provider 不存在都立即失败。 +2. 不做 providerName 单选,不存在运行时降级逻辑。 +3. Document 与 Graph 完全解耦,分别按注册列表一对多分发。 4. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs deleted file mode 100644 index 00a68af04..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFactory.cs +++ /dev/null @@ -1,63 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionDocumentStoreFactory - : IProjectionDocumentStoreFactory -{ - private readonly ILogger _logger; - - public ProjectionDocumentStoreFactory( - ILogger? logger = null) - { - _logger = logger ?? NullLogger.Instance; - } - - public IDocumentProjectionStore Create( - IServiceProvider serviceProvider, - string? requestedProviderName = null) - where TReadModel : class - { - ArgumentNullException.ThrowIfNull(serviceProvider); - - var registrations = serviceProvider - .GetServices>>() - .ToList(); - var selected = ProjectionStoreRegistrationSelector.Select( - registrations, - requestedProviderName, - typeof(TReadModel), - noRegistrationsReason: "No document store provider registrations were found.", - multipleRegistrationsReason: "Multiple document store providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested document store provider is not registered."); - - var startedAt = DateTimeOffset.UtcNow; - try - { - var store = selected.Create(serviceProvider); - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogInformation( - "Projection document store created. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result}", - selected.ProviderName, - typeof(TReadModel).FullName, - elapsedMs, - "ok"); - return store; - } - catch (Exception ex) - { - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogError( - ex, - "Projection document store creation failed. provider={Provider} readModelType={ReadModelType} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - selected.ProviderName, - typeof(TReadModel).FullName, - elapsedMs, - "failed", - ex.GetType().Name); - throw; - } - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs new file mode 100644 index 000000000..5b9cf2c38 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs @@ -0,0 +1,85 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionDocumentStoreFanout + : IDocumentProjectionStore + where TReadModel : class +{ + private readonly IReadOnlyList> _stores; + private readonly IDocumentProjectionStore _queryStore; + private readonly ILogger> _logger; + + public ProjectionDocumentStoreFanout( + IEnumerable>> registrations, + IServiceProvider serviceProvider, + ILogger>? logger = null) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var registrationList = registrations.ToList(); + _stores = registrationList + .Select(x => x.Create(serviceProvider)) + .ToList(); + _logger = logger ?? NullLogger>.Instance; + + if (_stores.Count == 0) + { + throw new InvalidOperationException( + $"No document projection store providers are registered for read model '{typeof(TReadModel).FullName}'."); + } + + _queryStore = _stores[0]; + _logger.LogInformation( + "Projection document fan-out initialized. readModelType={ReadModelType} storeCount={StoreCount}", + typeof(TReadModel).FullName, + _stores.Count); + } + + public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + + foreach (var store in _stores) + { + ct.ThrowIfCancellationRequested(); + await store.UpsertAsync(readModel, ct); + } + } + + public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + await _queryStore.MutateAsync(key, mutate, ct); + if (_stores.Count == 1) + return; + + var updated = await _queryStore.GetAsync(key, ct); + if (updated == null) + { + throw new InvalidOperationException( + $"Document fan-out mutate completed but query store returned null for read model '{typeof(TReadModel).FullName}'."); + } + + foreach (var store in _stores.Skip(1)) + { + ct.ThrowIfCancellationRequested(); + await store.UpsertAsync(updated, ct); + } + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + return _queryStore.GetAsync(key, ct); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + return _queryStore.ListAsync(take, ct); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs deleted file mode 100644 index 855ac6481..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFactory.cs +++ /dev/null @@ -1,59 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionGraphStoreFactory : IProjectionGraphStoreFactory -{ - private readonly ILogger _logger; - - public ProjectionGraphStoreFactory( - ILogger? logger = null) - { - _logger = logger ?? NullLogger.Instance; - } - - public IProjectionGraphStore Create( - IServiceProvider serviceProvider, - string? requestedProviderName = null) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - - var registrations = serviceProvider - .GetServices>() - .ToList(); - var selected = ProjectionStoreRegistrationSelector.Select( - registrations, - requestedProviderName, - typeof(ProjectionGraphNode), - noRegistrationsReason: "No relation store provider registrations were found.", - multipleRegistrationsReason: "Multiple relation store providers are registered but no explicit provider was requested.", - providerNotRegisteredReason: "Requested relation store provider is not registered."); - - var startedAt = DateTimeOffset.UtcNow; - try - { - var store = selected.Create(serviceProvider); - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogInformation( - "Projection relation store created. provider={Provider} elapsedMs={ElapsedMs} result={Result}", - selected.ProviderName, - elapsedMs, - "ok"); - return store; - } - catch (Exception ex) - { - var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; - _logger.LogError( - ex, - "Projection relation store creation failed. provider={Provider} elapsedMs={ElapsedMs} result={Result} errorType={ErrorType}", - selected.ProviderName, - elapsedMs, - "failed", - ex.GetType().Name); - throw; - } - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs new file mode 100644 index 000000000..95323359f --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs @@ -0,0 +1,83 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionGraphStoreFanout : IProjectionGraphStore +{ + private readonly IReadOnlyList _stores; + private readonly IProjectionGraphStore _queryStore; + private readonly ILogger _logger; + + public ProjectionGraphStoreFanout( + IEnumerable> registrations, + IServiceProvider serviceProvider, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(serviceProvider); + + _stores = registrations + .Select(x => x.Create(serviceProvider)) + .ToList(); + _logger = logger ?? NullLogger.Instance; + + if (_stores.Count == 0) + { + throw new InvalidOperationException( + "No graph projection store providers are registered."); + } + + _queryStore = _stores[0]; + _logger.LogInformation( + "Projection graph fan-out initialized. storeCount={StoreCount}", + _stores.Count); + } + + public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(node); + ct.ThrowIfCancellationRequested(); + + foreach (var store in _stores) + { + ct.ThrowIfCancellationRequested(); + await store.UpsertNodeAsync(node, ct); + } + } + + public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(edge); + ct.ThrowIfCancellationRequested(); + + foreach (var store in _stores) + { + ct.ThrowIfCancellationRequested(); + await store.UpsertEdgeAsync(edge, ct); + } + } + + public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + foreach (var store in _stores) + { + ct.ThrowIfCancellationRequested(); + await store.DeleteEdgeAsync(scope, edgeId, ct); + } + } + + public Task> GetNeighborsAsync( + ProjectionGraphQuery query, + CancellationToken ct = default) + { + return _queryStore.GetNeighborsAsync(query, ct); + } + + public Task GetSubgraphAsync( + ProjectionGraphQuery query, + CancellationToken ct = default) + { + return _queryStore.GetSubgraphAsync(query, ct); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs deleted file mode 100644 index e765e127e..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreRegistrationSelector.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -internal static class ProjectionStoreRegistrationSelector -{ - public static IProjectionStoreRegistration Select( - IReadOnlyList> registrations, - string? requestedProviderName, - Type logicalModelType, - string noRegistrationsReason, - string multipleRegistrationsReason, - string providerNotRegisteredReason) - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(logicalModelType); - - var requestedName = requestedProviderName?.Trim() ?? ""; - if (registrations.Count == 0) - { - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedName, - [], - noRegistrationsReason); - } - - if (requestedName.Length == 0) - { - if (registrations.Count == 1) - return registrations[0]; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedName, - registrations.Select(x => x.ProviderName).ToList(), - multipleRegistrationsReason); - } - - var matched = registrations - .FirstOrDefault(x => string.Equals( - x.ProviderName, - requestedName, - StringComparison.OrdinalIgnoreCase)); - if (matched != null) - return matched; - - throw new ProjectionProviderSelectionException( - logicalModelType, - requestedName, - registrations.Select(x => x.ProviderName).ToList(), - providerNotRegisteredReason); - } -} diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index d8610d2f9..829ebd6f3 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,7 +7,6 @@ using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Foundation.Abstractions.Deduplication; using Aevatar.CQRS.Projection.Runtime.DependencyInjection; -using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.CQRS.Projection.Core.DependencyInjection; using Aevatar.CQRS.Projection.Core.Orchestration; using Aevatar.CQRS.Projection.Core.Streaming; @@ -37,14 +36,9 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.Replace(ServiceDescriptor.Singleton(options)); services.TryAddSingleton(sp => sp.GetRequiredService()); - services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); - RegisterWorkflowDocumentStoreSelector(services); - RegisterWorkflowGraphStoreSelector(services); - RegisterWorkflowMaterializationRouter(services); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -122,38 +116,6 @@ private static void RegisterFromAssembly(IServiceCollection services, Assembly a ProjectionProjectorContract); } - private static void RegisterWorkflowDocumentStoreSelector(IServiceCollection services) - { - services.Replace(ServiceDescriptor.Singleton>(sp => - { - var storeFactory = sp.GetRequiredService(); - var runtimeOptions = sp.GetRequiredService(); - return storeFactory.Create( - sp, - runtimeOptions.ProviderName); - })); - } - - private static void RegisterWorkflowGraphStoreSelector(IServiceCollection services) - { - services.Replace(ServiceDescriptor.Singleton(sp => - { - var graphStoreFactory = sp.GetRequiredService(); - var runtimeOptions = sp.GetRequiredService(); - return graphStoreFactory.Create( - sp, - runtimeOptions.ProviderName); - })); - } - - private static void RegisterWorkflowMaterializationRouter(IServiceCollection services) - { - services.Replace(ServiceDescriptor.Singleton>(sp => - new ProjectionMaterializationRouter( - sp.GetRequiredService>(), - sp.GetRequiredService>()))); - } - private sealed class PassthroughEventDeduplicator : IEventDeduplicator { public Task TryRecordAsync(string eventId) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 305f09d50..fc92a9c8c 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -1,6 +1,7 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Projection.Configuration; using Aevatar.Workflow.Projection.ReadModels; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -11,27 +12,15 @@ internal sealed class WorkflowReadModelStartupValidationHostedService : IHostedS { private readonly IServiceProvider _serviceProvider; private readonly WorkflowExecutionProjectionOptions _options; - private readonly ProjectionDocumentRuntimeOptions _documentRuntimeOptions; - private readonly ProjectionGraphRuntimeOptions _graphRuntimeOptions; - private readonly IProjectionDocumentStoreFactory _documentStoreFactory; - private readonly IProjectionGraphStoreFactory _graphStoreFactory; private readonly ILogger _logger; public WorkflowReadModelStartupValidationHostedService( IServiceProvider serviceProvider, WorkflowExecutionProjectionOptions options, - ProjectionDocumentRuntimeOptions documentRuntimeOptions, - ProjectionGraphRuntimeOptions graphRuntimeOptions, - IProjectionDocumentStoreFactory documentStoreFactory, - IProjectionGraphStoreFactory graphStoreFactory, ILogger? logger = null) { _serviceProvider = serviceProvider; _options = options; - _documentRuntimeOptions = documentRuntimeOptions; - _graphRuntimeOptions = graphRuntimeOptions; - _documentStoreFactory = documentStoreFactory; - _graphStoreFactory = graphStoreFactory; _logger = logger ?? NullLogger.Instance; } @@ -41,26 +30,20 @@ public Task StartAsync(CancellationToken cancellationToken) if (!_options.Enabled) return Task.CompletedTask; - if (_options.ValidateDocumentProviderOnStartup && _documentRuntimeOptions.FailFastOnStartup) + if (_options.ValidateDocumentProviderOnStartup) { - _documentStoreFactory.Create( - _serviceProvider, - _documentRuntimeOptions.ProviderName); + _ = _serviceProvider.GetRequiredService>(); _logger.LogInformation( - "Workflow read-model provider startup validation passed. readModelType={ReadModelType} provider={Provider}", - typeof(WorkflowExecutionReport).FullName, - _documentRuntimeOptions.ProviderName); + "Workflow read-model document startup validation passed. readModelType={ReadModelType}", + typeof(WorkflowExecutionReport).FullName); } - if (_options.ValidateGraphProviderOnStartup && _graphRuntimeOptions.FailFastOnStartup) + if (_options.ValidateGraphProviderOnStartup) { - _graphStoreFactory.Create( - _serviceProvider, - _graphRuntimeOptions.ProviderName); + _ = _serviceProvider.GetRequiredService(); _logger.LogInformation( - "Workflow graph provider startup validation passed. graphType={GraphType} provider={Provider}", - typeof(ProjectionGraphNode).FullName, - _graphRuntimeOptions.ProviderName); + "Workflow read-model graph startup validation passed. graphType={GraphType}", + typeof(ProjectionGraphNode).FullName); } return Task.CompletedTask; } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 96fa2f5ac..cb08036f6 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -18,19 +18,19 @@ - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - `WorkflowProjectionQueryReader`(query 映射读取) - - Store 选择采用显式 providerName(Document/Graph 分离) + - Store Fan-out:Document/Graph 分别一对多广播(无 providerName 单选) - 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` - 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) - 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) - 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` - Provider 启动校验:由 `WorkflowReadModelStartupValidationHostedService` 执行 Document/Graph 两条独立 fail-fast 校验 -- ReadModel 选择规则:DI store 解析与 Startup validation 统一复用 Document/Graph runtime options(`ProjectionDocumentRuntimeOptions`、`ProjectionGraphRuntimeOptions`) +- ReadModel 存储解析规则:运行时通过 `ProjectionDocumentStoreFanout<,>` / `ProjectionGraphStoreFanout` 聚合已注册 Provider 本项目依赖: - `Aevatar.CQRS.Projection.Core.Abstractions`(投影管线/端口抽象) - `Aevatar.CQRS.Projection.Stores.Abstractions`(Document/Graph 存储契约) -- `Aevatar.CQRS.Projection.Runtime.Abstractions`(Provider 选择与 materialization 编排契约) +- `Aevatar.CQRS.Projection.Runtime.Abstractions`(Store 注册与 materialization 编排契约) - `Aevatar.CQRS.Projection.Core`(通用生命周期/订阅/协调实现) - `Aevatar.Foundation.Projection`(最小 read model 基类与读侧能力接口) - `Aevatar.Workflow.Extensions.AIProjection`(可选扩展:组合 `Aevatar.AI.Projection` 的通用 reducer/applier) @@ -90,14 +90,17 @@ FAQ: ## Provider 配置 -- Provider 选择统一配置入口: - - `Projection:Document:Provider`:`InMemory`(默认)/`Elasticsearch` - - `Projection:Graph:Provider`:`InMemory`(默认)/`Neo4j` -- `Projection:Policies:DenyInMemoryGraphFactStore`:禁用 InMemory graph 作为事实源(生产建议开启) +- 不再支持 `Projection:Document:Provider` / `Projection:Graph:Provider` 单选模型。 +- 使用一对多启用开关模型: + - `Projection:Document:Providers:InMemory:Enabled` + - `Projection:Document:Providers:Elasticsearch:Enabled` + - `Projection:Graph:Providers:InMemory:Enabled` + - `Projection:Graph:Providers:Neo4j:Enabled` - 文档 Provider 配置: - - `Projection:Document:Providers:Elasticsearch:*` + - `Projection:Document:Providers:Elasticsearch:*`(至少配置 `Endpoints`) - 图 Provider 配置: - - `Projection:Graph:Providers:Neo4j:*` + - `Projection:Graph:Providers:Neo4j:*`(至少配置 `Uri`) +- `Projection:Policies:DenyInMemoryGraphFactStore`:禁用 InMemory graph 作为事实源(生产建议开启) - `WorkflowExecutionProjection:ValidateDocumentProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) - `WorkflowExecutionProjection:ValidateGraphProviderOnStartup`:启动阶段预校验 graph provider(默认 `true`) - 图查询参数: diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 67ed08d2c..ea0916141 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -4,11 +4,9 @@ using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; using Aevatar.CQRS.Projection.Runtime.Abstractions; -using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; namespace Aevatar.Workflow.Extensions.Hosting; @@ -24,63 +22,153 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( if (services.Any(x => x.ServiceType == typeof(WorkflowProjectionProviderRegistrationsMarker))) return services; + EnsureLegacyProviderOptionsNotUsed(configuration); + services.AddSingleton(); - var providerSelection = ResolveProviderSelection(configuration); - EnforceGraphProviderPolicy(configuration, providerSelection.GraphProvider); - var documentRuntimeOptions = new ProjectionDocumentRuntimeOptions + var enableElasticsearchDocument = ResolveElasticsearchDocumentEnabled(configuration); + var enableNeo4jGraph = ResolveNeo4jGraphEnabled(configuration); + var enableInMemoryDocument = ResolveOptionalBool( + configuration["Projection:Document:Providers:InMemory:Enabled"], + fallbackValue: !enableElasticsearchDocument); + var enableInMemoryGraph = ResolveOptionalBool( + configuration["Projection:Graph:Providers:InMemory:Enabled"], + fallbackValue: !enableNeo4jGraph); + + EnforceGraphProviderPolicy(configuration, enableInMemoryGraph); + + var documentProviderCount = 0; + if (enableInMemoryDocument) { - ProviderName = providerSelection.DocumentProvider, - FailFastOnStartup = true, - }; - var graphRuntimeOptions = new ProjectionGraphRuntimeOptions + services.AddInMemoryDocumentStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.CreatedAt, + listTakeMax: 200); + documentProviderCount++; + } + + if (enableElasticsearchDocument) { - ProviderName = providerSelection.GraphProvider, - FailFastOnStartup = true, - }; + services.AddElasticsearchDocumentStoreRegistration( + optionsFactory: _ => BuildElasticsearchDocumentOptions(configuration), + metadataFactory: sp => + { + var metadataResolver = sp.GetRequiredService(); + return metadataResolver.Resolve(); + }, + keySelector: report => report.RootActorId, + keyFormatter: key => key); + documentProviderCount++; + } - services.Replace(ServiceDescriptor.Singleton(documentRuntimeOptions)); - services.Replace(ServiceDescriptor.Singleton(graphRuntimeOptions)); + var graphProviderCount = 0; + if (enableInMemoryGraph) + { + services.AddInMemoryGraphStoreRegistration(); + graphProviderCount++; + } - RegisterDocumentProvider(services, configuration, providerSelection.DocumentProvider); - RegisterGraphProvider(services, configuration, providerSelection.GraphProvider); + if (enableNeo4jGraph) + { + services.AddNeo4jGraphStoreRegistration( + optionsFactory: _ => BuildNeo4jGraphOptions(configuration), + scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); + graphProviderCount++; + } + + if (documentProviderCount == 0) + { + throw new InvalidOperationException( + "No document projection providers are enabled. Configure Projection:Document:Providers:InMemory:Enabled=true or Projection:Document:Providers:Elasticsearch with Endpoints."); + } + + if (graphProviderCount == 0) + { + throw new InvalidOperationException( + "No graph projection providers are enabled. Configure Projection:Graph:Providers:InMemory:Enabled=true or Projection:Graph:Providers:Neo4j with Uri."); + } return services; } - private static ProviderSelection ResolveProviderSelection(IConfiguration configuration) + private static void EnsureLegacyProviderOptionsNotUsed(IConfiguration configuration) { - var documentProvider = NormalizeDocumentProvider( - configuration["Projection:Document:Provider"], - ProjectionProviderNames.InMemory, - "Projection:Document:Provider"); + var legacyDocumentProvider = configuration["Projection:Document:Provider"]?.Trim(); + var legacyGraphProvider = configuration["Projection:Graph:Provider"]?.Trim(); - var graphProvider = NormalizeGraphProvider( - configuration["Projection:Graph:Provider"], - ProjectionProviderNames.InMemory, - "Projection:Graph:Provider"); + if (legacyDocumentProvider?.Length > 0 || legacyGraphProvider?.Length > 0) + { + throw new InvalidOperationException( + "Legacy provider single-selection options are no longer supported. " + + "Use Projection:Document:Providers:*:Enabled and Projection:Graph:Providers:*:Enabled for one-to-many provider registration."); + } + } - return new ProviderSelection(documentProvider, graphProvider); + private static bool ResolveElasticsearchDocumentEnabled(IConfiguration configuration) + { + var section = configuration.GetSection("Projection:Document:Providers:Elasticsearch"); + var explicitEnabled = section["Enabled"]; + var hasEndpoints = section + .GetSection("Endpoints") + .GetChildren() + .Select(x => x.Value?.Trim() ?? "") + .Any(x => x.Length > 0); + + return ResolveOptionalBool(explicitEnabled, hasEndpoints); + } + + private static bool ResolveNeo4jGraphEnabled(IConfiguration configuration) + { + var section = configuration.GetSection("Projection:Graph:Providers:Neo4j"); + var explicitEnabled = section["Enabled"]; + var hasUri = (section["Uri"]?.Trim().Length ?? 0) > 0; + + return ResolveOptionalBool(explicitEnabled, hasUri); + } + + private static ElasticsearchProjectionReadModelStoreOptions BuildElasticsearchDocumentOptions( + IConfiguration configuration) + { + var options = new ElasticsearchProjectionReadModelStoreOptions(); + configuration.GetSection("Projection:Document:Providers:Elasticsearch").Bind(options); + if (options.Endpoints.Count == 0) + { + throw new InvalidOperationException( + "Projection:Document:Providers:Elasticsearch is enabled but Endpoints is empty."); + } + + return options; + } + + private static Neo4jProjectionGraphStoreOptions BuildNeo4jGraphOptions(IConfiguration configuration) + { + var options = new Neo4jProjectionGraphStoreOptions(); + configuration.GetSection("Projection:Graph:Providers:Neo4j").Bind(options); + if (string.IsNullOrWhiteSpace(options.Uri)) + { + throw new InvalidOperationException( + "Projection:Graph:Providers:Neo4j is enabled but Uri is empty."); + } + + return options; } private static void EnforceGraphProviderPolicy( IConfiguration configuration, - string graphProviderName) + bool enableInMemoryGraphProvider) { - var denyInMemoryGraphProvider = ParseBool( - configuration["Projection:Policies:DenyInMemoryGraphFactStore"]); + var denyInMemoryGraphProvider = ResolveOptionalBool( + configuration["Projection:Policies:DenyInMemoryGraphFactStore"], + fallbackValue: false); var environment = ResolveRuntimeEnvironment(configuration["Projection:Policies:Environment"]); var production = IsProductionEnvironment(environment); - if ((denyInMemoryGraphProvider || production) && - string.Equals( - graphProviderName, - ProjectionProviderNames.InMemory, - StringComparison.OrdinalIgnoreCase)) + if ((denyInMemoryGraphProvider || production) && enableInMemoryGraphProvider) { throw new InvalidOperationException( "InMemory graph provider is not allowed by projection policy. " + - "Use a durable graph provider (for example Neo4j) for production/distributed deployments."); + "Disable Projection:Graph:Providers:InMemory:Enabled and configure Neo4j."); } } @@ -102,125 +190,16 @@ private static bool IsProductionEnvironment(string environment) return string.Equals(environment, "Production", StringComparison.OrdinalIgnoreCase); } - private static bool ParseBool(string? value) + private static bool ResolveOptionalBool(string? rawValue, bool fallbackValue) { - return bool.TryParse(value, out var parsed) && parsed; - } + if (string.IsNullOrWhiteSpace(rawValue)) + return fallbackValue; - private static string NormalizeDocumentProvider( - string? configuredValue, - string fallbackValue, - string optionPath) - { - var candidate = string.IsNullOrWhiteSpace(configuredValue) - ? fallbackValue - : configuredValue.Trim(); - - if (string.Equals(candidate, ProjectionProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) - return ProjectionProviderNames.InMemory; - if (string.Equals(candidate, ProjectionProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) - return ProjectionProviderNames.Elasticsearch; - if (string.Equals(candidate, ProjectionProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - "Neo4j cannot be used as document provider. " + - "Use Elasticsearch for document indexing and Neo4j for graph traversal."); - } + if (!bool.TryParse(rawValue, out var parsed)) + throw new InvalidOperationException($"Invalid boolean value '{rawValue}'."); - throw new InvalidOperationException( - $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + - $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Elasticsearch}."); - } - - private static string NormalizeGraphProvider( - string? configuredValue, - string fallbackValue, - string optionPath) - { - var candidate = string.IsNullOrWhiteSpace(configuredValue) - ? fallbackValue - : configuredValue.Trim(); - - if (string.Equals(candidate, ProjectionProviderNames.InMemory, StringComparison.OrdinalIgnoreCase)) - return ProjectionProviderNames.InMemory; - if (string.Equals(candidate, ProjectionProviderNames.Neo4j, StringComparison.OrdinalIgnoreCase)) - return ProjectionProviderNames.Neo4j; - if (string.Equals(candidate, ProjectionProviderNames.Elasticsearch, StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - "Elasticsearch cannot be used as graph provider. " + - "Use InMemory (dev/test) or Neo4j."); - } - - throw new InvalidOperationException( - $"Unsupported projection provider '{candidate}' configured at '{optionPath}'. " + - $"Allowed values: {ProjectionProviderNames.InMemory}, {ProjectionProviderNames.Neo4j}."); - } - - private static void RegisterDocumentProvider( - IServiceCollection services, - IConfiguration configuration, - string providerName) - { - switch (providerName) - { - case ProjectionProviderNames.InMemory: - services.AddInMemoryDocumentStoreRegistration( - keySelector: report => report.RootActorId, - keyFormatter: key => key, - listSortSelector: report => report.CreatedAt, - listTakeMax: 200); - break; - case ProjectionProviderNames.Elasticsearch: - services.AddElasticsearchDocumentStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new ElasticsearchProjectionReadModelStoreOptions(); - configuration.GetSection("Projection:Document:Providers:Elasticsearch").Bind(providerOptions); - return providerOptions; - }, - indexScopeFactory: sp => - { - var metadataResolver = sp.GetRequiredService(); - return metadataResolver.Resolve().IndexName; - }, - keySelector: report => report.RootActorId, - keyFormatter: key => key); - break; - default: - throw new InvalidOperationException($"Unsupported document provider '{providerName}'."); - } - } - - private static void RegisterGraphProvider( - IServiceCollection services, - IConfiguration configuration, - string providerName) - { - switch (providerName) - { - case ProjectionProviderNames.InMemory: - services.AddInMemoryGraphStoreRegistration(); - break; - case ProjectionProviderNames.Elasticsearch: - throw new InvalidOperationException( - "Elasticsearch cannot be used as graph provider. Use InMemory (dev/test) or Neo4j."); - case ProjectionProviderNames.Neo4j: - services.AddNeo4jGraphStoreRegistration( - optionsFactory: _ => - { - var providerOptions = new Neo4jProjectionGraphStoreOptions(); - configuration.GetSection("Projection:Graph:Providers:Neo4j").Bind(providerOptions); - return providerOptions; - }, - scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); - break; - default: - throw new InvalidOperationException($"Unsupported graph provider '{providerName}'."); - } + return parsed; } private sealed class WorkflowProjectionProviderRegistrationsMarker; - - private sealed record ProviderSelection(string DocumentProvider, string GraphProvider); } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs index f7dcfd8f7..a57292d0d 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs @@ -117,7 +117,11 @@ private static ElasticsearchProjectionReadModelStore Cre options.Endpoints = ["http://localhost:9200"]; return new ElasticsearchProjectionReadModelStore( options, - "workflow-execution-reports", + new DocumentIndexMetadata( + IndexName: "projection-core-tests", + MappingJson: "{}", + Settings: new Dictionary(), + Aliases: new Dictionary()), keySelector: model => model.Id, keyFormatter: key => key, httpMessageHandler: handler); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs index 9d14be7de..939e3fad8 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -20,7 +20,11 @@ public async Task ElasticsearchStore_ShouldRoundtripUpsertAndMutate() var indexScope = "projection-provider-e2e-" + Guid.NewGuid().ToString("N"); using var store = new ElasticsearchProjectionReadModelStore( options, - indexScope, + new DocumentIndexMetadata( + IndexName: indexScope, + MappingJson: "{}", + Settings: new Dictionary(), + Aliases: new Dictionary()), model => model.Id); var readModel = new ProviderStoreSmokeReadModel diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index 4fb8477be..3a64116a3 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -7,55 +7,66 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { [Fact] - public void DocumentStoreFactory_WhenRequestedProviderMatched_ShouldCreateRequestedProviderStore() + public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQueryStore() { + var primaryStore = new NamedDocumentStore("primary"); + var replicaStore = new NamedDocumentStore("replica"); var services = new ServiceCollection(); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "InMemory", - _ => new NamedDocumentStore("InMemory"))); + "primary", + _ => primaryStore)); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "Elasticsearch", - _ => new NamedDocumentStore("Elasticsearch"))); + "replica", + _ => replicaStore)); using var serviceProvider = services.BuildServiceProvider(); - var factory = new ProjectionDocumentStoreFactory(); + var fanout = new ProjectionDocumentStoreFanout( + serviceProvider.GetServices>>(), + serviceProvider); - var selected = factory.Create(serviceProvider, "inmemory"); - var typed = selected.Should().BeOfType().Subject; - typed.ProviderName.Should().Be("InMemory"); + var model = new TestReadModel + { + Id = "id-1", + Value = "v1", + }; + + await fanout.UpsertAsync(model); + + primaryStore.UpsertCount.Should().Be(1); + replicaStore.UpsertCount.Should().Be(1); + + var fetched = await fanout.GetAsync("id-1"); + fetched.Should().NotBeNull(); + fetched!.Value.Should().Be("v1"); } [Fact] - public void DocumentStoreFactory_WhenMultipleProvidersWithoutRequested_ShouldThrowStructuredException() + public void ProjectionDocumentStoreFanout_WhenNoRegistrations_ShouldThrow() { var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "InMemory", - _ => new NamedDocumentStore("InMemory"))); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "Elasticsearch", - _ => new NamedDocumentStore("Elasticsearch"))); - using var serviceProvider = services.BuildServiceProvider(); - var factory = new ProjectionDocumentStoreFactory(); - Action act = () => factory.Create(serviceProvider); + Action act = () => new ProjectionDocumentStoreFanout( + [], + serviceProvider); - act.Should().Throw() - .Where(ex => ex.ReadModelType == typeof(TestReadModel)); + act.Should().Throw() + .WithMessage("*No document projection store providers are registered*"); } public sealed class TestReadModel { public string Id { get; set; } = ""; + + public string Value { get; set; } = ""; } private sealed class NamedDocumentStore : IDocumentProjectionStore { + private readonly Dictionary _models = new(StringComparer.Ordinal); + public NamedDocumentStore(string providerName) { ProviderName = providerName; @@ -63,33 +74,37 @@ public NamedDocumentStore(string providerName) public string ProviderName { get; } + public int UpsertCount { get; private set; } + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) { - _ = readModel; - _ = ct; + _models[readModel.Id] = new TestReadModel + { + Id = readModel.Id, + Value = readModel.Value, + }; + UpsertCount++; return Task.CompletedTask; } public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) { - _ = key; - _ = mutate; - _ = ct; + if (!_models.TryGetValue(key, out var existing)) + throw new InvalidOperationException($"Missing key '{key}'."); + + mutate(existing); return Task.CompletedTask; } public Task GetAsync(string key, CancellationToken ct = default) { - _ = key; - _ = ct; - return Task.FromResult(null); + _models.TryGetValue(key, out var value); + return Task.FromResult(value); } public Task> ListAsync(int take = 50, CancellationToken ct = default) { - _ = take; - _ = ct; - return Task.FromResult>([]); + return Task.FromResult>(_models.Values.Take(take).ToList()); } } } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 91958c6ba..a32e154f1 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -7,55 +7,60 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelStoreSelectorTests { [Fact] - public void GraphStoreFactory_WhenRequestedProviderMatched_ShouldCreateRequestedProviderStore() + public async Task ProjectionGraphStoreFanout_ShouldFanoutWritesAndUsePrimaryQueryStore() { + var primaryStore = new NamedGraphStore("primary"); + var replicaStore = new NamedGraphStore("replica"); var services = new ServiceCollection(); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "inmemory", - _ => new NamedGraphStore("inmemory"))); + "primary", + _ => primaryStore)); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "neo4j", - _ => new NamedGraphStore("neo4j"))); + "replica", + _ => replicaStore)); using var serviceProvider = services.BuildServiceProvider(); - var factory = new ProjectionGraphStoreFactory(); + var fanout = new ProjectionGraphStoreFanout( + serviceProvider.GetServices>(), + serviceProvider); - var selected = factory.Create(serviceProvider, "Neo4J"); - var typed = selected.Should().BeOfType().Subject; - typed.ProviderName.Should().Be("neo4j"); - } - - [Fact] - public void GraphStoreFactory_WhenRequestedProviderMissing_ShouldThrow() - { - var services = new ServiceCollection(); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "inmemory", - _ => new NamedGraphStore("inmemory"))); - - using var serviceProvider = services.BuildServiceProvider(); - var factory = new ProjectionGraphStoreFactory(); - - Action act = () => factory.Create(serviceProvider, "elasticsearch"); - - act.Should().Throw() - .Where(ex => ex.Reason.Contains("Requested relation store provider is not registered", StringComparison.Ordinal)); + await fanout.UpsertNodeAsync(new ProjectionGraphNode + { + Scope = "projection-scope", + NodeId = "node-1", + NodeType = "Actor", + Properties = new Dictionary(), + UpdatedAt = DateTimeOffset.UtcNow, + }); + + var edges = await fanout.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "projection-scope", + RootNodeId = "node-1", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Depth = 1, + Take = 10, + }); + + primaryStore.UpsertNodeCount.Should().Be(1); + replicaStore.UpsertNodeCount.Should().Be(1); + edges.Should().HaveCount(1); + edges[0].EdgeType.Should().Be("primary"); } [Fact] - public void GraphStoreFactory_WhenNoProviderRegistered_ShouldThrow() + public void ProjectionGraphStoreFanout_WhenNoRegistrations_ShouldThrow() { var services = new ServiceCollection(); using var serviceProvider = services.BuildServiceProvider(); - var factory = new ProjectionGraphStoreFactory(); - Action act = () => factory.Create(serviceProvider, ProjectionProviderNames.InMemory); + Action act = () => new ProjectionGraphStoreFanout([], serviceProvider); - act.Should().Throw() - .Where(ex => ex.Reason.Contains("No relation store provider registrations", StringComparison.Ordinal)); + act.Should().Throw() + .WithMessage("*No graph projection store providers are registered*"); } private sealed class NamedGraphStore : IProjectionGraphStore @@ -67,16 +72,51 @@ public NamedGraphStore(string providerName) public string ProviderName { get; } - public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) => Task.CompletedTask; + public int UpsertNodeCount { get; private set; } + + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) + { + _ = node; + UpsertNodeCount++; + return Task.CompletedTask; + } - public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) => Task.CompletedTask; + public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) + { + _ = edge; + return Task.CompletedTask; + } - public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) => Task.CompletedTask; + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + _ = scope; + _ = edgeId; + return Task.CompletedTask; + } - public Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default) => - Task.FromResult>([]); + public Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default) + { + _ = query; + IReadOnlyList result = + [ + new ProjectionGraphEdge + { + Scope = "projection-scope", + EdgeId = "edge-1", + EdgeType = ProviderName, + FromNodeId = "node-1", + ToNodeId = "node-2", + Properties = new Dictionary(), + UpdatedAt = DateTimeOffset.UtcNow, + }, + ]; + return Task.FromResult(result); + } - public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) => - Task.FromResult(new ProjectionGraphSubgraph()); + public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) + { + _ = query; + return Task.FromResult(new ProjectionGraphSubgraph()); + } } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 6623f931c..6a65e8faa 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -1,16 +1,13 @@ using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; -using Aevatar.CQRS.Projection.Providers.InMemory.Stores; using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.DependencyInjection; using Aevatar.Workflow.Projection.ReadModels; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; namespace Aevatar.Workflow.Host.Api.Tests; @@ -26,12 +23,12 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNoProvidersRegistered_S await using var provider = services.BuildServiceProvider(); Func act = () => StartHostedServicesAsync(provider); - await act.Should().ThrowAsync() - .WithMessage("*No document store provider registrations were found*"); + await act.Should().ThrowAsync() + .WithMessage("*No document projection store providers are registered*"); } [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveInMemoryDocumentAndGraphStores() + public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveFanoutStores() { var services = new ServiceCollection(); RegisterInMemoryProviders(services); @@ -39,14 +36,12 @@ public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveInMemoryDocume await using var provider = services.BuildServiceProvider(); var documentStore = provider.GetRequiredService>(); - var readModelStore = provider.GetRequiredService>(); var relationStore = provider.GetRequiredService(); var graphStore = provider.GetRequiredService>(); var router = provider.GetRequiredService>(); - documentStore.Should().BeOfType>(); - readModelStore.Should().BeOfType>(); - relationStore.Should().BeOfType(); + documentStore.Should().BeOfType>(); + relationStore.Should().BeOfType(); graphStore.Should().BeOfType>(); router.Should().NotBeNull(); @@ -54,42 +49,18 @@ public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveInMemoryDocume await act.Should().NotThrowAsync(); } - [Fact] - public void AddWorkflowExecutionProjectionCQRS_WhenDocumentElasticsearchAndGraphInMemoryConfigured_ShouldResolveSplitProviders() - { - var services = new ServiceCollection(); - RegisterInMemoryProviders(services); - RegisterElasticsearchDocumentProvider(services); - ConfigureStoreSelectionOptions( - services, - documentProvider: ProjectionProviderNames.Elasticsearch, - graphProvider: ProjectionProviderNames.InMemory); - services.AddWorkflowExecutionProjectionCQRS(); - - using var provider = services.BuildServiceProvider(); - var readModelStore = provider.GetRequiredService>(); - var relationStore = provider.GetRequiredService(); - - readModelStore.Should().BeOfType>(); - relationStore.Should().BeOfType(); - } - [Fact] public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldThrowOnGraphStoreResolution() { var services = new ServiceCollection(); RegisterElasticsearchDocumentProvider(services); - ConfigureStoreSelectionOptions( - services, - documentProvider: ProjectionProviderNames.Elasticsearch, - graphProvider: ProjectionProviderNames.Elasticsearch); services.AddWorkflowExecutionProjectionCQRS(); using var provider = services.BuildServiceProvider(); Action act = () => provider.GetRequiredService(); - act.Should().Throw() - .WithMessage("*No relation store provider registrations were found*"); + act.Should().Throw() + .WithMessage("*No graph projection store providers are registered*"); } private static void RegisterInMemoryProviders(IServiceCollection services) @@ -109,35 +80,15 @@ private static void RegisterElasticsearchDocumentProvider(IServiceCollection ser { Endpoints = ["http://localhost:9200"], }, - indexScopeFactory: sp => + metadataFactory: sp => { var metadataResolver = sp.GetRequiredService(); - return metadataResolver.Resolve().IndexName; + return metadataResolver.Resolve(); }, keySelector: report => report.RootActorId, keyFormatter: key => key); } - private static void ConfigureStoreSelectionOptions( - IServiceCollection services, - string documentProvider, - string graphProvider) - { - var documentOptions = new ProjectionDocumentRuntimeOptions - { - ProviderName = documentProvider, - FailFastOnStartup = true, - }; - var graphOptions = new ProjectionGraphRuntimeOptions - { - ProviderName = graphProvider, - FailFastOnStartup = true, - }; - - services.Replace(ServiceDescriptor.Singleton(documentOptions)); - services.Replace(ServiceDescriptor.Singleton(graphOptions)); - } - private static async Task StartHostedServicesAsync(IServiceProvider provider) { var hostedServices = provider.GetServices().ToList(); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index f0348fd81..63f9d3985 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -2,7 +2,6 @@ using Aevatar.AI.Abstractions.ToolProviders; using Aevatar.AI.ToolProviders.MCP; using Aevatar.AI.ToolProviders.Skills; -using Aevatar.CQRS.Projection.Core.Abstractions; using Aevatar.Workflow.Application.Abstractions; using Aevatar.Workflow.Application.Abstractions.Runs; using Aevatar.Workflow.Application.Runs; @@ -45,10 +44,11 @@ public async Task AddWorkflowCapabilityWithAIDefaults_ShouldRegisterWorkflowAndA builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunRequestExecutor)).Should().BeTrue(); builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunActorPort)).Should().BeTrue(); - builder.Services.Any(x => x.ServiceType == typeof(IDocumentProjectionStore)).Should().BeTrue(); + builder.Services.Any(x => x.ServiceType == typeof(IDocumentProjectionStore<,>)).Should().BeTrue(); await using var provider = builder.Services.BuildServiceProvider(); provider.GetService().Should().NotBeNull(); + provider.GetService>().Should().NotBeNull(); var toolSources = provider.GetServices().ToList(); toolSources.Should().NotContain(x => x is MCPAgentToolSource); @@ -97,15 +97,18 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo } [Fact] - public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_ShouldRegisterConfiguredCombinationOnly() + public void AddWorkflowProjectionReadModelProviders_WhenDurableProvidersEnabled_ShouldRegisterDurableCombinationOnly() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:Document:Provider"] = ProjectionProviderNames.Elasticsearch, - ["Projection:Graph:Provider"] = ProjectionProviderNames.InMemory, + ["Projection:Document:Providers:InMemory:Enabled"] = "false", + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "true", ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + ["Projection:Graph:Providers:InMemory:Enabled"] = "false", + ["Projection:Graph:Providers:Neo4j:Enabled"] = "true", + ["Projection:Graph:Providers:Neo4j:Uri"] = "bolt://localhost:7687", }) .Build(); @@ -117,58 +120,26 @@ public void AddWorkflowProjectionReadModelProviders_WhenProvidersAreConfigured_S var relationRegistrations = services .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) .ToList(); - var documentRuntimeOptionRegistrations = services - .Where(x => x.ServiceType == typeof(ProjectionDocumentRuntimeOptions)) - .ToList(); - var graphRuntimeOptionRegistrations = services - .Where(x => x.ServiceType == typeof(ProjectionGraphRuntimeOptions)) - .ToList(); providerRegistrations.Should().HaveCount(1); relationRegistrations.Should().HaveCount(1); - documentRuntimeOptionRegistrations.Should().HaveCount(1); - graphRuntimeOptionRegistrations.Should().HaveCount(1); - - using var provider = services.BuildServiceProvider(); - var documentOptions = provider.GetRequiredService(); - var graphOptions = provider.GetRequiredService(); - documentOptions.ProviderName.Should().Be(ProjectionProviderNames.Elasticsearch); - graphOptions.ProviderName.Should().Be(ProjectionProviderNames.InMemory); } [Fact] - public void AddWorkflowProjectionReadModelProviders_WhenProviderConfiguredUnknown_ShouldThrow() + public void AddWorkflowProjectionReadModelProviders_WhenLegacyProviderConfigured_ShouldThrow() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:Document:Provider"] = "UnknownProvider", + ["Projection:Document:Provider"] = "InMemory", }) .Build(); Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); act.Should().Throw() - .WithMessage("*Unsupported projection provider*"); - } - - [Fact] - public void AddWorkflowProjectionReadModelProviders_WhenDocumentProviderConfiguredAsNeo4j_ShouldThrow() - { - var services = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Projection:Document:Provider"] = ProjectionProviderNames.Neo4j, - ["Projection:Graph:Provider"] = ProjectionProviderNames.Neo4j, - }) - .Build(); - - Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); - - act.Should().Throw() - .WithMessage("*Neo4j cannot be used as document provider*"); + .WithMessage("*Legacy provider single-selection options are no longer supported*"); } [Fact] @@ -178,9 +149,6 @@ public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRela var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { - ["Projection:Document:Provider"] = ProjectionProviderNames.Elasticsearch, - ["Projection:Graph:Provider"] = ProjectionProviderNames.InMemory, - ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", ["Projection:Policies:DenyInMemoryGraphFactStore"] = "true", }) .Build(); From ffb36c51c7dc47f903fdf36b29bc8bce50213552 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 02:02:14 +0800 Subject: [PATCH 34/46] Enhance Projection ReadModel with Primary Query Provider and Edge Management Features - Introduced a mechanism to explicitly specify a unique primary query provider for document and graph projections, enhancing clarity in multi-provider scenarios. - Updated the `IProjectionStoreRegistration` interface to include an `IsPrimaryQueryStore` property, ensuring only one primary provider is registered. - Implemented new methods in `IProjectionGraphStore` and `InMemoryProjectionGraphStore` to support edge retrieval by owner, facilitating precise cleanup operations. - Enhanced the `ProjectionGraphMaterializer` to manage edges based on ownership, improving the accuracy of graph updates and deletions. - Updated documentation across various components to reflect these architectural changes and clarify the new functionalities. --- ...readmodel-full-refactor-plan-2026-02-24.md | 15 + ...on-store-readmodel-scorecard-2026-02-24.md | 84 ++--- .../ServiceCollectionExtensions.cs | 2 + .../README.md | 3 +- .../ServiceCollectionExtensions.cs | 6 +- .../README.md | 5 +- .../Stores/InMemoryProjectionGraphStore.cs | 30 ++ .../ServiceCollectionExtensions.cs | 4 +- .../README.md | 5 +- .../Stores/Neo4jProjectionGraphStore.cs | 73 +++- .../DelegateProjectionStoreRegistration.cs | 4 + .../Core/IProjectionStoreRegistration.cs | 2 + .../README.md | 2 + src/Aevatar.CQRS.Projection.Runtime/README.md | 4 +- .../Runtime/ProjectionDocumentStoreFanout.cs | 54 ++- .../Runtime/ProjectionGraphMaterializer.cs | 82 +++-- .../Runtime/ProjectionGraphStoreFanout.cs | 48 ++- .../Graphs/IProjectionGraphStore.cs | 6 + .../ProjectionGraphSystemPropertyKeys.cs | 10 + ...tionProviderServiceCollectionExtensions.cs | 40 ++- .../ProjectionGraphMaterializerTests.cs | 338 ++++++++++++++++++ .../ProjectionReadModelRuntimeTests.cs | 98 ++++- .../ProjectionReadModelStoreSelectorTests.cs | 70 +++- ...lowExecutionProjectionRegistrationTests.cs | 4 +- .../WorkflowHostingExtensionsCoverageTests.cs | 43 +++ 25 files changed, 919 insertions(+), 113 deletions(-) create mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 135909c27..a01bf2a72 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -97,6 +97,21 @@ Rules: 1. Legacy single-select keys (`Projection:Document:Provider`, `Projection:Graph:Provider`) are rejected. 2. InMemory providers are fallback defaults when durable providers are not enabled. 3. `Projection:Policies:DenyInMemoryGraphFactStore=true` forbids in-memory graph fact store. +4. 多 provider 场景显式指定唯一 primary query provider: + - Document:优先 `Elasticsearch`,否则 `InMemory` + - Graph:优先 `Neo4j`,否则 `InMemory` + +## 3.6 Query Primary & Graph Cleanup Hardening + +- `IProjectionStoreRegistration` 增加 `IsPrimaryQueryStore`。 +- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` 改为: + - 多注册必须且仅允许一个 primary; + - 单注册允许无 primary(默认该唯一 provider 为 query store); + - 冲突或缺失时 fail-fast。 +- `IProjectionGraphStore` 增加 `ListEdgesByOwnerAsync(scope, ownerId, take)`。 +- `ProjectionGraphMaterializer` 从锚点子图清理重构为 owner-based 精确清理: + - 写边时注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` + - 清理时按 owner 列举已有边并做差集删除,不再依赖 `Depth/Take` 子图扫描窗口。 ## 3.5 Elasticsearch Metadata Behavior diff --git a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md index 868eaa7a8..a1e563656 100644 --- a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md @@ -1,4 +1,4 @@ -# Projection Store / ReadModel 严格评分卡(2026-02-24,重构后) +# Projection Store / ReadModel 严格评分卡(2026-02-24 复评分) ## 1. 审计范围 @@ -10,69 +10,61 @@ 6. `src/Aevatar.CQRS.Projection.Providers.Neo4j` 7. `src/workflow/Aevatar.Workflow.Projection` 8. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` -9. `test/Aevatar.CQRS.Projection.Core.Tests`、`test/Aevatar.Workflow.Host.Api.Tests` +9. `test/Aevatar.CQRS.Projection.Core.Tests` +10. `test/Aevatar.Workflow.Host.Api.Tests` -## 2. 验证基线 +## 2. 本次验证基线 -已执行并通过: +本次复评执行并通过: 1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test aevatar.slnx --nologo` -3. `bash tools/ci/architecture_guards.sh` -4. `bash tools/ci/projection_route_mapping_guard.sh` -5. `bash tools/ci/solution_split_guards.sh` -6. `bash tools/ci/solution_split_test_guards.sh` -7. `bash tools/ci/test_stability_guards.sh` +2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` +3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +4. `bash tools/ci/architecture_guards.sh` +5. `bash tools/ci/projection_route_mapping_guard.sh` ## 3. 总分结论 -- **总分:90 / 100** -- **等级:A-(严格口径)** -- **结论:架构主干已清晰收敛为“Document/Graph 分离 + 各自一对多 Fan-out + ReadModel 接口驱动”。主要剩余风险集中在 Graph 清理窗口与跨存储一致性策略。** +- **总分:84 / 100** +- **等级:B+(严格口径)** +- **结论:主架构已经从“单选 provider”收敛到“Document/Graph 分离的一对多 fan-out”,方向正确;但仍存在主查询源隐式选择、图清理窗口固定、跨 store 非事务一致性等高影响工程风险。** -## 4. 维度评分(严格扣分) +## 4. 维度评分(严格) -| 维度 | 权重 | 得分 | 说明 | +| 维度 | 权重 | 得分 | 证据与说明 | |---|---:|---:|---| -| 架构边界与抽象收敛 | 15 | 15 | 已删除 `Factory/Selector/RuntimeOptions/ProviderNames` 冗余层;Runtime 仅保留 fan-out 与 materialization。 | -| Provider 模型清晰度 | 15 | 15 | 从单选改为一对多广播:`ProjectionDocumentStoreFanout<,>`、`ProjectionGraphStoreFanout`。 | -| Document 索引语义完整性 | 20 | 18 | Elasticsearch 已使用 `DocumentIndexMetadata` 的 `MappingJson/Settings/Aliases` 初始化索引。 | -| Graph 关系语义与正确性 | 15 | 12 | `IGraphReadModel` 声明式节点/边清晰;但图清理仍依赖固定窗口扫描。 | -| 一致性与失败语义 | 10 | 8 | 双写为顺序 fan-out,非事务;故障语义仍是“部分成功即失败抛出”。 | -| Provider 实现质量与性能 | 10 | 8 | ES OCC 完整;Neo4j 子图遍历仍存在逐层多次查询放大风险。 | -| 可观测性 | 5 | 4 | 文档写路径日志较完整;图路径仍可补充统一成功日志与关键指标。 | -| 测试与治理门禁 | 10 | 10 | Core/Workflow 测试已对 fan-out 语义更新,build/test/guards 全通过。 | +| 架构边界与抽象收敛 | 15 | 14 | 已删 `Factory/Selector/RuntimeOptions`,Runtime 仅保留 fan-out + materialization(`Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:11-15`)。扣分:`IProjectionStoreRegistration` 仍保留 `ProviderName`,但运行时不再用其做治理约束(`Runtime.Abstractions/.../IProjectionStoreRegistration.cs:3-7`)。 | +| Provider 模型清晰度 | 15 | 14 | Fan-out 明确:`ProjectionDocumentStoreFanout` 与 `ProjectionGraphStoreFanout`(`.../ProjectionDocumentStoreFanout.cs:6-84`,`.../ProjectionGraphStoreFanout.cs:6-82`)。扣分:主查询源由“第一个注册”隐式决定(`ProjectionDocumentStoreFanout.cs:34`,`ProjectionGraphStoreFanout.cs:31`)。 | +| Document 索引语义完整性 | 20 | 17 | ES 已使用 `DocumentIndexMetadata` 初始化 mappings/settings/aliases(`ElasticsearchProjectionReadModelStore.cs:486-503`,`505-532`)。扣分:元数据结构仍是 `Dictionary`,复杂 settings/aliases 需字符串内嵌 JSON(`DocumentIndexMetadata.cs:3-7`,`ElasticsearchProjectionReadModelStore.cs:515-529`);Workflow 默认 metadata 仍是空 mapping(`WorkflowExecutionReportDocumentMetadataProvider.cs:8-12`)。 | +| Graph 关系语义与正确性 | 15 | 12 | `IGraphReadModel` 采用声明式节点/边接口(`IGraphReadModel.cs:3-9`);Workflow ReadModel 同时实现 Doc+Graph(`WorkflowExecutionReadModel.cs:32-45`)。扣分:图清理固定窗口 `Depth=8/Take=5000`(`ProjectionGraphMaterializer.cs:42-50`),且锚点推断为首节点/ReadModel.Id/首边 from(`66-79`)。 | +| 一致性与失败语义 | 10 | 7 | 路由按能力执行(`ProjectionMaterializationRouter.cs:31-37`)。扣分:跨 store 非事务;`Document` 先写成功后 `Graph` 失败会留下部分成功状态(`31-37`);`Mutate` 后再 fan-out 到其它 store(`ProjectionDocumentStoreFanout.cs:58-73`)。 | +| Provider 实现质量与性能 | 10 | 8 | ES OCC 重试完备(`ElasticsearchProjectionReadModelStore.cs:93-143`)。扣分:Neo4j 子图遍历逐层调用 `GetNeighborsAsync`,存在放大风险(`Neo4jProjectionGraphStore.cs:190-209`)。 | +| 可观测性 | 5 | 3 | 文档路径日志完整(`ElasticsearchProjectionReadModelStore.cs:282-355`,`ProjectionDocumentStoreFanout.cs:35-38`)。扣分:Graph provider 成功路径日志薄弱,Neo4j 仅反序列化 warning(`Neo4jProjectionGraphStore.cs:475-480`)。 | +| 测试与治理门禁 | 10 | 9 | Fan-out 行为有单测(`ProjectionReadModelRuntimeTests.cs:10-57`,`ProjectionReadModelStoreSelectorTests.cs:10-64`);Workflow 注册策略有覆盖(`WorkflowExecutionProjectionRegistrationTests.cs:18-64`,`WorkflowHostingExtensionsCoverageTests.cs:99-160`);架构/路由守卫通过。扣分:缺少 `DocumentIndexMetadata` 的 mapping/settings/aliases 初始化行为专门测试。 | -## 5. 关键发现 +## 5. 关键扣分项(按优先级) -### 高优先级 +### P1 -1. **Graph 清理窗口固定值风险** - - `ProjectionGraphMaterializer` 仍使用固定 `Depth/Take` 子图扫描清理旧边。 +1. **主查询源隐式顺序问题** + - 当前 query store 取第一个注册,缺少显式 primary 机制(`ProjectionDocumentStoreFanout.cs:34`,`ProjectionGraphStoreFanout.cs:31`)。 -### 中优先级 +2. **Graph 清理窗口固定值** + - `Depth=8/Take=5000` 可能导致边清理不完整(`ProjectionGraphMaterializer.cs:42-50`)。 -1. **跨 Provider 双写非事务** - - 当前策略是顺序写入,失败后由上层重试/补偿,不保证原子。 +### P2 -2. **Neo4j 子图查询存在潜在放大** - - 深度遍历为循环邻接查询模式,大图场景需继续压测与优化。 +1. **跨 provider 双写非事务** + - 先写后写失败不回滚(`ProjectionMaterializationRouter.cs:31-37`)。 -## 6. 严格整改建议 +2. **Neo4j 子图遍历放大风险** + - 每层每节点邻接查询(`Neo4jProjectionGraphStore.cs:190-209`)。 -### P1(必须) +### P3 -1. 为 Graph 清理增加可配置窗口、分批策略与 owner/version 标记。 +1. **Graph 可观测性偏弱** + - 缺少统一写入成功/失败指标日志模板。 -### P2(应做) +## 6. 复评判定 -1. 增加跨 Provider 写失败重试/补偿策略文档与可观测指标。 -2. 为 Neo4j 子图查询补充压力测试与查询计划基准。 - -### P3(可选) - -1. 引入图写入/清理统计指标(吞吐、延迟、清理命中率)。 - -## 7. 最终判定 - -重构后已达到“结构正确、边界清晰、可验证”的高质量状态;在严格口径下为 **90/100(A-)**。 +当前实现已经达到“架构方向正确、分层清晰、可落地运行”,但在严格工程口径下仍未达到 A 档稳定性。复评分为 **84/100(B+)**。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index a6aee7094..23604f38b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration optionsFactory, Func metadataFactory, Func keySelector, + bool isPrimaryQueryStore, Func? keyFormatter = null) where TReadModel : class { @@ -22,6 +23,7 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( "Elasticsearch", + isPrimaryQueryStore, provider => new ElasticsearchProjectionReadModelStore( optionsFactory(provider), metadataFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 9c9591361..4c968b331 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -11,10 +11,11 @@ ## DI 注册 -- `AddElasticsearchDocumentStoreRegistration(...)` +- `AddElasticsearchDocumentStoreRegistration(..., isPrimaryQueryStore, ...)` 关键参数: - `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 - `metadataFactory`:通常由 `IProjectionDocumentMetadataResolver` 解析 `IProjectionDocumentMetadataProvider`。 - `keySelector/keyFormatter`:ReadModel 主键映射。 +- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 90ce52acb..efa147571 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddInMemoryDocumentStoreRegistration( this IServiceCollection services, Func keySelector, + bool isPrimaryQueryStore, Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200) @@ -19,6 +20,7 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( "InMemory", + isPrimaryQueryStore, provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -30,11 +32,13 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>( new DelegateProjectionStoreRegistration( "InMemory", + isPrimaryQueryStore, _ => new InMemoryProjectionGraphStore())); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index c1705238d..f49fef138 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -9,11 +9,12 @@ ## DI 注册 -- `AddInMemoryDocumentStoreRegistration(...)` -- `AddInMemoryGraphStoreRegistration()` +- `AddInMemoryDocumentStoreRegistration(..., isPrimaryQueryStore, ...)` +- `AddInMemoryGraphStoreRegistration(isPrimaryQueryStore)` 关键参数: - `keySelector/keyFormatter`:ReadModel 主键映射。 +- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 - `listSortSelector`:`ListAsync` 排序字段(可选)。 - `listTakeMax`:`ListAsync` 硬上限。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index bb05510f4..35645ab7c 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -62,6 +62,36 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = return Task.CompletedTask; } + public Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return Task.FromResult>([]); + + var boundedTake = Math.Clamp(take, 1, 50000); + List edges; + lock (_gate) + { + edges = _edges.Values + .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) + .Where(x => + x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && + string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) + .OrderByDescending(x => x.UpdatedAt) + .Take(boundedTake) + .Select(CloneEdge) + .ToList(); + } + + return Task.FromResult>(edges); + } + public Task> GetNeighborsAsync( ProjectionGraphQuery query, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 2ffa1cfa9..44832e703 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -10,7 +10,8 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddNeo4jGraphStoreRegistration( this IServiceCollection services, Func optionsFactory, - Func scopeFactory) + Func scopeFactory, + bool isPrimaryQueryStore) { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); @@ -18,6 +19,7 @@ public static IServiceCollection AddNeo4jGraphStoreRegistration( services.AddSingleton>( new DelegateProjectionStoreRegistration( "Neo4j", + isPrimaryQueryStore, provider => new Neo4jProjectionGraphStore( optionsFactory(provider), scopeFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index 14969cdb2..7c577c8e9 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -5,13 +5,14 @@ - 不依赖任何业务域 read model。 - 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 - 基于官方 `Neo4j.Driver` 实现连接与会话管理。 -- 支持 schema 约束初始化、邻居查询、子图遍历。 +- 支持 schema 约束初始化、邻居查询、子图遍历、owner 维度边查询(用于精确清理)。 ## DI 注册 -- `AddNeo4jGraphStoreRegistration(...)` +- `AddNeo4jGraphStoreRegistration(..., isPrimaryQueryStore)` 关键参数: - `optionsFactory`:绑定 `Projection:Graph:Providers:Neo4j:*` 配置。 - `scopeFactory`:graph scope 提供器。 +- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index ec84ea055..7cb7b9291 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -96,12 +96,18 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct var updatedAtEpochMs = NormalizeTimestamp(edge.UpdatedAt); var propertiesJson = SerializeProperties(edge.Properties); + var projectionManaged = ResolveProjectionManaged(edge.Properties); + var projectionOwnerId = ResolveProjectionOwnerId(edge.Properties); var cypher = $"MERGE (from:{_nodeLabel} {{scope: $scope, nodeId: $fromNodeId}}) " + "ON CREATE SET from.nodeType = 'Unknown', from.propertiesJson = '{}', from.updatedAtEpochMs = $updatedAtEpochMs " + $"MERGE (to:{_nodeLabel} {{scope: $scope, nodeId: $toNodeId}}) " + "ON CREATE SET to.nodeType = 'Unknown', to.propertiesJson = '{}', to.updatedAtEpochMs = $updatedAtEpochMs " + $"MERGE (from)-[r:{_edgeType} {{scope: $scope, edgeId: $edgeId}}]->(to) " + - "SET r.relationType = $relationType, r.propertiesJson = $propertiesJson, r.updatedAtEpochMs = $updatedAtEpochMs"; + "SET r.relationType = $relationType, " + + "r.propertiesJson = $propertiesJson, " + + "r.updatedAtEpochMs = $updatedAtEpochMs, " + + "r.projectionManaged = $projectionManaged, " + + "r.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; var parameters = new Dictionary { ["scope"] = scope, @@ -111,6 +117,8 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct ["relationType"] = relationType, ["propertiesJson"] = propertiesJson, ["updatedAtEpochMs"] = updatedAtEpochMs, + ["projectionManaged"] = projectionManaged, + ["projectionOwnerId"] = projectionOwnerId, }; await EnsureSchemaAsync(ct); @@ -135,6 +143,49 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken await ExecuteWriteAsync(cypher, parameters, ct); } + public async Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return []; + + await EnsureSchemaAsync(ct); + var boundedTake = Math.Clamp(take, 1, 50000); + var cypher = $"MATCH ()-[r:{_edgeType} {{scope: $scope}}]->() " + + "WHERE coalesce(r.projectionManaged, false) = true " + + "AND r.projectionOwnerId = $ownerId " + + "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + var parameters = new Dictionary + { + ["scope"] = scopeValue, + ["ownerId"] = ownerValue, + ["take"] = boundedTake, + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var edges = new List(rows.Count); + foreach (var row in rows) + { + var edge = BuildEdgeFromRow(scopeValue, row); + if (edge != null) + edges.Add(edge); + } + + return edges; + } + public async Task> GetNeighborsAsync( ProjectionGraphQuery query, CancellationToken ct = default) @@ -452,6 +503,26 @@ private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) .ToArray(); } + private static bool ResolveProjectionManaged(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue)) + return false; + + var normalizedMarker = NormalizeToken(markerValue); + return string.Equals( + normalizedMarker, + ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, + StringComparison.Ordinal); + } + + private static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var ownerId)) + return ""; + + return NormalizeToken(ownerId); + } + private string SerializeProperties(IReadOnlyDictionary properties) { if (properties.Count == 0) diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 58c36550f..19539558f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -6,6 +6,7 @@ public sealed class DelegateProjectionStoreRegistration : IProjectionSto public DelegateProjectionStoreRegistration( string providerName, + bool isPrimaryQueryStore, Func factory) { if (string.IsNullOrWhiteSpace(providerName)) @@ -13,11 +14,14 @@ public DelegateProjectionStoreRegistration( ArgumentNullException.ThrowIfNull(factory); ProviderName = providerName.Trim(); + IsPrimaryQueryStore = isPrimaryQueryStore; _factory = factory; } public string ProviderName { get; } + public bool IsPrimaryQueryStore { get; } + public TStore Create(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index d43210ba0..18d8d1fc0 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -4,5 +4,7 @@ public interface IProjectionStoreRegistration { string ProviderName { get; } + bool IsPrimaryQueryStore { get; } + TStore Create(IServiceProvider serviceProvider); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 3c681b8be..09af3e4af 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -11,6 +11,8 @@ ## 关键契约 - Store 注册:`IProjectionStoreRegistration` + - `ProviderName` + - `IsPrimaryQueryStore`(多 provider 场景下唯一主查询存储) - Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` - Metadata:`IProjectionDocumentMetadataResolver` diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index 12e228d3f..4eafb56e6 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -25,6 +25,6 @@ ## 设计约束 1. 不承载业务 ReadModel 类型。 -2. 不做 providerName 单选,不存在运行时降级逻辑。 -3. Document 与 Graph 完全解耦,分别按注册列表一对多分发。 +2. 不做 providerName 单选,不存在运行时降级逻辑;多 provider 时必须显式且唯一 `IsPrimaryQueryStore=true`。 +3. Document 与 Graph 完全解耦,分别按注册列表一对多分发(写 fan-out,读走 primary)。 4. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs index 5b9cf2c38..ed2a369f9 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs @@ -8,7 +8,9 @@ public sealed class ProjectionDocumentStoreFanout where TReadModel : class { private readonly IReadOnlyList> _stores; + private readonly IReadOnlyList> _replicaStores; private readonly IDocumentProjectionStore _queryStore; + private readonly string _queryProviderName; private readonly ILogger> _logger; public ProjectionDocumentStoreFanout( @@ -20,9 +22,10 @@ public ProjectionDocumentStoreFanout( ArgumentNullException.ThrowIfNull(serviceProvider); var registrationList = registrations.ToList(); - _stores = registrationList + var resolvedStores = registrationList .Select(x => x.Create(serviceProvider)) .ToList(); + _stores = resolvedStores; _logger = logger ?? NullLogger>.Instance; if (_stores.Count == 0) @@ -31,11 +34,50 @@ public ProjectionDocumentStoreFanout( $"No document projection store providers are registered for read model '{typeof(TReadModel).FullName}'."); } - _queryStore = _stores[0]; + var primaryRegistrations = registrationList + .Where(x => x.IsPrimaryQueryStore) + .ToList(); + if (primaryRegistrations.Count == 0 && registrationList.Count > 1) + { + var providers = string.Join(", ", registrationList.Select(x => x.ProviderName)); + throw new InvalidOperationException( + $"Exactly one primary document projection store provider must be configured for read model '{typeof(TReadModel).FullName}'. registeredProviders=[{providers}]"); + } + + if (primaryRegistrations.Count > 1) + { + var providers = string.Join(", ", primaryRegistrations.Select(x => x.ProviderName)); + throw new InvalidOperationException( + $"Multiple primary document projection store providers are configured for read model '{typeof(TReadModel).FullName}'. primaryProviders=[{providers}]"); + } + + var queryRegistration = primaryRegistrations.Count == 1 + ? primaryRegistrations[0] + : registrationList[0]; + var queryIndex = registrationList.FindIndex(x => ReferenceEquals(x, queryRegistration)); + if (queryIndex < 0) + { + throw new InvalidOperationException( + $"Failed to resolve primary document projection store provider for read model '{typeof(TReadModel).FullName}'."); + } + + _queryStore = _stores[queryIndex]; + _queryProviderName = queryRegistration.ProviderName; + + var replicaStores = new List>(_stores.Count); + for (var i = 0; i < _stores.Count; i++) + { + if (i == queryIndex) + continue; + replicaStores.Add(_stores[i]); + } + + _replicaStores = replicaStores; _logger.LogInformation( - "Projection document fan-out initialized. readModelType={ReadModelType} storeCount={StoreCount}", + "Projection document fan-out initialized. readModelType={ReadModelType} storeCount={StoreCount} queryProvider={QueryProvider}", typeof(TReadModel).FullName, - _stores.Count); + _stores.Count, + _queryProviderName); } public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) @@ -56,7 +98,7 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT ct.ThrowIfCancellationRequested(); await _queryStore.MutateAsync(key, mutate, ct); - if (_stores.Count == 1) + if (_replicaStores.Count == 0) return; var updated = await _queryStore.GetAsync(key, ct); @@ -66,7 +108,7 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT $"Document fan-out mutate completed but query store returned null for read model '{typeof(TReadModel).FullName}'."); } - foreach (var store in _stores.Skip(1)) + foreach (var store in _replicaStores) { ct.ThrowIfCancellationRequested(); await store.UpsertAsync(updated, ct); diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs index a14140597..7012fe9f2 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs @@ -4,8 +4,6 @@ public sealed class ProjectionGraphMaterializer : IProjectionGraphMaterializer where TReadModel : class { - private const string ManagedMarkerKey = "projectionManaged"; - private const string ManagedMarkerValue = "true"; private readonly IProjectionGraphStore _graphStore; public ProjectionGraphMaterializer(IProjectionGraphStore graphStore) @@ -27,34 +25,26 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = $"Graph scope is required for read model '{typeof(TReadModel).FullName}'."); } + var ownerResolution = BuildManagedOwnerId(graphReadModel); + var ownerId = ownerResolution.OwnerId; + var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope); foreach (var node in normalizedNodes) await _graphStore.UpsertNodeAsync(node, ct); - var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope); + var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope, ownerId); foreach (var edge in normalizedEdges) await _graphStore.UpsertEdgeAsync(edge, ct); - var anchorNodeId = ResolveAnchorNodeId(graphReadModel, normalizedNodes, normalizedEdges); - if (anchorNodeId.Length == 0) - return; - - var existing = await _graphStore.GetSubgraphAsync( - new ProjectionGraphQuery - { - Scope = scope, - RootNodeId = anchorNodeId, - Direction = ProjectionGraphDirection.Both, - Depth = 8, - Take = 5000, - }, - ct); - var targetEdgeIds = normalizedEdges .Select(x => x.EdgeId) .ToHashSet(StringComparer.Ordinal); + if (!ownerResolution.CanCleanup) + return; + + var existingManagedEdges = await _graphStore.ListEdgesByOwnerAsync(scope, ownerId, take: 50000, ct); - foreach (var edge in existing.Edges.Where(IsManagedEdge)) + foreach (var edge in existingManagedEdges.Where(IsManagedEdge)) { if (targetEdgeIds.Contains(edge.EdgeId)) continue; @@ -63,26 +53,40 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = } } - private static string ResolveAnchorNodeId( - IGraphReadModel readModel, - IReadOnlyList nodes, - IReadOnlyList edges) + private static bool IsManagedEdge(ProjectionGraphEdge edge) { - var firstNodeId = nodes.FirstOrDefault()?.NodeId ?? ""; - if (firstNodeId.Length > 0) - return firstNodeId; + return edge.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue) && + string.Equals(markerValue, ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); + } + private static ManagedOwnerResolution BuildManagedOwnerId(IGraphReadModel readModel) + { var readModelId = NormalizeToken(readModel.Id); - if (readModelId.Length > 0) - return readModelId; + var canCleanup = readModelId.Length > 0; + if (readModelId.Length == 0) + { + readModelId = readModel.GraphNodes + .Select(x => NormalizeToken(x.NodeId)) + .FirstOrDefault(x => x.Length > 0) ?? ""; + canCleanup = readModelId.Length > 0; + } - return edges.FirstOrDefault()?.FromNodeId ?? ""; - } + if (readModelId.Length == 0) + { + readModelId = readModel.GraphEdges + .Select(x => NormalizeToken(x.FromNodeId)) + .FirstOrDefault(x => x.Length > 0) ?? ""; + canCleanup = readModelId.Length > 0; + } - private static bool IsManagedEdge(ProjectionGraphEdge edge) - { - return edge.Properties.TryGetValue(ManagedMarkerKey, out var markerValue) && - string.Equals(markerValue, ManagedMarkerValue, StringComparison.Ordinal); + if (readModelId.Length == 0) + readModelId = "unknown"; + + var readModelType = NormalizeToken(readModel.GetType().FullName); + var ownerId = readModelType.Length == 0 + ? readModelId + : $"{readModelType}:{readModelId}"; + return new ManagedOwnerResolution(ownerId, canCleanup); } private static IReadOnlyList NormalizeNodes( @@ -118,7 +122,8 @@ private static IReadOnlyList NormalizeNodes( private static IReadOnlyList NormalizeEdges( IReadOnlyList graphEdges, - string scope) + string scope, + string ownerId) { if (graphEdges.Count == 0) return []; @@ -140,7 +145,8 @@ private static IReadOnlyList NormalizeEdges( var properties = new Dictionary(graphEdge.Properties, StringComparer.Ordinal) { - [ManagedMarkerKey] = ManagedMarkerValue, + [ProjectionGraphSystemPropertyKeys.ManagedMarkerKey] = ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, + [ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey] = ownerId, }; edgesById[edgeId] = new ProjectionGraphEdge @@ -159,4 +165,8 @@ private static IReadOnlyList NormalizeEdges( } private static string NormalizeToken(string? token) => token?.Trim() ?? ""; + + private readonly record struct ManagedOwnerResolution( + string OwnerId, + bool CanCleanup); } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs index 95323359f..2c3f9b2f4 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs @@ -7,6 +7,7 @@ public sealed class ProjectionGraphStoreFanout : IProjectionGraphStore { private readonly IReadOnlyList _stores; private readonly IProjectionGraphStore _queryStore; + private readonly string _queryProviderName; private readonly ILogger _logger; public ProjectionGraphStoreFanout( @@ -17,7 +18,8 @@ public ProjectionGraphStoreFanout( ArgumentNullException.ThrowIfNull(registrations); ArgumentNullException.ThrowIfNull(serviceProvider); - _stores = registrations + var registrationList = registrations.ToList(); + _stores = registrationList .Select(x => x.Create(serviceProvider)) .ToList(); _logger = logger ?? NullLogger.Instance; @@ -28,10 +30,39 @@ public ProjectionGraphStoreFanout( "No graph projection store providers are registered."); } - _queryStore = _stores[0]; + var primaryRegistrations = registrationList + .Where(x => x.IsPrimaryQueryStore) + .ToList(); + if (primaryRegistrations.Count == 0 && registrationList.Count > 1) + { + var providers = string.Join(", ", registrationList.Select(x => x.ProviderName)); + throw new InvalidOperationException( + $"Exactly one primary graph projection store provider must be configured. registeredProviders=[{providers}]"); + } + + if (primaryRegistrations.Count > 1) + { + var providers = string.Join(", ", primaryRegistrations.Select(x => x.ProviderName)); + throw new InvalidOperationException( + $"Multiple primary graph projection store providers are configured. primaryProviders=[{providers}]"); + } + + var queryRegistration = primaryRegistrations.Count == 1 + ? primaryRegistrations[0] + : registrationList[0]; + var queryIndex = registrationList.FindIndex(x => ReferenceEquals(x, queryRegistration)); + if (queryIndex < 0) + { + throw new InvalidOperationException("Failed to resolve primary graph projection store provider."); + } + + _queryStore = _stores[queryIndex]; + _queryProviderName = queryRegistration.ProviderName; + _logger.LogInformation( - "Projection graph fan-out initialized. storeCount={StoreCount}", - _stores.Count); + "Projection graph fan-out initialized. storeCount={StoreCount} queryProvider={QueryProvider}", + _stores.Count, + _queryProviderName); } public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) @@ -67,6 +98,15 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken } } + public Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + return _queryStore.ListEdgesByOwnerAsync(scope, ownerId, take, ct); + } + public Task> GetNeighborsAsync( ProjectionGraphQuery query, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs index d67e8f2bc..df141cc58 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs @@ -8,6 +8,12 @@ public interface IProjectionGraphStore Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); + Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default); + Task> GetNeighborsAsync( ProjectionGraphQuery query, CancellationToken ct = default); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs new file mode 100644 index 000000000..897779c46 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs @@ -0,0 +1,10 @@ +namespace Aevatar.CQRS.Projection.Stores.Abstractions; + +public static class ProjectionGraphSystemPropertyKeys +{ + public const string ManagedMarkerKey = "projectionManaged"; + + public const string ManagedMarkerValue = "true"; + + public const string ManagedOwnerIdKey = "projectionOwnerId"; +} diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index ea0916141..e7b5d352e 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -12,6 +12,10 @@ namespace Aevatar.Workflow.Extensions.Hosting; public static class WorkflowProjectionProviderServiceCollectionExtensions { + private const string InMemoryProviderName = "InMemory"; + private const string ElasticsearchProviderName = "Elasticsearch"; + private const string Neo4jProviderName = "Neo4j"; + public static IServiceCollection AddWorkflowProjectionReadModelProviders( this IServiceCollection services, IConfiguration configuration) @@ -34,6 +38,8 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( var enableInMemoryGraph = ResolveOptionalBool( configuration["Projection:Graph:Providers:InMemory:Enabled"], fallbackValue: !enableNeo4jGraph); + var documentPrimaryProvider = ResolveDocumentPrimaryProvider(enableInMemoryDocument, enableElasticsearchDocument); + var graphPrimaryProvider = ResolveGraphPrimaryProvider(enableInMemoryGraph, enableNeo4jGraph); EnforceGraphProviderPolicy(configuration, enableInMemoryGraph); @@ -42,6 +48,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( { services.AddInMemoryDocumentStoreRegistration( keySelector: report => report.RootActorId, + isPrimaryQueryStore: string.Equals(documentPrimaryProvider, InMemoryProviderName, StringComparison.Ordinal), keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); @@ -58,6 +65,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( return metadataResolver.Resolve(); }, keySelector: report => report.RootActorId, + isPrimaryQueryStore: string.Equals(documentPrimaryProvider, ElasticsearchProviderName, StringComparison.Ordinal), keyFormatter: key => key); documentProviderCount++; } @@ -65,7 +73,8 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( var graphProviderCount = 0; if (enableInMemoryGraph) { - services.AddInMemoryGraphStoreRegistration(); + services.AddInMemoryGraphStoreRegistration( + isPrimaryQueryStore: string.Equals(graphPrimaryProvider, InMemoryProviderName, StringComparison.Ordinal)); graphProviderCount++; } @@ -73,7 +82,8 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( { services.AddNeo4jGraphStoreRegistration( optionsFactory: _ => BuildNeo4jGraphOptions(configuration), - scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); + scopeFactory: _ => WorkflowExecutionGraphConstants.Scope, + isPrimaryQueryStore: string.Equals(graphPrimaryProvider, Neo4jProviderName, StringComparison.Ordinal)); graphProviderCount++; } @@ -127,6 +137,32 @@ private static bool ResolveNeo4jGraphEnabled(IConfiguration configuration) return ResolveOptionalBool(explicitEnabled, hasUri); } + private static string ResolveDocumentPrimaryProvider( + bool enableInMemoryDocument, + bool enableElasticsearchDocument) + { + if (enableElasticsearchDocument) + return ElasticsearchProviderName; + + if (enableInMemoryDocument) + return InMemoryProviderName; + + return ""; + } + + private static string ResolveGraphPrimaryProvider( + bool enableInMemoryGraph, + bool enableNeo4jGraph) + { + if (enableNeo4jGraph) + return Neo4jProviderName; + + if (enableInMemoryGraph) + return InMemoryProviderName; + + return ""; + } + private static ElasticsearchProjectionReadModelStoreOptions BuildElasticsearchDocumentOptions( IConfiguration configuration) { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs new file mode 100644 index 000000000..65b24e264 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs @@ -0,0 +1,338 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public class ProjectionGraphMaterializerTests +{ + [Fact] + public async Task UpsertGraphAsync_ShouldRemoveDisconnectedStaleEdgesForSameOwner() + { + var store = new RecordingGraphStore(); + var materializer = new ProjectionGraphMaterializer(store); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + Node("left"), + Node("orphan-a"), + Node("orphan-b"), + ], + GraphEdges = + [ + Edge("edge-root", "root", "left"), + Edge("edge-orphan", "orphan-a", "orphan-b"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + Node("left"), + ], + GraphEdges = + [ + Edge("edge-root", "root", "left"), + ], + }); + + var rootNeighbors = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "root", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 20, + }); + var orphanNeighbors = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "orphan-a", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 20, + }); + + rootNeighbors.Select(x => x.EdgeId).Should().ContainSingle("edge-root"); + orphanNeighbors.Should().BeEmpty(); + } + + [Fact] + public async Task UpsertGraphAsync_ShouldNotDeleteEdgesOwnedByAnotherReadModel() + { + var store = new RecordingGraphStore(); + var materializer = new ProjectionGraphMaterializer(store); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("a"), + Node("b"), + ], + GraphEdges = + [ + Edge("edge-owner-1", "a", "b"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-2", + GraphScope = "scope-1", + GraphNodes = + [ + Node("c"), + Node("d"), + ], + GraphEdges = + [ + Edge("edge-owner-2", "c", "d"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("a"), + Node("b"), + ], + GraphEdges = [], + }); + + var owner1Edges = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "a", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 20, + }); + var owner2Edges = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "c", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 20, + }); + + owner1Edges.Should().BeEmpty(); + owner2Edges.Select(x => x.EdgeId).Should().ContainSingle("edge-owner-2"); + } + + private static GraphNodeDescriptor Node(string nodeId) + { + return new GraphNodeDescriptor( + nodeId, + "Actor", + new Dictionary(StringComparer.Ordinal), + DateTimeOffset.UtcNow); + } + + private static GraphEdgeDescriptor Edge(string edgeId, string fromNodeId, string toNodeId) + { + return new GraphEdgeDescriptor( + edgeId, + "LINK", + fromNodeId, + toNodeId, + new Dictionary(StringComparer.Ordinal), + DateTimeOffset.UtcNow); + } + + private sealed class TestGraphReadModel : IGraphReadModel + { + public string Id { get; init; } = ""; + + public string GraphScope { get; init; } = ""; + + public IReadOnlyList GraphNodes { get; init; } = []; + + public IReadOnlyList GraphEdges { get; init; } = []; + } + + private sealed class RecordingGraphStore : IProjectionGraphStore + { + private readonly object _gate = new(); + private readonly Dictionary _nodes = new(StringComparer.Ordinal); + private readonly Dictionary _edges = new(StringComparer.Ordinal); + + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + _nodes[BuildScopedKey(node.Scope, node.NodeId)] = CloneNode(node); + return Task.CompletedTask; + } + + public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + _edges[BuildScopedKey(edge.Scope, edge.EdgeId)] = CloneEdge(edge); + return Task.CompletedTask; + } + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + _edges.Remove(BuildScopedKey(scope, edgeId)); + return Task.CompletedTask; + } + + public Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return Task.FromResult>([]); + + List edges; + lock (_gate) + { + edges = _edges.Values + .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) + .Where(x => + x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && + string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) + .OrderByDescending(x => x.UpdatedAt) + .Take(Math.Clamp(take, 1, 50000)) + .Select(CloneEdge) + .ToList(); + } + + return Task.FromResult>(edges); + } + + public Task> GetNeighborsAsync( + ProjectionGraphQuery query, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scope = NormalizeToken(query.Scope); + var rootNodeId = NormalizeToken(query.RootNodeId); + if (scope.Length == 0 || rootNodeId.Length == 0) + return Task.FromResult>([]); + + var edgeTypes = query.EdgeTypes + .Select(NormalizeToken) + .Where(x => x.Length > 0) + .ToHashSet(StringComparer.Ordinal); + List edges; + lock (_gate) + { + edges = _edges.Values + .Where(x => string.Equals(x.Scope, scope, StringComparison.Ordinal)) + .Where(x => edgeTypes.Count == 0 || edgeTypes.Contains(x.EdgeType)) + .Where(x => MatchDirection(x, rootNodeId, query.Direction)) + .OrderByDescending(x => x.UpdatedAt) + .Take(Math.Clamp(query.Take, 1, 50000)) + .Select(CloneEdge) + .ToList(); + } + + return Task.FromResult>(edges); + } + + public async Task GetSubgraphAsync( + ProjectionGraphQuery query, + CancellationToken ct = default) + { + var edges = await GetNeighborsAsync(query, ct); + var nodeIds = edges + .SelectMany(x => new[] { x.FromNodeId, x.ToNodeId }) + .Append(query.RootNodeId) + .Where(x => NormalizeToken(x).Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + + List nodes; + lock (_gate) + { + nodes = nodeIds + .Select(nodeId => + { + if (_nodes.TryGetValue(BuildScopedKey(query.Scope, nodeId), out var node)) + return CloneNode(node); + + return new ProjectionGraphNode + { + Scope = query.Scope, + NodeId = nodeId, + NodeType = "Unknown", + Properties = new Dictionary(StringComparer.Ordinal), + UpdatedAt = DateTimeOffset.UtcNow, + }; + }) + .ToList(); + } + + return new ProjectionGraphSubgraph + { + Nodes = nodes, + Edges = edges, + }; + } + + private static bool MatchDirection(ProjectionGraphEdge edge, string rootNodeId, ProjectionGraphDirection direction) + { + return direction switch + { + ProjectionGraphDirection.Outbound => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal), + ProjectionGraphDirection.Inbound => string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), + _ => string.Equals(edge.FromNodeId, rootNodeId, StringComparison.Ordinal) || + string.Equals(edge.ToNodeId, rootNodeId, StringComparison.Ordinal), + }; + } + + private static ProjectionGraphNode CloneNode(ProjectionGraphNode source) + { + return new ProjectionGraphNode + { + Scope = source.Scope, + NodeId = source.NodeId, + NodeType = source.NodeType, + Properties = new Dictionary(source.Properties, StringComparer.Ordinal), + UpdatedAt = source.UpdatedAt, + }; + } + + private static ProjectionGraphEdge CloneEdge(ProjectionGraphEdge source) + { + return new ProjectionGraphEdge + { + Scope = source.Scope, + EdgeId = source.EdgeId, + FromNodeId = source.FromNodeId, + ToNodeId = source.ToNodeId, + EdgeType = source.EdgeType, + Properties = new Dictionary(source.Properties, StringComparer.Ordinal), + UpdatedAt = source.UpdatedAt, + }; + } + + private static string BuildScopedKey(string scope, string id) => $"{NormalizeToken(scope)}:{NormalizeToken(id)}"; + + private static string NormalizeToken(string? token) => token?.Trim() ?? ""; + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index 3a64116a3..3e220bbd4 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -12,14 +12,16 @@ public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQ var primaryStore = new NamedDocumentStore("primary"); var replicaStore = new NamedDocumentStore("replica"); var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "primary", - _ => primaryStore)); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( "replica", + false, _ => replicaStore)); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "primary", + true, + _ => primaryStore)); using var serviceProvider = services.BuildServiceProvider(); var fanout = new ProjectionDocumentStoreFanout( @@ -42,6 +44,85 @@ public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQ fetched!.Value.Should().Be("v1"); } + [Fact] + public async Task ProjectionDocumentStoreFanout_ShouldReadFromExplicitPrimary_WhenOrderDiffers() + { + var primaryStore = new NamedDocumentStore("primary"); + var replicaStore = new NamedDocumentStore("replica"); + primaryStore.Seed("id-1", "from-primary"); + replicaStore.Seed("id-1", "from-replica"); + + var services = new ServiceCollection(); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "replica", + false, + _ => replicaStore)); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "primary", + true, + _ => primaryStore)); + + using var serviceProvider = services.BuildServiceProvider(); + var fanout = new ProjectionDocumentStoreFanout( + serviceProvider.GetServices>>(), + serviceProvider); + + var fetched = await fanout.GetAsync("id-1"); + + fetched.Should().NotBeNull(); + fetched!.Value.Should().Be("from-primary"); + } + + [Fact] + public void ProjectionDocumentStoreFanout_WhenMultipleRegistrationsAndNoPrimary_ShouldThrow() + { + var services = new ServiceCollection(); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "replica-1", + false, + _ => new NamedDocumentStore("replica-1"))); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "replica-2", + false, + _ => new NamedDocumentStore("replica-2"))); + + using var serviceProvider = services.BuildServiceProvider(); + Action act = () => new ProjectionDocumentStoreFanout( + serviceProvider.GetServices>>(), + serviceProvider); + + act.Should().Throw() + .WithMessage("*Exactly one primary document projection store provider must be configured*"); + } + + [Fact] + public void ProjectionDocumentStoreFanout_WhenMultiplePrimaryRegistrations_ShouldThrow() + { + var services = new ServiceCollection(); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "primary-1", + true, + _ => new NamedDocumentStore("primary-1"))); + services.AddSingleton>>( + new DelegateProjectionStoreRegistration>( + "primary-2", + true, + _ => new NamedDocumentStore("primary-2"))); + + using var serviceProvider = services.BuildServiceProvider(); + Action act = () => new ProjectionDocumentStoreFanout( + serviceProvider.GetServices>>(), + serviceProvider); + + act.Should().Throw() + .WithMessage("*Multiple primary document projection store providers are configured*"); + } + [Fact] public void ProjectionDocumentStoreFanout_WhenNoRegistrations_ShouldThrow() { @@ -76,6 +157,15 @@ public NamedDocumentStore(string providerName) public int UpsertCount { get; private set; } + public void Seed(string key, string value) + { + _models[key] = new TestReadModel + { + Id = key, + Value = value, + }; + } + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) { _models[readModel.Id] = new TestReadModel diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index a32e154f1..513316576 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -12,14 +12,16 @@ public async Task ProjectionGraphStoreFanout_ShouldFanoutWritesAndUsePrimaryQuer var primaryStore = new NamedGraphStore("primary"); var replicaStore = new NamedGraphStore("replica"); var services = new ServiceCollection(); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "primary", - _ => primaryStore)); services.AddSingleton>( new DelegateProjectionStoreRegistration( "replica", + false, _ => replicaStore)); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "primary", + true, + _ => primaryStore)); using var serviceProvider = services.BuildServiceProvider(); var fanout = new ProjectionGraphStoreFanout( @@ -51,6 +53,54 @@ await fanout.UpsertNodeAsync(new ProjectionGraphNode edges[0].EdgeType.Should().Be("primary"); } + [Fact] + public void ProjectionGraphStoreFanout_WhenMultipleRegistrationsAndNoPrimary_ShouldThrow() + { + var services = new ServiceCollection(); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "replica-1", + false, + _ => new NamedGraphStore("replica-1"))); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "replica-2", + false, + _ => new NamedGraphStore("replica-2"))); + + using var serviceProvider = services.BuildServiceProvider(); + Action act = () => new ProjectionGraphStoreFanout( + serviceProvider.GetServices>(), + serviceProvider); + + act.Should().Throw() + .WithMessage("*Exactly one primary graph projection store provider must be configured*"); + } + + [Fact] + public void ProjectionGraphStoreFanout_WhenMultiplePrimaryRegistrations_ShouldThrow() + { + var services = new ServiceCollection(); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "primary-1", + true, + _ => new NamedGraphStore("primary-1"))); + services.AddSingleton>( + new DelegateProjectionStoreRegistration( + "primary-2", + true, + _ => new NamedGraphStore("primary-2"))); + + using var serviceProvider = services.BuildServiceProvider(); + Action act = () => new ProjectionGraphStoreFanout( + serviceProvider.GetServices>(), + serviceProvider); + + act.Should().Throw() + .WithMessage("*Multiple primary graph projection store providers are configured*"); + } + [Fact] public void ProjectionGraphStoreFanout_WhenNoRegistrations_ShouldThrow() { @@ -94,6 +144,18 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = return Task.CompletedTask; } + public Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + _ = scope; + _ = ownerId; + _ = take; + return Task.FromResult>([]); + } + public Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default) { _ = query; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 6a65e8faa..4446aab50 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -67,10 +67,11 @@ private static void RegisterInMemoryProviders(IServiceCollection services) { services.AddInMemoryDocumentStoreRegistration( keySelector: report => report.RootActorId, + isPrimaryQueryStore: true, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); - services.AddInMemoryGraphStoreRegistration(); + services.AddInMemoryGraphStoreRegistration(isPrimaryQueryStore: true); } private static void RegisterElasticsearchDocumentProvider(IServiceCollection services) @@ -86,6 +87,7 @@ private static void RegisterElasticsearchDocumentProvider(IServiceCollection ser return metadataResolver.Resolve(); }, keySelector: report => report.RootActorId, + isPrimaryQueryStore: true, keyFormatter: key => key); } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 63f9d3985..f74931429 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -125,6 +125,49 @@ public void AddWorkflowProjectionReadModelProviders_WhenDurableProvidersEnabled_ relationRegistrations.Should().HaveCount(1); } + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_ShouldSelectDurablePrimary() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:Document:Providers:InMemory:Enabled"] = "true", + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "true", + ["Projection:Document:Providers:Elasticsearch:Endpoints:0"] = "http://localhost:9200", + ["Projection:Graph:Providers:InMemory:Enabled"] = "true", + ["Projection:Graph:Providers:Neo4j:Enabled"] = "true", + ["Projection:Graph:Providers:Neo4j:Uri"] = "bolt://localhost:7687", + }) + .Build(); + + services.AddWorkflowProjectionReadModelProviders(configuration); + + using var provider = services.BuildServiceProvider(); + var documentRegistrations = provider + .GetServices>>() + .ToList(); + var graphRegistrations = provider + .GetServices>() + .ToList(); + + documentRegistrations.Should().HaveCount(2); + documentRegistrations.Should().ContainSingle(x => + string.Equals(x.ProviderName, "Elasticsearch", StringComparison.Ordinal) && + x.IsPrimaryQueryStore); + documentRegistrations.Should().ContainSingle(x => + string.Equals(x.ProviderName, "InMemory", StringComparison.Ordinal) && + !x.IsPrimaryQueryStore); + + graphRegistrations.Should().HaveCount(2); + graphRegistrations.Should().ContainSingle(x => + string.Equals(x.ProviderName, "Neo4j", StringComparison.Ordinal) && + x.IsPrimaryQueryStore); + graphRegistrations.Should().ContainSingle(x => + string.Equals(x.ProviderName, "InMemory", StringComparison.Ordinal) && + !x.IsPrimaryQueryStore); + } + [Fact] public void AddWorkflowProjectionReadModelProviders_WhenLegacyProviderConfigured_ShouldThrow() { From 7be347bff7dfdb6c7da505e0c28b9df7d1318bda Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 02:37:48 +0800 Subject: [PATCH 35/46] Refactor Projection ReadModel and Enhance Provider Registration - Updated the Projection ReadModel architecture to eliminate the need for an explicit primary query provider, simplifying the registration process for document and graph stores. - Consolidated provider registration methods across Elasticsearch and InMemory providers, removing the `isPrimaryQueryStore` parameter to streamline configuration. - Enhanced the `IProjectionGraphStore` interface with new methods for managing nodes and edges by owner, improving cleanup operations and data integrity. - Updated the `ProjectionGraphMaterializer` to ensure accurate management of graph nodes and edges based on ownership, including the removal of stale nodes. - Revised documentation to reflect these architectural changes and clarify the new provider registration semantics. --- ...readmodel-full-refactor-plan-2026-02-24.md | 38 +++-- ...on-store-readmodel-scorecard-2026-02-24.md | 54 ++++--- .../ServiceCollectionExtensions.cs | 2 - .../README.md | 3 +- .../ServiceCollectionExtensions.cs | 6 +- .../README.md | 5 +- .../Stores/InMemoryProjectionGraphStore.cs | 43 +++++ .../ServiceCollectionExtensions.cs | 4 +- .../README.md | 3 +- .../Stores/Neo4jProjectionGraphStore.cs | 95 ++++++++++- .../DelegateProjectionStoreRegistration.cs | 4 - .../Core/IProjectionStoreRegistration.cs | 2 - .../README.md | 5 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 4 +- .../Runtime/ProjectionDocumentStoreFanout.cs | 43 +---- .../Runtime/ProjectionGraphMaterializer.cs | 55 ++++++- .../Runtime/ProjectionGraphStoreFanout.cs | 49 +++--- .../Graphs/IProjectionGraphStore.cs | 8 + ...tionProviderServiceCollectionExtensions.cs | 66 ++------ .../ProjectionGraphMaterializerTests.cs | 148 ++++++++++++++++++ .../ProjectionReadModelRuntimeTests.cs | 86 ++-------- .../ProjectionReadModelStoreSelectorTests.cs | 93 +++++------ ...lowExecutionProjectionRegistrationTests.cs | 4 +- .../WorkflowHostingExtensionsCoverageTests.cs | 18 +-- 24 files changed, 516 insertions(+), 322 deletions(-) diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index a01bf2a72..00478bbbc 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -47,11 +47,11 @@ flowchart LR Q1["WorkflowProjectionQueryReader"] --> Q2["IDocumentProjectionStore"] Q1 --> Q3["IProjectionGraphStore"] - Q2 --> Q4["ProjectionDocumentStoreFanout (primary read store + fan-out write)"] - Q3 --> Q5["ProjectionGraphStoreFanout (primary read store + fan-out write)"] + Q2 --> Q4["ProjectionDocumentStoreFanout (first-registered read store + fan-out write)"] + Q3 --> Q5["ProjectionGraphStoreFanout (first-registered read store + fan-out write)"] - Q4 --> Q6["Document Primary Provider"] - Q5 --> Q7["Graph Primary Provider"] + Q4 --> Q6["Document Query Provider (registration[0])"] + Q5 --> Q7["Graph Query Provider (registration[0])"] ``` ## 3. Major Structural Changes @@ -97,23 +97,29 @@ Rules: 1. Legacy single-select keys (`Projection:Document:Provider`, `Projection:Graph:Provider`) are rejected. 2. InMemory providers are fallback defaults when durable providers are not enabled. 3. `Projection:Policies:DenyInMemoryGraphFactStore=true` forbids in-memory graph fact store. -4. 多 provider 场景显式指定唯一 primary query provider: - - Document:优先 `Elasticsearch`,否则 `InMemory` - - Graph:优先 `Neo4j`,否则 `InMemory` +4. 多 provider 查询语义使用“注册顺序即查询顺序”: + - query 读取总是走第一个注册 provider + - 其余 provider 仅承担 fan-out 写入 + - Workflow Host 采用“耐久优先”注册顺序(Document: `Elasticsearch -> InMemory`,Graph: `Neo4j -> InMemory`) -## 3.6 Query Primary & Graph Cleanup Hardening +## 3.6 Query Ordering & Graph Cleanup Hardening -- `IProjectionStoreRegistration` 增加 `IsPrimaryQueryStore`。 +- `IProjectionStoreRegistration` 收敛为最小契约:`ProviderName + Create(IServiceProvider)`。 - `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` 改为: - - 多注册必须且仅允许一个 primary; - - 单注册允许无 primary(默认该唯一 provider 为 query store); - - 冲突或缺失时 fail-fast。 -- `IProjectionGraphStore` 增加 `ListEdgesByOwnerAsync(scope, ownerId, take)`。 -- `ProjectionGraphMaterializer` 从锚点子图清理重构为 owner-based 精确清理: + - 第一个注册 provider 为 query store; + - 其余 provider 自动作为 fan-out 副本; + - 无需 `primary` 配置,降低宿主层配置负担。 +- `IProjectionGraphStore` 增加 owner 生命周期接口: + - `ListEdgesByOwnerAsync(scope, ownerId, take)` + - `ListNodesByOwnerAsync(scope, ownerId, take)` + - `DeleteNodeAsync(scope, nodeId)` +- `ProjectionGraphMaterializer` 从锚点子图清理重构为 owner-based 精确清理(边+节点): - 写边时注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` - - 清理时按 owner 列举已有边并做差集删除,不再依赖 `Depth/Take` 子图扫描窗口。 + - 写节点时同样注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` + - 清理时按 owner 列举已有边/节点并做差集删除,不再依赖 `Depth/Take` 子图扫描窗口。 + - 节点删除前执行邻接检查,仅删除无任何关系边的孤立节点,避免误删跨 owner 共享节点。 -## 3.5 Elasticsearch Metadata Behavior +## 3.7 Elasticsearch Metadata Behavior `ElasticsearchProjectionReadModelStore` now consumes full `DocumentIndexMetadata`: diff --git a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md index a1e563656..5249ed2b6 100644 --- a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md @@ -1,4 +1,4 @@ -# Projection Store / ReadModel 严格评分卡(2026-02-24 复评分) +# Projection Store / ReadModel 严格评分卡(2026-02-24 重新分析) ## 1. 审计范围 @@ -15,56 +15,58 @@ ## 2. 本次验证基线 -本次复评执行并通过: +本次重新分析执行并通过: 1. `dotnet build aevatar.slnx --nologo` 2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` 3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` 4. `bash tools/ci/architecture_guards.sh` 5. `bash tools/ci/projection_route_mapping_guard.sh` +6. `bash tools/ci/test_stability_guards.sh` +7. `dotnet test aevatar.slnx --nologo` ## 3. 总分结论 -- **总分:84 / 100** -- **等级:B+(严格口径)** -- **结论:主架构已经从“单选 provider”收敛到“Document/Graph 分离的一对多 fan-out”,方向正确;但仍存在主查询源隐式选择、图清理窗口固定、跨 store 非事务一致性等高影响工程风险。** +- **总分:89 / 100** +- **等级:B+(严格口径,高于上版 84)** +- **结论:核心架构问题(查询源规则不清、图清理窗口清理)已实质修复;当前主要短板转为一致性语义、owner 标识稳定性与可观测性。** ## 4. 维度评分(严格) | 维度 | 权重 | 得分 | 证据与说明 | |---|---:|---:|---| -| 架构边界与抽象收敛 | 15 | 14 | 已删 `Factory/Selector/RuntimeOptions`,Runtime 仅保留 fan-out + materialization(`Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:11-15`)。扣分:`IProjectionStoreRegistration` 仍保留 `ProviderName`,但运行时不再用其做治理约束(`Runtime.Abstractions/.../IProjectionStoreRegistration.cs:3-7`)。 | -| Provider 模型清晰度 | 15 | 14 | Fan-out 明确:`ProjectionDocumentStoreFanout` 与 `ProjectionGraphStoreFanout`(`.../ProjectionDocumentStoreFanout.cs:6-84`,`.../ProjectionGraphStoreFanout.cs:6-82`)。扣分:主查询源由“第一个注册”隐式决定(`ProjectionDocumentStoreFanout.cs:34`,`ProjectionGraphStoreFanout.cs:31`)。 | -| Document 索引语义完整性 | 20 | 17 | ES 已使用 `DocumentIndexMetadata` 初始化 mappings/settings/aliases(`ElasticsearchProjectionReadModelStore.cs:486-503`,`505-532`)。扣分:元数据结构仍是 `Dictionary`,复杂 settings/aliases 需字符串内嵌 JSON(`DocumentIndexMetadata.cs:3-7`,`ElasticsearchProjectionReadModelStore.cs:515-529`);Workflow 默认 metadata 仍是空 mapping(`WorkflowExecutionReportDocumentMetadataProvider.cs:8-12`)。 | -| Graph 关系语义与正确性 | 15 | 12 | `IGraphReadModel` 采用声明式节点/边接口(`IGraphReadModel.cs:3-9`);Workflow ReadModel 同时实现 Doc+Graph(`WorkflowExecutionReadModel.cs:32-45`)。扣分:图清理固定窗口 `Depth=8/Take=5000`(`ProjectionGraphMaterializer.cs:42-50`),且锚点推断为首节点/ReadModel.Id/首边 from(`66-79`)。 | -| 一致性与失败语义 | 10 | 7 | 路由按能力执行(`ProjectionMaterializationRouter.cs:31-37`)。扣分:跨 store 非事务;`Document` 先写成功后 `Graph` 失败会留下部分成功状态(`31-37`);`Mutate` 后再 fan-out 到其它 store(`ProjectionDocumentStoreFanout.cs:58-73`)。 | -| Provider 实现质量与性能 | 10 | 8 | ES OCC 重试完备(`ElasticsearchProjectionReadModelStore.cs:93-143`)。扣分:Neo4j 子图遍历逐层调用 `GetNeighborsAsync`,存在放大风险(`Neo4jProjectionGraphStore.cs:190-209`)。 | -| 可观测性 | 5 | 3 | 文档路径日志完整(`ElasticsearchProjectionReadModelStore.cs:282-355`,`ProjectionDocumentStoreFanout.cs:35-38`)。扣分:Graph provider 成功路径日志薄弱,Neo4j 仅反序列化 warning(`Neo4jProjectionGraphStore.cs:475-480`)。 | -| 测试与治理门禁 | 10 | 9 | Fan-out 行为有单测(`ProjectionReadModelRuntimeTests.cs:10-57`,`ProjectionReadModelStoreSelectorTests.cs:10-64`);Workflow 注册策略有覆盖(`WorkflowExecutionProjectionRegistrationTests.cs:18-64`,`WorkflowHostingExtensionsCoverageTests.cs:99-160`);架构/路由守卫通过。扣分:缺少 `DocumentIndexMetadata` 的 mapping/settings/aliases 初始化行为专门测试。 | +| 架构边界与抽象收敛 | 15 | 15 | `IProjectionStoreRegistration` 收敛为最小契约(`ProviderName + Create`),Runtime 组装边界清晰(`IProjectionStoreRegistration.cs`,`DelegateProjectionStoreRegistration.cs`)。 | +| Provider 模型清晰度 | 15 | 15 | Document/Graph fan-out 使用统一规则“注册顺序即查询顺序”(首注册 provider 读,全部 provider 写 fan-out)(`ProjectionDocumentStoreFanout.cs`,`ProjectionGraphStoreFanout.cs`);Workflow Host 落地耐久优先注册顺序(`WorkflowProjectionProviderServiceCollectionExtensions.cs`)。 | +| Document 索引语义完整性 | 20 | 17 | ES 初始化已消费 `DocumentIndexMetadata` 的 mappings/settings/aliases(`ElasticsearchProjectionReadModelStore.cs:486-503`,`515-532`)。扣分:`DocumentIndexMetadata.Settings/Aliases` 仍是 `Dictionary`(`DocumentIndexMetadata.cs:3-7`),复杂结构需字符串 JSON;Workflow 默认 metadata 仍是空 mapping(`WorkflowExecutionReportDocumentMetadataProvider.cs:8-12`)。 | +| Graph 关系语义与正确性 | 15 | 14 | Graph materializer 已改为 owner-based 差集清理(`ProjectionGraphMaterializer.cs`),并写入系统属性 `projectionManaged/projectionOwnerId`(`ProjectionGraphMaterializer.cs`);Graph store 抽象新增 `ListEdgesByOwnerAsync/ListNodesByOwnerAsync/DeleteNodeAsync`(`IProjectionGraphStore.cs`),InMemory/Neo4j 均实现(`InMemoryProjectionGraphStore.cs`,`Neo4jProjectionGraphStore.cs`)。扣分:当 readModel 无稳定 id 且无可用节点/边标识时仍会跳过 cleanup(`ProjectionGraphMaterializer.cs` 的 owner fallback 分支)。 | +| 一致性与失败语义 | 10 | 7 | Router 仍是顺序写入(先 Document,再 Graph)(`ProjectionMaterializationRouter.cs:31-37`),跨 provider 非事务;`Mutate` 后读回再刷新 graph(`ProjectionMaterializationRouter.cs:49-63`),失败时可能留部分成功状态。 | +| Provider 实现质量与性能 | 10 | 8 | ES `MutateAsync` OCC 重试与冲突处理较完整(`ElasticsearchProjectionReadModelStore.cs:93-143`)。扣分:Neo4j 子图遍历仍为逐层逐节点 `GetNeighborsAsync`(`Neo4jProjectionGraphStore.cs:241-260`),在高出度场景有查询放大风险。 | +| 可观测性 | 5 | 3 | Fan-out 初始化日志已有 provider 信息(`ProjectionDocumentStoreFanout.cs:76-80`,`ProjectionGraphStoreFanout.cs:62-65`);ES 冲突日志较完整(`ElasticsearchProjectionReadModelStore.cs:124-133`)。扣分:Graph provider 成功路径指标/日志仍偏薄,Neo4j 侧主要是反序列化 warning(`Neo4jProjectionGraphStore.cs:546-551`)。 | +| 测试与治理门禁 | 10 | 9 | fan-out 查询顺序与写扩散语义测试已补齐(`ProjectionReadModelRuntimeTests.cs`,`ProjectionReadModelStoreSelectorTests.cs`);owner 维度边/节点清理回归已补(`ProjectionGraphMaterializerTests.cs`);Workflow host 对 durable-first 注册顺序有覆盖(`WorkflowHostingExtensionsCoverageTests.cs`);门禁脚本通过。扣分:仍缺少 `DocumentIndexMetadata` 复杂 settings/aliases 组合的端到端初始化测试。 | ## 5. 关键扣分项(按优先级) ### P1 -1. **主查询源隐式顺序问题** - - 当前 query store 取第一个注册,缺少显式 primary 机制(`ProjectionDocumentStoreFanout.cs:34`,`ProjectionGraphStoreFanout.cs:31`)。 - -2. **Graph 清理窗口固定值** - - `Depth=8/Take=5000` 可能导致边清理不完整(`ProjectionGraphMaterializer.cs:42-50`)。 +1. **跨 Store 写入非原子** + - `ProjectionMaterializationRouter` 仍是串行双写,缺少统一事务/补偿机制(`ProjectionMaterializationRouter.cs:31-37`)。 ### P2 -1. **跨 provider 双写非事务** - - 先写后写失败不回滚(`ProjectionMaterializationRouter.cs:31-37`)。 +1. **Neo4j 子图遍历存在查询放大** + - `GetSubgraphAsync` 逐节点调用 `GetNeighborsAsync`(`Neo4jProjectionGraphStore.cs:241-260`)。 + +2. **Document 索引元数据表达力仍有限** + - `Settings/Aliases` 使用字符串字典,复杂结构可读性与校验能力有限(`DocumentIndexMetadata.cs:3-7`)。 -2. **Neo4j 子图遍历放大风险** - - 每层每节点邻接查询(`Neo4jProjectionGraphStore.cs:190-209`)。 +3. **Graph owner fallback 仍有残留风险** + - 当 readModel 缺少稳定标识且节点/边都为空时,owner 解析会降级并跳过 cleanup,可能保留历史 managed 数据(`ProjectionGraphMaterializer.cs`)。 ### P3 -1. **Graph 可观测性偏弱** - - 缺少统一写入成功/失败指标日志模板。 +1. **Graph 成功路径可观测性不足** + - 缺少统一写入吞吐、清理计数、owner 命中率等指标日志。 -## 6. 复评判定 +## 6. 重新评分判定 -当前实现已经达到“架构方向正确、分层清晰、可落地运行”,但在严格工程口径下仍未达到 A 档稳定性。复评分为 **84/100(B+)**。 +当前实现已进入“结构清晰 + 关键缺陷已修复”的阶段,但在严格生产工程标准下仍未到 A 档。重新评分为 **89/100(B+)**。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 23604f38b..a6aee7094 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,7 +12,6 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration optionsFactory, Func metadataFactory, Func keySelector, - bool isPrimaryQueryStore, Func? keyFormatter = null) where TReadModel : class { @@ -23,7 +22,6 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( "Elasticsearch", - isPrimaryQueryStore, provider => new ElasticsearchProjectionReadModelStore( optionsFactory(provider), metadataFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 4c968b331..9c9591361 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -11,11 +11,10 @@ ## DI 注册 -- `AddElasticsearchDocumentStoreRegistration(..., isPrimaryQueryStore, ...)` +- `AddElasticsearchDocumentStoreRegistration(...)` 关键参数: - `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 - `metadataFactory`:通常由 `IProjectionDocumentMetadataResolver` 解析 `IProjectionDocumentMetadataProvider`。 - `keySelector/keyFormatter`:ReadModel 主键映射。 -- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index efa147571..90ce52acb 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -9,7 +9,6 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddInMemoryDocumentStoreRegistration( this IServiceCollection services, Func keySelector, - bool isPrimaryQueryStore, Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200) @@ -20,7 +19,6 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>>( new DelegateProjectionStoreRegistration>( "InMemory", - isPrimaryQueryStore, provider => new InMemoryProjectionReadModelStore( keySelector, keyFormatter, @@ -32,13 +30,11 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>( new DelegateProjectionStoreRegistration( "InMemory", - isPrimaryQueryStore, _ => new InMemoryProjectionGraphStore())); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index f49fef138..c1705238d 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -9,12 +9,11 @@ ## DI 注册 -- `AddInMemoryDocumentStoreRegistration(..., isPrimaryQueryStore, ...)` -- `AddInMemoryGraphStoreRegistration(isPrimaryQueryStore)` +- `AddInMemoryDocumentStoreRegistration(...)` +- `AddInMemoryGraphStoreRegistration()` 关键参数: - `keySelector/keyFormatter`:ReadModel 主键映射。 -- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 - `listSortSelector`:`ListAsync` 排序字段(可选)。 - `listTakeMax`:`ListAsync` 硬上限。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index 35645ab7c..3a3771614 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -49,6 +49,19 @@ public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = def return Task.CompletedTask; } + public Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var nodeValue = NormalizeToken(nodeId); + if (scopeValue.Length == 0 || nodeValue.Length == 0) + return Task.CompletedTask; + + lock (_gate) + _nodes.Remove(BuildNodeKey(scopeValue, nodeValue)); + return Task.CompletedTask; + } + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -62,6 +75,36 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = return Task.CompletedTask; } + public Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return Task.FromResult>([]); + + var boundedTake = Math.Clamp(take, 1, 50000); + List nodes; + lock (_gate) + { + nodes = _nodes.Values + .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) + .Where(x => + x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && + string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) + .OrderByDescending(x => x.UpdatedAt) + .Take(boundedTake) + .Select(CloneNode) + .ToList(); + } + + return Task.FromResult>(nodes); + } + public Task> ListEdgesByOwnerAsync( string scope, string ownerId, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 44832e703..2ffa1cfa9 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -10,8 +10,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddNeo4jGraphStoreRegistration( this IServiceCollection services, Func optionsFactory, - Func scopeFactory, - bool isPrimaryQueryStore) + Func scopeFactory) { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); @@ -19,7 +18,6 @@ public static IServiceCollection AddNeo4jGraphStoreRegistration( services.AddSingleton>( new DelegateProjectionStoreRegistration( "Neo4j", - isPrimaryQueryStore, provider => new Neo4jProjectionGraphStore( optionsFactory(provider), scopeFactory(provider), diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index 7c577c8e9..7532a159e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -9,10 +9,9 @@ ## DI 注册 -- `AddNeo4jGraphStoreRegistration(..., isPrimaryQueryStore)` +- `AddNeo4jGraphStoreRegistration(...)` 关键参数: - `optionsFactory`:绑定 `Projection:Graph:Providers:Neo4j:*` 配置。 - `scopeFactory`:graph scope 提供器。 -- `isPrimaryQueryStore`:是否作为 Runtime 查询主存储。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 7cb7b9291..c297800f5 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -64,8 +64,14 @@ public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct nodeType = "Unknown"; var updatedAtEpochMs = NormalizeTimestamp(node.UpdatedAt); var propertiesJson = SerializeProperties(node.Properties); + var projectionManaged = ResolveProjectionManaged(node.Properties); + var projectionOwnerId = ResolveProjectionOwnerId(node.Properties); var cypher = $"MERGE (n:{_nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + - "SET n.nodeType = $nodeType, n.propertiesJson = $propertiesJson, n.updatedAtEpochMs = $updatedAtEpochMs"; + "SET n.nodeType = $nodeType, " + + "n.propertiesJson = $propertiesJson, " + + "n.updatedAtEpochMs = $updatedAtEpochMs, " + + "n.projectionManaged = $projectionManaged, " + + "n.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; var parameters = new Dictionary { ["scope"] = scope, @@ -73,6 +79,8 @@ public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct ["nodeType"] = nodeType, ["propertiesJson"] = propertiesJson, ["updatedAtEpochMs"] = updatedAtEpochMs, + ["projectionManaged"] = projectionManaged, + ["projectionOwnerId"] = projectionOwnerId, }; await EnsureSchemaAsync(ct); @@ -125,6 +133,25 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct await ExecuteWriteAsync(cypher, parameters, ct); } + public async Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var nodeIdValue = NormalizeToken(nodeId); + if (scopeValue.Length == 0 || nodeIdValue.Length == 0) + return; + + await EnsureSchemaAsync(ct); + var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + + "WHERE NOT (n)-[]-() DELETE n"; + var parameters = new Dictionary + { + ["scope"] = scopeValue, + ["nodeId"] = nodeIdValue, + }; + await ExecuteWriteAsync(cypher, parameters, ct); + } + public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -143,6 +170,72 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken await ExecuteWriteAsync(cypher, parameters, ct); } + public async Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return []; + + await EnsureSchemaAsync(ct); + var boundedTake = Math.Clamp(take, 1, 50000); + var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + + "WHERE coalesce(n.projectionManaged, false) = true " + + "AND n.projectionOwnerId = $ownerId " + + "RETURN n.nodeId AS nodeId, " + + "coalesce(n.nodeType, '') AS nodeType, " + + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + var parameters = new Dictionary + { + ["scope"] = scopeValue, + ["ownerId"] = ownerValue, + ["take"] = boundedTake, + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var nodes = new List(rows.Count); + foreach (var row in rows) + { + if (!row.TryGetValue("nodeId", out var nodeIdValue)) + continue; + + var resolvedNodeId = NormalizeToken(nodeIdValue.As()); + if (resolvedNodeId.Length == 0) + continue; + + var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) + ? NormalizeToken(nodeTypeValue.As()) + : "Unknown"; + if (nodeType.Length == 0) + nodeType = "Unknown"; + + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + nodes.Add(new ProjectionGraphNode + { + Scope = scopeValue, + NodeId = resolvedNodeId, + NodeType = nodeType, + Properties = DeserializeProperties(propertiesJson), + UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), + }); + } + + return nodes; + } + public async Task> ListEdgesByOwnerAsync( string scope, string ownerId, diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs index 19539558f..58c36550f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs @@ -6,7 +6,6 @@ public sealed class DelegateProjectionStoreRegistration : IProjectionSto public DelegateProjectionStoreRegistration( string providerName, - bool isPrimaryQueryStore, Func factory) { if (string.IsNullOrWhiteSpace(providerName)) @@ -14,14 +13,11 @@ public DelegateProjectionStoreRegistration( ArgumentNullException.ThrowIfNull(factory); ProviderName = providerName.Trim(); - IsPrimaryQueryStore = isPrimaryQueryStore; _factory = factory; } public string ProviderName { get; } - public bool IsPrimaryQueryStore { get; } - public TStore Create(IServiceProvider serviceProvider) { ArgumentNullException.ThrowIfNull(serviceProvider); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs index 18d8d1fc0..d43210ba0 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs @@ -4,7 +4,5 @@ public interface IProjectionStoreRegistration { string ProviderName { get; } - bool IsPrimaryQueryStore { get; } - TStore Create(IServiceProvider serviceProvider); } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 09af3e4af..2d56c396d 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -12,10 +12,13 @@ - Store 注册:`IProjectionStoreRegistration` - `ProviderName` - - `IsPrimaryQueryStore`(多 provider 场景下唯一主查询存储) - Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` - Metadata:`IProjectionDocumentMetadataResolver` +查询语义: + +- Fan-out Runtime 以注册顺序选择查询源(第一个注册的 provider 作为 query store,其余作为写入副本)。 + ## 约束 1. 不包含 ProviderName 选择与 RuntimeOptions。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index 4eafb56e6..e923f7c74 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -25,6 +25,6 @@ ## 设计约束 1. 不承载业务 ReadModel 类型。 -2. 不做 providerName 单选,不存在运行时降级逻辑;多 provider 时必须显式且唯一 `IsPrimaryQueryStore=true`。 -3. Document 与 Graph 完全解耦,分别按注册列表一对多分发(写 fan-out,读走 primary)。 +2. 不做 providerName 单选,不存在运行时降级逻辑;多 provider 采用“注册顺序即查询顺序”。 +3. Document 与 Graph 完全解耦,分别按注册列表一对多分发(写 fan-out,读走首注册 provider)。 4. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs index ed2a369f9..fa225ea7e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs @@ -33,46 +33,9 @@ public ProjectionDocumentStoreFanout( throw new InvalidOperationException( $"No document projection store providers are registered for read model '{typeof(TReadModel).FullName}'."); } - - var primaryRegistrations = registrationList - .Where(x => x.IsPrimaryQueryStore) - .ToList(); - if (primaryRegistrations.Count == 0 && registrationList.Count > 1) - { - var providers = string.Join(", ", registrationList.Select(x => x.ProviderName)); - throw new InvalidOperationException( - $"Exactly one primary document projection store provider must be configured for read model '{typeof(TReadModel).FullName}'. registeredProviders=[{providers}]"); - } - - if (primaryRegistrations.Count > 1) - { - var providers = string.Join(", ", primaryRegistrations.Select(x => x.ProviderName)); - throw new InvalidOperationException( - $"Multiple primary document projection store providers are configured for read model '{typeof(TReadModel).FullName}'. primaryProviders=[{providers}]"); - } - - var queryRegistration = primaryRegistrations.Count == 1 - ? primaryRegistrations[0] - : registrationList[0]; - var queryIndex = registrationList.FindIndex(x => ReferenceEquals(x, queryRegistration)); - if (queryIndex < 0) - { - throw new InvalidOperationException( - $"Failed to resolve primary document projection store provider for read model '{typeof(TReadModel).FullName}'."); - } - - _queryStore = _stores[queryIndex]; - _queryProviderName = queryRegistration.ProviderName; - - var replicaStores = new List>(_stores.Count); - for (var i = 0; i < _stores.Count; i++) - { - if (i == queryIndex) - continue; - replicaStores.Add(_stores[i]); - } - - _replicaStores = replicaStores; + _queryStore = _stores[0]; + _queryProviderName = registrationList[0].ProviderName; + _replicaStores = _stores.Skip(1).ToList(); _logger.LogInformation( "Projection document fan-out initialized. readModelType={ReadModelType} storeCount={StoreCount} queryProvider={QueryProvider}", typeof(TReadModel).FullName, diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs index 7012fe9f2..1dfcf006c 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs @@ -28,7 +28,7 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = var ownerResolution = BuildManagedOwnerId(graphReadModel); var ownerId = ownerResolution.OwnerId; - var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope); + var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope, ownerId); foreach (var node in normalizedNodes) await _graphStore.UpsertNodeAsync(node, ct); @@ -42,6 +42,10 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = if (!ownerResolution.CanCleanup) return; + var targetNodeIds = normalizedNodes + .Select(x => x.NodeId) + .Where(x => x.Length > 0) + .ToHashSet(StringComparer.Ordinal); var existingManagedEdges = await _graphStore.ListEdgesByOwnerAsync(scope, ownerId, take: 50000, ct); foreach (var edge in existingManagedEdges.Where(IsManagedEdge)) @@ -51,6 +55,17 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = await _graphStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); } + + var existingManagedNodes = await _graphStore.ListNodesByOwnerAsync(scope, ownerId, take: 50000, ct); + foreach (var node in existingManagedNodes.Where(IsManagedNode)) + { + if (targetNodeIds.Contains(node.NodeId)) + continue; + if (!await CanDeleteNodeAsync(scope, node.NodeId, ct)) + continue; + + await _graphStore.DeleteNodeAsync(scope, node.NodeId, ct); + } } private static bool IsManagedEdge(ProjectionGraphEdge edge) @@ -59,6 +74,33 @@ private static bool IsManagedEdge(ProjectionGraphEdge edge) string.Equals(markerValue, ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); } + private static bool IsManagedNode(ProjectionGraphNode node) + { + return node.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue) && + string.Equals(markerValue, ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); + } + + private async Task CanDeleteNodeAsync( + string scope, + string nodeId, + CancellationToken ct) + { + if (nodeId.Length == 0) + return false; + + var neighbors = await _graphStore.GetNeighborsAsync( + new ProjectionGraphQuery + { + Scope = scope, + RootNodeId = nodeId, + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 1, + }, + ct); + return neighbors.Count == 0; + } + private static ManagedOwnerResolution BuildManagedOwnerId(IGraphReadModel readModel) { var readModelId = NormalizeToken(readModel.Id); @@ -91,7 +133,8 @@ private static ManagedOwnerResolution BuildManagedOwnerId(IGraphReadModel readMo private static IReadOnlyList NormalizeNodes( IReadOnlyList graphNodes, - string scope) + string scope, + string ownerId) { if (graphNodes.Count == 0) return []; @@ -107,12 +150,18 @@ private static IReadOnlyList NormalizeNodes( if (nodeType.Length == 0) nodeType = "Unknown"; + var properties = new Dictionary(graphNode.Properties, StringComparer.Ordinal) + { + [ProjectionGraphSystemPropertyKeys.ManagedMarkerKey] = ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, + [ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey] = ownerId, + }; + nodesById[nodeId] = new ProjectionGraphNode { Scope = scope, NodeId = nodeId, NodeType = nodeType, - Properties = new Dictionary(graphNode.Properties, StringComparer.Ordinal), + Properties = properties, UpdatedAt = graphNode.UpdatedAt == default ? DateTimeOffset.UtcNow : graphNode.UpdatedAt, }; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs index 2c3f9b2f4..3001fc8dc 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs @@ -29,35 +29,8 @@ public ProjectionGraphStoreFanout( throw new InvalidOperationException( "No graph projection store providers are registered."); } - - var primaryRegistrations = registrationList - .Where(x => x.IsPrimaryQueryStore) - .ToList(); - if (primaryRegistrations.Count == 0 && registrationList.Count > 1) - { - var providers = string.Join(", ", registrationList.Select(x => x.ProviderName)); - throw new InvalidOperationException( - $"Exactly one primary graph projection store provider must be configured. registeredProviders=[{providers}]"); - } - - if (primaryRegistrations.Count > 1) - { - var providers = string.Join(", ", primaryRegistrations.Select(x => x.ProviderName)); - throw new InvalidOperationException( - $"Multiple primary graph projection store providers are configured. primaryProviders=[{providers}]"); - } - - var queryRegistration = primaryRegistrations.Count == 1 - ? primaryRegistrations[0] - : registrationList[0]; - var queryIndex = registrationList.FindIndex(x => ReferenceEquals(x, queryRegistration)); - if (queryIndex < 0) - { - throw new InvalidOperationException("Failed to resolve primary graph projection store provider."); - } - - _queryStore = _stores[queryIndex]; - _queryProviderName = queryRegistration.ProviderName; + _queryStore = _stores[0]; + _queryProviderName = registrationList[0].ProviderName; _logger.LogInformation( "Projection graph fan-out initialized. storeCount={StoreCount} queryProvider={QueryProvider}", @@ -89,6 +62,15 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct } } + public async Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + foreach (var store in _stores) + { + ct.ThrowIfCancellationRequested(); + await store.DeleteNodeAsync(scope, nodeId, ct); + } + } + public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { foreach (var store in _stores) @@ -98,6 +80,15 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken } } + public Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + return _queryStore.ListNodesByOwnerAsync(scope, ownerId, take, ct); + } + public Task> ListEdgesByOwnerAsync( string scope, string ownerId, diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs index df141cc58..135a1a1b0 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs @@ -6,8 +6,16 @@ public interface IProjectionGraphStore Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default); + Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default); + Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default); + Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default); + Task> ListEdgesByOwnerAsync( string scope, string ownerId, diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index e7b5d352e..be0c7c51f 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -12,10 +12,6 @@ namespace Aevatar.Workflow.Extensions.Hosting; public static class WorkflowProjectionProviderServiceCollectionExtensions { - private const string InMemoryProviderName = "InMemory"; - private const string ElasticsearchProviderName = "Elasticsearch"; - private const string Neo4jProviderName = "Neo4j"; - public static IServiceCollection AddWorkflowProjectionReadModelProviders( this IServiceCollection services, IConfiguration configuration) @@ -38,23 +34,10 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( var enableInMemoryGraph = ResolveOptionalBool( configuration["Projection:Graph:Providers:InMemory:Enabled"], fallbackValue: !enableNeo4jGraph); - var documentPrimaryProvider = ResolveDocumentPrimaryProvider(enableInMemoryDocument, enableElasticsearchDocument); - var graphPrimaryProvider = ResolveGraphPrimaryProvider(enableInMemoryGraph, enableNeo4jGraph); EnforceGraphProviderPolicy(configuration, enableInMemoryGraph); var documentProviderCount = 0; - if (enableInMemoryDocument) - { - services.AddInMemoryDocumentStoreRegistration( - keySelector: report => report.RootActorId, - isPrimaryQueryStore: string.Equals(documentPrimaryProvider, InMemoryProviderName, StringComparison.Ordinal), - keyFormatter: key => key, - listSortSelector: report => report.CreatedAt, - listTakeMax: 200); - documentProviderCount++; - } - if (enableElasticsearchDocument) { services.AddElasticsearchDocumentStoreRegistration( @@ -65,25 +48,32 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( return metadataResolver.Resolve(); }, keySelector: report => report.RootActorId, - isPrimaryQueryStore: string.Equals(documentPrimaryProvider, ElasticsearchProviderName, StringComparison.Ordinal), keyFormatter: key => key); documentProviderCount++; } - var graphProviderCount = 0; - if (enableInMemoryGraph) + if (enableInMemoryDocument) { - services.AddInMemoryGraphStoreRegistration( - isPrimaryQueryStore: string.Equals(graphPrimaryProvider, InMemoryProviderName, StringComparison.Ordinal)); - graphProviderCount++; + services.AddInMemoryDocumentStoreRegistration( + keySelector: report => report.RootActorId, + keyFormatter: key => key, + listSortSelector: report => report.CreatedAt, + listTakeMax: 200); + documentProviderCount++; } + var graphProviderCount = 0; if (enableNeo4jGraph) { services.AddNeo4jGraphStoreRegistration( optionsFactory: _ => BuildNeo4jGraphOptions(configuration), - scopeFactory: _ => WorkflowExecutionGraphConstants.Scope, - isPrimaryQueryStore: string.Equals(graphPrimaryProvider, Neo4jProviderName, StringComparison.Ordinal)); + scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); + graphProviderCount++; + } + + if (enableInMemoryGraph) + { + services.AddInMemoryGraphStoreRegistration(); graphProviderCount++; } @@ -137,32 +127,6 @@ private static bool ResolveNeo4jGraphEnabled(IConfiguration configuration) return ResolveOptionalBool(explicitEnabled, hasUri); } - private static string ResolveDocumentPrimaryProvider( - bool enableInMemoryDocument, - bool enableElasticsearchDocument) - { - if (enableElasticsearchDocument) - return ElasticsearchProviderName; - - if (enableInMemoryDocument) - return InMemoryProviderName; - - return ""; - } - - private static string ResolveGraphPrimaryProvider( - bool enableInMemoryGraph, - bool enableNeo4jGraph) - { - if (enableNeo4jGraph) - return Neo4jProviderName; - - if (enableInMemoryGraph) - return InMemoryProviderName; - - return ""; - } - private static ElasticsearchProjectionReadModelStoreOptions BuildElasticsearchDocumentOptions( IConfiguration configuration) { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs index 65b24e264..ed8dc5fba 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs @@ -134,6 +134,111 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel owner2Edges.Select(x => x.EdgeId).Should().ContainSingle("edge-owner-2"); } + [Fact] + public async Task UpsertGraphAsync_ShouldRemoveDisconnectedStaleNodesForSameOwner() + { + var store = new RecordingGraphStore(); + var materializer = new ProjectionGraphMaterializer(store); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + Node("left"), + Node("orphan-a"), + Node("orphan-b"), + ], + GraphEdges = + [ + Edge("edge-root", "root", "left"), + Edge("edge-orphan", "orphan-a", "orphan-b"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + Node("left"), + ], + GraphEdges = + [ + Edge("edge-root", "root", "left"), + ], + }); + + var ownerNodes = await store.ListNodesByOwnerAsync("scope-1", BuildOwnerId("owner-1"), take: 20); + + ownerNodes.Select(x => x.NodeId).Should().BeEquivalentTo("root", "left"); + store.ContainsNode("scope-1", "orphan-a").Should().BeFalse(); + store.ContainsNode("scope-1", "orphan-b").Should().BeFalse(); + } + + [Fact] + public async Task UpsertGraphAsync_ShouldKeepStaleNodeWhenStillReferencedByAnotherOwner() + { + var store = new RecordingGraphStore(); + var materializer = new ProjectionGraphMaterializer(store); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = + [ + Node("shared"), + Node("owner-1-node"), + ], + GraphEdges = + [ + Edge("edge-owner-1", "shared", "owner-1-node"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-2", + GraphScope = "scope-1", + GraphNodes = + [ + Node("owner-2-node"), + ], + GraphEdges = + [ + Edge("edge-owner-2", "shared", "owner-2-node"), + ], + }); + + await materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "owner-1", + GraphScope = "scope-1", + GraphNodes = [], + GraphEdges = [], + }); + + var sharedNeighbors = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "shared", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 20, + }); + + sharedNeighbors.Select(x => x.EdgeId).Should().ContainSingle("edge-owner-2"); + store.ContainsNode("scope-1", "shared").Should().BeTrue(); + store.ContainsNode("scope-1", "owner-1-node").Should().BeFalse(); + } + + private static string BuildOwnerId(string id) => $"{typeof(TestGraphReadModel).FullName}:{id}"; + private static GraphNodeDescriptor Node(string nodeId) { return new GraphNodeDescriptor( @@ -187,6 +292,14 @@ public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = def return Task.CompletedTask; } + public Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + lock (_gate) + _nodes.Remove(BuildScopedKey(scope, nodeId)); + return Task.CompletedTask; + } + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -224,6 +337,35 @@ public Task> ListEdgesByOwnerAsync( return Task.FromResult>(edges); } + public Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var scopeValue = NormalizeToken(scope); + var ownerValue = NormalizeToken(ownerId); + if (scopeValue.Length == 0 || ownerValue.Length == 0) + return Task.FromResult>([]); + + List nodes; + lock (_gate) + { + nodes = _nodes.Values + .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) + .Where(x => + x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && + string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) + .OrderByDescending(x => x.UpdatedAt) + .Take(Math.Clamp(take, 1, 50000)) + .Select(CloneNode) + .ToList(); + } + + return Task.FromResult>(nodes); + } + public Task> GetNeighborsAsync( ProjectionGraphQuery query, CancellationToken ct = default) @@ -294,6 +436,12 @@ public async Task GetSubgraphAsync( }; } + public bool ContainsNode(string scope, string nodeId) + { + lock (_gate) + return _nodes.ContainsKey(BuildScopedKey(scope, nodeId)); + } + private static bool MatchDirection(ProjectionGraphEdge edge, string rootNodeId, ProjectionGraphDirection direction) { return direction switch diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs index 3e220bbd4..0d2fff30a 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs @@ -7,21 +7,19 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelRuntimeTests { [Fact] - public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQueryStore() + public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUseFirstRegisteredQueryStore() { - var primaryStore = new NamedDocumentStore("primary"); + var queryStore = new NamedDocumentStore("query"); var replicaStore = new NamedDocumentStore("replica"); var services = new ServiceCollection(); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "replica", - false, - _ => replicaStore)); + "query", + _ => queryStore)); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "primary", - true, - _ => primaryStore)); + "replica", + _ => replicaStore)); using var serviceProvider = services.BuildServiceProvider(); var fanout = new ProjectionDocumentStoreFanout( @@ -36,7 +34,7 @@ public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQ await fanout.UpsertAsync(model); - primaryStore.UpsertCount.Should().Be(1); + queryStore.UpsertCount.Should().Be(1); replicaStore.UpsertCount.Should().Be(1); var fetched = await fanout.GetAsync("id-1"); @@ -45,24 +43,22 @@ public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUsePrimaryQ } [Fact] - public async Task ProjectionDocumentStoreFanout_ShouldReadFromExplicitPrimary_WhenOrderDiffers() + public async Task ProjectionDocumentStoreFanout_ShouldReadFromFirstRegistration_WhenOrderDiffers() { - var primaryStore = new NamedDocumentStore("primary"); - var replicaStore = new NamedDocumentStore("replica"); - primaryStore.Seed("id-1", "from-primary"); - replicaStore.Seed("id-1", "from-replica"); + var firstStore = new NamedDocumentStore("first"); + var secondStore = new NamedDocumentStore("second"); + firstStore.Seed("id-1", "from-first"); + secondStore.Seed("id-1", "from-second"); var services = new ServiceCollection(); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "replica", - false, - _ => replicaStore)); + "first", + _ => firstStore)); services.AddSingleton>>( new DelegateProjectionStoreRegistration>( - "primary", - true, - _ => primaryStore)); + "second", + _ => secondStore)); using var serviceProvider = services.BuildServiceProvider(); var fanout = new ProjectionDocumentStoreFanout( @@ -72,55 +68,7 @@ public async Task ProjectionDocumentStoreFanout_ShouldReadFromExplicitPrimary_Wh var fetched = await fanout.GetAsync("id-1"); fetched.Should().NotBeNull(); - fetched!.Value.Should().Be("from-primary"); - } - - [Fact] - public void ProjectionDocumentStoreFanout_WhenMultipleRegistrationsAndNoPrimary_ShouldThrow() - { - var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "replica-1", - false, - _ => new NamedDocumentStore("replica-1"))); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "replica-2", - false, - _ => new NamedDocumentStore("replica-2"))); - - using var serviceProvider = services.BuildServiceProvider(); - Action act = () => new ProjectionDocumentStoreFanout( - serviceProvider.GetServices>>(), - serviceProvider); - - act.Should().Throw() - .WithMessage("*Exactly one primary document projection store provider must be configured*"); - } - - [Fact] - public void ProjectionDocumentStoreFanout_WhenMultiplePrimaryRegistrations_ShouldThrow() - { - var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "primary-1", - true, - _ => new NamedDocumentStore("primary-1"))); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "primary-2", - true, - _ => new NamedDocumentStore("primary-2"))); - - using var serviceProvider = services.BuildServiceProvider(); - Action act = () => new ProjectionDocumentStoreFanout( - serviceProvider.GetServices>>(), - serviceProvider); - - act.Should().Throw() - .WithMessage("*Multiple primary document projection store providers are configured*"); + fetched!.Value.Should().Be("from-first"); } [Fact] diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs index 513316576..ddead8353 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs @@ -7,21 +7,19 @@ namespace Aevatar.CQRS.Projection.Core.Tests; public class ProjectionReadModelStoreSelectorTests { [Fact] - public async Task ProjectionGraphStoreFanout_ShouldFanoutWritesAndUsePrimaryQueryStore() + public async Task ProjectionGraphStoreFanout_ShouldFanoutWritesAndUseFirstRegisteredQueryStore() { - var primaryStore = new NamedGraphStore("primary"); - var replicaStore = new NamedGraphStore("replica"); + var firstStore = new NamedGraphStore("first"); + var secondStore = new NamedGraphStore("second"); var services = new ServiceCollection(); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "replica", - false, - _ => replicaStore)); + "first", + _ => firstStore)); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "primary", - true, - _ => primaryStore)); + "second", + _ => secondStore)); using var serviceProvider = services.BuildServiceProvider(); var fanout = new ProjectionGraphStoreFanout( @@ -47,58 +45,44 @@ await fanout.UpsertNodeAsync(new ProjectionGraphNode Take = 10, }); - primaryStore.UpsertNodeCount.Should().Be(1); - replicaStore.UpsertNodeCount.Should().Be(1); + firstStore.UpsertNodeCount.Should().Be(1); + secondStore.UpsertNodeCount.Should().Be(1); edges.Should().HaveCount(1); - edges[0].EdgeType.Should().Be("primary"); + edges[0].EdgeType.Should().Be("first"); } [Fact] - public void ProjectionGraphStoreFanout_WhenMultipleRegistrationsAndNoPrimary_ShouldThrow() + public async Task ProjectionGraphStoreFanout_ShouldReadFromFirstRegistration_WhenOrderDiffers() { + var firstStore = new NamedGraphStore("from-first"); + var secondStore = new NamedGraphStore("from-second"); var services = new ServiceCollection(); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "replica-1", - false, - _ => new NamedGraphStore("replica-1"))); + "first", + _ => firstStore)); services.AddSingleton>( new DelegateProjectionStoreRegistration( - "replica-2", - false, - _ => new NamedGraphStore("replica-2"))); + "second", + _ => secondStore)); using var serviceProvider = services.BuildServiceProvider(); - Action act = () => new ProjectionGraphStoreFanout( + var fanout = new ProjectionGraphStoreFanout( serviceProvider.GetServices>(), serviceProvider); - act.Should().Throw() - .WithMessage("*Exactly one primary graph projection store provider must be configured*"); - } - - [Fact] - public void ProjectionGraphStoreFanout_WhenMultiplePrimaryRegistrations_ShouldThrow() - { - var services = new ServiceCollection(); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "primary-1", - true, - _ => new NamedGraphStore("primary-1"))); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "primary-2", - true, - _ => new NamedGraphStore("primary-2"))); - - using var serviceProvider = services.BuildServiceProvider(); - Action act = () => new ProjectionGraphStoreFanout( - serviceProvider.GetServices>(), - serviceProvider); + var edges = await fanout.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "projection-scope", + RootNodeId = "node-1", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Depth = 1, + Take = 10, + }); - act.Should().Throw() - .WithMessage("*Multiple primary graph projection store providers are configured*"); + edges.Should().ContainSingle(); + edges[0].EdgeType.Should().Be("from-first"); } [Fact] @@ -137,6 +121,13 @@ public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = def return Task.CompletedTask; } + public Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + _ = scope; + _ = nodeId; + return Task.CompletedTask; + } + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { _ = scope; @@ -144,6 +135,18 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = return Task.CompletedTask; } + public Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int take = 5000, + CancellationToken ct = default) + { + _ = scope; + _ = ownerId; + _ = take; + return Task.FromResult>([]); + } + public Task> ListEdgesByOwnerAsync( string scope, string ownerId, diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 4446aab50..6a65e8faa 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -67,11 +67,10 @@ private static void RegisterInMemoryProviders(IServiceCollection services) { services.AddInMemoryDocumentStoreRegistration( keySelector: report => report.RootActorId, - isPrimaryQueryStore: true, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); - services.AddInMemoryGraphStoreRegistration(isPrimaryQueryStore: true); + services.AddInMemoryGraphStoreRegistration(); } private static void RegisterElasticsearchDocumentProvider(IServiceCollection services) @@ -87,7 +86,6 @@ private static void RegisterElasticsearchDocumentProvider(IServiceCollection ser return metadataResolver.Resolve(); }, keySelector: report => report.RootActorId, - isPrimaryQueryStore: true, keyFormatter: key => key); } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index f74931429..9236118d0 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -126,7 +126,7 @@ public void AddWorkflowProjectionReadModelProviders_WhenDurableProvidersEnabled_ } [Fact] - public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_ShouldSelectDurablePrimary() + public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_ShouldRegisterDurableProvidersFirst() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() @@ -152,20 +152,12 @@ public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_Shou .ToList(); documentRegistrations.Should().HaveCount(2); - documentRegistrations.Should().ContainSingle(x => - string.Equals(x.ProviderName, "Elasticsearch", StringComparison.Ordinal) && - x.IsPrimaryQueryStore); - documentRegistrations.Should().ContainSingle(x => - string.Equals(x.ProviderName, "InMemory", StringComparison.Ordinal) && - !x.IsPrimaryQueryStore); + documentRegistrations[0].ProviderName.Should().Be("Elasticsearch"); + documentRegistrations[1].ProviderName.Should().Be("InMemory"); graphRegistrations.Should().HaveCount(2); - graphRegistrations.Should().ContainSingle(x => - string.Equals(x.ProviderName, "Neo4j", StringComparison.Ordinal) && - x.IsPrimaryQueryStore); - graphRegistrations.Should().ContainSingle(x => - string.Equals(x.ProviderName, "InMemory", StringComparison.Ordinal) && - !x.IsPrimaryQueryStore); + graphRegistrations[0].ProviderName.Should().Be("Neo4j"); + graphRegistrations[1].ProviderName.Should().Be("InMemory"); } [Fact] From 2e473fb7ac61000cbbdff6186d3f620600bdfe2a Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 04:03:11 +0800 Subject: [PATCH 36/46] Refactor Projection ReadModel and Enhance Metadata Handling - Updated the Projection ReadModel architecture to utilize structured metadata objects for Elasticsearch, improving clarity and usability in index initialization. - Enhanced the `DocumentIndexMetadata` to support structured mappings, settings, and aliases, replacing the previous JSON string representation. - Refactored the `ElasticsearchProjectionReadModelStore` to consume the new structured metadata format, ensuring accurate index creation and configuration. - Optimized the Neo4j subgraph query logic to reduce N+1 query risks by consolidating edge retrieval into a single Cypher query, improving performance in high-degree scenarios. - Revised documentation to reflect these architectural changes and clarify the new metadata handling processes. --- ...readmodel-full-refactor-plan-2026-02-24.md | 14 +- ...on-store-readmodel-scorecard-2026-02-24.md | 24 ++- .../README.md | 2 +- .../ElasticsearchProjectionReadModelStore.cs | 147 ++++++++++++++---- .../Stores/Neo4jProjectionGraphStore.cs | 103 ++++++------ .../Runtime/ProjectionGraphMaterializer.cs | 31 +--- .../ReadModels/DocumentIndexMetadata.cs | 6 +- .../README.md | 2 +- ...ExecutionReportDocumentMetadataProvider.cs | 9 +- .../WorkflowProjectionReadModelUpdater.cs | 6 + .../WorkflowExecutionReadModelProjector.cs | 6 + ...chProjectionReadModelStoreBehaviorTests.cs | 73 ++++++++- .../ProjectionGraphMaterializerTests.cs | 21 +++ .../ProjectionProviderE2EIntegrationTests.cs | 6 +- 14 files changed, 307 insertions(+), 143 deletions(-) diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 00478bbbc..932a1736a 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -118,17 +118,23 @@ Rules: - 写节点时同样注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` - 清理时按 owner 列举已有边/节点并做差集删除,不再依赖 `Depth/Take` 子图扫描窗口。 - 节点删除前执行邻接检查,仅删除无任何关系边的孤立节点,避免误删跨 owner 共享节点。 + - graph owner 标识收敛为 `IGraphReadModel.Id`;`Id` 为空直接 fail-fast,禁止 fallback 到节点/边推断。 ## 3.7 Elasticsearch Metadata Behavior `ElasticsearchProjectionReadModelStore` now consumes full `DocumentIndexMetadata`: - `IndexName` as logical scope input -- `MappingJson` as index mappings object -- `Settings` as index settings object -- `Aliases` as index alias object +- `Mappings` as structured mappings object (`IReadOnlyDictionary`) +- `Settings` as structured settings object (`IReadOnlyDictionary`) +- `Aliases` as structured aliases object (`IReadOnlyDictionary`) -Index bootstrap now uses metadata payload instead of fixed `{"mappings":{"dynamic":true}}`. +Index bootstrap now uses structured metadata payload instead of stringified JSON fragments. + +## 3.8 Neo4j Subgraph Query Optimization + +- `Neo4jProjectionGraphStore.GetSubgraphAsync` 从“逐层逐节点 `GetNeighborsAsync` 循环”重构为单次 Cypher 拉取边(按 `direction/depth/edgeTypes/take`),随后一次节点补全查询。 +- 移除子图遍历的 N+1 风险,降低高出度场景下查询放大。 ## 4. Project-Level Responsibility Split (Post-Refactor) diff --git a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md index 5249ed2b6..04bb3e296 100644 --- a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md @@ -27,9 +27,9 @@ ## 3. 总分结论 -- **总分:89 / 100** +- **总分:92 / 100** - **等级:B+(严格口径,高于上版 84)** -- **结论:核心架构问题(查询源规则不清、图清理窗口清理)已实质修复;当前主要短板转为一致性语义、owner 标识稳定性与可观测性。** +- **结论:核心架构问题(查询源规则不清、图清理窗口清理、Neo4j 子图查询放大)已实质修复;当前主要短板转为跨 Store 一致性与可观测性。** ## 4. 维度评分(严格) @@ -37,12 +37,12 @@ |---|---:|---:|---| | 架构边界与抽象收敛 | 15 | 15 | `IProjectionStoreRegistration` 收敛为最小契约(`ProviderName + Create`),Runtime 组装边界清晰(`IProjectionStoreRegistration.cs`,`DelegateProjectionStoreRegistration.cs`)。 | | Provider 模型清晰度 | 15 | 15 | Document/Graph fan-out 使用统一规则“注册顺序即查询顺序”(首注册 provider 读,全部 provider 写 fan-out)(`ProjectionDocumentStoreFanout.cs`,`ProjectionGraphStoreFanout.cs`);Workflow Host 落地耐久优先注册顺序(`WorkflowProjectionProviderServiceCollectionExtensions.cs`)。 | -| Document 索引语义完整性 | 20 | 17 | ES 初始化已消费 `DocumentIndexMetadata` 的 mappings/settings/aliases(`ElasticsearchProjectionReadModelStore.cs:486-503`,`515-532`)。扣分:`DocumentIndexMetadata.Settings/Aliases` 仍是 `Dictionary`(`DocumentIndexMetadata.cs:3-7`),复杂结构需字符串 JSON;Workflow 默认 metadata 仍是空 mapping(`WorkflowExecutionReportDocumentMetadataProvider.cs:8-12`)。 | -| Graph 关系语义与正确性 | 15 | 14 | Graph materializer 已改为 owner-based 差集清理(`ProjectionGraphMaterializer.cs`),并写入系统属性 `projectionManaged/projectionOwnerId`(`ProjectionGraphMaterializer.cs`);Graph store 抽象新增 `ListEdgesByOwnerAsync/ListNodesByOwnerAsync/DeleteNodeAsync`(`IProjectionGraphStore.cs`),InMemory/Neo4j 均实现(`InMemoryProjectionGraphStore.cs`,`Neo4jProjectionGraphStore.cs`)。扣分:当 readModel 无稳定 id 且无可用节点/边标识时仍会跳过 cleanup(`ProjectionGraphMaterializer.cs` 的 owner fallback 分支)。 | +| Document 索引语义完整性 | 20 | 19 | `DocumentIndexMetadata` 已升级为结构化对象(`Mappings/Settings/Aliases`),ES 初始化直接消费结构化元数据(`DocumentIndexMetadata.cs`,`ElasticsearchProjectionReadModelStore.cs`);复杂 metadata 组合行为已有单测覆盖(`ElasticsearchProjectionReadModelStoreBehaviorTests.cs`)。扣分:仍缺少真实 ES 集群下复杂 settings/aliases 的端到端回归。 | +| Graph 关系语义与正确性 | 15 | 15 | Graph materializer 已完成 owner-based 边/节点差集清理,节点删除前有邻接检查防误删共享节点(`ProjectionGraphMaterializer.cs`);owner 标识收敛为 `IGraphReadModel.Id`,`Id` 为空 fail-fast,消除 fallback 残留语义(`ProjectionGraphMaterializer.cs`、`ProjectionGraphMaterializerTests.cs`)。 | | 一致性与失败语义 | 10 | 7 | Router 仍是顺序写入(先 Document,再 Graph)(`ProjectionMaterializationRouter.cs:31-37`),跨 provider 非事务;`Mutate` 后读回再刷新 graph(`ProjectionMaterializationRouter.cs:49-63`),失败时可能留部分成功状态。 | -| Provider 实现质量与性能 | 10 | 8 | ES `MutateAsync` OCC 重试与冲突处理较完整(`ElasticsearchProjectionReadModelStore.cs:93-143`)。扣分:Neo4j 子图遍历仍为逐层逐节点 `GetNeighborsAsync`(`Neo4jProjectionGraphStore.cs:241-260`),在高出度场景有查询放大风险。 | +| Provider 实现质量与性能 | 10 | 9 | ES `MutateAsync` OCC 重试与冲突处理较完整(`ElasticsearchProjectionReadModelStore.cs`);Neo4j `GetSubgraphAsync` 已从逐节点循环改为单次 Cypher 拉边 + 一次节点补全(`Neo4jProjectionGraphStore.cs`),显著降低查询放大。扣分:仍缺少高规模数据集下的性能基准回归。 | | 可观测性 | 5 | 3 | Fan-out 初始化日志已有 provider 信息(`ProjectionDocumentStoreFanout.cs:76-80`,`ProjectionGraphStoreFanout.cs:62-65`);ES 冲突日志较完整(`ElasticsearchProjectionReadModelStore.cs:124-133`)。扣分:Graph provider 成功路径指标/日志仍偏薄,Neo4j 侧主要是反序列化 warning(`Neo4jProjectionGraphStore.cs:546-551`)。 | -| 测试与治理门禁 | 10 | 9 | fan-out 查询顺序与写扩散语义测试已补齐(`ProjectionReadModelRuntimeTests.cs`,`ProjectionReadModelStoreSelectorTests.cs`);owner 维度边/节点清理回归已补(`ProjectionGraphMaterializerTests.cs`);Workflow host 对 durable-first 注册顺序有覆盖(`WorkflowHostingExtensionsCoverageTests.cs`);门禁脚本通过。扣分:仍缺少 `DocumentIndexMetadata` 复杂 settings/aliases 组合的端到端初始化测试。 | +| 测试与治理门禁 | 10 | 9 | fan-out 查询顺序与写扩散语义测试已补齐(`ProjectionReadModelRuntimeTests.cs`,`ProjectionReadModelStoreSelectorTests.cs`);owner 维度边/节点清理与空 Id fail-fast 回归已补(`ProjectionGraphMaterializerTests.cs`);structured metadata 初始化测试已补(`ElasticsearchProjectionReadModelStoreBehaviorTests.cs`);门禁脚本通过。扣分:仍缺少真实 Neo4j/ES 双 provider 联动压力测试。 | ## 5. 关键扣分项(按优先级) @@ -53,14 +53,8 @@ ### P2 -1. **Neo4j 子图遍历存在查询放大** - - `GetSubgraphAsync` 逐节点调用 `GetNeighborsAsync`(`Neo4jProjectionGraphStore.cs:241-260`)。 - -2. **Document 索引元数据表达力仍有限** - - `Settings/Aliases` 使用字符串字典,复杂结构可读性与校验能力有限(`DocumentIndexMetadata.cs:3-7`)。 - -3. **Graph owner fallback 仍有残留风险** - - 当 readModel 缺少稳定标识且节点/边都为空时,owner 解析会降级并跳过 cleanup,可能保留历史 managed 数据(`ProjectionGraphMaterializer.cs`)。 +1. **跨 Provider 高规模性能基线不足** + - 缺少 Neo4j/ES 双 provider 联动的压力测试与容量基线,尚未形成性能门禁。 ### P3 @@ -69,4 +63,4 @@ ## 6. 重新评分判定 -当前实现已进入“结构清晰 + 关键缺陷已修复”的阶段,但在严格生产工程标准下仍未到 A 档。重新评分为 **89/100(B+)**。 +当前实现已进入“结构清晰 + 关键缺陷大幅收敛”的阶段,但在严格生产工程标准下仍未到 A 档。重新评分为 **92/100(B+)**。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 9c9591361..4b253f469 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -7,7 +7,7 @@ - `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 - `AutoCreateIndex=false` 时可通过 `MissingIndexBehavior` 控制索引缺失行为(默认抛错)。 - `ListSortField` 为空时默认按 `CreatedAt desc -> _id desc` 排序。 -- 索引初始化支持 `DocumentIndexMetadata`:`MappingJson`、`Settings`、`Aliases`。 +- 索引初始化支持 `DocumentIndexMetadata`:`Mappings`、`Settings`、`Aliases`(结构化对象)。 ## DI 注册 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs index b97052df4..04ac0c4b5 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs @@ -485,9 +485,12 @@ private async Task EnsureIndexAsync(CancellationToken ct) private string BuildIndexInitializationPayload() { - object mappings = new { dynamic = true }; - if (!string.IsNullOrWhiteSpace(_indexMetadata.MappingJson)) - mappings = ParseJsonObject(_indexMetadata.MappingJson, "DocumentIndexMetadata.MappingJson"); + var mappings = _indexMetadata.Mappings.Count == 0 + ? new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + } + : new Dictionary(_indexMetadata.Mappings, StringComparer.Ordinal); var root = new Dictionary { @@ -495,9 +498,9 @@ private string BuildIndexInitializationPayload() }; if (_indexMetadata.Settings.Count > 0) - root["settings"] = _indexMetadata.Settings; + root["settings"] = new Dictionary(_indexMetadata.Settings, StringComparer.Ordinal); if (_indexMetadata.Aliases.Count > 0) - root["aliases"] = BuildAliasPayload(_indexMetadata.Aliases); + root["aliases"] = new Dictionary(_indexMetadata.Aliases, StringComparer.Ordinal); return JsonSerializer.Serialize(root, _jsonOptions); } @@ -505,50 +508,130 @@ private string BuildIndexInitializationPayload() private static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) { ArgumentNullException.ThrowIfNull(metadata); + var normalizedMappings = NormalizeObjectMap(metadata.Mappings, "DocumentIndexMetadata.Mappings"); + var normalizedSettings = NormalizeObjectMap(metadata.Settings, "DocumentIndexMetadata.Settings"); + var normalizedAliases = NormalizeObjectMap(metadata.Aliases, "DocumentIndexMetadata.Aliases"); return new DocumentIndexMetadata( metadata.IndexName?.Trim() ?? "", - metadata.MappingJson?.Trim() ?? "{}", - new Dictionary(metadata.Settings, StringComparer.Ordinal), - new Dictionary(metadata.Aliases, StringComparer.Ordinal)); + normalizedMappings, + normalizedSettings, + normalizedAliases); } - private static IReadOnlyDictionary BuildAliasPayload( - IReadOnlyDictionary aliases) + private static Dictionary NormalizeObjectMap( + IReadOnlyDictionary source, + string context) { - var payload = new Dictionary(StringComparer.Ordinal); - foreach (var alias in aliases) + ArgumentNullException.ThrowIfNull(source); + var normalized = new Dictionary(StringComparer.Ordinal); + foreach (var pair in source) { - var aliasName = alias.Key?.Trim() ?? ""; - if (aliasName.Length == 0) - continue; + var key = pair.Key?.Trim() ?? ""; + if (key.Length == 0) + throw new InvalidOperationException($"{context} contains an empty key."); - var aliasConfig = alias.Value?.Trim() ?? ""; - payload[aliasName] = aliasConfig.Length == 0 - ? new Dictionary(StringComparer.Ordinal) - : ParseJsonObject(aliasConfig, $"DocumentIndexMetadata.Aliases['{aliasName}']"); + normalized[key] = NormalizeObjectValue(pair.Value, $"{context}['{key}']"); } - return payload; + return normalized; } - private static object ParseJsonObject(string payload, string context) + private static object? NormalizeObjectValue(object? value, string context) { - try + if (value == null) + return null; + + if (value is string || + value is bool || + value is byte || + value is sbyte || + value is short || + value is ushort || + value is int || + value is uint || + value is long || + value is ulong || + value is float || + value is double || + value is decimal) { - using var json = JsonDocument.Parse(payload); - if (json.RootElement.ValueKind != JsonValueKind.Object) - { - throw new InvalidOperationException( - $"{context} must be a JSON object. actualKind={json.RootElement.ValueKind}"); - } + return value; + } + + if (value is JsonElement jsonElement) + return NormalizeJsonElement(jsonElement, context); - return JsonSerializer.Deserialize>( - json.RootElement.GetRawText()) ?? new Dictionary(StringComparer.Ordinal); + if (value is IReadOnlyDictionary readonlyObjectMap) + return NormalizeObjectMap(readonlyObjectMap, context); + + if (value is IDictionary mutableObjectMap) + return NormalizeObjectMap( + new Dictionary(mutableObjectMap, StringComparer.Ordinal), + context); + + if (value is IReadOnlyDictionary readonlyStringMap) + { + var converted = readonlyStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); } - catch (Exception ex) when (ex is JsonException or NotSupportedException) + + if (value is IDictionary mutableStringMap) { - throw new InvalidOperationException($"{context} is invalid JSON object: {ex.Message}", ex); + var converted = mutableStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); } + + if (value is IEnumerable objectSequence) + return objectSequence.Select((x, i) => NormalizeObjectValue(x, $"{context}[{i}]")).ToList(); + + if (value is IEnumerable stringSequence) + return stringSequence.Cast().ToList(); + + throw new InvalidOperationException( + $"{context} contains unsupported value type '{value.GetType().FullName}'."); + } + + private static object? NormalizeJsonElement(JsonElement element, string context) + { + return element.ValueKind switch + { + JsonValueKind.Object => element + .EnumerateObject() + .ToDictionary( + x => x.Name, + x => NormalizeJsonElement(x.Value, $"{context}['{x.Name}']"), + StringComparer.Ordinal), + JsonValueKind.Array => element + .EnumerateArray() + .Select((x, i) => NormalizeJsonElement(x, $"{context}[{i}]")) + .ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => NormalizeJsonNumber(element, context), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => throw new InvalidOperationException( + $"{context} contains unsupported json value kind '{element.ValueKind}'."), + }; + } + + private static object NormalizeJsonNumber(JsonElement numberElement, string context) + { + if (numberElement.TryGetInt64(out var int64Value)) + return int64Value; + if (numberElement.TryGetDecimal(out var decimalValue)) + return decimalValue; + if (numberElement.TryGetDouble(out var doubleValue)) + return doubleValue; + + throw new InvalidOperationException($"{context} contains an invalid JSON number value."); } private static async Task EnsureSuccessAsync( diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index c297800f5..123f9bb95 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -325,54 +325,34 @@ public async Task GetSubgraphAsync( if (scope.Length == 0 || rootNodeId.Length == 0) return new ProjectionGraphSubgraph(); + await EnsureSchemaAsync(ct); var depth = Math.Clamp(query.Depth, 1, _maxTraversalDepth); var take = Math.Clamp(query.Take, 1, 5000); - var visitedNodeIds = new HashSet(StringComparer.Ordinal) { rootNodeId }; - var frontier = new HashSet(StringComparer.Ordinal) { rootNodeId }; - var collectedEdges = new Dictionary(StringComparer.Ordinal); - - for (var currentDepth = 0; currentDepth < depth; currentDepth++) + var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); + var cypher = BuildSubgraphEdgesCypher(query.Direction, depth); + var parameters = new Dictionary { - if (frontier.Count == 0 || collectedEdges.Count >= take) - break; + ["scope"] = scope, + ["rootNodeId"] = rootNodeId, + ["edgeTypes"] = edgeTypes, + ["take"] = take, + }; - var nextFrontier = new HashSet(StringComparer.Ordinal); - foreach (var nodeId in frontier) - { - ct.ThrowIfCancellationRequested(); - var neighbors = await GetNeighborsAsync( - new ProjectionGraphQuery - { - Scope = scope, - RootNodeId = nodeId, - Direction = query.Direction, - EdgeTypes = query.EdgeTypes, - Depth = 1, - Take = take - collectedEdges.Count, - }, - ct); - - foreach (var edge in neighbors) - { - if (collectedEdges.Count >= take) - break; - - if (!collectedEdges.ContainsKey(edge.EdgeId)) - collectedEdges[edge.EdgeId] = edge; - - var counterpartNodeId = ResolveCounterpartNodeId(edge, nodeId); - if (counterpartNodeId.Length == 0) - continue; - - if (visitedNodeIds.Add(counterpartNodeId)) - nextFrontier.Add(counterpartNodeId); - } - } - - frontier = nextFrontier; + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var edges = new List(rows.Count); + foreach (var row in rows) + { + var edge = BuildEdgeFromRow(scope, row); + if (edge != null) + edges.Add(edge); } - var nodes = await GetNodesByIdsAsync(scope, visitedNodeIds, ct); + var nodeIds = edges + .SelectMany(x => new[] { x.FromNodeId, x.ToNodeId }) + .Append(rootNodeId) + .Where(x => NormalizeToken(x).Length > 0) + .ToHashSet(StringComparer.Ordinal); + var nodes = await GetNodesByIdsAsync(scope, nodeIds, ct); if (!nodes.Any(x => string.Equals(x.NodeId, rootNodeId, StringComparison.Ordinal))) { nodes.Add(new ProjectionGraphNode @@ -388,7 +368,7 @@ public async Task GetSubgraphAsync( return new ProjectionGraphSubgraph { Nodes = nodes, - Edges = collectedEdges.Values.ToList(), + Edges = edges, }; } @@ -521,6 +501,34 @@ private string BuildNeighborCypher(ProjectionGraphDirection direction, int take) }; } + private string BuildSubgraphEdgesCypher(ProjectionGraphDirection direction, int depth) + { + var boundedDepth = Math.Clamp(depth, 1, _maxTraversalDepth); + var pathPattern = direction switch + { + ProjectionGraphDirection.Outbound => + $"(root)-[:{_edgeType}*1..{boundedDepth}]->()", + ProjectionGraphDirection.Inbound => + $"(root)<-[:{_edgeType}*1..{boundedDepth}]-()", + _ => + $"(root)-[:{_edgeType}*1..{boundedDepth}]-()", + }; + return $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}}) " + + $"OPTIONAL MATCH p={pathPattern} " + + "WHERE p IS NULL OR (" + + "all(n IN nodes(p) WHERE coalesce(n.scope, '') = $scope) " + + "AND (size($edgeTypes) = 0 OR all(rel IN relationships(p) WHERE rel.relationType IN $edgeTypes))) " + + "UNWIND CASE WHEN p IS NULL THEN [] ELSE relationships(p) END AS r " + + "WITH DISTINCT r " + + "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + } + private async Task EnsureSchemaAsync(CancellationToken ct) { if (!_autoCreateConstraints || _schemaInitialized) @@ -578,15 +586,6 @@ private IAsyncSession CreateSession(AccessMode accessMode) }); } - private static string ResolveCounterpartNodeId(ProjectionGraphEdge edge, string nodeId) - { - if (string.Equals(edge.FromNodeId, nodeId, StringComparison.Ordinal)) - return edge.ToNodeId; - if (string.Equals(edge.ToNodeId, nodeId, StringComparison.Ordinal)) - return edge.FromNodeId; - return ""; - } - private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) { return edgeTypes diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs index 1dfcf006c..11d6f8d4e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs @@ -25,8 +25,7 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = $"Graph scope is required for read model '{typeof(TReadModel).FullName}'."); } - var ownerResolution = BuildManagedOwnerId(graphReadModel); - var ownerId = ownerResolution.OwnerId; + var ownerId = BuildManagedOwnerId(graphReadModel); var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope, ownerId); foreach (var node in normalizedNodes) @@ -39,8 +38,6 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = var targetEdgeIds = normalizedEdges .Select(x => x.EdgeId) .ToHashSet(StringComparer.Ordinal); - if (!ownerResolution.CanCleanup) - return; var targetNodeIds = normalizedNodes .Select(x => x.NodeId) @@ -101,34 +98,19 @@ private async Task CanDeleteNodeAsync( return neighbors.Count == 0; } - private static ManagedOwnerResolution BuildManagedOwnerId(IGraphReadModel readModel) + private static string BuildManagedOwnerId(IGraphReadModel readModel) { var readModelId = NormalizeToken(readModel.Id); - var canCleanup = readModelId.Length > 0; - if (readModelId.Length == 0) - { - readModelId = readModel.GraphNodes - .Select(x => NormalizeToken(x.NodeId)) - .FirstOrDefault(x => x.Length > 0) ?? ""; - canCleanup = readModelId.Length > 0; - } - if (readModelId.Length == 0) { - readModelId = readModel.GraphEdges - .Select(x => NormalizeToken(x.FromNodeId)) - .FirstOrDefault(x => x.Length > 0) ?? ""; - canCleanup = readModelId.Length > 0; + throw new InvalidOperationException( + $"Graph read model '{readModel.GetType().FullName}' requires a non-empty Id for owner lifecycle management."); } - if (readModelId.Length == 0) - readModelId = "unknown"; - var readModelType = NormalizeToken(readModel.GetType().FullName); - var ownerId = readModelType.Length == 0 + return readModelType.Length == 0 ? readModelId : $"{readModelType}:{readModelId}"; - return new ManagedOwnerResolution(ownerId, canCleanup); } private static IReadOnlyList NormalizeNodes( @@ -215,7 +197,4 @@ private static IReadOnlyList NormalizeEdges( private static string NormalizeToken(string? token) => token?.Trim() ?? ""; - private readonly record struct ManagedOwnerResolution( - string OwnerId, - bool CanCleanup); } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs index 7abc355aa..a7976d98d 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs @@ -2,6 +2,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public sealed record DocumentIndexMetadata( string IndexName, - string MappingJson, - IReadOnlyDictionary Settings, - IReadOnlyDictionary Aliases); + IReadOnlyDictionary Mappings, + IReadOnlyDictionary Settings, + IReadOnlyDictionary Aliases); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md index c0e66fb06..b073c7532 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -12,7 +12,7 @@ - 读模型存储:`IDocumentProjectionStore<,>` - 图存储:`IProjectionGraphStore` - ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`(marker)、`IGraphReadModel` -- 文档索引元数据声明:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider` +- 文档索引元数据声明:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider`;`DocumentIndexMetadata` 使用结构化对象字段(`Mappings/Settings/Aliases`)表达索引元数据。 - 图结构描述:`GraphNodeDescriptor`、`GraphEdgeDescriptor` ## 约束 diff --git a/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs index a63f76d64..2e60c71df 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Metadata/WorkflowExecutionReportDocumentMetadataProvider.cs @@ -7,7 +7,10 @@ public sealed class WorkflowExecutionReportDocumentMetadataProvider { public DocumentIndexMetadata Metadata { get; } = new( IndexName: "workflow-execution-reports", - MappingJson: "{}", - Settings: new Dictionary(StringComparer.Ordinal), - Aliases: new Dictionary(StringComparer.Ordinal)); + Mappings: new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + }, + Settings: new Dictionary(StringComparer.Ordinal), + Aliases: new Dictionary(StringComparer.Ordinal)); } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs index 3044a45fd..3e994b8a5 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs @@ -24,6 +24,9 @@ public Task RefreshMetadataAsync( var updatedAt = _clock.UtcNow; return _materializationRouter.MutateAsync(actorId, report => { + report.Id = actorId; + if (string.IsNullOrWhiteSpace(report.RootActorId)) + report.RootActorId = actorId; report.CommandId = context.CommandId; report.WorkflowName = context.WorkflowName; report.Input = context.Input; @@ -44,6 +47,9 @@ public Task MarkStoppedAsync( var updatedAt = _clock.UtcNow; return _materializationRouter.MutateAsync(actorId, report => { + report.Id = actorId; + if (string.IsNullOrWhiteSpace(report.RootActorId)) + report.RootActorId = actorId; if (report.CompletionStatus == WorkflowExecutionCompletionStatus.Running) report.CompletionStatus = WorkflowExecutionCompletionStatus.Stopped; diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs index f85dd6006..9e28f8cc6 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs @@ -71,6 +71,9 @@ public async ValueTask ProjectAsync(WorkflowExecutionProjectionContext context, var now = ResolveEventTimestamp(envelope, _clock.UtcNow); await _materializationRouter.MutateAsync(context.RootActorId, report => { + report.Id = context.RootActorId; + if (string.IsNullOrWhiteSpace(report.RootActorId)) + report.RootActorId = context.RootActorId; var mutated = false; foreach (var reducer in reducers) mutated |= reducer.Reduce(report, context, envelope, now); @@ -91,6 +94,9 @@ public ValueTask CompleteAsync( var completedAt = _clock.UtcNow; return new ValueTask(_materializationRouter.MutateAsync(context.RootActorId, report => { + report.Id = context.RootActorId; + if (string.IsNullOrWhiteSpace(report.RootActorId)) + report.RootActorId = context.RootActorId; report.Topology = topology.Select(x => new WorkflowExecutionTopologyEdge(x.Parent, x.Child)).ToList(); report.TopologySource = WorkflowExecutionTopologySource.RuntimeSnapshot; if (report.EndedAt < report.StartedAt) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs index a57292d0d..dafee1efb 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs @@ -110,6 +110,73 @@ public async Task MutateAsync_WhenOptimisticConflictOccurs_ShouldRetryWithLatest handler.CapturedRequests[3].Body.Should().Contain("\"Value\":\"v2\""); } + [Fact] + public async Task UpsertAsync_WhenMetadataContainsStructuredObjects_ShouldSendStructuredIndexInitializationPayload() + { + var handler = new ScriptedHttpMessageHandler(); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"acknowledged":true}""")); + handler.EnqueueResponse(_ => CreateJsonResponse( + HttpStatusCode.OK, + """{"result":"created"}""")); + + var options = new ElasticsearchProjectionReadModelStoreOptions + { + AutoCreateIndex = true, + }; + options.Endpoints = ["http://localhost:9200"]; + + using var store = new ElasticsearchProjectionReadModelStore( + options, + new DocumentIndexMetadata( + IndexName: "projection-core-tests", + Mappings: new Dictionary + { + ["properties"] = new Dictionary + { + ["Value"] = new Dictionary + { + ["type"] = "keyword", + }, + }, + }, + Settings: new Dictionary + { + ["index"] = new Dictionary + { + ["number_of_shards"] = 1, + ["number_of_replicas"] = 0, + }, + }, + Aliases: new Dictionary + { + ["projection-core-tests-alias"] = new Dictionary + { + ["is_write_index"] = true, + }, + }), + keySelector: model => model.Id, + keyFormatter: key => key, + httpMessageHandler: handler); + + await store.UpsertAsync(new StoreReadModel + { + Id = "actor-1", + Value = "v1", + }); + + handler.CapturedRequests.Should().HaveCount(2); + handler.CapturedRequests[0].Method.Should().Be("PUT"); + handler.CapturedRequests[0].PathAndQuery.Should().NotContain("/_doc/"); + handler.CapturedRequests[0].Body.Should().Contain("\"mappings\""); + handler.CapturedRequests[0].Body.Should().Contain("\"properties\""); + handler.CapturedRequests[0].Body.Should().Contain("\"Value\""); + handler.CapturedRequests[0].Body.Should().Contain("\"number_of_shards\":1"); + handler.CapturedRequests[0].Body.Should().Contain("\"projection-core-tests-alias\""); + handler.CapturedRequests[0].Body.Should().Contain("\"is_write_index\":true"); + } + private static ElasticsearchProjectionReadModelStore CreateStore( ElasticsearchProjectionReadModelStoreOptions options, HttpMessageHandler handler) @@ -119,9 +186,9 @@ private static ElasticsearchProjectionReadModelStore Cre options, new DocumentIndexMetadata( IndexName: "projection-core-tests", - MappingJson: "{}", - Settings: new Dictionary(), - Aliases: new Dictionary()), + Mappings: new Dictionary(), + Settings: new Dictionary(), + Aliases: new Dictionary()), keySelector: model => model.Id, keyFormatter: key => key, httpMessageHandler: handler); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs index ed8dc5fba..58a20d1cd 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs @@ -237,6 +237,27 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel store.ContainsNode("scope-1", "owner-1-node").Should().BeFalse(); } + [Fact] + public async Task UpsertGraphAsync_WhenReadModelIdIsEmpty_ShouldThrow() + { + var store = new RecordingGraphStore(); + var materializer = new ProjectionGraphMaterializer(store); + + Func act = () => materializer.UpsertGraphAsync(new TestGraphReadModel + { + Id = "", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + ], + GraphEdges = [], + }); + + await act.Should().ThrowAsync() + .WithMessage("*requires a non-empty Id*"); + } + private static string BuildOwnerId(string id) => $"{typeof(TestGraphReadModel).FullName}:{id}"; private static GraphNodeDescriptor Node(string nodeId) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs index 939e3fad8..f6fca9cea 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -22,9 +22,9 @@ public async Task ElasticsearchStore_ShouldRoundtripUpsertAndMutate() options, new DocumentIndexMetadata( IndexName: indexScope, - MappingJson: "{}", - Settings: new Dictionary(), - Aliases: new Dictionary()), + Mappings: new Dictionary(), + Settings: new Dictionary(), + Aliases: new Dictionary()), model => model.Id); var readModel = new ProviderStoreSmokeReadModel From 22f4aaf732e8300dcc610461c21234107a00fc5c Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 04:12:15 +0800 Subject: [PATCH 37/46] Refactor Projection Ownership Management and Update Interfaces - Replaced `WorkflowProjectionLeaseManager` with `IProjectionOwnershipCoordinator` to streamline ownership management in the projection lifecycle. - Updated related services and interfaces to utilize the new ownership coordinator, enhancing clarity and reducing complexity in the orchestration of projections. - Removed deprecated interfaces for lease management and activation services, ensuring a cleaner codebase. - Revised documentation to reflect the architectural changes and clarify the new ownership management processes. --- docs/CQRS_ARCHITECTURE.md | 2 +- docs/FOUNDATION.md | 2 +- .../ServiceCollectionExtensions.cs | 9 ++-- .../IWorkflowProjectionActivationService.cs | 6 --- .../IWorkflowProjectionLeaseManager.cs | 14 ------ .../IWorkflowProjectionLiveSinkForwarder.cs | 7 --- .../IWorkflowProjectionReleaseService.cs | 6 --- ...rkflowProjectionSinkSubscriptionManager.cs | 10 ----- ...flowExecutionProjectionLifecycleService.cs | 8 ++-- .../WorkflowProjectionActivationService.cs | 13 +++--- .../WorkflowProjectionLeaseManager.cs | 25 ----------- .../WorkflowProjectionLiveSinkForwarder.cs | 3 +- .../WorkflowProjectionReleaseService.cs | 16 +++---- .../WorkflowProjectionSinkFailurePolicy.cs | 4 +- ...rkflowProjectionSinkSubscriptionManager.cs | 9 +--- .../Aevatar.Workflow.Projection/README.md | 4 +- ...WorkflowExecutionProjectionServiceTests.cs | 12 ++---- ...owProjectionOrchestrationComponentTests.cs | 43 ++++++------------- 18 files changed, 48 insertions(+), 145 deletions(-) delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLeaseManager.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs delete mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLeaseManager.cs diff --git a/docs/CQRS_ARCHITECTURE.md b/docs/CQRS_ARCHITECTURE.md index 38014d47b..3710ee11e 100644 --- a/docs/CQRS_ARCHITECTURE.md +++ b/docs/CQRS_ARCHITECTURE.md @@ -63,7 +63,7 @@ flowchart LR `ProjectionQueryPortServiceBase<>`(通用基类) + `WorkflowProjectionActivationService`(激活) + `WorkflowProjectionReleaseService`(释放) + - `WorkflowProjectionLeaseManager`(ownership) + + `IProjectionOwnershipCoordinator`(ownership) + `WorkflowProjectionSinkSubscriptionManager`(订阅生命周期) + `WorkflowProjectionLiveSinkForwarder`(sink 转发) + `WorkflowProjectionSinkFailurePolicy`(异常策略) + diff --git a/docs/FOUNDATION.md b/docs/FOUNDATION.md index f53f859c3..e0d1ad904 100644 --- a/docs/FOUNDATION.md +++ b/docs/FOUNDATION.md @@ -135,7 +135,7 @@ Agent 收到 `EventEnvelope` 后,会将两类处理器合并执行: - 两者复用 `Aevatar.CQRS.Projection.Core` 的通用基类:`ProjectionLifecyclePortServiceBase<>` / `ProjectionQueryPortServiceBase<>` - `WorkflowProjectionActivationService` 负责 projection 启动与上下文激活 - `WorkflowProjectionReleaseService` 负责 idle 检测与 stop/release - - `WorkflowProjectionLeaseManager` 负责 ownership acquire/release + - `IProjectionOwnershipCoordinator` 负责 ownership acquire/release(由 Core 抽象直接注入) - `WorkflowProjectionSinkSubscriptionManager` 负责 live sink attach/detach - `WorkflowProjectionLiveSinkForwarder` 负责 run-event 推送与失败策略桥接 - `WorkflowProjectionSinkFailurePolicy` 负责 sink 异常降级与错误事件发布 diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 829ebd6f3..91434a162 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -51,12 +51,11 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(); services.TryAddSingleton, WorkflowRunEventSessionCodec>(); services.TryAddSingleton, ProjectionSessionEventHub>(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton, WorkflowProjectionActivationService>(); + services.TryAddSingleton, WorkflowProjectionReleaseService>(); + services.TryAddSingleton, WorkflowProjectionSinkSubscriptionManager>(); services.TryAddSingleton(); - services.TryAddSingleton(); + services.TryAddSingleton, WorkflowProjectionLiveSinkForwarder>(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton>, ProjectionLifecycleService>>(); diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs deleted file mode 100644 index 6d056f3d6..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionActivationService.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Abstractions; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public interface IWorkflowProjectionActivationService - : IProjectionPortActivationService; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLeaseManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLeaseManager.cs deleted file mode 100644 index 73c7fffae..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLeaseManager.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Aevatar.Workflow.Projection.Orchestration; - -public interface IWorkflowProjectionLeaseManager -{ - Task AcquireAsync( - string rootActorId, - string commandId, - CancellationToken ct = default); - - Task ReleaseAsync( - string rootActorId, - string commandId, - CancellationToken ct = default); -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs deleted file mode 100644 index 5705aeab1..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionLiveSinkForwarder.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Aevatar.Workflow.Application.Abstractions.Runs; -using Aevatar.CQRS.Projection.Core.Abstractions; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public interface IWorkflowProjectionLiveSinkForwarder - : IProjectionPortLiveSinkForwarder; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs deleted file mode 100644 index e8446ba2f..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionReleaseService.cs +++ /dev/null @@ -1,6 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Abstractions; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public interface IWorkflowProjectionReleaseService - : IProjectionPortReleaseService; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs deleted file mode 100644 index 5a908e31f..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IWorkflowProjectionSinkSubscriptionManager.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Aevatar.Workflow.Application.Abstractions.Runs; -using Aevatar.CQRS.Projection.Core.Abstractions; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public interface IWorkflowProjectionSinkSubscriptionManager - : IProjectionPortSinkSubscriptionManager -{ - int GetSubscriptionCount(WorkflowExecutionRuntimeLease lease); -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs index de3afb5e9..21bceef9b 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs @@ -11,10 +11,10 @@ public sealed class WorkflowExecutionProjectionLifecycleService { public WorkflowExecutionProjectionLifecycleService( WorkflowExecutionProjectionOptions options, - IWorkflowProjectionActivationService activationService, - IWorkflowProjectionReleaseService releaseService, - IWorkflowProjectionSinkSubscriptionManager sinkSubscriptionManager, - IWorkflowProjectionLiveSinkForwarder liveSinkForwarder) + IProjectionPortActivationService activationService, + IProjectionPortReleaseService releaseService, + IProjectionPortSinkSubscriptionManager sinkSubscriptionManager, + IProjectionPortLiveSinkForwarder liveSinkForwarder) : base( () => options.Enabled, activationService, diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs index 5f183d7a7..b6def7398 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionActivationService.cs @@ -1,26 +1,27 @@ using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; namespace Aevatar.Workflow.Projection.Orchestration; -public sealed class WorkflowProjectionActivationService : IWorkflowProjectionActivationService +public sealed class WorkflowProjectionActivationService : IProjectionPortActivationService { private readonly IProjectionLifecycleService> _lifecycle; private readonly IProjectionClock _clock; private readonly IWorkflowExecutionProjectionContextFactory _contextFactory; - private readonly IWorkflowProjectionLeaseManager _leaseManager; + private readonly IProjectionOwnershipCoordinator _ownershipCoordinator; private readonly IWorkflowProjectionReadModelUpdater _readModelUpdater; public WorkflowProjectionActivationService( IProjectionLifecycleService> lifecycle, IProjectionClock clock, IWorkflowExecutionProjectionContextFactory contextFactory, - IWorkflowProjectionLeaseManager leaseManager, + IProjectionOwnershipCoordinator ownershipCoordinator, IWorkflowProjectionReadModelUpdater readModelUpdater) { _lifecycle = lifecycle; _clock = clock; _contextFactory = contextFactory; - _leaseManager = leaseManager; + _ownershipCoordinator = ownershipCoordinator; _readModelUpdater = readModelUpdater; } @@ -33,7 +34,7 @@ public async Task EnsureAsync( { ArgumentException.ThrowIfNullOrWhiteSpace(rootActorId); - await _leaseManager.AcquireAsync(rootActorId, commandId, ct); + await _ownershipCoordinator.AcquireAsync(rootActorId, commandId, ct); try { var startedAt = _clock.UtcNow; @@ -62,7 +63,7 @@ private async Task TryReleaseProjectionOwnershipAsync( { try { - await _leaseManager.ReleaseAsync(rootActorId, commandId, CancellationToken.None); + await _ownershipCoordinator.ReleaseAsync(rootActorId, commandId, CancellationToken.None); } catch { diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLeaseManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLeaseManager.cs deleted file mode 100644 index a24b35000..000000000 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLeaseManager.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Aevatar.CQRS.Projection.Core.Orchestration; - -namespace Aevatar.Workflow.Projection.Orchestration; - -public sealed class WorkflowProjectionLeaseManager : IWorkflowProjectionLeaseManager -{ - private readonly IProjectionOwnershipCoordinator _ownershipCoordinator; - - public WorkflowProjectionLeaseManager(IProjectionOwnershipCoordinator ownershipCoordinator) - { - _ownershipCoordinator = ownershipCoordinator; - } - - public Task AcquireAsync( - string rootActorId, - string commandId, - CancellationToken ct = default) => - _ownershipCoordinator.AcquireAsync(rootActorId, commandId, ct); - - public Task ReleaseAsync( - string rootActorId, - string commandId, - CancellationToken ct = default) => - _ownershipCoordinator.ReleaseAsync(rootActorId, commandId, ct); -} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLiveSinkForwarder.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLiveSinkForwarder.cs index 34dd9d299..c1882662b 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLiveSinkForwarder.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionLiveSinkForwarder.cs @@ -2,7 +2,8 @@ namespace Aevatar.Workflow.Projection.Orchestration; -public sealed class WorkflowProjectionLiveSinkForwarder : IWorkflowProjectionLiveSinkForwarder +public sealed class WorkflowProjectionLiveSinkForwarder + : IProjectionPortLiveSinkForwarder { private readonly IWorkflowProjectionSinkFailurePolicy _sinkFailurePolicy; diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs index 2ec9277ec..6aff41894 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReleaseService.cs @@ -1,24 +1,22 @@ using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; namespace Aevatar.Workflow.Projection.Orchestration; -public sealed class WorkflowProjectionReleaseService : IWorkflowProjectionReleaseService +public sealed class WorkflowProjectionReleaseService : IProjectionPortReleaseService { private readonly IProjectionLifecycleService> _lifecycle; - private readonly IWorkflowProjectionSinkSubscriptionManager _sinkSubscriptionManager; private readonly IWorkflowProjectionReadModelUpdater _readModelUpdater; - private readonly IWorkflowProjectionLeaseManager _leaseManager; + private readonly IProjectionOwnershipCoordinator _ownershipCoordinator; public WorkflowProjectionReleaseService( IProjectionLifecycleService> lifecycle, - IWorkflowProjectionSinkSubscriptionManager sinkSubscriptionManager, IWorkflowProjectionReadModelUpdater readModelUpdater, - IWorkflowProjectionLeaseManager leaseManager) + IProjectionOwnershipCoordinator ownershipCoordinator) { _lifecycle = lifecycle; - _sinkSubscriptionManager = sinkSubscriptionManager; _readModelUpdater = readModelUpdater; - _leaseManager = leaseManager; + _ownershipCoordinator = ownershipCoordinator; } public async Task ReleaseIfIdleAsync( @@ -28,12 +26,12 @@ public async Task ReleaseIfIdleAsync( ArgumentNullException.ThrowIfNull(runtimeLease); ct.ThrowIfCancellationRequested(); - if (_sinkSubscriptionManager.GetSubscriptionCount(runtimeLease) > 0) + if (runtimeLease.GetLiveSinkSubscriptionCount() > 0) return; var context = runtimeLease.Context; await _lifecycle.StopAsync(context, ct); await _readModelUpdater.MarkStoppedAsync(context.RootActorId, ct); - await _leaseManager.ReleaseAsync(context.RootActorId, runtimeLease.CommandId, ct); + await _ownershipCoordinator.ReleaseAsync(context.RootActorId, runtimeLease.CommandId, ct); } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkFailurePolicy.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkFailurePolicy.cs index c705417a2..b724e67e9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkFailurePolicy.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkFailurePolicy.cs @@ -7,12 +7,12 @@ public sealed class WorkflowProjectionSinkFailurePolicy : IWorkflowProjectionSin public const string SinkBackpressureErrorCode = "RUN_SINK_BACKPRESSURE"; public const string SinkWriteErrorCode = "RUN_SINK_WRITE_FAILED"; - private readonly IWorkflowProjectionSinkSubscriptionManager _sinkSubscriptionManager; + private readonly IProjectionPortSinkSubscriptionManager _sinkSubscriptionManager; private readonly IProjectionSessionEventHub _runEventStreamHub; private readonly IProjectionClock _clock; public WorkflowProjectionSinkFailurePolicy( - IWorkflowProjectionSinkSubscriptionManager sinkSubscriptionManager, + IProjectionPortSinkSubscriptionManager sinkSubscriptionManager, IProjectionSessionEventHub runEventStreamHub, IProjectionClock clock) { diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkSubscriptionManager.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkSubscriptionManager.cs index 780f9344b..9a85fc4c9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkSubscriptionManager.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionSinkSubscriptionManager.cs @@ -2,7 +2,8 @@ namespace Aevatar.Workflow.Projection.Orchestration; -public sealed class WorkflowProjectionSinkSubscriptionManager : IWorkflowProjectionSinkSubscriptionManager +public sealed class WorkflowProjectionSinkSubscriptionManager + : IProjectionPortSinkSubscriptionManager { private readonly IProjectionSessionEventHub _runEventStreamHub; @@ -46,10 +47,4 @@ public async Task DetachAsync( if (streamSubscription != null) await streamSubscription.DisposeAsync(); } - - public int GetSubscriptionCount(WorkflowExecutionRuntimeLease lease) - { - ArgumentNullException.ThrowIfNull(lease); - return lease.GetLiveSinkSubscriptionCount(); - } } diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index cb08036f6..4b0556ba1 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -12,7 +12,7 @@ - 编排组件拆分(避免单类过重): - `WorkflowProjectionActivationService`(projection 启动与上下文激活) - `WorkflowProjectionReleaseService`(idle 检测与停止/释放) - - `WorkflowProjectionLeaseManager`(ownership acquire/release) + - `IProjectionOwnershipCoordinator`(ownership acquire/release,由 Core 抽象直接注入) - `WorkflowProjectionSinkSubscriptionManager`(live sink attach/detach/replace) - `WorkflowProjectionLiveSinkForwarder`(sink 推送与失败策略路由) - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) @@ -37,7 +37,7 @@ ## 统一运行链路 -1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionLifecycleService` 转发到 `WorkflowProjectionActivationService`,先通过 `WorkflowProjectionLeaseManager`(底层复用 `Aevatar.CQRS.Projection.Core` ownership coordinator)申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 +1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionLifecycleService` 转发到 `WorkflowProjectionActivationService`,直接通过 `IProjectionOwnershipCoordinator` 申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 2. 每条 `EventEnvelope` 进入统一 coordinator,一对多调用已注册 projector 3. `WorkflowExecutionReadModelProjector` 驱动 reducers 生成并更新 read model,并通过 `IProjectionMaterializationRouter` 执行 Document/Graph 单写或双写 4. AI 通用事件通过 `Aevatar.Workflow.Extensions.AIProjection` 扩展接入,扩展内部复用 `Aevatar.AI.Projection` 的默认 applier + reducer,将事件写入 `WorkflowExecutionReport` 的 AI 能力字段,业务层无需重复维护映射代码 diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index 9edd8701a..c11aef7e9 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -582,7 +582,6 @@ private static ProjectionPortsHarness CreateService( streams, new WorkflowRunEventSessionCodec()); var mapper = new WorkflowExecutionReadModelMapper(); - var leaseManager = new WorkflowProjectionLeaseManager(ownershipCoordinator); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventStreamHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventStreamHub, resolvedClock); var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, resolvedClock); @@ -594,13 +593,12 @@ private static ProjectionPortsHarness CreateService( lifecycle, resolvedClock, new DefaultWorkflowExecutionProjectionContextFactory(), - leaseManager, + ownershipCoordinator, readModelUpdater); var releaseService = new WorkflowProjectionReleaseService( lifecycle, - sinkManager, readModelUpdater, - leaseManager); + ownershipCoordinator); var liveSinkForwarder = new WorkflowProjectionLiveSinkForwarder(sinkFailurePolicy); var lifecyclePort = new WorkflowExecutionProjectionLifecycleService( @@ -628,7 +626,6 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( graphStore); var runEventHub = new NoOpWorkflowRunEventHub(); var mapper = new WorkflowExecutionReadModelMapper(); - var leaseManager = new WorkflowProjectionLeaseManager(ownershipCoordinator); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventHub, clock); var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, clock); @@ -640,13 +637,12 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( lifecycle, clock, new DefaultWorkflowExecutionProjectionContextFactory(), - leaseManager, + ownershipCoordinator, readModelUpdater); var releaseService = new WorkflowProjectionReleaseService( lifecycle, - sinkManager, readModelUpdater, - leaseManager); + ownershipCoordinator); var liveSinkForwarder = new WorkflowProjectionLiveSinkForwarder(sinkFailurePolicy); var options = new WorkflowExecutionProjectionOptions diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index e02c802c1..0076dd1ba 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -12,19 +12,6 @@ namespace Aevatar.Workflow.Host.Api.Tests; public sealed class WorkflowProjectionOrchestrationComponentTests { - [Fact] - public async Task LeaseManager_ShouldForwardAcquireAndRelease() - { - var ownership = new TrackingOwnershipCoordinator(); - var manager = new WorkflowProjectionLeaseManager(ownership); - - await manager.AcquireAsync("actor-1", "cmd-1"); - await manager.ReleaseAsync("actor-1", "cmd-1"); - - ownership.Acquired.Should().ContainSingle().Which.Should().Be(("actor-1", "cmd-1")); - ownership.Released.Should().ContainSingle().Which.Should().Be(("actor-1", "cmd-1")); - } - [Fact] public async Task ActivationService_ShouldStartProjectionAndReturnRuntimeLease() { @@ -35,7 +22,7 @@ public async Task ActivationService_ShouldStartProjectionAndReturnRuntimeLease() lifecycle, new FixedClock(new DateTimeOffset(2026, 2, 21, 10, 0, 0, TimeSpan.Zero)), new DefaultWorkflowExecutionProjectionContextFactory(), - new WorkflowProjectionLeaseManager(ownership), + ownership, readModelUpdater); var lease = await activationService.EnsureAsync( @@ -60,7 +47,7 @@ public async Task ActivationService_WhenStartFails_ShouldReleaseOwnershipAndReth new ThrowingLifecycleService(new InvalidOperationException("start failed")), new FixedClock(new DateTimeOffset(2026, 2, 21, 10, 0, 0, TimeSpan.Zero)), new DefaultWorkflowExecutionProjectionContextFactory(), - new WorkflowProjectionLeaseManager(ownership), + ownership, new RecordingReadModelUpdater()); var act = async () => await activationService.EnsureAsync( @@ -192,17 +179,17 @@ public async Task SinkSubscriptionManager_ShouldReplaceSameSinkSubscription() await manager.AttachOrReplaceAsync(lease, sink, _ => ValueTask.CompletedTask); var first = hub.Subscriptions.Should().ContainSingle().Subject; - manager.GetSubscriptionCount(lease).Should().Be(1); + lease.GetLiveSinkSubscriptionCount().Should().Be(1); await manager.AttachOrReplaceAsync(lease, sink, _ => ValueTask.CompletedTask); hub.Subscriptions.Should().HaveCount(2); first.Disposed.Should().BeTrue(); - manager.GetSubscriptionCount(lease).Should().Be(1); + lease.GetLiveSinkSubscriptionCount().Should().Be(1); var second = hub.Subscriptions[1]; await manager.DetachAsync(lease, sink); second.Disposed.Should().BeTrue(); - manager.GetSubscriptionCount(lease).Should().Be(0); + lease.GetLiveSinkSubscriptionCount().Should().Be(0); } [Fact] @@ -256,14 +243,12 @@ public async Task SinkFailurePolicy_ShouldHandleBackpressureAndCompletedAndUnkno public async Task ReleaseService_WhenNoLiveSink_ShouldStopMarkAndRelease() { var lifecycle = new RecordingLifecycleService(); - var sinkManager = new RecordingSinkSubscriptionManager(); var readModelUpdater = new RecordingReadModelUpdater(); var ownership = new TrackingOwnershipCoordinator(); var releaseService = new WorkflowProjectionReleaseService( lifecycle, - sinkManager, readModelUpdater, - new WorkflowProjectionLeaseManager(ownership)); + ownership); var lease = CreateLease("actor-release", "cmd-release"); await releaseService.ReleaseIfIdleAsync(lease, CancellationToken.None); @@ -277,15 +262,16 @@ public async Task ReleaseService_WhenNoLiveSink_ShouldStopMarkAndRelease() public async Task ReleaseService_WhenLiveSinkExists_ShouldSkipStopAndRelease() { var lifecycle = new RecordingLifecycleService(); - var sinkManager = new RecordingSinkSubscriptionManager { SubscriptionCount = 1 }; var readModelUpdater = new RecordingReadModelUpdater(); var ownership = new TrackingOwnershipCoordinator(); var releaseService = new WorkflowProjectionReleaseService( lifecycle, - sinkManager, readModelUpdater, - new WorkflowProjectionLeaseManager(ownership)); + ownership); var lease = CreateLease("actor-busy", "cmd-busy"); + lease.AttachOrReplaceLiveSinkSubscription( + new NoopRunEventSink(), + new TrackingSubscription()); await releaseService.ReleaseIfIdleAsync(lease, CancellationToken.None); @@ -382,9 +368,9 @@ public FixedClock(DateTimeOffset utcNow) public DateTimeOffset UtcNow { get; } } - private sealed class RecordingSinkSubscriptionManager : IWorkflowProjectionSinkSubscriptionManager + private sealed class RecordingSinkSubscriptionManager + : IProjectionPortSinkSubscriptionManager { - public int SubscriptionCount { get; set; } public int DetachCalls { get; private set; } public Task AttachOrReplaceAsync( @@ -412,11 +398,6 @@ public Task DetachAsync( return Task.CompletedTask; } - public int GetSubscriptionCount(WorkflowExecutionRuntimeLease lease) - { - _ = lease; - return SubscriptionCount; - } } private sealed class RecordingReadModelUpdater : IWorkflowProjectionReadModelUpdater From be1abf6c1eb2fec255ca69e114a8d28310821985 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 04:29:55 +0800 Subject: [PATCH 38/46] Add Projection Storage Architecture Audit Scorecard - Introduced a new audit scorecard for the Projection storage architecture, detailing the objectives, scope, and evaluation criteria for assessing the relationship between DocumentStore and GraphStore. - Defined a clear scoring methodology and outlined the current architectural conclusions, highlighting areas of redundancy and structural issues. - Removed the outdated Projection Store / ReadModel scorecard to streamline documentation and focus on the new audit framework. - Updated documentation to reflect the latest findings and recommendations for future architectural improvements and refactoring priorities. --- ...orage-architecture-scorecard-2026-02-24.md | 151 ++++++++++++++++++ ...on-store-readmodel-scorecard-2026-02-24.md | 66 -------- 2 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md delete mode 100644 docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md diff --git a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md new file mode 100644 index 000000000..82f51d727 --- /dev/null +++ b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md @@ -0,0 +1,151 @@ +# Projection 存储完整审计与架构评分(2026-02-24,按“ReadModel -> N Stores”口径重评) + +## 1. 审计目标 + +1. 全面审计 Projection 存储层(抽象、Runtime、Provider、Workflow 接入)。 +2. 严格确认 `DocumentStore` 与 `GraphStore` 的关系。 +3. 严格确认“一对多(1:N)”是否按你定义落地:`1个ReadModel -> 多个Store`。 +4. 找出全部可定位冗余并重新评分。 + +## 2. 评分口径声明(本次重评) + +1. **你的口径是唯一准绳**:`DocumentStore` 与 `GraphStore` 都应是 `Store` 家族中的平行实现类型。 +2. **一对多定义**:不是“Document 一对多 + Graph 一对多”两条主干,而是“同一个 ReadModel 可被同一投影链路分发到多个 Store(其中包含 Document/Graph)”。 +3. 若实现为“双主干 + 分支编排”,即便功能可用,也按“并行性不足”扣分。 + +## 3. 审计范围 + +1. `src/Aevatar.CQRS.Projection.Stores.Abstractions` +2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions` +3. `src/Aevatar.CQRS.Projection.Runtime` +4. `src/Aevatar.CQRS.Projection.Providers.InMemory` +5. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` +6. `src/Aevatar.CQRS.Projection.Providers.Neo4j` +7. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` +8. `test/Aevatar.CQRS.Projection.Core.Tests` +9. `test/Aevatar.Workflow.Host.Api.Tests` + +## 4. 验证基线(本轮沿用) + +1. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` +2. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +3. `bash tools/ci/architecture_guards.sh` + +结果:通过。 + +## 5. 结构结论(按新口径) + +### 5.1 结论 A:概念上“平行实现”成立 + +1. `WorkflowExecutionReport` 同时实现 `IDocumentReadModel` 与 `IGraphReadModel`,具备“双存储能力”事实(`src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs:36-37`)。 +2. Provider 注册侧确实可同时启用 ES 与 Neo4j(`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:43-76`)。 + +### 5.2 结论 B:实现上“同层并行”**不成立(你的感觉是对的)** + +1. **根抽象不是同层模型**: + - Document:`IDocumentProjectionStore`(CRUD形状) + - Graph:`IProjectionGraphStore`(节点/边形状) + 这不是统一 `IProjectionStore` 的同层实现。 +2. **路由是硬编码双分支**: + - `ProjectionMaterializationRouter` 用 `IsAssignableFrom` 分别判断 `IDocumentReadModel/IGraphReadModel`(`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs:13-14`)。 + - 这意味着“ReadModel -> Store列表”不是统一分发表达,而是“Document链 + Graph链”。 +3. **Graph 多一层专有中间件**: + - Graph 需要 `ProjectionGraphMaterializer` 做 descriptor -> store model 转换(`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs:116-196`)。 + - Document 无对等层,导致链路层级不对称。 +4. **Metadata 机制只有 Document 分支**: + - `IProjectionDocumentMetadataProvider` / `IProjectionDocumentMetadataResolver` 存在(`src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs:3-6`,`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5-19`)。 + - Graph 没有同层 metadata 契约。 + +## 6. 当前实际架构图(显示“非同层并行”) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + RM["ReadModel(TReadModel)"] --> RT["ProjectionMaterializationRouter(双分支判定)"] + + RT -->|"IDocumentReadModel"| DOCF["ProjectionDocumentStoreFanout"] + RT -->|"IGraphReadModel"| GRM["ProjectionGraphMaterializer(专有转换层)"] + GRM --> GRAF["ProjectionGraphStoreFanout"] + + DOCM["Document Metadata Resolver"] --> DOCF + + DOCF --> D1["Document Provider #1(Query)"] + DOCF --> D2["Document Provider #2...#N"] + + GRAF --> G1["Graph Provider #1(Query)"] + GRAF --> G2["Graph Provider #2...#N"] +``` + +## 7. 冗余问题清单(重排后) + +### P1-1(高)“ReadModel -> 多Store”未被统一抽象表达 + +1. 当前是 Document/Graph 双主干 + 分支路由,不是单一 `Store` 家族同层并行。 +2. 直接导致你感知的“实现不并行”。 + +### P1-2(高)Graph 双模型并存且高度重复 + +1. ReadModel 侧:`GraphNodeDescriptor`/`GraphEdgeDescriptor`。 +2. Store 侧:`ProjectionGraphNode`/`ProjectionGraphEdge`。 +3. Runtime 强制桥接转换。 + +### P1-3(高)能力判定依赖 marker + 运行时反射 + +1. `IDocumentReadModel` 是空 marker。 +2. `ProjectionMaterializationRouter` 通过 `IsAssignableFrom` 路由。 +3. 编译期无法直接保证“同层 Store 分发能力矩阵”。 + +### P1-4(高)Metadata 能力只在 Document 分支存在 + +1. Document 有 metadata provider/resolver。 +2. Graph 分支没有等价契约,进一步拉大两条链路语义差距。 + +### P2-5(中)运行态系统键暴露到 Stores.Abstractions + +1. `ProjectionGraphSystemPropertyKeys` 位于抽象层。 +2. 实际语义是 runtime/provider 的内部生命周期键。 + +### P2-6(中)Neo4j managed 信息双存 + +1. `propertiesJson` 与 `projectionManaged/projectionOwnerId` 同时存储。 +2. 引入冗余与一致性维护成本。 + +### P2-7(中)文档陈旧术语残留 + +1. 仍有 `IProjectionReadModelStore` 等旧术语残留。 + +### P3-8(低)`ProjectionGraphSubgraph` 可变结构可进一步收敛 + +1. 非功能缺陷,但语义表达可更简化。 + +## 8. 架构评分(严格,按你口径) + +### 8.1 评分维度与结果 + +| 维度 | 权重 | 得分 | 说明 | +|---|---:|---:|---| +| `ReadModel -> N Stores` 口径符合度 | 20 | 13 | 能同时投影到多 provider,但分发不是统一 store 家族。 | +| Document/Graph 同层并行一致性 | 20 | 10 | 目前是双主干 + Graph 专有中间层,层级不对齐。 | +| 抽象最小化与去冗余 | 20 | 12 | 双模型、marker+反射、系统键外泄导致冗余偏高。 | +| 数据模型一致性 | 15 | 10 | Graph descriptor/store model 重复,Neo4j managed 双存。 | +| Provider 扩展一致性 | 10 | 8 | 注册模式一致,但能力面不对称(metadata、materializer)。 | +| 测试与治理门禁 | 10 | 9 | fan-out 语义与基础门禁覆盖较好。 | +| 文档一致性 | 5 | 2 | 仍有旧术语未清理。 | + +### 8.2 总分 + +- **64 / 100(C-,严格口径)** + +## 9. 审计结论(直接回答你的问题) + +1. 你说的“一对多”定义是对的,且应作为重构目标模型。 +2. 当前代码**功能上可并行写入**,但**架构形状并不并行**,你的不适感来自真实的结构不对称。 +3. 现状更准确描述是:`ReadModel` 经过“Document链 + Graph链”双分支处理,而不是统一 `Store` 家族的一体化多路分发。 + +## 10. 后续重构优先级(无兼容性) + +1. **P0**:引入统一 Store 抽象与统一分发器,把 `DocumentStore/GraphStore` 收敛为同层实现。 +2. **P0**:删除 Graph 双模型之一,消灭 `ProjectionGraphMaterializer` 的桥接角色。 +3. **P1**:移除 marker + 反射路由,改成显式能力注册或类型化 Store 绑定。 +4. **P1**:将 runtime 生命周期系统键从 `Stores.Abstractions` 下沉到 runtime/provider。 +5. **P1**:补齐文档清理,消除旧术语和旧架构描述。 diff --git a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md deleted file mode 100644 index 04bb3e296..000000000 --- a/docs/audit-scorecard/projection-store-readmodel-scorecard-2026-02-24.md +++ /dev/null @@ -1,66 +0,0 @@ -# Projection Store / ReadModel 严格评分卡(2026-02-24 重新分析) - -## 1. 审计范围 - -1. `src/Aevatar.CQRS.Projection.Stores.Abstractions` -2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions` -3. `src/Aevatar.CQRS.Projection.Runtime` -4. `src/Aevatar.CQRS.Projection.Providers.InMemory` -5. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` -6. `src/Aevatar.CQRS.Projection.Providers.Neo4j` -7. `src/workflow/Aevatar.Workflow.Projection` -8. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` -9. `test/Aevatar.CQRS.Projection.Core.Tests` -10. `test/Aevatar.Workflow.Host.Api.Tests` - -## 2. 本次验证基线 - -本次重新分析执行并通过: - -1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -4. `bash tools/ci/architecture_guards.sh` -5. `bash tools/ci/projection_route_mapping_guard.sh` -6. `bash tools/ci/test_stability_guards.sh` -7. `dotnet test aevatar.slnx --nologo` - -## 3. 总分结论 - -- **总分:92 / 100** -- **等级:B+(严格口径,高于上版 84)** -- **结论:核心架构问题(查询源规则不清、图清理窗口清理、Neo4j 子图查询放大)已实质修复;当前主要短板转为跨 Store 一致性与可观测性。** - -## 4. 维度评分(严格) - -| 维度 | 权重 | 得分 | 证据与说明 | -|---|---:|---:|---| -| 架构边界与抽象收敛 | 15 | 15 | `IProjectionStoreRegistration` 收敛为最小契约(`ProviderName + Create`),Runtime 组装边界清晰(`IProjectionStoreRegistration.cs`,`DelegateProjectionStoreRegistration.cs`)。 | -| Provider 模型清晰度 | 15 | 15 | Document/Graph fan-out 使用统一规则“注册顺序即查询顺序”(首注册 provider 读,全部 provider 写 fan-out)(`ProjectionDocumentStoreFanout.cs`,`ProjectionGraphStoreFanout.cs`);Workflow Host 落地耐久优先注册顺序(`WorkflowProjectionProviderServiceCollectionExtensions.cs`)。 | -| Document 索引语义完整性 | 20 | 19 | `DocumentIndexMetadata` 已升级为结构化对象(`Mappings/Settings/Aliases`),ES 初始化直接消费结构化元数据(`DocumentIndexMetadata.cs`,`ElasticsearchProjectionReadModelStore.cs`);复杂 metadata 组合行为已有单测覆盖(`ElasticsearchProjectionReadModelStoreBehaviorTests.cs`)。扣分:仍缺少真实 ES 集群下复杂 settings/aliases 的端到端回归。 | -| Graph 关系语义与正确性 | 15 | 15 | Graph materializer 已完成 owner-based 边/节点差集清理,节点删除前有邻接检查防误删共享节点(`ProjectionGraphMaterializer.cs`);owner 标识收敛为 `IGraphReadModel.Id`,`Id` 为空 fail-fast,消除 fallback 残留语义(`ProjectionGraphMaterializer.cs`、`ProjectionGraphMaterializerTests.cs`)。 | -| 一致性与失败语义 | 10 | 7 | Router 仍是顺序写入(先 Document,再 Graph)(`ProjectionMaterializationRouter.cs:31-37`),跨 provider 非事务;`Mutate` 后读回再刷新 graph(`ProjectionMaterializationRouter.cs:49-63`),失败时可能留部分成功状态。 | -| Provider 实现质量与性能 | 10 | 9 | ES `MutateAsync` OCC 重试与冲突处理较完整(`ElasticsearchProjectionReadModelStore.cs`);Neo4j `GetSubgraphAsync` 已从逐节点循环改为单次 Cypher 拉边 + 一次节点补全(`Neo4jProjectionGraphStore.cs`),显著降低查询放大。扣分:仍缺少高规模数据集下的性能基准回归。 | -| 可观测性 | 5 | 3 | Fan-out 初始化日志已有 provider 信息(`ProjectionDocumentStoreFanout.cs:76-80`,`ProjectionGraphStoreFanout.cs:62-65`);ES 冲突日志较完整(`ElasticsearchProjectionReadModelStore.cs:124-133`)。扣分:Graph provider 成功路径指标/日志仍偏薄,Neo4j 侧主要是反序列化 warning(`Neo4jProjectionGraphStore.cs:546-551`)。 | -| 测试与治理门禁 | 10 | 9 | fan-out 查询顺序与写扩散语义测试已补齐(`ProjectionReadModelRuntimeTests.cs`,`ProjectionReadModelStoreSelectorTests.cs`);owner 维度边/节点清理与空 Id fail-fast 回归已补(`ProjectionGraphMaterializerTests.cs`);structured metadata 初始化测试已补(`ElasticsearchProjectionReadModelStoreBehaviorTests.cs`);门禁脚本通过。扣分:仍缺少真实 Neo4j/ES 双 provider 联动压力测试。 | - -## 5. 关键扣分项(按优先级) - -### P1 - -1. **跨 Store 写入非原子** - - `ProjectionMaterializationRouter` 仍是串行双写,缺少统一事务/补偿机制(`ProjectionMaterializationRouter.cs:31-37`)。 - -### P2 - -1. **跨 Provider 高规模性能基线不足** - - 缺少 Neo4j/ES 双 provider 联动的压力测试与容量基线,尚未形成性能门禁。 - -### P3 - -1. **Graph 成功路径可观测性不足** - - 缺少统一写入吞吐、清理计数、owner 命中率等指标日志。 - -## 6. 重新评分判定 - -当前实现已进入“结构清晰 + 关键缺陷大幅收敛”的阶段,但在严格生产工程标准下仍未到 A 档。重新评分为 **92/100(B+)**。 From cd141f9414cd529eb2728560367092ce34c0a612 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 04:40:06 +0800 Subject: [PATCH 39/46] Enhance Projection Storage Architecture Documentation - Added a new section detailing the target architecture diagram for the Projection storage, illustrating the relationship between ReadModel, UnifiedProjectionStoreDispatcher, and various StoreBindings. - Clarified the semantics of the architecture, emphasizing the single-instance provider model for DocumentStore and GraphStore. - Updated the redundancy issue list and refactored future refactoring priorities to align with the new architectural insights. - Revised documentation to improve clarity and ensure consistency with the latest architectural framework. --- ...orage-architecture-scorecard-2026-02-24.md | 152 +++++++++++++++++- 1 file changed, 145 insertions(+), 7 deletions(-) diff --git a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md index 82f51d727..35c939510 100644 --- a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md @@ -76,6 +76,30 @@ flowchart LR GRAF --> G2["Graph Provider #2...#N"] ``` +### 6.1 目标架构图(同层并行,且同类 Provider 仅 1 个) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + RM["ReadModel(TReadModel)"] --> UD["UnifiedProjectionStoreDispatcher"] + UD --> W1["StoreBinding #1: DocumentStoreAdapter"] + UD --> W2["StoreBinding #2: GraphStoreAdapter"] + UD --> Wn["StoreBinding #N: OtherStoreAdapter"] + + M1["ReadModelDocumentMetadata"] --> W1 + M2["ReadModelGraphMetadata"] --> W2 + + W1 --> D1["Document Provider(单实例): Elasticsearch"] + + W2 --> G1["Graph Provider(单实例): Neo4j"] +``` + +目标语义: + +1. `DocumentStore` 与 `GraphStore` 处于同一层抽象(`StoreBinding`),只是在能力类型上不同。 +2. 一对多以 `ReadModel` 为中心,由统一分发器一次性分发到多个 `StoreBinding`。 +3. 每个 `StoreBinding` 仅绑定一个同类 provider(Document 1个、Graph 1个),不再引入 provider 级 fan-out 与“首注册查询源”语义。 + ## 7. 冗余问题清单(重排后) ### P1-1(高)“ReadModel -> 多Store”未被统一抽象表达 @@ -129,7 +153,7 @@ flowchart LR | 抽象最小化与去冗余 | 20 | 12 | 双模型、marker+反射、系统键外泄导致冗余偏高。 | | 数据模型一致性 | 15 | 10 | Graph descriptor/store model 重复,Neo4j managed 双存。 | | Provider 扩展一致性 | 10 | 8 | 注册模式一致,但能力面不对称(metadata、materializer)。 | -| 测试与治理门禁 | 10 | 9 | fan-out 语义与基础门禁覆盖较好。 | +| 测试与治理门禁 | 10 | 9 | 关键路由与投影流程门禁覆盖较好。 | | 文档一致性 | 5 | 2 | 仍有旧术语未清理。 | ### 8.2 总分 @@ -142,10 +166,124 @@ flowchart LR 2. 当前代码**功能上可并行写入**,但**架构形状并不并行**,你的不适感来自真实的结构不对称。 3. 现状更准确描述是:`ReadModel` 经过“Document链 + Graph链”双分支处理,而不是统一 `Store` 家族的一体化多路分发。 -## 10. 后续重构优先级(无兼容性) +## 10. 实施计划(彻底重构,无兼容性) + +### 10.1 约束与完成定义 + +1. 不保留兼容层,不保留旧接口转发壳。 +2. 同类 Provider 仅 1 个实例:Document 1 个、Graph 1 个。 +3. 删除 fan-out 与“主存储/首注册查询源”语义。 +4. `ReadModel -> N Stores` 由统一分发器一次分发完成,`DocumentStore` 与 `GraphStore` 为同层实现。 +5. 完成定义:旧双主干代码删除,Workflow 投影链路跑通,核心测试与架构门禁通过。 + +### 10.2 总体实施图(阶段化) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + P0["Phase 0: 冻结目标与删除范围"] --> P1["Phase 1: 抽象重建(统一Store分发)"] + P1 --> P2["Phase 2: Runtime替换(去Fanout/去双路由)"] + P2 --> P3["Phase 3: Provider重接入(单实例)"] + P3 --> P4["Phase 4: Workflow接入改造"] + P4 --> P5["Phase 5: 测试迁移与门禁收敛"] + P5 --> P6["Phase 6: 文档清理与最终复评分"] +``` -1. **P0**:引入统一 Store 抽象与统一分发器,把 `DocumentStore/GraphStore` 收敛为同层实现。 -2. **P0**:删除 Graph 双模型之一,消灭 `ProjectionGraphMaterializer` 的桥接角色。 -3. **P1**:移除 marker + 反射路由,改成显式能力注册或类型化 Store 绑定。 -4. **P1**:将 runtime 生命周期系统键从 `Stores.Abstractions` 下沉到 runtime/provider。 -5. **P1**:补齐文档清理,消除旧术语和旧架构描述。 +### 10.3 Phase 0:冻结目标与删除范围 + +1. 固化目标模型:统一分发器 + 同层 StoreBinding + 同类 Provider 单实例。 +2. 固化删除范围(本次重构必须删除): + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs` +3. 冻结决策:不再接受“恢复 fan-out”或“恢复主存储”回退。 + +### 10.4 Phase 1:抽象重建(统一 Store 分发) + +1. 在 `Aevatar.CQRS.Projection.Stores.Abstractions` 新增统一 StoreBinding 抽象(建议放在 `Abstractions/Stores/`)。 +2. 用统一写入契约替代 `IDocumentProjectionStore` 与 `IProjectionGraphStore` 的分裂入口。 +3. 保留“索引元数据、关系语义”,但改为按 ReadModel 泛型/绑定提供,不再依赖 marker 能力链。 +4. 统一 Graph 模型:删除 `GraphNodeDescriptor/GraphEdgeDescriptor` 与 `ProjectionGraphNode/ProjectionGraphEdge` 的双模型并存状态,只保留一套权威模型。 +5. 将 `ProjectionGraphSystemPropertyKeys` 下沉到 Runtime 或 Provider,抽象层不暴露运行态内部键。 + +### 10.5 Phase 2:Runtime 替换(去 Fanout/去双路由) + +1. 在 `Aevatar.CQRS.Projection.Runtime` 新增统一分发器实现(单入口、一次分发)。 +2. 删除旧双路由注册: + - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` 中移除 `ProjectionDocumentStoreFanout/ProjectionGraphStoreFanout/ProjectionMaterializationRouter/ProjectionGraphMaterializer` 注册。 +3. Runtime DI 改为注册: + - 统一 Dispatcher + - StoreBinding 列表 + - ReadModel 元数据解析器(若保留) +4. 禁止运行时 `IsAssignableFrom` 能力判定路径。 + +### 10.6 Phase 3:Provider 重接入(单实例) + +1. `Aevatar.CQRS.Projection.Providers.Elasticsearch` 仅提供 Document 绑定实现,不再提供注册到 fan-out 的适配。 +2. `Aevatar.CQRS.Projection.Providers.Neo4j` 仅提供 Graph 绑定实现,不再提供注册到 fan-out 的适配。 +3. `Aevatar.CQRS.Projection.Providers.InMemory` 保留开发/测试用途实现,但同样走统一 Binding 协议。 +4. 宿主层强约束:同类 provider 只能注册 1 个,多于 1 个直接 fail-fast。 + +### 10.7 Phase 4:Workflow 接入改造 + +1. 改造 `src/workflow/Aevatar.Workflow.Projection`: + - Projector/Updater 从旧 `IProjectionMaterializationRouter<,>` 切换到统一 dispatcher。 + - QueryReader 保持读侧语义,但查询端口来自统一 StoreBinding 模型。 +2. 改造 `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs`: + - 删除旧 `Add*StoreRegistration` 路径。 + - 使用“Document 单实例 + Graph 单实例”显式注册。 + - 发现同类重复注册时抛异常(启动失败)。 +3. `WorkflowExecutionReport` 保留索引、关系语义提供能力,但不再承担 marker 路由职责。 + +### 10.8 Phase 5:测试迁移与门禁收敛 + +1. 删除或改写依赖 fan-out/首注册语义的测试: + - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs` + - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs` +2. 新增统一分发器测试矩阵: + - 单 ReadModel 同时写 Document+Graph + - 同类 provider 重复注册 fail-fast + - Graph/Document 任一写入失败时的错误传播策略 +3. Workflow 侧新增端到端断言: + - 一次投影后,ES 可检索,Neo4j 可遍历,且来源同一 ReadModel 版本。 +4. 必跑命令: + - `dotnet build aevatar.slnx --nologo` + - `dotnet test aevatar.slnx --nologo` + - `bash tools/ci/architecture_guards.sh` + - `bash tools/ci/projection_route_mapping_guard.sh` + - `bash tools/ci/test_stability_guards.sh` + +### 10.9 Phase 6:文档清理与最终复评分 + +1. 清理旧术语: + - `IProjectionReadModelStore` + - `Projection*Fanout` + - `主存储/首注册查询源` +2. 更新文档: + - `src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md` + - `src/Aevatar.CQRS.Projection.Runtime/README.md` + - `src/workflow/Aevatar.Workflow.Projection/README.md` + - `docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md` +3. 复评输出:在 `docs/audit-scorecard/` 新增重构后评分文档,验证目标分数应达到 `>= 90`。 + +### 10.10 风险与控制 + +1. 风险:一次性删除旧抽象导致大面积编译错误。 +控制:按 Phase 提交,每阶段必须 `build + targeted test` 全绿后进入下一阶段。 +2. 风险:Workflow 查询端口在接口切换时出现行为回归。 +控制:先补端到端基线测试,再替换实现。 +3. 风险:Neo4j 属性模型收敛时丢失 managed 生命周期信息。 +控制:先定义唯一事实字段,再做一次性数据迁移脚本或重建策略。 + +### 10.11 里程碑验收清单(必须全部满足) + +1. 代码中不存在 `ProjectionDocumentStoreFanout`、`ProjectionGraphStoreFanout`、`ProjectionMaterializationRouter`、`ProjectionGraphMaterializer`。 +2. Runtime 不存在基于 marker 的 `IsAssignableFrom` 路由分支。 +3. 同类 Provider 重复注册时启动即失败(明确错误消息)。 +4. 单次投影可同时落 Document 与 Graph,两边查询结果一致。 +5. 全量测试与门禁命令通过。 From 4c5613ea0ad24f6a1e141867dd236ccaba9dcb88 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 05:05:20 +0800 Subject: [PATCH 40/46] Refactor Projection Store Interfaces and Update Dependency Injection - Replaced `IDocumentProjectionStore` with `IProjectionDocumentStore` across various components to unify the projection store interface. - Updated the `ServiceCollectionExtensions` for both InMemory and Elasticsearch providers to reflect the new interface, enhancing clarity in service registration. - Introduced `ElasticsearchProjectionDocumentStoreOptions` for better configuration management of Elasticsearch settings. - Enhanced documentation to clarify the changes in projection store interfaces and their registration processes, ensuring consistency with the updated architecture. --- .../ServiceCollectionExtensions.cs | 2 +- .../Orchestration/CaseProjectionService.cs | 4 +- .../Projectors/CaseReadModelProjector.cs | 4 +- .../Stores/InMemoryCaseReadModelStore.cs | 2 +- docs/CQRS_ARCHITECTURE.md | 2 +- ...ng-elasticsearch-readmodel-requirements.md | 19 +- ...readmodel-full-refactor-plan-2026-02-24.md | 231 +++++------- ...orage-architecture-scorecard-2026-02-24.md | 334 ++++-------------- ...icsearchProjectionDocumentStoreOptions.cs} | 2 +- .../ServiceCollectionExtensions.cs | 20 +- .../README.md | 24 +- ...> ElasticsearchProjectionDocumentStore.cs} | 16 +- .../ServiceCollectionExtensions.cs | 25 +- .../README.md | 23 +- ....cs => InMemoryProjectionDocumentStore.cs} | 12 +- .../Stores/InMemoryProjectionGraphStore.cs | 4 +- .../ServiceCollectionExtensions.cs | 14 +- .../README.md | 21 +- .../Stores/Neo4jProjectionGraphStore.cs | 6 +- .../DelegateProjectionStoreRegistration.cs | 26 -- .../Core/IProjectionStoreRegistration.cs | 8 - .../ProjectionGraphManagedPropertyKeys.cs} | 4 +- .../IProjectionDocumentMetadataResolver.cs | 2 +- .../Selection/IProjectionGraphMaterializer.cs | 7 - .../IProjectionQueryableStoreBinding.cs | 12 + .../Stores/IProjectionStoreBinding.cs | 9 + .../IProjectionStoreDispatcher.cs} | 2 +- .../README.md | 33 +- .../ServiceCollectionExtensions.cs | 7 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 29 +- .../ProjectionDocumentMetadataResolver.cs | 2 +- .../Runtime/ProjectionDocumentStoreBinding.cs | 35 ++ .../Runtime/ProjectionDocumentStoreFanout.cs | 90 ----- ...izer.cs => ProjectionGraphStoreBinding.cs} | 138 ++++---- .../Runtime/ProjectionGraphStoreFanout.cs | 114 ------ .../ProjectionMaterializationRouter.cs | 101 ------ .../Runtime/ProjectionStoreDispatcher.cs | 94 +++++ .../ReadModels/GraphEdgeDescriptor.cs | 9 - .../ReadModels/GraphNodeDescriptor.cs | 7 - .../ReadModels/IDocumentReadModel.cs | 3 - .../ReadModels/IGraphReadModel.cs | 4 +- .../IProjectionDocumentMetadataProvider.cs | 2 +- ...onStore.cs => IProjectionDocumentStore.cs} | 2 +- .../README.md | 28 +- .../WorkflowProjectionQueryReader.cs | 4 +- .../WorkflowProjectionReadModelUpdater.cs | 10 +- ...ReadModelStartupValidationHostedService.cs | 2 +- .../WorkflowExecutionReadModelProjector.cs | 12 +- .../Aevatar.Workflow.Projection/README.md | 137 ++----- .../ReadModels/WorkflowExecutionReadModel.cs | 97 ++--- src/workflow/README.md | 4 +- ...Aevatar.Workflow.Extensions.Hosting.csproj | 1 + ...tionProviderServiceCollectionExtensions.cs | 53 ++- ...chProjectionDocumentStoreBehaviorTests.cs} | 20 +- ...cs => ProjectionGraphStoreBindingTests.cs} | 177 ++-------- .../ProjectionProviderE2EIntegrationTests.cs | 4 +- .../ProjectionReadModelRuntimeTests.cs | 148 -------- .../ProjectionReadModelStoreSelectorTests.cs | 187 ---------- .../ProjectionStoreDispatcherTests.cs | 154 ++++++++ ...lowExecutionProjectionRegistrationTests.cs | 27 +- ...WorkflowExecutionProjectionServiceTests.cs | 40 ++- ...orkflowExecutionReadModelProjectorTests.cs | 28 +- .../WorkflowHostingExtensionsCoverageTests.cs | 61 ++-- ...owProjectionOrchestrationComponentTests.cs | 13 +- tools/ci/architecture_guards.sh | 6 +- 65 files changed, 943 insertions(+), 1775 deletions(-) rename src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/{ElasticsearchProjectionReadModelStoreOptions.cs => ElasticsearchProjectionDocumentStoreOptions.cs} (91%) rename src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/{ElasticsearchProjectionReadModelStore.cs => ElasticsearchProjectionDocumentStore.cs} (97%) rename src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/{InMemoryProjectionReadModelStore.cs => InMemoryProjectionDocumentStore.cs} (93%) delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs rename src/{Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs => Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs} (63%) delete mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs rename src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/{Selection/IProjectionMaterializationRouter.cs => Stores/IProjectionStoreDispatcher.cs} (86%) create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs rename src/Aevatar.CQRS.Projection.Runtime/Runtime/{ProjectionGraphMaterializer.cs => ProjectionGraphStoreBinding.cs} (63%) delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs delete mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs delete mode 100644 src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs rename src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/{IDocumentProjectionStore.cs => IProjectionDocumentStore.cs} (87%) rename test/Aevatar.CQRS.Projection.Core.Tests/{ElasticsearchProjectionReadModelStoreBehaviorTests.cs => ElasticsearchProjectionDocumentStoreBehaviorTests.cs} (92%) rename test/Aevatar.CQRS.Projection.Core.Tests/{ProjectionGraphMaterializerTests.cs => ProjectionGraphStoreBindingTests.cs} (71%) delete mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs delete mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs create mode 100644 test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs diff --git a/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs b/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs index 3c8f45564..dd736d051 100644 --- a/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/demos/Aevatar.Demos.CaseProjection/DependencyInjection/ServiceCollectionExtensions.cs @@ -23,7 +23,7 @@ public static IServiceCollection AddCaseProjectionDemo( services.TryAddSingleton(sp => sp.GetRequiredService()); services.TryAddSingleton(); - services.TryAddSingleton>(sp => + services.TryAddSingleton>(sp => sp.GetRequiredService()); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs index 100c5b5ba..ef49ba771 100644 --- a/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs +++ b/demos/Aevatar.Demos.CaseProjection/Orchestration/CaseProjectionService.cs @@ -11,7 +11,7 @@ public sealed class CaseProjectionService : ICaseProjectionService { private readonly CaseProjectionOptions _options; private readonly IProjectionLifecycleService> _lifecycle; - private readonly IDocumentProjectionStore _store; + private readonly IProjectionDocumentStore _store; private readonly IProjectionClock _clock; private readonly ICaseProjectionContextFactory _contextFactory; private readonly ConcurrentDictionary _contexts = new(StringComparer.Ordinal); @@ -19,7 +19,7 @@ public sealed class CaseProjectionService : ICaseProjectionService public CaseProjectionService( CaseProjectionOptions options, IProjectionLifecycleService> lifecycle, - IDocumentProjectionStore store, + IProjectionDocumentStore store, IProjectionClock clock, ICaseProjectionContextFactory contextFactory) { diff --git a/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs b/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs index 3a3d06b71..f9db72150 100644 --- a/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs +++ b/demos/Aevatar.Demos.CaseProjection/Projectors/CaseReadModelProjector.cs @@ -5,11 +5,11 @@ namespace Aevatar.Demos.CaseProjection.Projectors; public sealed class CaseReadModelProjector : IProjectionProjector> { - private readonly IDocumentProjectionStore _store; + private readonly IProjectionDocumentStore _store; private readonly IReadOnlyDictionary>> _reducersByType; public CaseReadModelProjector( - IDocumentProjectionStore store, + IProjectionDocumentStore store, IEnumerable> reducers) { _store = store; diff --git a/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs b/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs index 0dff85f88..909249582 100644 --- a/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs +++ b/demos/Aevatar.Demos.CaseProjection/Stores/InMemoryCaseReadModelStore.cs @@ -1,6 +1,6 @@ namespace Aevatar.Demos.CaseProjection.Stores; -public sealed class InMemoryCaseReadModelStore : IDocumentProjectionStore +public sealed class InMemoryCaseReadModelStore : IProjectionDocumentStore { private readonly object _gate = new(); private readonly Dictionary _reports = new(StringComparer.Ordinal); diff --git a/docs/CQRS_ARCHITECTURE.md b/docs/CQRS_ARCHITECTURE.md index 3710ee11e..4fcb3d23b 100644 --- a/docs/CQRS_ARCHITECTURE.md +++ b/docs/CQRS_ARCHITECTURE.md @@ -45,7 +45,7 @@ flowchart LR 3. 未命中 reducer 的事件必须为 no-op。 4. Workflow 投影生命周期通过 lease/session 句柄管理,不允许 `actorId -> context` 反查。 5. 同一 `EventEnvelope` 分发到多个 projector 时采用“一对多全分支尝试”语义:单个 projector 失败不阻断其他 projector 执行,最终以聚合异常统一回传。 -6. 禁止 `Projection:ReadModel:Bindings` 与任何 BindingResolver 路由;投影存储路由必须由 `IDocumentReadModel/IGraphReadModel` 能力自动决策。 +6. 禁止 `Projection:ReadModel:Bindings` 与任何 BindingResolver 路由;投影存储路由统一由 `IProjectionStoreDispatcher` + Store Binding (`IProjectionDocumentStore` / `IProjectionGraphStore`) 决策。 7. Host 组合层按配置仅注册所需 provider 组合,不允许无条件并列注册 InMemory/Elasticsearch/Neo4j。 ## 5.1 编排减重落地(2026-02-22) diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index fc9d0a49c..9fd252db7 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -88,15 +88,16 @@ ### 6.3 Provider Runtime(已落地) - 运行时主干: - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelProviderSelector.cs` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionReadModelStoreFactory.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs` - Provider 项目: - `src/Aevatar.CQRS.Projection.Providers.InMemory` - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` - `src/Aevatar.CQRS.Projection.Providers.Neo4j` - 当前行为: - 1. 多 Provider 未显式指定时直接失败(确定性路由)。 - 2. 能力不匹配且开启 fail-fast 时抛异常。 + 1. 一个 ReadModel 可绑定多个 Store(Document + Graph)。 + 2. 必须且仅能有一个 queryable binding(用于 Get/List/Mutate)。 3. Provider 写路径日志结构已统一。 ### 6.4 Workflow 接入现状(已完成) @@ -182,11 +183,11 @@ flowchart LR ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - P1["Projection Runtime"] --> P2["Provider Registry"] - P2 --> P3["Provider Selector"] - P3 --> P4["Capability Validator"] - P4 --> P5["Store Factory"] - P5 --> P6["IProjectionReadModelStore"] + P1["Projection Runtime"] --> P2["IProjectionStoreDispatcher"] + P2 --> P3["ProjectionDocumentStoreBinding"] + P2 --> P4["ProjectionGraphStoreBinding"] + P3 --> P5["IProjectionDocumentStore"] + P4 --> P6["IProjectionGraphStore"] P1 --> P7["Projector: ReadModel"] P1 --> P8["Projector: AGUI"] ``` diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md index 932a1736a..7ffd3b7e3 100644 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md @@ -4,173 +4,108 @@ - Status: Completed - Scope: `Aevatar.CQRS.Projection.*` + `Aevatar.Workflow.Projection` + `Aevatar.Workflow.Extensions.Hosting` -## 1. Refactor Goals - -1. Remove single-provider selection (`providerName + factory + runtime options`) and switch to one-to-many fan-out. -2. Keep Document/Graph as independent provider categories. -3. Make read model routing capability-driven: - - `IDocumentReadModel` -> document store fan-out - - `IGraphReadModel` -> graph store fan-out - - both interfaces -> both paths -4. Preserve index metadata and graph relation semantics: - - document: `DocumentIndexMetadata` - - graph: `IGraphReadModel.GraphNodes/GraphEdges` -5. Delete redundant abstraction layers and dead code without compatibility shims. +## 1. Refactor Targets + +1. 单一主干:Projection 只保留一条权威运行链路(Dispatcher + Bindings)。 +2. 一对多:一个 ReadModel 可同时投影到多个 Store。 +3. 平行关系:Document Store 与 Graph Store 是同层平行实现。 +4. 同类单实现:同类 Provider 在同一 Host 内只允许一个(Document 只能一个,Graph 只能一个)。 +5. 删除冗余:移除 Router/Fanout/Registration/Marker/双模型等无效层。 ## 2. Target Architecture -### 2.1 Write Pipeline +### 2.1 Write Path ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - A["Domain Event Stream"] --> B["Projection Coordinator / Dispatcher"] - B --> C["Workflow ReadModel Reducers + Projectors"] - C --> D["IProjectionMaterializationRouter"] - - D --> E["ProjectionDocumentStoreFanout"] - D --> F["ProjectionGraphMaterializer"] - - E --> E1["Document Provider #1 (InMemory)"] - E --> E2["Document Provider #2 (Elasticsearch)"] - - F --> G["ProjectionGraphStoreFanout"] - G --> G1["Graph Provider #1 (InMemory)"] - G --> G2["Graph Provider #2 (Neo4j)"] + E["Domain Event Stream"] --> R["Projector + Reducers"] + R --> D["IProjectionStoreDispatcher"] + D --> B1["ProjectionDocumentStoreBinding"] + D --> B2["ProjectionGraphStoreBinding"] + B1 --> S1["IProjectionDocumentStore"] + B2 --> S2["IProjectionGraphStore"] + S1 --> P1["Document Provider (Elasticsearch or InMemory)"] + S2 --> P2["Graph Provider (Neo4j or InMemory)"] ``` -### 2.2 Query Pipeline +### 2.2 Query Path ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - Q1["WorkflowProjectionQueryReader"] --> Q2["IDocumentProjectionStore"] - Q1 --> Q3["IProjectionGraphStore"] + Q["WorkflowProjectionQueryReader"] --> DQ["IProjectionDocumentStore"] + Q --> GQ["IProjectionGraphStore"] +``` - Q2 --> Q4["ProjectionDocumentStoreFanout (first-registered read store + fan-out write)"] - Q3 --> Q5["ProjectionGraphStoreFanout (first-registered read store + fan-out write)"] +### 2.3 一对多语义 - Q4 --> Q6["Document Query Provider (registration[0])"] - Q5 --> Q7["Graph Query Provider (registration[0])"] -``` +- ReadModel 是上层语义实体。 +- Store 是下层持久化投影目标。 +- `1 ReadModel : N Stores`(当前落地为 `N=2`: Document + Graph)。 + +## 3. Hard Refactor Scope + +### 3.1 Removed + +- `IProjectionStoreRegistration` / `DelegateProjectionStoreRegistration` +- `IProjectionMaterializationRouter` / `ProjectionMaterializationRouter` +- `IProjectionGraphMaterializer` / `ProjectionGraphMaterializer` +- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` +- `IDocumentReadModel` marker +- `GraphNodeDescriptor` / `GraphEdgeDescriptor` +- `ProjectionGraphSystemPropertyKeys` -## 3. Major Structural Changes +### 3.2 Added -## 3.1 Removed (hard delete) - -- `ProjectionDocumentRuntimeOptions` -- `ProjectionGraphRuntimeOptions` -- `ProjectionProviderNames` -- `IProjectionDocumentStoreFactory` -- `IProjectionGraphStoreFactory` -- `ProjectionProviderSelectionException` -- `ProjectionDocumentStoreFactory` -- `ProjectionGraphStoreFactory` -- `ProjectionStoreRegistrationSelector` - -## 3.2 Added - -- `ProjectionDocumentStoreFanout` -- `ProjectionGraphStoreFanout` - -## 3.3 Runtime DI Model - -`AddProjectionReadModelRuntime()` now registers: - -- `IDocumentProjectionStore<,>` -> `ProjectionDocumentStoreFanout<,>` -- `IProjectionGraphStore` -> `ProjectionGraphStoreFanout` -- `IProjectionGraphMaterializer<>` -- `IProjectionMaterializationRouter<,>` -- `IProjectionDocumentMetadataResolver` - -## 3.4 Workflow Provider Registration Model - -`AddWorkflowProjectionReadModelProviders(configuration)` now uses enable flags: - -- `Projection:Document:Providers:InMemory:Enabled` -- `Projection:Document:Providers:Elasticsearch:Enabled` -- `Projection:Graph:Providers:InMemory:Enabled` -- `Projection:Graph:Providers:Neo4j:Enabled` - -Rules: - -1. Legacy single-select keys (`Projection:Document:Provider`, `Projection:Graph:Provider`) are rejected. -2. InMemory providers are fallback defaults when durable providers are not enabled. -3. `Projection:Policies:DenyInMemoryGraphFactStore=true` forbids in-memory graph fact store. -4. 多 provider 查询语义使用“注册顺序即查询顺序”: - - query 读取总是走第一个注册 provider - - 其余 provider 仅承担 fan-out 写入 - - Workflow Host 采用“耐久优先”注册顺序(Document: `Elasticsearch -> InMemory`,Graph: `Neo4j -> InMemory`) - -## 3.6 Query Ordering & Graph Cleanup Hardening - -- `IProjectionStoreRegistration` 收敛为最小契约:`ProviderName + Create(IServiceProvider)`。 -- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` 改为: - - 第一个注册 provider 为 query store; - - 其余 provider 自动作为 fan-out 副本; - - 无需 `primary` 配置,降低宿主层配置负担。 -- `IProjectionGraphStore` 增加 owner 生命周期接口: - - `ListEdgesByOwnerAsync(scope, ownerId, take)` - - `ListNodesByOwnerAsync(scope, ownerId, take)` - - `DeleteNodeAsync(scope, nodeId)` -- `ProjectionGraphMaterializer` 从锚点子图清理重构为 owner-based 精确清理(边+节点): - - 写边时注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` - - 写节点时同样注入系统属性:`projectionManaged=true`、`projectionOwnerId=:` - - 清理时按 owner 列举已有边/节点并做差集删除,不再依赖 `Depth/Take` 子图扫描窗口。 - - 节点删除前执行邻接检查,仅删除无任何关系边的孤立节点,避免误删跨 owner 共享节点。 - - graph owner 标识收敛为 `IGraphReadModel.Id`;`Id` 为空直接 fail-fast,禁止 fallback 到节点/边推断。 - -## 3.7 Elasticsearch Metadata Behavior - -`ElasticsearchProjectionReadModelStore` now consumes full `DocumentIndexMetadata`: - -- `IndexName` as logical scope input -- `Mappings` as structured mappings object (`IReadOnlyDictionary`) -- `Settings` as structured settings object (`IReadOnlyDictionary`) -- `Aliases` as structured aliases object (`IReadOnlyDictionary`) - -Index bootstrap now uses structured metadata payload instead of stringified JSON fragments. - -## 3.8 Neo4j Subgraph Query Optimization - -- `Neo4jProjectionGraphStore.GetSubgraphAsync` 从“逐层逐节点 `GetNeighborsAsync` 循环”重构为单次 Cypher 拉取边(按 `direction/depth/edgeTypes/take`),随后一次节点补全查询。 -- 移除子图遍历的 N+1 风险,降低高出度场景下查询放大。 - -## 4. Project-Level Responsibility Split (Post-Refactor) - -- `Aevatar.CQRS.Projection.Stores.Abstractions` - - pure read model/store contracts and metadata contracts -- `Aevatar.CQRS.Projection.Runtime.Abstractions` - - store registration + materialization contracts only -- `Aevatar.CQRS.Projection.Runtime` - - fan-out composition + graph materialization + metadata resolver -- `Aevatar.CQRS.Projection.Providers.*` - - concrete provider implementations only -- `Aevatar.Workflow.Projection` - - workflow reducers/projectors/read model/query services -- `Aevatar.Workflow.Extensions.Hosting` - - host-layer provider enablement and policy binding - -## 5. Implementation Status by Module - -- Runtime: completed -- Runtime.Abstractions: completed -- Provider extensions: completed -- Workflow projection composition: completed -- Workflow hosting provider extension: completed -- Tests: completed and updated to fan-out semantics -- Readme/docs sync: completed +- `IProjectionStoreDispatcher` +- `IProjectionStoreBinding` +- `IProjectionQueryableStoreBinding` +- `ProjectionStoreDispatcher` +- `ProjectionDocumentStoreBinding` +- `ProjectionGraphStoreBinding` +- `ProjectionGraphManagedPropertyKeys` + +### 3.3 Renamed For Parallel Semantics + +- `IDocumentProjectionStore` -> `IProjectionDocumentStore` +- `InMemoryProjectionReadModelStore` -> `InMemoryProjectionDocumentStore` +- `ElasticsearchProjectionReadModelStore` -> `ElasticsearchProjectionDocumentStore` +- `ElasticsearchProjectionReadModelStoreOptions` -> `ElasticsearchProjectionDocumentStoreOptions` + +## 4. Provider Policy + +`AddWorkflowProjectionReadModelProviders(configuration)` 强制: + +1. Document Provider exactly one: + - `Projection:Document:Providers:Elasticsearch:Enabled=true` + - or `Projection:Document:Providers:InMemory:Enabled=true` +2. Graph Provider exactly one: + - `Projection:Graph:Providers:Neo4j:Enabled=true` + - or `Projection:Graph:Providers:InMemory:Enabled=true` +3. 禁止旧配置:`Projection:Document:Provider` / `Projection:Graph:Provider` +4. 可用策略:`Projection:Policies:DenyInMemoryGraphFactStore` + +## 5. Implementation Completion Checklist + +- [x] Runtime 从 Router/Fanout 切换到 Dispatcher/Bindings +- [x] Workflow Projector/Updater 切换到 `IProjectionStoreDispatcher` +- [x] Graph 读模型统一为 `ProjectionGraphNode/ProjectionGraphEdge` +- [x] Provider DI 改为直接 Store 注册 +- [x] Document/Graph Provider 命名体系并行化 +- [x] 测试更新到 Dispatcher + Binding 模式 +- [x] CI 架构门禁脚本同步新命名 +- [x] 文档与 README 全量更新 ## 6. Verification -Commands executed: +执行命令: 1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test aevatar.slnx --nologo` -3. `bash tools/ci/architecture_guards.sh` -4. `bash tools/ci/projection_route_mapping_guard.sh` -5. `bash tools/ci/solution_split_guards.sh` -6. `bash tools/ci/solution_split_test_guards.sh` -7. `bash tools/ci/test_stability_guards.sh` - -Result: all passed. +2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` +3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +4. `bash tools/ci/architecture_guards.sh` +5. `bash tools/ci/projection_route_mapping_guard.sh` +6. `bash tools/ci/test_stability_guards.sh` + +结果:通过。 diff --git a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md index 35c939510..b3d8426d4 100644 --- a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md +++ b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md @@ -1,289 +1,99 @@ -# Projection 存储完整审计与架构评分(2026-02-24,按“ReadModel -> N Stores”口径重评) +# Projection Store / ReadModel 架构审计与打分(2026-02-24) -## 1. 审计目标 +- 范围: + - `src/Aevatar.CQRS.Projection.Stores.Abstractions` + - `src/Aevatar.CQRS.Projection.Core.Abstractions` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions` + - `src/Aevatar.CQRS.Projection.Runtime` + - `src/Aevatar.CQRS.Projection.Providers.*` + - `src/workflow/Aevatar.Workflow.Projection` + - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` +- 审计目标:确认 `DocumentStore` 与 `GraphStore` 为平行关系,且 `ReadModel -> Stores` 为一对多模型。 -1. 全面审计 Projection 存储层(抽象、Runtime、Provider、Workflow 接入)。 -2. 严格确认 `DocumentStore` 与 `GraphStore` 的关系。 -3. 严格确认“一对多(1:N)”是否按你定义落地:`1个ReadModel -> 多个Store`。 -4. 找出全部可定位冗余并重新评分。 +## 1. 总分 -## 2. 评分口径声明(本次重评) +- **9.3 / 10** -1. **你的口径是唯一准绳**:`DocumentStore` 与 `GraphStore` 都应是 `Store` 家族中的平行实现类型。 -2. **一对多定义**:不是“Document 一对多 + Graph 一对多”两条主干,而是“同一个 ReadModel 可被同一投影链路分发到多个 Store(其中包含 Document/Graph)”。 -3. 若实现为“双主干 + 分支编排”,即便功能可用,也按“并行性不足”扣分。 +扣分点: -## 3. 审计范围 +1. Runtime 仍允许通过额外 binding 扩展更多写入目标(通用能力),对首次接入者理解门槛略高。(-0.4) +2. 历史文档存在过旧版本痕迹(已重写主要文档,但仓库仍可能有历史性参考文档)。(-0.3) -1. `src/Aevatar.CQRS.Projection.Stores.Abstractions` -2. `src/Aevatar.CQRS.Projection.Runtime.Abstractions` -3. `src/Aevatar.CQRS.Projection.Runtime` -4. `src/Aevatar.CQRS.Projection.Providers.InMemory` -5. `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` -6. `src/Aevatar.CQRS.Projection.Providers.Neo4j` -7. `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` -8. `test/Aevatar.CQRS.Projection.Core.Tests` -9. `test/Aevatar.Workflow.Host.Api.Tests` +## 2. 分项打分 -## 4. 验证基线(本轮沿用) +| 维度 | 分数 | 结论 | +|---|---:|---| +| 分层清晰度 | 9.5 | Stores.Abstractions / Runtime.Abstractions / Runtime / Providers 职责边界清晰 | +| 并行一致性(Document vs Graph) | 9.4 | 命名与实现层次已并行(`IProjectionDocumentStore` vs `IProjectionGraphStore`) | +| 一对多模型完整性 | 9.2 | `IProjectionStoreDispatcher + Bindings` 明确支持一个 ReadModel 多 Store 分发 | +| 冗余消除程度 | 9.3 | Fanout/Router/Registration/Marker/双模型已删除 | +| Host 装配正确性 | 9.4 | Workflow Host 强制同类 Provider 仅一个,并支持 Document+Graph 双写 | +| 可测试性 | 9.1 | Dispatcher/GraphBinding/Workflow host tests 覆盖关键路径 | +| 运维可控性 | 9.0 | 策略位与 fail-fast 完整,日志和 guard 已对齐新模型 | -1. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -2. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -3. `bash tools/ci/architecture_guards.sh` +## 3. 核心审计结论 -结果:通过。 +1. `DocumentStore` 与 `GraphStore` 为平行关系,不存在继承或主从关系。 +2. `ReadModel` 与 Store 是 `1:N` 关系:一个 ReadModel 可写入多个 Store。 +3. 当前 Workflow 生产组合为 `N=2`:Document + Graph 同步投影。 +4. 同类 Provider 强制单实现: + - Document: Elasticsearch 或 InMemory(二选一) + - Graph: Neo4j 或 InMemory(二选一) -## 5. 结构结论(按新口径) - -### 5.1 结论 A:概念上“平行实现”成立 - -1. `WorkflowExecutionReport` 同时实现 `IDocumentReadModel` 与 `IGraphReadModel`,具备“双存储能力”事实(`src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs:36-37`)。 -2. Provider 注册侧确实可同时启用 ES 与 Neo4j(`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs:43-76`)。 - -### 5.2 结论 B:实现上“同层并行”**不成立(你的感觉是对的)** - -1. **根抽象不是同层模型**: - - Document:`IDocumentProjectionStore`(CRUD形状) - - Graph:`IProjectionGraphStore`(节点/边形状) - 这不是统一 `IProjectionStore` 的同层实现。 -2. **路由是硬编码双分支**: - - `ProjectionMaterializationRouter` 用 `IsAssignableFrom` 分别判断 `IDocumentReadModel/IGraphReadModel`(`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs:13-14`)。 - - 这意味着“ReadModel -> Store列表”不是统一分发表达,而是“Document链 + Graph链”。 -3. **Graph 多一层专有中间件**: - - Graph 需要 `ProjectionGraphMaterializer` 做 descriptor -> store model 转换(`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs:116-196`)。 - - Document 无对等层,导致链路层级不对称。 -4. **Metadata 机制只有 Document 分支**: - - `IProjectionDocumentMetadataProvider` / `IProjectionDocumentMetadataResolver` 存在(`src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs:3-6`,`src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5-19`)。 - - Graph 没有同层 metadata 契约。 - -## 6. 当前实际架构图(显示“非同层并行”) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - RM["ReadModel(TReadModel)"] --> RT["ProjectionMaterializationRouter(双分支判定)"] - - RT -->|"IDocumentReadModel"| DOCF["ProjectionDocumentStoreFanout"] - RT -->|"IGraphReadModel"| GRM["ProjectionGraphMaterializer(专有转换层)"] - GRM --> GRAF["ProjectionGraphStoreFanout"] - - DOCM["Document Metadata Resolver"] --> DOCF - - DOCF --> D1["Document Provider #1(Query)"] - DOCF --> D2["Document Provider #2...#N"] - - GRAF --> G1["Graph Provider #1(Query)"] - GRAF --> G2["Graph Provider #2...#N"] -``` - -### 6.1 目标架构图(同层并行,且同类 Provider 仅 1 个) +## 4. 目标架构图 ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR - RM["ReadModel(TReadModel)"] --> UD["UnifiedProjectionStoreDispatcher"] - UD --> W1["StoreBinding #1: DocumentStoreAdapter"] - UD --> W2["StoreBinding #2: GraphStoreAdapter"] - UD --> Wn["StoreBinding #N: OtherStoreAdapter"] - - M1["ReadModelDocumentMetadata"] --> W1 - M2["ReadModelGraphMetadata"] --> W2 - - W1 --> D1["Document Provider(单实例): Elasticsearch"] - - W2 --> G1["Graph Provider(单实例): Neo4j"] + RM["WorkflowExecutionReport(ReadModel)"] --> DSP["IProjectionStoreDispatcher"] + DSP --> DB["ProjectionDocumentStoreBinding"] + DSP --> GB["ProjectionGraphStoreBinding"] + DB --> DS["IProjectionDocumentStore"] + GB --> GS["IProjectionGraphStore"] + DS --> DP["Document Provider(Elasticsearch/InMemory)"] + GS --> GP["Graph Provider(Neo4j/InMemory)"] ``` -目标语义: - -1. `DocumentStore` 与 `GraphStore` 处于同一层抽象(`StoreBinding`),只是在能力类型上不同。 -2. 一对多以 `ReadModel` 为中心,由统一分发器一次性分发到多个 `StoreBinding`。 -3. 每个 `StoreBinding` 仅绑定一个同类 provider(Document 1个、Graph 1个),不再引入 provider 级 fan-out 与“首注册查询源”语义。 - -## 7. 冗余问题清单(重排后) - -### P1-1(高)“ReadModel -> 多Store”未被统一抽象表达 - -1. 当前是 Document/Graph 双主干 + 分支路由,不是单一 `Store` 家族同层并行。 -2. 直接导致你感知的“实现不并行”。 - -### P1-2(高)Graph 双模型并存且高度重复 - -1. ReadModel 侧:`GraphNodeDescriptor`/`GraphEdgeDescriptor`。 -2. Store 侧:`ProjectionGraphNode`/`ProjectionGraphEdge`。 -3. Runtime 强制桥接转换。 - -### P1-3(高)能力判定依赖 marker + 运行时反射 - -1. `IDocumentReadModel` 是空 marker。 -2. `ProjectionMaterializationRouter` 通过 `IsAssignableFrom` 路由。 -3. 编译期无法直接保证“同层 Store 分发能力矩阵”。 - -### P1-4(高)Metadata 能力只在 Document 分支存在 - -1. Document 有 metadata provider/resolver。 -2. Graph 分支没有等价契约,进一步拉大两条链路语义差距。 - -### P2-5(中)运行态系统键暴露到 Stores.Abstractions - -1. `ProjectionGraphSystemPropertyKeys` 位于抽象层。 -2. 实际语义是 runtime/provider 的内部生命周期键。 - -### P2-6(中)Neo4j managed 信息双存 - -1. `propertiesJson` 与 `projectionManaged/projectionOwnerId` 同时存储。 -2. 引入冗余与一致性维护成本。 - -### P2-7(中)文档陈旧术语残留 - -1. 仍有 `IProjectionReadModelStore` 等旧术语残留。 - -### P3-8(低)`ProjectionGraphSubgraph` 可变结构可进一步收敛 - -1. 非功能缺陷,但语义表达可更简化。 - -## 8. 架构评分(严格,按你口径) - -### 8.1 评分维度与结果 - -| 维度 | 权重 | 得分 | 说明 | -|---|---:|---:|---| -| `ReadModel -> N Stores` 口径符合度 | 20 | 13 | 能同时投影到多 provider,但分发不是统一 store 家族。 | -| Document/Graph 同层并行一致性 | 20 | 10 | 目前是双主干 + Graph 专有中间层,层级不对齐。 | -| 抽象最小化与去冗余 | 20 | 12 | 双模型、marker+反射、系统键外泄导致冗余偏高。 | -| 数据模型一致性 | 15 | 10 | Graph descriptor/store model 重复,Neo4j managed 双存。 | -| Provider 扩展一致性 | 10 | 8 | 注册模式一致,但能力面不对称(metadata、materializer)。 | -| 测试与治理门禁 | 10 | 9 | 关键路由与投影流程门禁覆盖较好。 | -| 文档一致性 | 5 | 2 | 仍有旧术语未清理。 | - -### 8.2 总分 - -- **64 / 100(C-,严格口径)** - -## 9. 审计结论(直接回答你的问题) - -1. 你说的“一对多”定义是对的,且应作为重构目标模型。 -2. 当前代码**功能上可并行写入**,但**架构形状并不并行**,你的不适感来自真实的结构不对称。 -3. 现状更准确描述是:`ReadModel` 经过“Document链 + Graph链”双分支处理,而不是统一 `Store` 家族的一体化多路分发。 - -## 10. 实施计划(彻底重构,无兼容性) - -### 10.1 约束与完成定义 - -1. 不保留兼容层,不保留旧接口转发壳。 -2. 同类 Provider 仅 1 个实例:Document 1 个、Graph 1 个。 -3. 删除 fan-out 与“主存储/首注册查询源”语义。 -4. `ReadModel -> N Stores` 由统一分发器一次分发完成,`DocumentStore` 与 `GraphStore` 为同层实现。 -5. 完成定义:旧双主干代码删除,Workflow 投影链路跑通,核心测试与架构门禁通过。 - -### 10.2 总体实施图(阶段化) - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - P0["Phase 0: 冻结目标与删除范围"] --> P1["Phase 1: 抽象重建(统一Store分发)"] - P1 --> P2["Phase 2: Runtime替换(去Fanout/去双路由)"] - P2 --> P3["Phase 3: Provider重接入(单实例)"] - P3 --> P4["Phase 4: Workflow接入改造"] - P4 --> P5["Phase 5: 测试迁移与门禁收敛"] - P5 --> P6["Phase 6: 文档清理与最终复评分"] -``` - -### 10.3 Phase 0:冻结目标与删除范围 - -1. 固化目标模型:统一分发器 + 同层 StoreBinding + 同类 Provider 单实例。 -2. 固化删除范围(本次重构必须删除): - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs` - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs` - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs` - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs` - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs` -3. 冻结决策:不再接受“恢复 fan-out”或“恢复主存储”回退。 - -### 10.4 Phase 1:抽象重建(统一 Store 分发) - -1. 在 `Aevatar.CQRS.Projection.Stores.Abstractions` 新增统一 StoreBinding 抽象(建议放在 `Abstractions/Stores/`)。 -2. 用统一写入契约替代 `IDocumentProjectionStore` 与 `IProjectionGraphStore` 的分裂入口。 -3. 保留“索引元数据、关系语义”,但改为按 ReadModel 泛型/绑定提供,不再依赖 marker 能力链。 -4. 统一 Graph 模型:删除 `GraphNodeDescriptor/GraphEdgeDescriptor` 与 `ProjectionGraphNode/ProjectionGraphEdge` 的双模型并存状态,只保留一套权威模型。 -5. 将 `ProjectionGraphSystemPropertyKeys` 下沉到 Runtime 或 Provider,抽象层不暴露运行态内部键。 - -### 10.5 Phase 2:Runtime 替换(去 Fanout/去双路由) - -1. 在 `Aevatar.CQRS.Projection.Runtime` 新增统一分发器实现(单入口、一次分发)。 -2. 删除旧双路由注册: - - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` 中移除 `ProjectionDocumentStoreFanout/ProjectionGraphStoreFanout/ProjectionMaterializationRouter/ProjectionGraphMaterializer` 注册。 -3. Runtime DI 改为注册: - - 统一 Dispatcher - - StoreBinding 列表 - - ReadModel 元数据解析器(若保留) -4. 禁止运行时 `IsAssignableFrom` 能力判定路径。 - -### 10.6 Phase 3:Provider 重接入(单实例) +## 5. 并行性核对(结论:通过) -1. `Aevatar.CQRS.Projection.Providers.Elasticsearch` 仅提供 Document 绑定实现,不再提供注册到 fan-out 的适配。 -2. `Aevatar.CQRS.Projection.Providers.Neo4j` 仅提供 Graph 绑定实现,不再提供注册到 fan-out 的适配。 -3. `Aevatar.CQRS.Projection.Providers.InMemory` 保留开发/测试用途实现,但同样走统一 Binding 协议。 -4. 宿主层强约束:同类 provider 只能注册 1 个,多于 1 个直接 fail-fast。 +| 层级 | Document | Graph | 结果 | +|---|---|---|---| +| 抽象层 | `IProjectionDocumentStore` | `IProjectionGraphStore` | 平行 | +| Runtime Binding | `ProjectionDocumentStoreBinding` | `ProjectionGraphStoreBinding` | 平行 | +| Runtime 分发 | `IProjectionStoreDispatcher` 统一调度 | `IProjectionStoreDispatcher` 统一调度 | 平行 | +| InMemory Provider | `InMemoryProjectionDocumentStore` | `InMemoryProjectionGraphStore` | 平行 | +| Durable Provider | `ElasticsearchProjectionDocumentStore` | `Neo4jProjectionGraphStore` | 平行 | -### 10.7 Phase 4:Workflow 接入改造 +## 6. 冗余审计(已清理) -1. 改造 `src/workflow/Aevatar.Workflow.Projection`: - - Projector/Updater 从旧 `IProjectionMaterializationRouter<,>` 切换到统一 dispatcher。 - - QueryReader 保持读侧语义,但查询端口来自统一 StoreBinding 模型。 -2. 改造 `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs`: - - 删除旧 `Add*StoreRegistration` 路径。 - - 使用“Document 单实例 + Graph 单实例”显式注册。 - - 发现同类重复注册时抛异常(启动失败)。 -3. `WorkflowExecutionReport` 保留索引、关系语义提供能力,但不再承担 marker 路由职责。 +已删除冗余层: -### 10.8 Phase 5:测试迁移与门禁收敛 +- `IProjectionStoreRegistration` / `DelegateProjectionStoreRegistration` +- `IProjectionMaterializationRouter` / `ProjectionMaterializationRouter` +- `IProjectionGraphMaterializer` / `ProjectionGraphMaterializer` +- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` +- `IDocumentReadModel` +- `GraphNodeDescriptor` / `GraphEdgeDescriptor` +- `ProjectionGraphSystemPropertyKeys` -1. 删除或改写依赖 fan-out/首注册语义的测试: - - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs` - - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs` -2. 新增统一分发器测试矩阵: - - 单 ReadModel 同时写 Document+Graph - - 同类 provider 重复注册 fail-fast - - Graph/Document 任一写入失败时的错误传播策略 -3. Workflow 侧新增端到端断言: - - 一次投影后,ES 可检索,Neo4j 可遍历,且来源同一 ReadModel 版本。 -4. 必跑命令: - - `dotnet build aevatar.slnx --nologo` - - `dotnet test aevatar.slnx --nologo` - - `bash tools/ci/architecture_guards.sh` - - `bash tools/ci/projection_route_mapping_guard.sh` - - `bash tools/ci/test_stability_guards.sh` +## 7. 实施核对 -### 10.9 Phase 6:文档清理与最终复评分 +已落地关键项: -1. 清理旧术语: - - `IProjectionReadModelStore` - - `Projection*Fanout` - - `主存储/首注册查询源` -2. 更新文档: - - `src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md` - - `src/Aevatar.CQRS.Projection.Runtime/README.md` - - `src/workflow/Aevatar.Workflow.Projection/README.md` - - `docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md` -3. 复评输出:在 `docs/audit-scorecard/` 新增重构后评分文档,验证目标分数应达到 `>= 90`。 +1. Workflow Projector / Updater 全部使用 `IProjectionStoreDispatcher`。 +2. Workflow Host Provider 装配强制“同类 Provider 仅一个”。 +3. Document/Graph 双写通过 Dispatcher + Binding 实现,不依赖 Router/Fanout。 +4. CI 守卫脚本已更新到新命名(DocumentStore 命名体系)。 -### 10.10 风险与控制 +## 8. 验证记录 -1. 风险:一次性删除旧抽象导致大面积编译错误。 -控制:按 Phase 提交,每阶段必须 `build + targeted test` 全绿后进入下一阶段。 -2. 风险:Workflow 查询端口在接口切换时出现行为回归。 -控制:先补端到端基线测试,再替换实现。 -3. 风险:Neo4j 属性模型收敛时丢失 managed 生命周期信息。 -控制:先定义唯一事实字段,再做一次性数据迁移脚本或重建策略。 +执行通过: -### 10.11 里程碑验收清单(必须全部满足) +1. `dotnet build aevatar.slnx --nologo` +2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` +3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` +4. `bash tools/ci/architecture_guards.sh` +5. `bash tools/ci/projection_route_mapping_guard.sh` +6. `bash tools/ci/test_stability_guards.sh` -1. 代码中不存在 `ProjectionDocumentStoreFanout`、`ProjectionGraphStoreFanout`、`ProjectionMaterializationRouter`、`ProjectionGraphMaterializer`。 -2. Runtime 不存在基于 marker 的 `IsAssignableFrom` 路由分支。 -3. 同类 Provider 重复注册时启动即失败(明确错误消息)。 -4. 单次投影可同时落 Document 与 Graph,两边查询结果一致。 -5. 全量测试与门禁命令通过。 +结论:当前 Projection Store/ReadModel 架构已满足“彻底重构,无兼容性包袱”的目标。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs similarity index 91% rename from src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs rename to src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs index 21121ae17..a75fc8cb6 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionReadModelStoreOptions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; -public sealed class ElasticsearchProjectionReadModelStoreOptions +public sealed class ElasticsearchProjectionDocumentStoreOptions { public List Endpoints { get; set; } = []; diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index a6aee7094..cfb3d4657 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,9 +7,9 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddElasticsearchDocumentStoreRegistration( + public static IServiceCollection AddElasticsearchDocumentProjectionStore( this IServiceCollection services, - Func optionsFactory, + Func optionsFactory, Func metadataFactory, Func keySelector, Func? keyFormatter = null) @@ -19,15 +19,13 @@ public static IServiceCollection AddElasticsearchDocumentStoreRegistration>>( - new DelegateProjectionStoreRegistration>( - "Elasticsearch", - provider => new ElasticsearchProjectionReadModelStore( - optionsFactory(provider), - metadataFactory(provider), - keySelector, - keyFormatter, - provider.GetService>>()))); + services.AddSingleton>(provider => + new ElasticsearchProjectionDocumentStore( + optionsFactory(provider), + metadataFactory(provider), + keySelector, + keyFormatter, + provider.GetService>>())); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md index 4b253f469..e6b6677c2 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/README.md @@ -1,20 +1,18 @@ # Aevatar.CQRS.Projection.Providers.Elasticsearch -通用 Elasticsearch Document ReadModel Provider。 +Elasticsearch Document Provider。 -- 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration>` 与上层模块解耦集成。 -- `MutateAsync` 基于 `seq_no/primary_term` 执行 OCC(冲突可重试,超限失败)。 -- `AutoCreateIndex=false` 时可通过 `MissingIndexBehavior` 控制索引缺失行为(默认抛错)。 -- `ListSortField` 为空时默认按 `CreatedAt desc -> _id desc` 排序。 -- 索引初始化支持 `DocumentIndexMetadata`:`Mappings`、`Settings`、`Aliases`(结构化对象)。 +## 能力 -## DI 注册 +- `ElasticsearchProjectionDocumentStore` +- OCC 更新:`seq_no/primary_term` +- 基于 `DocumentIndexMetadata` 的索引初始化(`Mappings/Settings/Aliases`) -- `AddElasticsearchDocumentStoreRegistration(...)` +## DI -关键参数: +- `AddElasticsearchDocumentProjectionStore(...)` -- `optionsFactory`:绑定 `Projection:Document:Providers:Elasticsearch:*` 配置。 -- `metadataFactory`:通常由 `IProjectionDocumentMetadataResolver` 解析 `IProjectionDocumentMetadataProvider`。 -- `keySelector/keyFormatter`:ReadModel 主键映射。 +## 配置 + +- `Projection:Document:Providers:Elasticsearch:*` +- 至少配置 `Endpoints` diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs similarity index 97% rename from src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs rename to src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs index 04ac0c4b5..84334f187 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs @@ -8,8 +8,8 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; -public sealed class ElasticsearchProjectionReadModelStore - : IDocumentProjectionStore, +public sealed class ElasticsearchProjectionDocumentStore + : IProjectionDocumentStore, IDisposable where TReadModel : class { @@ -27,7 +27,7 @@ public sealed class ElasticsearchProjectionReadModelStore private readonly ElasticsearchMissingIndexBehavior _missingIndexBehavior; private readonly int _mutateMaxRetryCount; private readonly DocumentIndexMetadata _indexMetadata; - private readonly ILogger> _logger; + private readonly ILogger> _logger; private readonly SemaphoreSlim _indexInitializationLock = new(1, 1); private readonly JsonSerializerOptions _jsonOptions = new() { @@ -35,12 +35,12 @@ public sealed class ElasticsearchProjectionReadModelStore }; private bool _indexInitialized; - public ElasticsearchProjectionReadModelStore( - ElasticsearchProjectionReadModelStoreOptions options, + public ElasticsearchProjectionDocumentStore( + ElasticsearchProjectionDocumentStoreOptions options, DocumentIndexMetadata indexMetadata, Func keySelector, Func? keyFormatter = null, - ILogger>? logger = null, + ILogger>? logger = null, HttpMessageHandler? httpMessageHandler = null) { ArgumentNullException.ThrowIfNull(options); @@ -73,7 +73,7 @@ public ElasticsearchProjectionReadModelStore( _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortField = options.ListSortField?.Trim() ?? ""; - _logger = logger ?? NullLogger>.Instance; + _logger = logger ?? NullLogger>.Instance; } public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) => @@ -329,7 +329,7 @@ private InvalidOperationException BuildMissingIndexException(string operation, s { return new InvalidOperationException( $"Elasticsearch index '{_indexName}' was not found during '{operation}' for read-model '{typeof(TReadModel).FullName}'. " + - $"Configure index bootstrap or set '{nameof(ElasticsearchProjectionReadModelStoreOptions.AutoCreateIndex)}=true'. " + + $"Configure index bootstrap or set '{nameof(ElasticsearchProjectionDocumentStoreOptions.AutoCreateIndex)}=true'. " + $"body={TruncatePayload(payload)}"); } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 90ce52acb..4f65f38d8 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddInMemoryDocumentStoreRegistration( + public static IServiceCollection AddInMemoryDocumentProjectionStore( this IServiceCollection services, Func keySelector, Func? keyFormatter = null, @@ -16,26 +16,21 @@ public static IServiceCollection AddInMemoryDocumentStoreRegistration>>( - new DelegateProjectionStoreRegistration>( - "InMemory", - provider => new InMemoryProjectionReadModelStore( - keySelector, - keyFormatter, - listSortSelector, - listTakeMax, - provider.GetService>>()))); + services.AddSingleton>(provider => + new InMemoryProjectionDocumentStore( + keySelector, + keyFormatter, + listSortSelector, + listTakeMax, + provider.GetService>>())); return services; } - public static IServiceCollection AddInMemoryGraphStoreRegistration( + public static IServiceCollection AddInMemoryGraphProjectionStore( this IServiceCollection services) { - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "InMemory", - _ => new InMemoryProjectionGraphStore())); + services.AddSingleton(); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md index c1705238d..c005c9512 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/README.md @@ -1,19 +1,18 @@ # Aevatar.CQRS.Projection.Providers.InMemory -通用 InMemory Provider(支持 Document/Graph 两类能力)。 +通用 InMemory Provider,提供 Document 与 Graph 两类平行实现。 -- 不依赖业务域模型。 -- 支持按 keySelector 注册任意 `IDocumentProjectionStore`(Document)。 -- 支持图存储注册(Graph)。 -- 仅用于开发和测试语义,不作为生产事实源。 +## 能力 -## DI 注册 +- Document:`InMemoryProjectionDocumentStore` +- Graph:`InMemoryProjectionGraphStore` -- `AddInMemoryDocumentStoreRegistration(...)` -- `AddInMemoryGraphStoreRegistration()` +## DI -关键参数: +- `AddInMemoryDocumentProjectionStore(...)` +- `AddInMemoryGraphProjectionStore()` -- `keySelector/keyFormatter`:ReadModel 主键映射。 -- `listSortSelector`:`ListAsync` 排序字段(可选)。 -- `listTakeMax`:`ListAsync` 硬上限。 +## 说明 + +- 仅用于开发/测试语义。 +- 不作为生产事实源。 diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs similarity index 93% rename from src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs rename to src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs index 0e8e9ae79..391a3958c 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs @@ -4,8 +4,8 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; -public sealed class InMemoryProjectionReadModelStore - : IDocumentProjectionStore +public sealed class InMemoryProjectionDocumentStore + : IProjectionDocumentStore where TReadModel : class { private const string ProviderName = "InMemory"; @@ -15,22 +15,22 @@ public sealed class InMemoryProjectionReadModelStore private readonly Func _keyFormatter; private readonly Func? _listSortSelector; private readonly int _listTakeMax; - private readonly ILogger> _logger; + private readonly ILogger> _logger; private readonly JsonSerializerOptions _jsonOptions = new(); - public InMemoryProjectionReadModelStore( + public InMemoryProjectionDocumentStore( Func keySelector, Func? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200, - ILogger>? logger = null) + ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(keySelector); _keySelector = keySelector; _keyFormatter = keyFormatter ?? (key => key?.ToString() ?? ""); _listSortSelector = listSortSelector; _listTakeMax = listTakeMax > 0 ? listTakeMax : 200; - _logger = logger ?? NullLogger>.Instance; + _logger = logger ?? NullLogger>.Instance; } public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index 3a3771614..cd5eb427a 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -94,7 +94,7 @@ public Task> ListNodesByOwnerAsync( nodes = _nodes.Values .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) .Where(x => - x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && + x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) .Take(boundedTake) @@ -124,7 +124,7 @@ public Task> ListEdgesByOwnerAsync( edges = _edges.Values .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) .Where(x => - x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && + x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) .Take(boundedTake) diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 2ffa1cfa9..47ef3fcb2 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; public static class ServiceCollectionExtensions { - public static IServiceCollection AddNeo4jGraphStoreRegistration( + public static IServiceCollection AddNeo4jGraphProjectionStore( this IServiceCollection services, Func optionsFactory, Func scopeFactory) @@ -15,13 +15,11 @@ public static IServiceCollection AddNeo4jGraphStoreRegistration( ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(scopeFactory); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "Neo4j", - provider => new Neo4jProjectionGraphStore( - optionsFactory(provider), - scopeFactory(provider), - provider.GetService>()))); + services.AddSingleton(provider => + new Neo4jProjectionGraphStore( + optionsFactory(provider), + scopeFactory(provider), + provider.GetService>())); return services; } diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md index 7532a159e..0f8fbcd24 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/README.md @@ -1,17 +1,18 @@ # Aevatar.CQRS.Projection.Providers.Neo4j -通用 Neo4j Provider(仅 Graph 能力)。 +Neo4j Graph Provider。 -- 不依赖任何业务域 read model。 -- 通过 `IProjectionStoreRegistration` 与上层模块解耦集成(Graph)。 -- 基于官方 `Neo4j.Driver` 实现连接与会话管理。 -- 支持 schema 约束初始化、邻居查询、子图遍历、owner 维度边查询(用于精确清理)。 +## 能力 -## DI 注册 +- `Neo4jProjectionGraphStore` +- 邻居查询 / 子图查询 +- owner 维度节点与边查询(用于精确清理) -- `AddNeo4jGraphStoreRegistration(...)` +## DI -关键参数: +- `AddNeo4jGraphProjectionStore(...)` -- `optionsFactory`:绑定 `Projection:Graph:Providers:Neo4j:*` 配置。 -- `scopeFactory`:graph scope 提供器。 +## 配置 + +- `Projection:Graph:Providers:Neo4j:*` +- 至少配置 `Uri` diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 123f9bb95..085c574a7 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -597,19 +597,19 @@ private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) private static bool ResolveProjectionManaged(IReadOnlyDictionary properties) { - if (!properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue)) + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue)) return false; var normalizedMarker = NormalizeToken(markerValue); return string.Equals( normalizedMarker, - ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, + ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); } private static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) { - if (!properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var ownerId)) + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var ownerId)) return ""; return NormalizeToken(ownerId); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs deleted file mode 100644 index 58c36550f..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/DelegateProjectionStoreRegistration.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public sealed class DelegateProjectionStoreRegistration : IProjectionStoreRegistration -{ - private readonly Func _factory; - - public DelegateProjectionStoreRegistration( - string providerName, - Func factory) - { - if (string.IsNullOrWhiteSpace(providerName)) - throw new ArgumentException("Provider name must not be empty.", nameof(providerName)); - ArgumentNullException.ThrowIfNull(factory); - - ProviderName = providerName.Trim(); - _factory = factory; - } - - public string ProviderName { get; } - - public TStore Create(IServiceProvider serviceProvider) - { - ArgumentNullException.ThrowIfNull(serviceProvider); - return _factory(serviceProvider); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs deleted file mode 100644 index d43210ba0..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Core/IProjectionStoreRegistration.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionStoreRegistration -{ - string ProviderName { get; } - - TStore Create(IServiceProvider serviceProvider); -} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs similarity index 63% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs index 897779c46..c34f78a97 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSystemPropertyKeys.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs @@ -1,6 +1,6 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public static class ProjectionGraphSystemPropertyKeys +public static class ProjectionGraphManagedPropertyKeys { public const string ManagedMarkerKey = "projectionManaged"; diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs index 4cc868952..15a667fd9 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs @@ -3,5 +3,5 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionDocumentMetadataResolver { DocumentIndexMetadata Resolve() - where TReadModel : class, IDocumentReadModel; + where TReadModel : class, IProjectionReadModel; } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs deleted file mode 100644 index 8a8f0cb50..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionGraphMaterializer.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Aevatar.CQRS.Projection.Runtime.Abstractions; - -public interface IProjectionGraphMaterializer - where TReadModel : class -{ - Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default); -} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs new file mode 100644 index 000000000..bed846859 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs @@ -0,0 +1,12 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionQueryableStoreBinding + : IProjectionStoreBinding + where TReadModel : class, IProjectionReadModel +{ + Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default); + + Task GetAsync(TKey key, CancellationToken ct = default); + + Task> ListAsync(int take = 50, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs new file mode 100644 index 000000000..8f8196c90 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionStoreBinding + where TReadModel : class, IProjectionReadModel +{ + string StoreName { get; } + + Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs similarity index 86% rename from src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs rename to src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs index 4f503ad2d..78a088f59 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Selection/IProjectionMaterializationRouter.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; -public interface IProjectionMaterializationRouter +public interface IProjectionStoreDispatcher where TReadModel : class, IProjectionReadModel { Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index 2d56c396d..e085b8c1e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -1,26 +1,23 @@ # Aevatar.CQRS.Projection.Runtime.Abstractions -`Aevatar.CQRS.Projection.Runtime.Abstractions` 承载 Projection Runtime 的编排契约,不承载任何具体 Provider 实现。 +`Aevatar.CQRS.Projection.Runtime.Abstractions` 定义 Runtime 层的最小编排契约。 -## 目录结构 +## 契约清单 -- `Abstractions/Core`:Store 注册契约(`IProjectionStoreRegistration`) -- `Abstractions/ReadModels`:Document metadata resolver -- `Abstractions/Selection`:Materialization 路由与 graph materializer 契约 +- `IProjectionStoreDispatcher` +- `IProjectionStoreBinding` +- `IProjectionQueryableStoreBinding` +- `IProjectionDocumentMetadataResolver` +- `ProjectionGraphManagedPropertyKeys` -## 关键契约 +## 模型说明 -- Store 注册:`IProjectionStoreRegistration` - - `ProviderName` -- Materialization:`IProjectionMaterializationRouter`、`IProjectionGraphMaterializer` -- Metadata:`IProjectionDocumentMetadataResolver` +1. 一个 ReadModel 可绑定多个 Store(例如 Document + Graph)。 +2. 仅允许一个 `IProjectionQueryableStoreBinding` 作为查询/读取来源。 +3. 其余 binding 作为写入目标参与分发。 -查询语义: +## 边界 -- Fan-out Runtime 以注册顺序选择查询源(第一个注册的 provider 作为 query store,其余作为写入副本)。 - -## 约束 - -1. 不包含 ProviderName 选择与 RuntimeOptions。 -2. 不包含能力协商模型(Capabilities/Requirements/CapabilityValidator)。 -3. 仅依赖 `Aevatar.CQRS.Projection.Stores.Abstractions`。 +- 不包含具体 Provider 实现。 +- 不包含业务域 ReadModel。 +- 仅依赖 `Aevatar.CQRS.Projection.Stores.Abstractions`。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 909929674..bacc46ac3 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,10 +8,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { - services.TryAddSingleton(typeof(IDocumentProjectionStore<,>), typeof(ProjectionDocumentStoreFanout<,>)); - services.TryAddSingleton(); - services.TryAddSingleton(typeof(IProjectionGraphMaterializer<>), typeof(ProjectionGraphMaterializer<>)); - services.TryAddSingleton(typeof(IProjectionMaterializationRouter<,>), typeof(ProjectionMaterializationRouter<,>)); + services.TryAddSingleton(typeof(IProjectionStoreDispatcher<,>), typeof(ProjectionStoreDispatcher<,>)); + services.TryAddSingleton(typeof(IProjectionQueryableStoreBinding<,>), typeof(ProjectionDocumentStoreBinding<,>)); + services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IProjectionStoreBinding<,>), typeof(ProjectionDocumentStoreBinding<,>))); services.TryAddSingleton(); return services; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index e923f7c74..f39bc5cea 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -1,14 +1,13 @@ # Aevatar.CQRS.Projection.Runtime -通用 ReadModel Runtime 组装层。 +通用 Projection Runtime 组装层。 ## 职责 -- Store Fan-out 组合: - - `ProjectionDocumentStoreFanout` - - `ProjectionGraphStoreFanout` -- Materialization 路由:`IProjectionMaterializationRouter`、`ProjectionGraphMaterializer` -- Document metadata 解析:`IProjectionDocumentMetadataResolver` +- 统一分发:`ProjectionStoreDispatcher` +- Document Binding:`ProjectionDocumentStoreBinding` +- Graph Binding:`ProjectionGraphStoreBinding` +- Document Metadata 解析:`ProjectionDocumentMetadataResolver` ## DI 入口 @@ -16,15 +15,13 @@ 默认注册: -- `IDocumentProjectionStore<,>` -> `ProjectionDocumentStoreFanout<,>` -- `IProjectionGraphStore` -> `ProjectionGraphStoreFanout` -- `IProjectionGraphMaterializer<>` -- `IProjectionMaterializationRouter<,>` -- `IProjectionDocumentMetadataResolver` +- `IProjectionStoreDispatcher<,>` -> `ProjectionStoreDispatcher<,>` +- `IProjectionQueryableStoreBinding<,>` -> `ProjectionDocumentStoreBinding<,>` +- `IProjectionStoreBinding<,>`(默认) -> `ProjectionDocumentStoreBinding<,>` +- `IProjectionDocumentMetadataResolver` -> `ProjectionDocumentMetadataResolver` -## 设计约束 +## 语义 -1. 不承载业务 ReadModel 类型。 -2. 不做 providerName 单选,不存在运行时降级逻辑;多 provider 采用“注册顺序即查询顺序”。 -3. Document 与 Graph 完全解耦,分别按注册列表一对多分发(写 fan-out,读走首注册 provider)。 -4. 仅依赖抽象契约与 DI;具体 Provider 由上层注册。 +1. Runtime 负责“一对多 store 分发”,不做 ProviderName 路由。 +2. Document 与 Graph 保持平行;Graph 通过额外 binding 接入。 +3. 查询统一走唯一 queryable binding;写入同时分发到所有 binding。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs index 397341711..7c3a110c0 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs @@ -12,7 +12,7 @@ public ProjectionDocumentMetadataResolver(IServiceProvider serviceProvider) } public DocumentIndexMetadata Resolve() - where TReadModel : class, IDocumentReadModel + where TReadModel : class, IProjectionReadModel { var provider = _serviceProvider.GetRequiredService>(); return provider.Metadata; diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs new file mode 100644 index 000000000..4f08e174e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs @@ -0,0 +1,35 @@ +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionDocumentStoreBinding + : IProjectionQueryableStoreBinding + where TReadModel : class, IProjectionReadModel +{ + private readonly IProjectionDocumentStore _store; + + public ProjectionDocumentStoreBinding(IProjectionDocumentStore store) + { + _store = store; + } + + public string StoreName => "Document"; + + public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + return _store.UpsertAsync(readModel, ct); + } + + public Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + return _store.MutateAsync(key, mutate, ct); + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + return _store.GetAsync(key, ct); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + return _store.ListAsync(take, ct); + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs deleted file mode 100644 index fa225ea7e..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreFanout.cs +++ /dev/null @@ -1,90 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionDocumentStoreFanout - : IDocumentProjectionStore - where TReadModel : class -{ - private readonly IReadOnlyList> _stores; - private readonly IReadOnlyList> _replicaStores; - private readonly IDocumentProjectionStore _queryStore; - private readonly string _queryProviderName; - private readonly ILogger> _logger; - - public ProjectionDocumentStoreFanout( - IEnumerable>> registrations, - IServiceProvider serviceProvider, - ILogger>? logger = null) - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(serviceProvider); - - var registrationList = registrations.ToList(); - var resolvedStores = registrationList - .Select(x => x.Create(serviceProvider)) - .ToList(); - _stores = resolvedStores; - _logger = logger ?? NullLogger>.Instance; - - if (_stores.Count == 0) - { - throw new InvalidOperationException( - $"No document projection store providers are registered for read model '{typeof(TReadModel).FullName}'."); - } - _queryStore = _stores[0]; - _queryProviderName = registrationList[0].ProviderName; - _replicaStores = _stores.Skip(1).ToList(); - _logger.LogInformation( - "Projection document fan-out initialized. readModelType={ReadModelType} storeCount={StoreCount} queryProvider={QueryProvider}", - typeof(TReadModel).FullName, - _stores.Count, - _queryProviderName); - } - - public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(readModel); - ct.ThrowIfCancellationRequested(); - - foreach (var store in _stores) - { - ct.ThrowIfCancellationRequested(); - await store.UpsertAsync(readModel, ct); - } - } - - public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(mutate); - ct.ThrowIfCancellationRequested(); - - await _queryStore.MutateAsync(key, mutate, ct); - if (_replicaStores.Count == 0) - return; - - var updated = await _queryStore.GetAsync(key, ct); - if (updated == null) - { - throw new InvalidOperationException( - $"Document fan-out mutate completed but query store returned null for read model '{typeof(TReadModel).FullName}'."); - } - - foreach (var store in _replicaStores) - { - ct.ThrowIfCancellationRequested(); - await store.UpsertAsync(updated, ct); - } - } - - public Task GetAsync(TKey key, CancellationToken ct = default) - { - return _queryStore.GetAsync(key, ct); - } - - public Task> ListAsync(int take = 50, CancellationToken ct = default) - { - return _queryStore.ListAsync(take, ct); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs similarity index 63% rename from src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs rename to src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs index 11d6f8d4e..3868ee693 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphMaterializer.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs @@ -1,50 +1,47 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; -public sealed class ProjectionGraphMaterializer - : IProjectionGraphMaterializer - where TReadModel : class +public sealed class ProjectionGraphStoreBinding + : IProjectionStoreBinding + where TReadModel : class, IGraphReadModel { private readonly IProjectionGraphStore _graphStore; - public ProjectionGraphMaterializer(IProjectionGraphStore graphStore) + public ProjectionGraphStoreBinding(IProjectionGraphStore graphStore) { _graphStore = graphStore; } - public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = default) + public string StoreName => "Graph"; + + public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(readModel); ct.ThrowIfCancellationRequested(); - if (readModel is not IGraphReadModel graphReadModel) - return; - var scope = NormalizeToken(graphReadModel.GraphScope); + var scope = NormalizeToken(readModel.GraphScope); if (scope.Length == 0) { throw new InvalidOperationException( $"Graph scope is required for read model '{typeof(TReadModel).FullName}'."); } - var ownerId = BuildManagedOwnerId(graphReadModel); - - var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope, ownerId); + var ownerId = BuildManagedOwnerId(readModel); + var normalizedNodes = NormalizeNodes(readModel.GraphNodes, scope, ownerId); foreach (var node in normalizedNodes) await _graphStore.UpsertNodeAsync(node, ct); - var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope, ownerId); + var normalizedEdges = NormalizeEdges(readModel.GraphEdges, scope, ownerId); foreach (var edge in normalizedEdges) await _graphStore.UpsertEdgeAsync(edge, ct); + var targetNodeIds = normalizedNodes + .Select(x => x.NodeId) + .ToHashSet(StringComparer.Ordinal); var targetEdgeIds = normalizedEdges .Select(x => x.EdgeId) .ToHashSet(StringComparer.Ordinal); - var targetNodeIds = normalizedNodes - .Select(x => x.NodeId) - .Where(x => x.Length > 0) - .ToHashSet(StringComparer.Ordinal); var existingManagedEdges = await _graphStore.ListEdgesByOwnerAsync(scope, ownerId, take: 50000, ct); - foreach (var edge in existingManagedEdges.Where(IsManagedEdge)) { if (targetEdgeIds.Contains(edge.EdgeId)) @@ -65,39 +62,6 @@ public async Task UpsertGraphAsync(TReadModel readModel, CancellationToken ct = } } - private static bool IsManagedEdge(ProjectionGraphEdge edge) - { - return edge.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue) && - string.Equals(markerValue, ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); - } - - private static bool IsManagedNode(ProjectionGraphNode node) - { - return node.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedMarkerKey, out var markerValue) && - string.Equals(markerValue, ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); - } - - private async Task CanDeleteNodeAsync( - string scope, - string nodeId, - CancellationToken ct) - { - if (nodeId.Length == 0) - return false; - - var neighbors = await _graphStore.GetNeighborsAsync( - new ProjectionGraphQuery - { - Scope = scope, - RootNodeId = nodeId, - Direction = ProjectionGraphDirection.Both, - EdgeTypes = [], - Take = 1, - }, - ct); - return neighbors.Count == 0; - } - private static string BuildManagedOwnerId(IGraphReadModel readModel) { var readModelId = NormalizeToken(readModel.Id); @@ -114,7 +78,7 @@ private static string BuildManagedOwnerId(IGraphReadModel readModel) } private static IReadOnlyList NormalizeNodes( - IReadOnlyList graphNodes, + IReadOnlyList graphNodes, string scope, string ownerId) { @@ -122,20 +86,20 @@ private static IReadOnlyList NormalizeNodes( return []; var nodesById = new Dictionary(StringComparer.Ordinal); - foreach (var graphNode in graphNodes) + foreach (var sourceNode in graphNodes) { - var nodeId = NormalizeToken(graphNode.NodeId); + var nodeId = NormalizeToken(sourceNode.NodeId); if (nodeId.Length == 0) continue; - var nodeType = NormalizeToken(graphNode.NodeType); + var nodeType = NormalizeToken(sourceNode.NodeType); if (nodeType.Length == 0) nodeType = "Unknown"; - var properties = new Dictionary(graphNode.Properties, StringComparer.Ordinal) + var properties = new Dictionary(sourceNode.Properties, StringComparer.Ordinal) { - [ProjectionGraphSystemPropertyKeys.ManagedMarkerKey] = ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, - [ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey] = ownerId, + [ProjectionGraphManagedPropertyKeys.ManagedMarkerKey] = ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, + [ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey] = ownerId, }; nodesById[nodeId] = new ProjectionGraphNode @@ -144,7 +108,7 @@ private static IReadOnlyList NormalizeNodes( NodeId = nodeId, NodeType = nodeType, Properties = properties, - UpdatedAt = graphNode.UpdatedAt == default ? DateTimeOffset.UtcNow : graphNode.UpdatedAt, + UpdatedAt = sourceNode.UpdatedAt == default ? DateTimeOffset.UtcNow : sourceNode.UpdatedAt, }; } @@ -152,7 +116,7 @@ private static IReadOnlyList NormalizeNodes( } private static IReadOnlyList NormalizeEdges( - IReadOnlyList graphEdges, + IReadOnlyList graphEdges, string scope, string ownerId) { @@ -160,41 +124,73 @@ private static IReadOnlyList NormalizeEdges( return []; var edgesById = new Dictionary(StringComparer.Ordinal); - foreach (var graphEdge in graphEdges) + foreach (var sourceEdge in graphEdges) { - var edgeId = NormalizeToken(graphEdge.EdgeId); - var relationType = NormalizeToken(graphEdge.EdgeType); - var fromNodeId = NormalizeToken(graphEdge.FromNodeId); - var toNodeId = NormalizeToken(graphEdge.ToNodeId); + var edgeId = NormalizeToken(sourceEdge.EdgeId); + var edgeType = NormalizeToken(sourceEdge.EdgeType); + var fromNodeId = NormalizeToken(sourceEdge.FromNodeId); + var toNodeId = NormalizeToken(sourceEdge.ToNodeId); if (edgeId.Length == 0 || - relationType.Length == 0 || + edgeType.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) { continue; } - var properties = new Dictionary(graphEdge.Properties, StringComparer.Ordinal) + var properties = new Dictionary(sourceEdge.Properties, StringComparer.Ordinal) { - [ProjectionGraphSystemPropertyKeys.ManagedMarkerKey] = ProjectionGraphSystemPropertyKeys.ManagedMarkerValue, - [ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey] = ownerId, + [ProjectionGraphManagedPropertyKeys.ManagedMarkerKey] = ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, + [ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey] = ownerId, }; edgesById[edgeId] = new ProjectionGraphEdge { Scope = scope, EdgeId = edgeId, - EdgeType = relationType, + EdgeType = edgeType, FromNodeId = fromNodeId, ToNodeId = toNodeId, Properties = properties, - UpdatedAt = graphEdge.UpdatedAt == default ? DateTimeOffset.UtcNow : graphEdge.UpdatedAt, + UpdatedAt = sourceEdge.UpdatedAt == default ? DateTimeOffset.UtcNow : sourceEdge.UpdatedAt, }; } return edgesById.Values.ToList(); } - private static string NormalizeToken(string? token) => token?.Trim() ?? ""; + private static bool IsManagedNode(ProjectionGraphNode node) + { + return node.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue) && + string.Equals(markerValue, ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); + } + + private static bool IsManagedEdge(ProjectionGraphEdge edge) + { + return edge.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue) && + string.Equals(markerValue, ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, StringComparison.Ordinal); + } + + private async Task CanDeleteNodeAsync( + string scope, + string nodeId, + CancellationToken ct) + { + if (nodeId.Length == 0) + return false; + var neighbors = await _graphStore.GetNeighborsAsync( + new ProjectionGraphQuery + { + Scope = scope, + RootNodeId = nodeId, + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 1, + }, + ct); + return neighbors.Count == 0; + } + + private static string NormalizeToken(string? token) => token?.Trim() ?? ""; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs deleted file mode 100644 index 3001fc8dc..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreFanout.cs +++ /dev/null @@ -1,114 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionGraphStoreFanout : IProjectionGraphStore -{ - private readonly IReadOnlyList _stores; - private readonly IProjectionGraphStore _queryStore; - private readonly string _queryProviderName; - private readonly ILogger _logger; - - public ProjectionGraphStoreFanout( - IEnumerable> registrations, - IServiceProvider serviceProvider, - ILogger? logger = null) - { - ArgumentNullException.ThrowIfNull(registrations); - ArgumentNullException.ThrowIfNull(serviceProvider); - - var registrationList = registrations.ToList(); - _stores = registrationList - .Select(x => x.Create(serviceProvider)) - .ToList(); - _logger = logger ?? NullLogger.Instance; - - if (_stores.Count == 0) - { - throw new InvalidOperationException( - "No graph projection store providers are registered."); - } - _queryStore = _stores[0]; - _queryProviderName = registrationList[0].ProviderName; - - _logger.LogInformation( - "Projection graph fan-out initialized. storeCount={StoreCount} queryProvider={QueryProvider}", - _stores.Count, - _queryProviderName); - } - - public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(node); - ct.ThrowIfCancellationRequested(); - - foreach (var store in _stores) - { - ct.ThrowIfCancellationRequested(); - await store.UpsertNodeAsync(node, ct); - } - } - - public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(edge); - ct.ThrowIfCancellationRequested(); - - foreach (var store in _stores) - { - ct.ThrowIfCancellationRequested(); - await store.UpsertEdgeAsync(edge, ct); - } - } - - public async Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) - { - foreach (var store in _stores) - { - ct.ThrowIfCancellationRequested(); - await store.DeleteNodeAsync(scope, nodeId, ct); - } - } - - public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) - { - foreach (var store in _stores) - { - ct.ThrowIfCancellationRequested(); - await store.DeleteEdgeAsync(scope, edgeId, ct); - } - } - - public Task> ListNodesByOwnerAsync( - string scope, - string ownerId, - int take = 5000, - CancellationToken ct = default) - { - return _queryStore.ListNodesByOwnerAsync(scope, ownerId, take, ct); - } - - public Task> ListEdgesByOwnerAsync( - string scope, - string ownerId, - int take = 5000, - CancellationToken ct = default) - { - return _queryStore.ListEdgesByOwnerAsync(scope, ownerId, take, ct); - } - - public Task> GetNeighborsAsync( - ProjectionGraphQuery query, - CancellationToken ct = default) - { - return _queryStore.GetNeighborsAsync(query, ct); - } - - public Task GetSubgraphAsync( - ProjectionGraphQuery query, - CancellationToken ct = default) - { - return _queryStore.GetSubgraphAsync(query, ct); - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs deleted file mode 100644 index a67661c81..000000000 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionMaterializationRouter.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Aevatar.CQRS.Projection.Runtime.Runtime; - -public sealed class ProjectionMaterializationRouter - : IProjectionMaterializationRouter - where TReadModel : class, IProjectionReadModel -{ - private readonly IDocumentProjectionStore? _documentStore; - private readonly IProjectionGraphMaterializer? _graphMaterializer; - private readonly ILogger> _logger; - private readonly bool _requiresDocumentStore = typeof(IDocumentReadModel).IsAssignableFrom(typeof(TReadModel)); - private readonly bool _requiresGraphStore = typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel)); - - public ProjectionMaterializationRouter( - IDocumentProjectionStore? documentStore = null, - IProjectionGraphMaterializer? graphMaterializer = null, - ILogger>? logger = null) - { - _documentStore = documentStore; - _graphMaterializer = graphMaterializer; - _logger = logger ?? NullLogger>.Instance; - } - - public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(readModel); - ct.ThrowIfCancellationRequested(); - - EnsureStoresReady(); - if (_requiresDocumentStore) - await _documentStore!.UpsertAsync(readModel, ct); - - if (_requiresGraphStore) - await _graphMaterializer!.UpsertGraphAsync(readModel, ct); - } - - public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(mutate); - ct.ThrowIfCancellationRequested(); - - EnsureStoresReady(); - if (_documentStore == null) - throw new InvalidOperationException( - $"Projection materialization mutate requires a document store for read model '{typeof(TReadModel).FullName}'."); - - await _documentStore.MutateAsync(key, mutate, ct); - if (!_requiresGraphStore) - return; - - var updated = await _documentStore.GetAsync(key, ct); - if (updated == null) - { - _logger.LogWarning( - "Projection materialization graph refresh skipped because the document snapshot is missing. readModelType={ReadModelType}", - typeof(TReadModel).FullName); - return; - } - - await _graphMaterializer!.UpsertGraphAsync(updated, ct); - } - - public Task GetAsync(TKey key, CancellationToken ct = default) - { - if (_documentStore == null) - { - throw new InvalidOperationException( - $"Projection materialization query requires a document store for read model '{typeof(TReadModel).FullName}'."); - } - - return _documentStore.GetAsync(key, ct); - } - - public Task> ListAsync(int take = 50, CancellationToken ct = default) - { - if (_documentStore == null) - { - throw new InvalidOperationException( - $"Projection materialization query requires a document store for read model '{typeof(TReadModel).FullName}'."); - } - - return _documentStore.ListAsync(take, ct); - } - - private void EnsureStoresReady() - { - if (_requiresDocumentStore && _documentStore == null) - { - throw new InvalidOperationException( - $"Document capability is required by read model '{typeof(TReadModel).FullName}', but no document projection store is registered."); - } - - if (_requiresGraphStore && _graphMaterializer == null) - { - throw new InvalidOperationException( - $"Graph capability is required by read model '{typeof(TReadModel).FullName}', but no graph projection materializer is registered."); - } - } -} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs new file mode 100644 index 000000000..3fa293dc5 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class ProjectionStoreDispatcher + : IProjectionStoreDispatcher + where TReadModel : class, IProjectionReadModel +{ + private readonly IReadOnlyList> _bindings; + private readonly IReadOnlyList> _writeOnlyBindings; + private readonly IProjectionQueryableStoreBinding _queryBinding; + private readonly ILogger> _logger; + + public ProjectionStoreDispatcher( + IEnumerable> bindings, + ILogger>? logger = null) + { + ArgumentNullException.ThrowIfNull(bindings); + _logger = logger ?? NullLogger>.Instance; + + _bindings = bindings.ToList(); + if (_bindings.Count == 0) + { + throw new InvalidOperationException( + $"No projection store bindings are registered for read model '{typeof(TReadModel).FullName}'."); + } + + var queryBindings = _bindings + .OfType>() + .ToList(); + if (queryBindings.Count != 1) + { + throw new InvalidOperationException( + $"Exactly one queryable projection store binding is required for read model '{typeof(TReadModel).FullName}', but {queryBindings.Count} were registered."); + } + + _queryBinding = queryBindings[0]; + _writeOnlyBindings = _bindings + .Where(x => x is not IProjectionQueryableStoreBinding) + .ToList(); + + _logger.LogInformation( + "Projection store dispatcher initialized. readModelType={ReadModelType} bindingCount={BindingCount} queryStore={QueryStore}", + typeof(TReadModel).FullName, + _bindings.Count, + _queryBinding.StoreName); + } + + public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(readModel); + ct.ThrowIfCancellationRequested(); + + foreach (var binding in _bindings) + { + ct.ThrowIfCancellationRequested(); + await binding.UpsertAsync(readModel, ct); + } + } + + public async Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(mutate); + ct.ThrowIfCancellationRequested(); + + await _queryBinding.MutateAsync(key, mutate, ct); + if (_writeOnlyBindings.Count == 0) + return; + + var updated = await _queryBinding.GetAsync(key, ct); + if (updated == null) + { + throw new InvalidOperationException( + $"Projection store mutate completed but query store '{_queryBinding.StoreName}' returned null for read model '{typeof(TReadModel).FullName}'."); + } + + foreach (var binding in _writeOnlyBindings) + { + ct.ThrowIfCancellationRequested(); + await binding.UpsertAsync(updated, ct); + } + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + return _queryBinding.GetAsync(key, ct); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + return _queryBinding.ListAsync(take, ct); + } +} diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs deleted file mode 100644 index 1b580bca8..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphEdgeDescriptor.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed record GraphEdgeDescriptor( - string EdgeId, - string EdgeType, - string FromNodeId, - string ToNodeId, - IReadOnlyDictionary Properties, - DateTimeOffset UpdatedAt); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs deleted file mode 100644 index f719b3bb8..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/GraphNodeDescriptor.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public sealed record GraphNodeDescriptor( - string NodeId, - string NodeType, - IReadOnlyDictionary Properties, - DateTimeOffset UpdatedAt); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs deleted file mode 100644 index 093084136..000000000 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentReadModel.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Aevatar.CQRS.Projection.Stores.Abstractions; - -public interface IDocumentReadModel : IProjectionReadModel; diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs index 9a1e725b3..e87026370 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs @@ -4,7 +4,7 @@ public interface IGraphReadModel : IProjectionReadModel { string GraphScope { get; } - IReadOnlyList GraphNodes { get; } + IReadOnlyList GraphNodes { get; } - IReadOnlyList GraphEdges { get; } + IReadOnlyList GraphEdges { get; } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs index 8a2d73235..10c9838ef 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs @@ -1,7 +1,7 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionDocumentMetadataProvider - where TReadModel : class, IDocumentReadModel + where TReadModel : class, IProjectionReadModel { DocumentIndexMetadata Metadata { get; } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs similarity index 87% rename from src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs rename to src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs index 035789d27..5bf50a3cd 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IDocumentProjectionStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs @@ -1,6 +1,6 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; -public interface IDocumentProjectionStore +public interface IProjectionDocumentStore where TReadModel : class { Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md index b073c7532..e43fddbf2 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/README.md @@ -1,22 +1,18 @@ # Aevatar.CQRS.Projection.Stores.Abstractions -`Aevatar.CQRS.Projection.Stores.Abstractions` 只包含投影存储能力契约与读模型结构契约。 +`Aevatar.CQRS.Projection.Stores.Abstractions` 仅包含 Projection 存储契约与 ReadModel 结构契约,不包含任何运行时编排或 Provider 选择逻辑。 -## 目录结构 +## 契约清单 -- `Abstractions/ReadModels`:读模型能力与文档存储契约 -- `Abstractions/Graphs`:图存储契约与图查询模型 +- ReadModel 基础:`IProjectionReadModel` +- Graph ReadModel:`IGraphReadModel` +- Document Store:`IProjectionDocumentStore` +- Graph Store:`IProjectionGraphStore` +- Document 索引元数据:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider` +- Graph 数据结构:`ProjectionGraphNode`、`ProjectionGraphEdge`、`ProjectionGraphQuery`、`ProjectionGraphSubgraph` -## 包含内容 +## 设计边界 -- 读模型存储:`IDocumentProjectionStore<,>` -- 图存储:`IProjectionGraphStore` -- ReadModel 能力模型:`IProjectionReadModel`、`IDocumentReadModel`(marker)、`IGraphReadModel` -- 文档索引元数据声明:`DocumentIndexMetadata`、`IProjectionDocumentMetadataProvider`;`DocumentIndexMetadata` 使用结构化对象字段(`Mappings/Settings/Aliases`)表达索引元数据。 -- 图结构描述:`GraphNodeDescriptor`、`GraphEdgeDescriptor` - -## 约束 - -1. 不包含 Provider 选择、Factory、Runtime options、Materialization Router 等运行时编排契约。 -2. 不包含投影主链路编排接口(这些在 `Aevatar.CQRS.Projection.Core.Abstractions`)。 -3. 不包含业务模型、DI 装配与具体 provider 实现。 +1. Document 与 Graph 是平行的两类存储契约。 +2. 不包含 Router/Fanout/Factory/ProviderName 选择逻辑。 +3. 不包含业务域实现、DI 装配和具体存储实现。 diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs index bfef488f1..56f49fd3b 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs @@ -5,12 +5,12 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionQueryReader : IWorkflowProjectionQueryReader { - private readonly IDocumentProjectionStore _documentStore; + private readonly IProjectionDocumentStore _documentStore; private readonly IProjectionGraphStore _graphStore; private readonly WorkflowExecutionReadModelMapper _mapper; public WorkflowProjectionQueryReader( - IDocumentProjectionStore documentStore, + IProjectionDocumentStore documentStore, WorkflowExecutionReadModelMapper mapper, IProjectionGraphStore graphStore) { diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs index 3e994b8a5..476e0c266 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionReadModelUpdater.cs @@ -5,14 +5,14 @@ namespace Aevatar.Workflow.Projection.Orchestration; public sealed class WorkflowProjectionReadModelUpdater : IWorkflowProjectionReadModelUpdater { - private readonly IProjectionMaterializationRouter _materializationRouter; + private readonly IProjectionStoreDispatcher _storeDispatcher; private readonly IProjectionClock _clock; public WorkflowProjectionReadModelUpdater( - IProjectionMaterializationRouter materializationRouter, + IProjectionStoreDispatcher storeDispatcher, IProjectionClock clock) { - _materializationRouter = materializationRouter; + _storeDispatcher = storeDispatcher; _clock = clock; } @@ -22,7 +22,7 @@ public Task RefreshMetadataAsync( CancellationToken ct = default) { var updatedAt = _clock.UtcNow; - return _materializationRouter.MutateAsync(actorId, report => + return _storeDispatcher.MutateAsync(actorId, report => { report.Id = actorId; if (string.IsNullOrWhiteSpace(report.RootActorId)) @@ -45,7 +45,7 @@ public Task MarkStoppedAsync( CancellationToken ct = default) { var updatedAt = _clock.UtcNow; - return _materializationRouter.MutateAsync(actorId, report => + return _storeDispatcher.MutateAsync(actorId, report => { report.Id = actorId; if (string.IsNullOrWhiteSpace(report.RootActorId)) diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index fc92a9c8c..5a4cd38f6 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -32,7 +32,7 @@ public Task StartAsync(CancellationToken cancellationToken) if (_options.ValidateDocumentProviderOnStartup) { - _ = _serviceProvider.GetRequiredService>(); + _ = _serviceProvider.GetRequiredService>(); _logger.LogInformation( "Workflow read-model document startup validation passed. readModelType={ReadModelType}", typeof(WorkflowExecutionReport).FullName); diff --git a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs index 9e28f8cc6..554a8ed1e 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Projectors/WorkflowExecutionReadModelProjector.cs @@ -10,18 +10,18 @@ namespace Aevatar.Workflow.Projection.Projectors; public sealed class WorkflowExecutionReadModelProjector : IProjectionProjector> { - private readonly IProjectionMaterializationRouter _materializationRouter; + private readonly IProjectionStoreDispatcher _storeDispatcher; private readonly IEventDeduplicator _deduplicator; private readonly IProjectionClock _clock; private readonly IReadOnlyDictionary>> _reducersByType; public WorkflowExecutionReadModelProjector( - IProjectionMaterializationRouter materializationRouter, + IProjectionStoreDispatcher storeDispatcher, IEventDeduplicator deduplicator, IProjectionClock clock, IEnumerable> reducers) { - _materializationRouter = materializationRouter; + _storeDispatcher = storeDispatcher; _deduplicator = deduplicator; _clock = clock; _reducersByType = reducers @@ -51,7 +51,7 @@ public ValueTask InitializeAsync(WorkflowExecutionProjectionContext context, Can Input = context.Input, }; report.Summary = new WorkflowExecutionSummary(); - return new ValueTask(_materializationRouter.UpsertAsync(report, ct)); + return new ValueTask(_storeDispatcher.UpsertAsync(report, ct)); } public async ValueTask ProjectAsync(WorkflowExecutionProjectionContext context, EventEnvelope envelope, CancellationToken ct = default) @@ -69,7 +69,7 @@ public async ValueTask ProjectAsync(WorkflowExecutionProjectionContext context, } var now = ResolveEventTimestamp(envelope, _clock.UtcNow); - await _materializationRouter.MutateAsync(context.RootActorId, report => + await _storeDispatcher.MutateAsync(context.RootActorId, report => { report.Id = context.RootActorId; if (string.IsNullOrWhiteSpace(report.RootActorId)) @@ -92,7 +92,7 @@ public ValueTask CompleteAsync( CancellationToken ct = default) { var completedAt = _clock.UtcNow; - return new ValueTask(_materializationRouter.MutateAsync(context.RootActorId, report => + return new ValueTask(_storeDispatcher.MutateAsync(context.RootActorId, report => { report.Id = context.RootActorId; if (string.IsNullOrWhiteSpace(report.RootActorId)) diff --git a/src/workflow/Aevatar.Workflow.Projection/README.md b/src/workflow/Aevatar.Workflow.Projection/README.md index 4b0556ba1..dd0a0dfd9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/README.md +++ b/src/workflow/Aevatar.Workflow.Projection/README.md @@ -1,116 +1,47 @@ # Aevatar.Workflow.Projection -`Aevatar.Workflow.Projection` 是 Workflow 领域的 CQRS 读侧扩展层。 +`Aevatar.Workflow.Projection` 是 Workflow 领域的 CQRS 读侧实现层。 -## 职责边界 +## 职责 -- 应用层投影端口实现: - - `IWorkflowExecutionProjectionLifecyclePort`(`Ensure/Attach/Detach/Release`) - - `IWorkflowExecutionProjectionQueryPort`(`Snapshot/Timeline/GraphEdges/GraphSubgraph/GraphEnriched`) - - 默认实现分别为 `WorkflowExecutionProjectionLifecycleService` 与 `WorkflowExecutionProjectionQueryService` - - 两个实现分别继承 `ProjectionLifecyclePortServiceBase` / `ProjectionQueryPortServiceBase`,通用端口编排已下沉到 `Aevatar.CQRS.Projection.Core` -- 编排组件拆分(避免单类过重): - - `WorkflowProjectionActivationService`(projection 启动与上下文激活) - - `WorkflowProjectionReleaseService`(idle 检测与停止/释放) - - `IProjectionOwnershipCoordinator`(ownership acquire/release,由 Core 抽象直接注入) - - `WorkflowProjectionSinkSubscriptionManager`(live sink attach/detach/replace) - - `WorkflowProjectionLiveSinkForwarder`(sink 推送与失败策略路由) - - `WorkflowProjectionSinkFailurePolicy`(sink 异常策略) - - `WorkflowProjectionReadModelUpdater`(read model 元信息更新) - - `WorkflowProjectionQueryReader`(query 映射读取) - - Store Fan-out:Document/Graph 分别一对多广播(无 providerName 单选) -- 领域上下文:`IWorkflowExecutionProjectionContextFactory`、`WorkflowExecutionProjectionContext` -- 实时输出契约:`WorkflowRunEvent`、`IWorkflowRunEventSink`、`WorkflowRunEventChannel`(定义于 `Aevatar.Workflow.Application.Abstractions`) -- 领域投影实现:reducers、projectors、read model(不包含 Provider Store 实现) -- 领域 DI 组合:`AddWorkflowExecutionProjectionCQRS(...)` -- Provider 启动校验:由 `WorkflowReadModelStartupValidationHostedService` 执行 Document/Graph 两条独立 fail-fast 校验 -- ReadModel 存储解析规则:运行时通过 `ProjectionDocumentStoreFanout<,>` / `ProjectionGraphStoreFanout` 聚合已注册 Provider +- Workflow ReadModel 与 Reducer/Projector +- Projection 生命周期编排(启动、订阅、释放) +- Query 映射(Snapshot/Timeline/Graph) +- 与 Runtime Store Dispatcher 集成 -本项目依赖: +## 统一投影链路 -- `Aevatar.CQRS.Projection.Core.Abstractions`(投影管线/端口抽象) -- `Aevatar.CQRS.Projection.Stores.Abstractions`(Document/Graph 存储契约) -- `Aevatar.CQRS.Projection.Runtime.Abstractions`(Store 注册与 materialization 编排契约) -- `Aevatar.CQRS.Projection.Core`(通用生命周期/订阅/协调实现) -- `Aevatar.Foundation.Projection`(最小 read model 基类与读侧能力接口) -- `Aevatar.Workflow.Extensions.AIProjection`(可选扩展:组合 `Aevatar.AI.Projection` 的通用 reducer/applier) +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + E["EventEnvelope Stream"] --> C["ProjectionCoordinator"] + C --> P["WorkflowExecutionReadModelProjector"] + P --> D["IProjectionStoreDispatcher"] + D --> DS["ProjectionDocumentStoreBinding -> IProjectionDocumentStore"] + D --> GS["ProjectionGraphStoreBinding -> IProjectionGraphStore"] + Q["WorkflowProjectionQueryReader"] --> DS + Q --> GS +``` -## 统一运行链路 +## 关键约束 -1. `EnsureActorProjectionAsync` 由 `WorkflowExecutionProjectionLifecycleService` 转发到 `WorkflowProjectionActivationService`,直接通过 `IProjectionOwnershipCoordinator` 申请 ownership,再创建 projection 上下文并注册 actor stream 订阅 -2. 每条 `EventEnvelope` 进入统一 coordinator,一对多调用已注册 projector -3. `WorkflowExecutionReadModelProjector` 驱动 reducers 生成并更新 read model,并通过 `IProjectionMaterializationRouter` 执行 Document/Graph 单写或双写 -4. AI 通用事件通过 `Aevatar.Workflow.Extensions.AIProjection` 扩展接入,扩展内部复用 `Aevatar.AI.Projection` 的默认 applier + reducer,将事件写入 `WorkflowExecutionReport` 的 AI 能力字段,业务层无需重复维护映射代码 -5. AGUI 分支与读模型分支共享同一输入事件流;AGUI projector 将 run 输出发布到 `workflow-run:{actorId}:{commandId}` 事件流 +1. `WorkflowExecutionReadModelProjector` 通过 `IProjectionStoreDispatcher` 同步写入 Document + Graph。 +2. Query 来源是 Document Store(`Get/List`);Graph 用于关系查询与子图遍历。 +3. Document 与 Graph Provider 为平行关系,不存在主从继承关系。 -AGUI 输出与 CQRS 读模型共享同一链路,只是在 projector 分支不同。 -应用层通过 `AttachLiveSinkAsync/DetachLiveSinkAsync` 订阅/退订 run-event stream(由 `WorkflowProjectionSinkSubscriptionManager` 承载,sink 写入由 `WorkflowProjectionLiveSinkForwarder` 统一转发); -AGUI 分支实现位于 `Aevatar.Workflow.Presentation.AGUIAdapter`。 -`ReleaseActorProjectionAsync` 由 `WorkflowProjectionReleaseService` 在无 live sink 绑定时停止投影并释放协调 Actor ownership;保证同一 `rootActorId` 不会并发启动多个投影视图(release 前由 `WorkflowProjectionReadModelUpdater` 写入 stopped 元信息)。 -run-event 严格按 `EventEnvelope.CorrelationId` 与 commandId 绑定匹配,不对空 correlation 做广播投递。 +## Provider 组合(Host 层) -## 订阅判定规则(事件如何进入 ReadModel) +- 由 `Aevatar.Workflow.Extensions.Hosting` 装配。 +- 同类 Provider 只允许一个: + - Document: `Elasticsearch` 或 `InMemory` + - Graph: `Neo4j` 或 `InMemory` +- 一个 ReadModel 同时写入 Document + Graph(一对多)。 -关键区分: +## 配置 -1. `ReadModel` 字段/能力接口(如 `IHasProjectionTimeline`)只表示“可被写入”。 -2. 真正决定“是否订阅某事件”的是 reducer 的 `EventTypeUrl` 声明 + DI 注册。 -3. applier 只负责把已命中的强类型事件写入字段,不负责事件入口匹配。 +- `Projection:Document:Providers:Elasticsearch:Enabled` +- `Projection:Document:Providers:InMemory:Enabled` +- `Projection:Graph:Providers:Neo4j:Enabled` +- `Projection:Graph:Providers:InMemory:Enabled` +- `Projection:Policies:DenyInMemoryGraphFactStore` -最小落地清单: - -1. 定义 ReadModel:声明业务字段与能力接口。 -2. 定义 reducer:实现 `IProjectionEventReducer` 并声明 `EventTypeUrl`。 -3. 定义 applier(可选但推荐):把字段映射逻辑从 reducer 拆出到 `IProjectionEventApplier<,,>`。 -4. DI 注册 reducer/applier:未注册则不会进入投影链路。 -5. projector 在运行时按 `payload.TypeUrl` 命中 `reducersByType`,只执行匹配项。 - -FAQ: - -1. 只有 ReadModel 能处理某事件,就算“定义了事件订阅”吗?不是,必须有 reducer 注册。 -2. 同一个事件能被多个 ReadModel 处理吗?可以,多 projector/多 reducer 并行订阅同一 `EventTypeUrl`。 -3. 一个 ReadModel 能处理多个事件吗?可以,注册多个不同 `EventTypeUrl` 的 reducer 即可。 - -## 扩展方式 - -- 新增 reducer: - - 实现 `IProjectionEventReducer` - - 在 DI 中注册 -- 新增事件 applier(推荐): - - 实现 `IProjectionEventApplier` - - 由对应事件 reducer 调用;Foundation/AI 通用事件建议放在对应分层项目,不在 Workflow 层重复实现 -- 新增 projector: - - 实现 `IProjectionProjector>` - - 在 DI 中注册 -- 扩展 ReadModel Provider(推荐): - - 文档存储注册:`IProjectionStoreRegistration>` - - 图存储注册:`IProjectionStoreRegistration` - - 在 Host/Extensions 侧注册(例如 `Aevatar.Workflow.Extensions.Hosting.AddWorkflowProjectionReadModelProviders(...)`) - - 通过 `Projection:Document:*` 与 `Projection:Graph:*` 配置选择 Provider - -## Provider 配置 - -- 不再支持 `Projection:Document:Provider` / `Projection:Graph:Provider` 单选模型。 -- 使用一对多启用开关模型: - - `Projection:Document:Providers:InMemory:Enabled` - - `Projection:Document:Providers:Elasticsearch:Enabled` - - `Projection:Graph:Providers:InMemory:Enabled` - - `Projection:Graph:Providers:Neo4j:Enabled` -- 文档 Provider 配置: - - `Projection:Document:Providers:Elasticsearch:*`(至少配置 `Endpoints`) -- 图 Provider 配置: - - `Projection:Graph:Providers:Neo4j:*`(至少配置 `Uri`) -- `Projection:Policies:DenyInMemoryGraphFactStore`:禁用 InMemory graph 作为事实源(生产建议开启) -- `WorkflowExecutionProjection:ValidateDocumentProviderOnStartup`:启动阶段预校验 document provider(默认 `true`) -- `WorkflowExecutionProjection:ValidateGraphProviderOnStartup`:启动阶段预校验 graph provider(默认 `true`) -- 图查询参数: - - `/actors/{actorId}/graph-edges` 支持 `direction` 与 `edgeTypes` - - `/actors/{actorId}/graph-subgraph` 支持 `direction` 与 `edgeTypes` - - `/actors/{actorId}/graph-enriched` 支持 `direction` 与 `edgeTypes` -- 扩展 run 输出协议: - - 保持 `WorkflowRunEvent` 不变,新增 presentation adapter 进行协议映射 - - 不改 Application 用例编排代码 - -## 与 API 的关系 - -`Aevatar.Workflow.Host.Api` 通过 `Aevatar.Workflow.Application` 调用本项目,不直接编排投影内核细节。API 仅负责协议适配(SSE/WebSocket/HTTP Query)。 diff --git a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs index d3c5fca21..cc2c4b09d 100644 --- a/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs +++ b/src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs @@ -33,16 +33,15 @@ public sealed class WorkflowExecutionReport : AevatarReadModelBase, IHasProjectionTimeline, IHasProjectionRoleReplies, - IDocumentReadModel, IGraphReadModel { private const string UnknownToken = "unknown"; public string GraphScope => WorkflowExecutionGraphConstants.Scope; - public IReadOnlyList GraphNodes => BuildGraphNodes(); + public IReadOnlyList GraphNodes => BuildGraphNodes(); - public IReadOnlyList GraphEdges => BuildGraphEdges(); + public IReadOnlyList GraphEdges => BuildGraphEdges(); public string RootActorId { get; set; } = ""; public string CommandId { get; set; } = ""; @@ -95,41 +94,49 @@ public void AddRoleReply(ProjectionRoleReply roleReply) }); } - private IReadOnlyList BuildGraphNodes() + private IReadOnlyList BuildGraphNodes() { var updatedAt = UpdatedAt == default ? DateTimeOffset.UtcNow : UpdatedAt; var rootActorId = NormalizeToken(RootActorId); var runNodeId = BuildRunNodeId(rootActorId, CommandId); - var nodes = new Dictionary(StringComparer.Ordinal); + var nodes = new Dictionary(StringComparer.Ordinal); - nodes[rootActorId] = new GraphNodeDescriptor( - rootActorId, - WorkflowExecutionGraphConstants.ActorNodeType, - new Dictionary(StringComparer.Ordinal) + nodes[rootActorId] = new ProjectionGraphNode + { + Scope = GraphScope, + NodeId = rootActorId, + NodeType = WorkflowExecutionGraphConstants.ActorNodeType, + Properties = new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", }, - updatedAt); + UpdatedAt = updatedAt, + }; - nodes[runNodeId] = new GraphNodeDescriptor( - runNodeId, - WorkflowExecutionGraphConstants.RunNodeType, - new Dictionary(StringComparer.Ordinal) + nodes[runNodeId] = new ProjectionGraphNode + { + Scope = GraphScope, + NodeId = runNodeId, + NodeType = WorkflowExecutionGraphConstants.RunNodeType, + Properties = new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = rootActorId, ["workflowName"] = WorkflowName ?? "", ["commandId"] = NormalizeToken(CommandId), ["input"] = Input ?? "", }, - updatedAt); + UpdatedAt = updatedAt, + }; foreach (var step in Steps) { var stepNodeId = BuildStepNodeId(rootActorId, CommandId, step.StepId); - nodes[stepNodeId] = new GraphNodeDescriptor( - stepNodeId, - WorkflowExecutionGraphConstants.StepNodeType, - new Dictionary(StringComparer.Ordinal) + nodes[stepNodeId] = new ProjectionGraphNode + { + Scope = GraphScope, + NodeId = stepNodeId, + NodeType = WorkflowExecutionGraphConstants.StepNodeType, + Properties = new Dictionary(StringComparer.Ordinal) { ["rootActorId"] = rootActorId, ["commandId"] = NormalizeToken(CommandId), @@ -139,7 +146,8 @@ private IReadOnlyList BuildGraphNodes() ["workerId"] = step.WorkerId ?? "", ["success"] = step.Success?.ToString() ?? "", }, - updatedAt); + UpdatedAt = updatedAt, + }; } foreach (var topologyEdge in Topology) @@ -148,38 +156,44 @@ private IReadOnlyList BuildGraphNodes() var childId = NormalizeToken(topologyEdge.Child); if (parentId.Length > 0 && !nodes.ContainsKey(parentId)) { - nodes[parentId] = new GraphNodeDescriptor( - parentId, - WorkflowExecutionGraphConstants.ActorNodeType, - new Dictionary(StringComparer.Ordinal) + nodes[parentId] = new ProjectionGraphNode + { + Scope = GraphScope, + NodeId = parentId, + NodeType = WorkflowExecutionGraphConstants.ActorNodeType, + Properties = new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", }, - updatedAt); + UpdatedAt = updatedAt, + }; } if (childId.Length > 0 && !nodes.ContainsKey(childId)) { - nodes[childId] = new GraphNodeDescriptor( - childId, - WorkflowExecutionGraphConstants.ActorNodeType, - new Dictionary(StringComparer.Ordinal) + nodes[childId] = new ProjectionGraphNode + { + Scope = GraphScope, + NodeId = childId, + NodeType = WorkflowExecutionGraphConstants.ActorNodeType, + Properties = new Dictionary(StringComparer.Ordinal) { ["workflowName"] = WorkflowName ?? "", }, - updatedAt); + UpdatedAt = updatedAt, + }; } } return nodes.Values.ToList(); } - private IReadOnlyList BuildGraphEdges() + private IReadOnlyList BuildGraphEdges() { var updatedAt = UpdatedAt == default ? DateTimeOffset.UtcNow : UpdatedAt; var rootActorId = NormalizeToken(RootActorId); var runNodeId = BuildRunNodeId(rootActorId, CommandId); - var edges = new Dictionary(StringComparer.Ordinal); + var edges = new Dictionary(StringComparer.Ordinal); var ownsEdge = CreateEdge( WorkflowExecutionGraphConstants.EdgeTypeOwns, @@ -224,7 +238,7 @@ private IReadOnlyList BuildGraphEdges() return edges.Values.ToList(); } - private static GraphEdgeDescriptor CreateEdge( + private ProjectionGraphEdge CreateEdge( string relationType, string fromNodeId, string toNodeId, @@ -235,13 +249,16 @@ private static GraphEdgeDescriptor CreateEdge( var normalizedToNodeId = NormalizeToken(toNodeId); var normalizedEdgeType = NormalizeToken(relationType); var edgeId = BuildEdgeId(normalizedEdgeType, normalizedFromNodeId, normalizedToNodeId); - return new GraphEdgeDescriptor( - edgeId, - normalizedEdgeType, - normalizedFromNodeId, - normalizedToNodeId, - new Dictionary(properties, StringComparer.Ordinal), - updatedAt); + return new ProjectionGraphEdge + { + Scope = GraphScope, + EdgeId = edgeId, + EdgeType = normalizedEdgeType, + FromNodeId = normalizedFromNodeId, + ToNodeId = normalizedToNodeId, + Properties = new Dictionary(properties, StringComparer.Ordinal), + UpdatedAt = updatedAt, + }; } private static string BuildRunNodeId(string rootActorId, string commandId) diff --git a/src/workflow/README.md b/src/workflow/README.md index a0876bd62..2cd2f7c23 100644 --- a/src/workflow/README.md +++ b/src/workflow/README.md @@ -109,7 +109,7 @@ flowchart LR RM["WorkflowExecutionReadModelProjector"] RED["Reducers(Start/Step/TextEnd/Completed)"] - STORE["IProjectionReadModelStore(WorkflowExecutionReport)"] + STORE["IProjectionStoreDispatcher + IProjectionDocumentStore + IProjectionGraphStore"] AGP["WorkflowExecutionAGUIEventProjector"] MAP["EventEnvelopeToAGUIEventMapper + Handlers"] @@ -195,7 +195,7 @@ sequenceDiagram participant Stream as "Actor Stream(EventEnvelope)" participant Dispatcher as "ProjectionDispatcher" participant Projector as "WorkflowExecutionReadModelProjector" - participant Store as "IProjectionReadModelStore" + participant Store as "IProjectionDocumentStore" participant Query as "IWorkflowExecutionQueryApplicationService" participant Api as "GET /api/actors/{actorId}" diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj index 09ff66b15..604aed408 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/Aevatar.Workflow.Extensions.Hosting.csproj @@ -8,6 +8,7 @@ + diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index be0c7c51f..6e27e9fb4 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -37,10 +38,23 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( EnforceGraphProviderPolicy(configuration, enableInMemoryGraph); - var documentProviderCount = 0; + var documentProviderCount = (enableElasticsearchDocument ? 1 : 0) + (enableInMemoryDocument ? 1 : 0); + if (documentProviderCount != 1) + { + throw new InvalidOperationException( + "Exactly one document projection provider must be enabled. Configure either Projection:Document:Providers:Elasticsearch:Enabled=true or Projection:Document:Providers:InMemory:Enabled=true."); + } + + var graphProviderCount = (enableNeo4jGraph ? 1 : 0) + (enableInMemoryGraph ? 1 : 0); + if (graphProviderCount != 1) + { + throw new InvalidOperationException( + "Exactly one graph projection provider must be enabled. Configure either Projection:Graph:Providers:Neo4j:Enabled=true or Projection:Graph:Providers:InMemory:Enabled=true."); + } + if (enableElasticsearchDocument) { - services.AddElasticsearchDocumentStoreRegistration( + services.AddElasticsearchDocumentProjectionStore( optionsFactory: _ => BuildElasticsearchDocumentOptions(configuration), metadataFactory: sp => { @@ -49,45 +63,28 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( }, keySelector: report => report.RootActorId, keyFormatter: key => key); - documentProviderCount++; } - - if (enableInMemoryDocument) + else { - services.AddInMemoryDocumentStoreRegistration( + services.AddInMemoryDocumentProjectionStore( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); - documentProviderCount++; } - var graphProviderCount = 0; if (enableNeo4jGraph) { - services.AddNeo4jGraphStoreRegistration( + services.AddNeo4jGraphProjectionStore( optionsFactory: _ => BuildNeo4jGraphOptions(configuration), scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); - graphProviderCount++; } - - if (enableInMemoryGraph) - { - services.AddInMemoryGraphStoreRegistration(); - graphProviderCount++; - } - - if (documentProviderCount == 0) + else { - throw new InvalidOperationException( - "No document projection providers are enabled. Configure Projection:Document:Providers:InMemory:Enabled=true or Projection:Document:Providers:Elasticsearch with Endpoints."); + services.AddInMemoryGraphProjectionStore(); } - if (graphProviderCount == 0) - { - throw new InvalidOperationException( - "No graph projection providers are enabled. Configure Projection:Graph:Providers:InMemory:Enabled=true or Projection:Graph:Providers:Neo4j with Uri."); - } + services.AddSingleton, ProjectionGraphStoreBinding>(); return services; } @@ -101,7 +98,7 @@ private static void EnsureLegacyProviderOptionsNotUsed(IConfiguration configurat { throw new InvalidOperationException( "Legacy provider single-selection options are no longer supported. " + - "Use Projection:Document:Providers:*:Enabled and Projection:Graph:Providers:*:Enabled for one-to-many provider registration."); + "Use Projection:Document:Providers:*:Enabled and Projection:Graph:Providers:*:Enabled with exactly one provider enabled per store type."); } } @@ -127,10 +124,10 @@ private static bool ResolveNeo4jGraphEnabled(IConfiguration configuration) return ResolveOptionalBool(explicitEnabled, hasUri); } - private static ElasticsearchProjectionReadModelStoreOptions BuildElasticsearchDocumentOptions( + private static ElasticsearchProjectionDocumentStoreOptions BuildElasticsearchDocumentOptions( IConfiguration configuration) { - var options = new ElasticsearchProjectionReadModelStoreOptions(); + var options = new ElasticsearchProjectionDocumentStoreOptions(); configuration.GetSection("Projection:Document:Providers:Elasticsearch").Bind(options); if (options.Endpoints.Count == 0) { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs similarity index 92% rename from test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs rename to test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs index dafee1efb..fa031f654 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionReadModelStoreBehaviorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs @@ -6,7 +6,7 @@ namespace Aevatar.CQRS.Projection.Core.Tests; -public sealed class ElasticsearchProjectionReadModelStoreBehaviorTests +public sealed class ElasticsearchProjectionDocumentStoreBehaviorTests { [Fact] public async Task GetAsync_WhenIndexMissingAndAutoCreateDisabled_ShouldThrowByDefault() @@ -17,7 +17,7 @@ public async Task GetAsync_WhenIndexMissingAndAutoCreateDisabled_ShouldThrowByDe """{"error":{"type":"index_not_found_exception"},"status":404}""")); using var store = CreateStore( - new ElasticsearchProjectionReadModelStoreOptions + new ElasticsearchProjectionDocumentStoreOptions { AutoCreateIndex = false, }, @@ -38,7 +38,7 @@ public async Task GetAsync_WhenIndexMissingAndWarnBehaviorEnabled_ShouldReturnNu """{"error":{"type":"index_not_found_exception"},"status":404}""")); using var store = CreateStore( - new ElasticsearchProjectionReadModelStoreOptions + new ElasticsearchProjectionDocumentStoreOptions { AutoCreateIndex = false, MissingIndexBehavior = ElasticsearchMissingIndexBehavior.WarnAndReturnEmpty, @@ -59,7 +59,7 @@ public async Task ListAsync_WhenSortFieldNotConfigured_ShouldUseDeterministicDef """{"hits":{"hits":[]}}""")); using var store = CreateStore( - new ElasticsearchProjectionReadModelStoreOptions + new ElasticsearchProjectionDocumentStoreOptions { AutoCreateIndex = false, ListSortField = "", @@ -93,7 +93,7 @@ public async Task MutateAsync_WhenOptimisticConflictOccurs_ShouldRetryWithLatest """{"result":"updated"}""")); using var store = CreateStore( - new ElasticsearchProjectionReadModelStoreOptions + new ElasticsearchProjectionDocumentStoreOptions { AutoCreateIndex = false, MutateMaxRetryCount = 1, @@ -121,13 +121,13 @@ public async Task UpsertAsync_WhenMetadataContainsStructuredObjects_ShouldSendSt HttpStatusCode.OK, """{"result":"created"}""")); - var options = new ElasticsearchProjectionReadModelStoreOptions + var options = new ElasticsearchProjectionDocumentStoreOptions { AutoCreateIndex = true, }; options.Endpoints = ["http://localhost:9200"]; - using var store = new ElasticsearchProjectionReadModelStore( + using var store = new ElasticsearchProjectionDocumentStore( options, new DocumentIndexMetadata( IndexName: "projection-core-tests", @@ -177,12 +177,12 @@ await store.UpsertAsync(new StoreReadModel handler.CapturedRequests[0].Body.Should().Contain("\"is_write_index\":true"); } - private static ElasticsearchProjectionReadModelStore CreateStore( - ElasticsearchProjectionReadModelStoreOptions options, + private static ElasticsearchProjectionDocumentStore CreateStore( + ElasticsearchProjectionDocumentStoreOptions options, HttpMessageHandler handler) { options.Endpoints = ["http://localhost:9200"]; - return new ElasticsearchProjectionReadModelStore( + return new ElasticsearchProjectionDocumentStore( options, new DocumentIndexMetadata( IndexName: "projection-core-tests", diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs similarity index 71% rename from test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs rename to test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs index 58a20d1cd..18eb0c419 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphMaterializerTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs @@ -3,15 +3,15 @@ namespace Aevatar.CQRS.Projection.Core.Tests; -public class ProjectionGraphMaterializerTests +public class ProjectionGraphStoreBindingTests { [Fact] - public async Task UpsertGraphAsync_ShouldRemoveDisconnectedStaleEdgesForSameOwner() + public async Task UpsertAsync_ShouldRemoveDisconnectedStaleEdgesForSameOwner() { var store = new RecordingGraphStore(); - var materializer = new ProjectionGraphMaterializer(store); + var binding = new ProjectionGraphStoreBinding(store); - await materializer.UpsertGraphAsync(new TestGraphReadModel + await binding.UpsertAsync(new TestGraphReadModel { Id = "owner-1", GraphScope = "scope-1", @@ -29,7 +29,7 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel ], }); - await materializer.UpsertGraphAsync(new TestGraphReadModel + await binding.UpsertAsync(new TestGraphReadModel { Id = "owner-1", GraphScope = "scope-1", @@ -66,12 +66,12 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel } [Fact] - public async Task UpsertGraphAsync_ShouldNotDeleteEdgesOwnedByAnotherReadModel() + public async Task UpsertAsync_ShouldNotDeleteEdgesOwnedByAnotherReadModel() { var store = new RecordingGraphStore(); - var materializer = new ProjectionGraphMaterializer(store); + var binding = new ProjectionGraphStoreBinding(store); - await materializer.UpsertGraphAsync(new TestGraphReadModel + await binding.UpsertAsync(new TestGraphReadModel { Id = "owner-1", GraphScope = "scope-1", @@ -86,7 +86,7 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel ], }); - await materializer.UpsertGraphAsync(new TestGraphReadModel + await binding.UpsertAsync(new TestGraphReadModel { Id = "owner-2", GraphScope = "scope-1", @@ -101,7 +101,7 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel ], }); - await materializer.UpsertGraphAsync(new TestGraphReadModel + await binding.UpsertAsync(new TestGraphReadModel { Id = "owner-1", GraphScope = "scope-1", @@ -135,115 +135,12 @@ await materializer.UpsertGraphAsync(new TestGraphReadModel } [Fact] - public async Task UpsertGraphAsync_ShouldRemoveDisconnectedStaleNodesForSameOwner() + public async Task UpsertAsync_WhenReadModelIdIsEmpty_ShouldThrow() { var store = new RecordingGraphStore(); - var materializer = new ProjectionGraphMaterializer(store); + var binding = new ProjectionGraphStoreBinding(store); - await materializer.UpsertGraphAsync(new TestGraphReadModel - { - Id = "owner-1", - GraphScope = "scope-1", - GraphNodes = - [ - Node("root"), - Node("left"), - Node("orphan-a"), - Node("orphan-b"), - ], - GraphEdges = - [ - Edge("edge-root", "root", "left"), - Edge("edge-orphan", "orphan-a", "orphan-b"), - ], - }); - - await materializer.UpsertGraphAsync(new TestGraphReadModel - { - Id = "owner-1", - GraphScope = "scope-1", - GraphNodes = - [ - Node("root"), - Node("left"), - ], - GraphEdges = - [ - Edge("edge-root", "root", "left"), - ], - }); - - var ownerNodes = await store.ListNodesByOwnerAsync("scope-1", BuildOwnerId("owner-1"), take: 20); - - ownerNodes.Select(x => x.NodeId).Should().BeEquivalentTo("root", "left"); - store.ContainsNode("scope-1", "orphan-a").Should().BeFalse(); - store.ContainsNode("scope-1", "orphan-b").Should().BeFalse(); - } - - [Fact] - public async Task UpsertGraphAsync_ShouldKeepStaleNodeWhenStillReferencedByAnotherOwner() - { - var store = new RecordingGraphStore(); - var materializer = new ProjectionGraphMaterializer(store); - - await materializer.UpsertGraphAsync(new TestGraphReadModel - { - Id = "owner-1", - GraphScope = "scope-1", - GraphNodes = - [ - Node("shared"), - Node("owner-1-node"), - ], - GraphEdges = - [ - Edge("edge-owner-1", "shared", "owner-1-node"), - ], - }); - - await materializer.UpsertGraphAsync(new TestGraphReadModel - { - Id = "owner-2", - GraphScope = "scope-1", - GraphNodes = - [ - Node("owner-2-node"), - ], - GraphEdges = - [ - Edge("edge-owner-2", "shared", "owner-2-node"), - ], - }); - - await materializer.UpsertGraphAsync(new TestGraphReadModel - { - Id = "owner-1", - GraphScope = "scope-1", - GraphNodes = [], - GraphEdges = [], - }); - - var sharedNeighbors = await store.GetNeighborsAsync(new ProjectionGraphQuery - { - Scope = "scope-1", - RootNodeId = "shared", - Direction = ProjectionGraphDirection.Both, - EdgeTypes = [], - Take = 20, - }); - - sharedNeighbors.Select(x => x.EdgeId).Should().ContainSingle("edge-owner-2"); - store.ContainsNode("scope-1", "shared").Should().BeTrue(); - store.ContainsNode("scope-1", "owner-1-node").Should().BeFalse(); - } - - [Fact] - public async Task UpsertGraphAsync_WhenReadModelIdIsEmpty_ShouldThrow() - { - var store = new RecordingGraphStore(); - var materializer = new ProjectionGraphMaterializer(store); - - Func act = () => materializer.UpsertGraphAsync(new TestGraphReadModel + Func act = () => binding.UpsertAsync(new TestGraphReadModel { Id = "", GraphScope = "scope-1", @@ -260,24 +157,30 @@ await act.Should().ThrowAsync() private static string BuildOwnerId(string id) => $"{typeof(TestGraphReadModel).FullName}:{id}"; - private static GraphNodeDescriptor Node(string nodeId) + private static ProjectionGraphNode Node(string nodeId) { - return new GraphNodeDescriptor( - nodeId, - "Actor", - new Dictionary(StringComparer.Ordinal), - DateTimeOffset.UtcNow); + return new ProjectionGraphNode + { + Scope = "scope-1", + NodeId = nodeId, + NodeType = "Actor", + Properties = new Dictionary(StringComparer.Ordinal), + UpdatedAt = DateTimeOffset.UtcNow, + }; } - private static GraphEdgeDescriptor Edge(string edgeId, string fromNodeId, string toNodeId) + private static ProjectionGraphEdge Edge(string edgeId, string fromNodeId, string toNodeId) { - return new GraphEdgeDescriptor( - edgeId, - "LINK", - fromNodeId, - toNodeId, - new Dictionary(StringComparer.Ordinal), - DateTimeOffset.UtcNow); + return new ProjectionGraphEdge + { + Scope = "scope-1", + EdgeId = edgeId, + EdgeType = "LINK", + FromNodeId = fromNodeId, + ToNodeId = toNodeId, + Properties = new Dictionary(StringComparer.Ordinal), + UpdatedAt = DateTimeOffset.UtcNow, + }; } private sealed class TestGraphReadModel : IGraphReadModel @@ -286,9 +189,9 @@ private sealed class TestGraphReadModel : IGraphReadModel public string GraphScope { get; init; } = ""; - public IReadOnlyList GraphNodes { get; init; } = []; + public IReadOnlyList GraphNodes { get; init; } = []; - public IReadOnlyList GraphEdges { get; init; } = []; + public IReadOnlyList GraphEdges { get; init; } = []; } private sealed class RecordingGraphStore : IProjectionGraphStore @@ -347,7 +250,7 @@ public Task> ListEdgesByOwnerAsync( edges = _edges.Values .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) .Where(x => - x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && + x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) .Take(Math.Clamp(take, 1, 50000)) @@ -376,7 +279,7 @@ public Task> ListNodesByOwnerAsync( nodes = _nodes.Values .Where(x => string.Equals(x.Scope, scopeValue, StringComparison.Ordinal)) .Where(x => - x.Properties.TryGetValue(ProjectionGraphSystemPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && + x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) .Take(Math.Clamp(take, 1, 50000)) @@ -457,12 +360,6 @@ public async Task GetSubgraphAsync( }; } - public bool ContainsNode(string scope, string nodeId) - { - lock (_gate) - return _nodes.ContainsKey(BuildScopedKey(scope, nodeId)); - } - private static bool MatchDirection(ProjectionGraphEdge edge, string rootNodeId, ProjectionGraphDirection direction) { return direction switch diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs index f6fca9cea..280e7829f 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -10,7 +10,7 @@ public sealed class ProjectionProviderE2EIntegrationTests public async Task ElasticsearchStore_ShouldRoundtripUpsertAndMutate() { var endpoint = GetRequiredEnvironmentVariable("AEVATAR_TEST_ELASTICSEARCH_ENDPOINT"); - var options = new ElasticsearchProjectionReadModelStoreOptions + var options = new ElasticsearchProjectionDocumentStoreOptions { Endpoints = [endpoint], IndexPrefix = "aevatar-e2e", @@ -18,7 +18,7 @@ public async Task ElasticsearchStore_ShouldRoundtripUpsertAndMutate() RequestTimeoutMs = 10000, }; var indexScope = "projection-provider-e2e-" + Guid.NewGuid().ToString("N"); - using var store = new ElasticsearchProjectionReadModelStore( + using var store = new ElasticsearchProjectionDocumentStore( options, new DocumentIndexMetadata( IndexName: indexScope, diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs deleted file mode 100644 index 0d2fff30a..000000000 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelRuntimeTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using Aevatar.CQRS.Projection.Runtime.Runtime; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.CQRS.Projection.Core.Tests; - -public class ProjectionReadModelRuntimeTests -{ - [Fact] - public async Task ProjectionDocumentStoreFanout_ShouldFanoutWritesAndUseFirstRegisteredQueryStore() - { - var queryStore = new NamedDocumentStore("query"); - var replicaStore = new NamedDocumentStore("replica"); - var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "query", - _ => queryStore)); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "replica", - _ => replicaStore)); - - using var serviceProvider = services.BuildServiceProvider(); - var fanout = new ProjectionDocumentStoreFanout( - serviceProvider.GetServices>>(), - serviceProvider); - - var model = new TestReadModel - { - Id = "id-1", - Value = "v1", - }; - - await fanout.UpsertAsync(model); - - queryStore.UpsertCount.Should().Be(1); - replicaStore.UpsertCount.Should().Be(1); - - var fetched = await fanout.GetAsync("id-1"); - fetched.Should().NotBeNull(); - fetched!.Value.Should().Be("v1"); - } - - [Fact] - public async Task ProjectionDocumentStoreFanout_ShouldReadFromFirstRegistration_WhenOrderDiffers() - { - var firstStore = new NamedDocumentStore("first"); - var secondStore = new NamedDocumentStore("second"); - firstStore.Seed("id-1", "from-first"); - secondStore.Seed("id-1", "from-second"); - - var services = new ServiceCollection(); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "first", - _ => firstStore)); - services.AddSingleton>>( - new DelegateProjectionStoreRegistration>( - "second", - _ => secondStore)); - - using var serviceProvider = services.BuildServiceProvider(); - var fanout = new ProjectionDocumentStoreFanout( - serviceProvider.GetServices>>(), - serviceProvider); - - var fetched = await fanout.GetAsync("id-1"); - - fetched.Should().NotBeNull(); - fetched!.Value.Should().Be("from-first"); - } - - [Fact] - public void ProjectionDocumentStoreFanout_WhenNoRegistrations_ShouldThrow() - { - var services = new ServiceCollection(); - using var serviceProvider = services.BuildServiceProvider(); - - Action act = () => new ProjectionDocumentStoreFanout( - [], - serviceProvider); - - act.Should().Throw() - .WithMessage("*No document projection store providers are registered*"); - } - - public sealed class TestReadModel - { - public string Id { get; set; } = ""; - - public string Value { get; set; } = ""; - } - - private sealed class NamedDocumentStore : IDocumentProjectionStore - { - private readonly Dictionary _models = new(StringComparer.Ordinal); - - public NamedDocumentStore(string providerName) - { - ProviderName = providerName; - } - - public string ProviderName { get; } - - public int UpsertCount { get; private set; } - - public void Seed(string key, string value) - { - _models[key] = new TestReadModel - { - Id = key, - Value = value, - }; - } - - public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) - { - _models[readModel.Id] = new TestReadModel - { - Id = readModel.Id, - Value = readModel.Value, - }; - UpsertCount++; - return Task.CompletedTask; - } - - public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) - { - if (!_models.TryGetValue(key, out var existing)) - throw new InvalidOperationException($"Missing key '{key}'."); - - mutate(existing); - return Task.CompletedTask; - } - - public Task GetAsync(string key, CancellationToken ct = default) - { - _models.TryGetValue(key, out var value); - return Task.FromResult(value); - } - - public Task> ListAsync(int take = 50, CancellationToken ct = default) - { - return Task.FromResult>(_models.Values.Take(take).ToList()); - } - } -} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs deleted file mode 100644 index ddead8353..000000000 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionReadModelStoreSelectorTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -using Aevatar.CQRS.Projection.Runtime.Runtime; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; - -namespace Aevatar.CQRS.Projection.Core.Tests; - -public class ProjectionReadModelStoreSelectorTests -{ - [Fact] - public async Task ProjectionGraphStoreFanout_ShouldFanoutWritesAndUseFirstRegisteredQueryStore() - { - var firstStore = new NamedGraphStore("first"); - var secondStore = new NamedGraphStore("second"); - var services = new ServiceCollection(); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "first", - _ => firstStore)); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "second", - _ => secondStore)); - - using var serviceProvider = services.BuildServiceProvider(); - var fanout = new ProjectionGraphStoreFanout( - serviceProvider.GetServices>(), - serviceProvider); - - await fanout.UpsertNodeAsync(new ProjectionGraphNode - { - Scope = "projection-scope", - NodeId = "node-1", - NodeType = "Actor", - Properties = new Dictionary(), - UpdatedAt = DateTimeOffset.UtcNow, - }); - - var edges = await fanout.GetNeighborsAsync(new ProjectionGraphQuery - { - Scope = "projection-scope", - RootNodeId = "node-1", - Direction = ProjectionGraphDirection.Both, - EdgeTypes = [], - Depth = 1, - Take = 10, - }); - - firstStore.UpsertNodeCount.Should().Be(1); - secondStore.UpsertNodeCount.Should().Be(1); - edges.Should().HaveCount(1); - edges[0].EdgeType.Should().Be("first"); - } - - [Fact] - public async Task ProjectionGraphStoreFanout_ShouldReadFromFirstRegistration_WhenOrderDiffers() - { - var firstStore = new NamedGraphStore("from-first"); - var secondStore = new NamedGraphStore("from-second"); - var services = new ServiceCollection(); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "first", - _ => firstStore)); - services.AddSingleton>( - new DelegateProjectionStoreRegistration( - "second", - _ => secondStore)); - - using var serviceProvider = services.BuildServiceProvider(); - var fanout = new ProjectionGraphStoreFanout( - serviceProvider.GetServices>(), - serviceProvider); - - var edges = await fanout.GetNeighborsAsync(new ProjectionGraphQuery - { - Scope = "projection-scope", - RootNodeId = "node-1", - Direction = ProjectionGraphDirection.Both, - EdgeTypes = [], - Depth = 1, - Take = 10, - }); - - edges.Should().ContainSingle(); - edges[0].EdgeType.Should().Be("from-first"); - } - - [Fact] - public void ProjectionGraphStoreFanout_WhenNoRegistrations_ShouldThrow() - { - var services = new ServiceCollection(); - using var serviceProvider = services.BuildServiceProvider(); - - Action act = () => new ProjectionGraphStoreFanout([], serviceProvider); - - act.Should().Throw() - .WithMessage("*No graph projection store providers are registered*"); - } - - private sealed class NamedGraphStore : IProjectionGraphStore - { - public NamedGraphStore(string providerName) - { - ProviderName = providerName; - } - - public string ProviderName { get; } - - public int UpsertNodeCount { get; private set; } - - public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) - { - _ = node; - UpsertNodeCount++; - return Task.CompletedTask; - } - - public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) - { - _ = edge; - return Task.CompletedTask; - } - - public Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) - { - _ = scope; - _ = nodeId; - return Task.CompletedTask; - } - - public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) - { - _ = scope; - _ = edgeId; - return Task.CompletedTask; - } - - public Task> ListNodesByOwnerAsync( - string scope, - string ownerId, - int take = 5000, - CancellationToken ct = default) - { - _ = scope; - _ = ownerId; - _ = take; - return Task.FromResult>([]); - } - - public Task> ListEdgesByOwnerAsync( - string scope, - string ownerId, - int take = 5000, - CancellationToken ct = default) - { - _ = scope; - _ = ownerId; - _ = take; - return Task.FromResult>([]); - } - - public Task> GetNeighborsAsync(ProjectionGraphQuery query, CancellationToken ct = default) - { - _ = query; - IReadOnlyList result = - [ - new ProjectionGraphEdge - { - Scope = "projection-scope", - EdgeId = "edge-1", - EdgeType = ProviderName, - FromNodeId = "node-1", - ToNodeId = "node-2", - Properties = new Dictionary(), - UpdatedAt = DateTimeOffset.UtcNow, - }, - ]; - return Task.FromResult(result); - } - - public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) - { - _ = query; - return Task.FromResult(new ProjectionGraphSubgraph()); - } - } -} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs new file mode 100644 index 000000000..cfe020743 --- /dev/null +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs @@ -0,0 +1,154 @@ +using Aevatar.CQRS.Projection.Runtime.Runtime; +using FluentAssertions; + +namespace Aevatar.CQRS.Projection.Core.Tests; + +public class ProjectionStoreDispatcherTests +{ + [Fact] + public async Task UpsertAsync_ShouldWriteToAllBindings() + { + var queryBinding = new TestQueryableBinding(); + var graphBinding = new RecordingBinding("graph"); + var dispatcher = new ProjectionStoreDispatcher( + [queryBinding, graphBinding]); + + var readModel = new TestReadModel + { + Id = "id-1", + Value = "v1", + }; + + await dispatcher.UpsertAsync(readModel); + + queryBinding.UpsertCount.Should().Be(1); + graphBinding.UpsertCount.Should().Be(1); + } + + [Fact] + public async Task MutateAsync_ShouldMutateQueryableStore_AndRefreshWriteOnlyBindings() + { + var queryBinding = new TestQueryableBinding(); + var graphBinding = new RecordingBinding("graph"); + var dispatcher = new ProjectionStoreDispatcher( + [queryBinding, graphBinding]); + + await dispatcher.UpsertAsync(new TestReadModel + { + Id = "id-1", + Value = "before", + }); + + await dispatcher.MutateAsync("id-1", model => model.Value = "after"); + + var fetched = await dispatcher.GetAsync("id-1"); + fetched.Should().NotBeNull(); + fetched!.Value.Should().Be("after"); + graphBinding.UpsertCount.Should().Be(2); + graphBinding.LastValue.Should().Be("after"); + } + + [Fact] + public void Ctor_WhenQueryableBindingMissing_ShouldThrow() + { + Action act = () => new ProjectionStoreDispatcher( + [new RecordingBinding("write-only")]); + + act.Should().Throw() + .WithMessage("*Exactly one queryable projection store binding is required*"); + } + + [Fact] + public void Ctor_WhenMultipleQueryableBindings_ShouldThrow() + { + Action act = () => new ProjectionStoreDispatcher( + [new TestQueryableBinding(), new TestQueryableBinding()]); + + act.Should().Throw() + .WithMessage("*Exactly one queryable projection store binding is required*"); + } + + private sealed class TestReadModel : IProjectionReadModel + { + public string Id { get; set; } = ""; + + public string Value { get; set; } = ""; + } + + private sealed class RecordingBinding : IProjectionStoreBinding + { + public RecordingBinding(string name) + { + StoreName = name; + } + + public string StoreName { get; } + + public int UpsertCount { get; private set; } + + public string LastValue { get; private set; } = ""; + + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + UpsertCount++; + LastValue = readModel.Value; + return Task.CompletedTask; + } + } + + private sealed class TestQueryableBinding : IProjectionQueryableStoreBinding + { + private readonly Dictionary _items = new(StringComparer.Ordinal); + + public string StoreName => "document"; + + public int UpsertCount { get; private set; } + + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _items[readModel.Id] = Clone(readModel); + UpsertCount++; + return Task.CompletedTask; + } + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_items.TryGetValue(key, out var model)) + throw new InvalidOperationException($"Missing read model '{key}'."); + + mutate(model); + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_items.TryGetValue(key, out var model)) + return Task.FromResult(null); + + return Task.FromResult(Clone(model)); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var items = _items.Values + .Take(Math.Clamp(take, 1, 200)) + .Select(Clone) + .ToList(); + return Task.FromResult>(items); + } + + private static TestReadModel Clone(TestReadModel source) + { + return new TestReadModel + { + Id = source.Id, + Value = source.Value, + }; + } + } +} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 6a65e8faa..e18a3253a 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -24,26 +24,24 @@ public async Task AddWorkflowExecutionProjectionCQRS_WhenNoProvidersRegistered_S Func act = () => StartHostedServicesAsync(provider); await act.Should().ThrowAsync() - .WithMessage("*No document projection store providers are registered*"); + .WithMessage("*IProjectionDocumentStore*"); } [Fact] - public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveFanoutStores() + public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveDispatcherAndStores() { var services = new ServiceCollection(); RegisterInMemoryProviders(services); services.AddWorkflowExecutionProjectionCQRS(); await using var provider = services.BuildServiceProvider(); - var documentStore = provider.GetRequiredService>(); + var documentStore = provider.GetRequiredService>(); var relationStore = provider.GetRequiredService(); - var graphStore = provider.GetRequiredService>(); - var router = provider.GetRequiredService>(); + var dispatcher = provider.GetRequiredService>(); - documentStore.Should().BeOfType>(); - relationStore.Should().BeOfType(); - graphStore.Should().BeOfType>(); - router.Should().NotBeNull(); + documentStore.Should().NotBeNull(); + relationStore.Should().NotBeNull(); + dispatcher.Should().NotBeNull(); Func act = () => StartHostedServicesAsync(provider); await act.Should().NotThrowAsync(); @@ -60,23 +58,24 @@ public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldTh Action act = () => provider.GetRequiredService(); act.Should().Throw() - .WithMessage("*No graph projection store providers are registered*"); + .WithMessage("*IProjectionGraphStore*"); } private static void RegisterInMemoryProviders(IServiceCollection services) { - services.AddInMemoryDocumentStoreRegistration( + services.AddInMemoryDocumentProjectionStore( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.CreatedAt, listTakeMax: 200); - services.AddInMemoryGraphStoreRegistration(); + services.AddInMemoryGraphProjectionStore(); + services.AddSingleton, ProjectionGraphStoreBinding>(); } private static void RegisterElasticsearchDocumentProvider(IServiceCollection services) { - services.AddElasticsearchDocumentStoreRegistration( - optionsFactory: _ => new ElasticsearchProjectionReadModelStoreOptions + services.AddElasticsearchDocumentProjectionStore( + optionsFactory: _ => new ElasticsearchProjectionDocumentStoreOptions { Endpoints = ["http://localhost:9200"], }, diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs index c11aef7e9..958ad53a2 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionServiceTests.cs @@ -514,7 +514,7 @@ await act.Should().ThrowAsync() private static ProjectionPortsHarness CreateService( WorkflowExecutionProjectionOptions options, out InMemoryStreamProvider streams, - out ObservableWorkflowExecutionReadModelStore store, + out ObservableWorkflowExecutionDocumentStore store, IProjectionClock? clock = null) { return CreateService( @@ -528,7 +528,7 @@ private static ProjectionPortsHarness CreateService( private static ProjectionPortsHarness CreateService( WorkflowExecutionProjectionOptions options, out InMemoryStreamProvider streams, - out ObservableWorkflowExecutionReadModelStore store, + out ObservableWorkflowExecutionDocumentStore store, out IProjectionSessionEventHub runEventStreamHub, IProjectionClock? clock = null) { @@ -538,15 +538,17 @@ private static ProjectionPortsHarness CreateService( Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance, forwardingRegistry); var subscriptionHub = new ActorStreamSubscriptionHub(streams); - store = new ObservableWorkflowExecutionReadModelStore(); + store = new ObservableWorkflowExecutionDocumentStore(); var resolvedClock = clock ?? new SystemProjectionClock(); var relationStore = new InMemoryProjectionGraphStore(); - var graphStore = new ProjectionGraphMaterializer(relationStore); - var materializationRouter = new ProjectionMaterializationRouter( - store, - graphStore); + var bindings = new IProjectionStoreBinding[] + { + new ProjectionDocumentStoreBinding(store), + new ProjectionGraphStoreBinding(relationStore), + }; + var storeDispatcher = new ProjectionStoreDispatcher(bindings); var projector = new WorkflowExecutionReadModelProjector( - materializationRouter, + storeDispatcher, new TestEventDeduplicator(), resolvedClock, BuildReducers()); @@ -584,7 +586,7 @@ private static ProjectionPortsHarness CreateService( var mapper = new WorkflowExecutionReadModelMapper(); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventStreamHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventStreamHub, resolvedClock); - var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, resolvedClock); + var readModelUpdater = new WorkflowProjectionReadModelUpdater(storeDispatcher, resolvedClock); var queryReader = new WorkflowProjectionQueryReader( store, mapper, @@ -620,15 +622,17 @@ private static ProjectionPortsHarness CreateServiceForStartFailure( var store = CreateStore(); var clock = new SystemProjectionClock(); var relationStore = new InMemoryProjectionGraphStore(); - var graphStore = new ProjectionGraphMaterializer(relationStore); - var materializationRouter = new ProjectionMaterializationRouter( - store, - graphStore); + var bindings = new IProjectionStoreBinding[] + { + new ProjectionDocumentStoreBinding(store), + new ProjectionGraphStoreBinding(relationStore), + }; + var storeDispatcher = new ProjectionStoreDispatcher(bindings); var runEventHub = new NoOpWorkflowRunEventHub(); var mapper = new WorkflowExecutionReadModelMapper(); var sinkManager = new WorkflowProjectionSinkSubscriptionManager(runEventHub); var sinkFailurePolicy = new WorkflowProjectionSinkFailurePolicy(sinkManager, runEventHub, clock); - var readModelUpdater = new WorkflowProjectionReadModelUpdater(materializationRouter, clock); + var readModelUpdater = new WorkflowProjectionReadModelUpdater(storeDispatcher, clock); var queryReader = new WorkflowProjectionQueryReader( store, mapper, @@ -680,7 +684,7 @@ [new AIToolResultProjectionApplier CreateStore() => new( + private static InMemoryProjectionDocumentStore CreateStore() => new( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.StartedAt); @@ -820,10 +824,10 @@ public Task GetActorGraphSubgraphAsync( => _queryPort.GetActorGraphEnrichedSnapshotAsync(actorId, depth, take, options, ct); } - private sealed class ObservableWorkflowExecutionReadModelStore - : IDocumentProjectionStore + private sealed class ObservableWorkflowExecutionDocumentStore + : IProjectionDocumentStore { - private readonly InMemoryProjectionReadModelStore _inner = CreateStore(); + private readonly InMemoryProjectionDocumentStore _inner = CreateStore(); private readonly object _gate = new(); private readonly List _waiters = []; diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs index 844eba5f4..7dc8c02e0 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionReadModelProjectorTests.cs @@ -21,15 +21,21 @@ namespace Aevatar.Workflow.Host.Api.Tests; public class WorkflowExecutionReadModelProjectorTests { private static IEventDeduplicator CreateDeduplicator() => new TestEventDeduplicator(); - private static InMemoryProjectionReadModelStore CreateStore() => new( + private static InMemoryProjectionDocumentStore CreateStore() => new( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.StartedAt); - private static IProjectionMaterializationRouter CreateRouter( - InMemoryProjectionReadModelStore store) => - new ProjectionMaterializationRouter( - store, - new ProjectionGraphMaterializer(new InMemoryProjectionGraphStore())); + private static IProjectionStoreDispatcher CreateDispatcher( + InMemoryProjectionDocumentStore store) + { + var graphStore = new InMemoryProjectionGraphStore(); + var bindings = new IProjectionStoreBinding[] + { + new ProjectionDocumentStoreBinding(store), + new ProjectionGraphStoreBinding(graphStore), + }; + return new ProjectionStoreDispatcher(bindings); + } private static IReadOnlyList> BuildReducers() => [ @@ -67,7 +73,7 @@ public async Task Projector_ShouldBuildRunReadModel_EndToEnd() { var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector( - CreateRouter(store), + CreateDispatcher(store), CreateDeduplicator(), new SystemProjectionClock(), BuildReducers()); @@ -135,7 +141,7 @@ public async Task Projector_ShouldIgnoreUnknownEvents() { var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector( - CreateRouter(store), + CreateDispatcher(store), CreateDeduplicator(), new SystemProjectionClock(), BuildReducers()); @@ -169,7 +175,7 @@ public async Task Projector_ShouldDeduplicateByEnvelopeId() { var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector( - CreateRouter(store), + CreateDispatcher(store), CreateDeduplicator(), new SystemProjectionClock(), BuildReducers()); @@ -213,7 +219,7 @@ public async Task Projector_NoOpReducer_ShouldNotAdvanceStateVersion() new TextMessageStartProjectionReducer([]), ]; var projector = new WorkflowExecutionReadModelProjector( - CreateRouter(store), + CreateDispatcher(store), CreateDeduplicator(), new SystemProjectionClock(), reducers); @@ -249,7 +255,7 @@ public async Task Projector_ShouldUseEnvelopeTimestamp_WhenProvided() { var store = CreateStore(); var projector = new WorkflowExecutionReadModelProjector( - CreateRouter(store), + CreateDispatcher(store), CreateDeduplicator(), new SystemProjectionClock(), BuildReducers()); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 9236118d0..02e405045 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -44,11 +44,11 @@ public async Task AddWorkflowCapabilityWithAIDefaults_ShouldRegisterWorkflowAndA builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunRequestExecutor)).Should().BeTrue(); builder.Services.Any(x => x.ServiceType == typeof(IWorkflowRunActorPort)).Should().BeTrue(); - builder.Services.Any(x => x.ServiceType == typeof(IDocumentProjectionStore<,>)).Should().BeTrue(); + builder.Services.Any(x => x.ServiceType == typeof(IProjectionDocumentStore)).Should().BeTrue(); await using var provider = builder.Services.BuildServiceProvider(); provider.GetService().Should().NotBeNull(); - provider.GetService>().Should().NotBeNull(); + provider.GetService>().Should().NotBeNull(); var toolSources = provider.GetServices().ToList(); toolSources.Should().NotContain(x => x is MCPAgentToolSource); @@ -65,15 +65,15 @@ public void AddWorkflowCapabilityWithAIDefaults_ShouldRegisterReadModelProviders builder.AddWorkflowCapabilityWithAIDefaults(); - var providerRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + var documentStores = builder.Services + .Where(x => x.ServiceType == typeof(IProjectionDocumentStore)) .ToList(); - providerRegistrations.Should().HaveCount(1); + documentStores.Should().HaveCount(1); - var relationRegistrations = builder.Services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + var graphStores = builder.Services + .Where(x => x.ServiceType == typeof(IProjectionGraphStore)) .ToList(); - relationRegistrations.Should().HaveCount(1); + graphStores.Should().HaveCount(1); } [Fact] @@ -85,15 +85,15 @@ public void AddWorkflowProjectionReadModelProviders_MultipleCalls_ShouldBeIdempo services.AddWorkflowProjectionReadModelProviders(configuration); services.AddWorkflowProjectionReadModelProviders(configuration); - var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + var documentStores = services + .Where(x => x.ServiceType == typeof(IProjectionDocumentStore)) .ToList(); - providerRegistrations.Should().HaveCount(1); + documentStores.Should().HaveCount(1); - var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + var graphStores = services + .Where(x => x.ServiceType == typeof(IProjectionGraphStore)) .ToList(); - relationRegistrations.Should().HaveCount(1); + graphStores.Should().HaveCount(1); } [Fact] @@ -114,19 +114,19 @@ public void AddWorkflowProjectionReadModelProviders_WhenDurableProvidersEnabled_ services.AddWorkflowProjectionReadModelProviders(configuration); - var providerRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration>)) + var documentStores = services + .Where(x => x.ServiceType == typeof(IProjectionDocumentStore)) .ToList(); - var relationRegistrations = services - .Where(x => x.ServiceType == typeof(IProjectionStoreRegistration)) + var graphStores = services + .Where(x => x.ServiceType == typeof(IProjectionGraphStore)) .ToList(); - providerRegistrations.Should().HaveCount(1); - relationRegistrations.Should().HaveCount(1); + documentStores.Should().HaveCount(1); + graphStores.Should().HaveCount(1); } [Fact] - public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_ShouldRegisterDurableProvidersFirst() + public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_ShouldThrow() { var services = new ServiceCollection(); var configuration = new ConfigurationBuilder() @@ -141,23 +141,10 @@ public void AddWorkflowProjectionReadModelProviders_WhenAllProvidersEnabled_Shou }) .Build(); - services.AddWorkflowProjectionReadModelProviders(configuration); - - using var provider = services.BuildServiceProvider(); - var documentRegistrations = provider - .GetServices>>() - .ToList(); - var graphRegistrations = provider - .GetServices>() - .ToList(); - - documentRegistrations.Should().HaveCount(2); - documentRegistrations[0].ProviderName.Should().Be("Elasticsearch"); - documentRegistrations[1].ProviderName.Should().Be("InMemory"); + Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); - graphRegistrations.Should().HaveCount(2); - graphRegistrations[0].ProviderName.Should().Be("Neo4j"); - graphRegistrations[1].ProviderName.Should().Be("InMemory"); + act.Should().Throw() + .WithMessage("*Exactly one document projection provider must be enabled*"); } [Fact] diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs index 0076dd1ba..5b003f70c 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionOrchestrationComponentTests.cs @@ -78,10 +78,15 @@ await store.UpsertAsync(new WorkflowExecutionReport EndedAt = startedAt.AddMinutes(-6), CompletionStatus = WorkflowExecutionCompletionStatus.Running, }); + var relationStore = new InMemoryProjectionGraphStore(); + var dispatcher = new ProjectionStoreDispatcher( + new IProjectionStoreBinding[] + { + new ProjectionDocumentStoreBinding(store), + new ProjectionGraphStoreBinding(relationStore), + }); var updater = new WorkflowProjectionReadModelUpdater( - new ProjectionMaterializationRouter( - store, - new ProjectionGraphMaterializer(new InMemoryProjectionGraphStore())), + dispatcher, new FixedClock(stoppedAt)); var context = new WorkflowExecutionProjectionContext { @@ -322,7 +327,7 @@ public async Task LiveSinkForwarder_WhenPolicyDoesNotHandle_ShouldRethrowOrigina policy.Calls.Should().ContainSingle(); } - private static InMemoryProjectionReadModelStore CreateStore() => new( + private static InMemoryProjectionDocumentStore CreateStore() => new( keySelector: report => report.RootActorId, keyFormatter: key => key, listSortSelector: report => report.StartedAt); diff --git a/tools/ci/architecture_guards.sh b/tools/ci/architecture_guards.sh index 2aafdd383..78e6f344e 100755 --- a/tools/ci/architecture_guards.sh +++ b/tools/ci/architecture_guards.sh @@ -657,8 +657,8 @@ if [ -n "${projection_provider_business_using_hits}" ]; then fi projection_provider_store_files=( - "src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionReadModelStore.cs" - "src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionReadModelStore.cs" + "src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs" + "src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs" ) for provider_store_file in "${projection_provider_store_files[@]}"; do @@ -681,7 +681,7 @@ for provider_store_file in "${projection_provider_store_files[@]}"; do done command_side_readmodel_violations="$( - rg -n "IProjectionReadModelStore<|ReadModelStore" \ + rg -n "IProjectionDocumentStore<|IProjectionGraphStore|ProjectionDocumentStore|ProjectionGraphStore" \ src/workflow/Aevatar.Workflow.Application \ src/workflow/Aevatar.Workflow.Host.Api \ src/Aevatar.Mainnet.Host.Api \ From 3c967fd746ed3c9b57f93f054e7b6f3523e3f707 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 05:13:39 +0800 Subject: [PATCH 41/46] Remove Outdated Projection Store and Audit Scorecard Documentation - Deleted the obsolete Projection Store/ReadModel full refactor plan and audit scorecard documents, which were no longer aligned with the current architecture. - Streamlined documentation to focus on the latest architectural insights and audit frameworks, ensuring clarity and relevance for future development. - Updated related documentation to reflect the removal of deprecated content and maintain consistency with the current projection storage architecture. --- ...readmodel-full-refactor-plan-2026-02-24.md | 111 ------ ...orage-architecture-scorecard-2026-02-24.md | 99 ----- ...tore-full-architecture-audit-2026-02-24.md | 352 ++++++++++++++++++ 3 files changed, 352 insertions(+), 210 deletions(-) delete mode 100644 docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md delete mode 100644 docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md create mode 100644 docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md diff --git a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md b/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md deleted file mode 100644 index 7ffd3b7e3..000000000 --- a/docs/architecture/projection-readmodel-full-refactor-plan-2026-02-24.md +++ /dev/null @@ -1,111 +0,0 @@ -# Projection Store/ReadModel Full Refactor Plan (No Compatibility) - -- Date: 2026-02-24 -- Status: Completed -- Scope: `Aevatar.CQRS.Projection.*` + `Aevatar.Workflow.Projection` + `Aevatar.Workflow.Extensions.Hosting` - -## 1. Refactor Targets - -1. 单一主干:Projection 只保留一条权威运行链路(Dispatcher + Bindings)。 -2. 一对多:一个 ReadModel 可同时投影到多个 Store。 -3. 平行关系:Document Store 与 Graph Store 是同层平行实现。 -4. 同类单实现:同类 Provider 在同一 Host 内只允许一个(Document 只能一个,Graph 只能一个)。 -5. 删除冗余:移除 Router/Fanout/Registration/Marker/双模型等无效层。 - -## 2. Target Architecture - -### 2.1 Write Path - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - E["Domain Event Stream"] --> R["Projector + Reducers"] - R --> D["IProjectionStoreDispatcher"] - D --> B1["ProjectionDocumentStoreBinding"] - D --> B2["ProjectionGraphStoreBinding"] - B1 --> S1["IProjectionDocumentStore"] - B2 --> S2["IProjectionGraphStore"] - S1 --> P1["Document Provider (Elasticsearch or InMemory)"] - S2 --> P2["Graph Provider (Neo4j or InMemory)"] -``` - -### 2.2 Query Path - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - Q["WorkflowProjectionQueryReader"] --> DQ["IProjectionDocumentStore"] - Q --> GQ["IProjectionGraphStore"] -``` - -### 2.3 一对多语义 - -- ReadModel 是上层语义实体。 -- Store 是下层持久化投影目标。 -- `1 ReadModel : N Stores`(当前落地为 `N=2`: Document + Graph)。 - -## 3. Hard Refactor Scope - -### 3.1 Removed - -- `IProjectionStoreRegistration` / `DelegateProjectionStoreRegistration` -- `IProjectionMaterializationRouter` / `ProjectionMaterializationRouter` -- `IProjectionGraphMaterializer` / `ProjectionGraphMaterializer` -- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` -- `IDocumentReadModel` marker -- `GraphNodeDescriptor` / `GraphEdgeDescriptor` -- `ProjectionGraphSystemPropertyKeys` - -### 3.2 Added - -- `IProjectionStoreDispatcher` -- `IProjectionStoreBinding` -- `IProjectionQueryableStoreBinding` -- `ProjectionStoreDispatcher` -- `ProjectionDocumentStoreBinding` -- `ProjectionGraphStoreBinding` -- `ProjectionGraphManagedPropertyKeys` - -### 3.3 Renamed For Parallel Semantics - -- `IDocumentProjectionStore` -> `IProjectionDocumentStore` -- `InMemoryProjectionReadModelStore` -> `InMemoryProjectionDocumentStore` -- `ElasticsearchProjectionReadModelStore` -> `ElasticsearchProjectionDocumentStore` -- `ElasticsearchProjectionReadModelStoreOptions` -> `ElasticsearchProjectionDocumentStoreOptions` - -## 4. Provider Policy - -`AddWorkflowProjectionReadModelProviders(configuration)` 强制: - -1. Document Provider exactly one: - - `Projection:Document:Providers:Elasticsearch:Enabled=true` - - or `Projection:Document:Providers:InMemory:Enabled=true` -2. Graph Provider exactly one: - - `Projection:Graph:Providers:Neo4j:Enabled=true` - - or `Projection:Graph:Providers:InMemory:Enabled=true` -3. 禁止旧配置:`Projection:Document:Provider` / `Projection:Graph:Provider` -4. 可用策略:`Projection:Policies:DenyInMemoryGraphFactStore` - -## 5. Implementation Completion Checklist - -- [x] Runtime 从 Router/Fanout 切换到 Dispatcher/Bindings -- [x] Workflow Projector/Updater 切换到 `IProjectionStoreDispatcher` -- [x] Graph 读模型统一为 `ProjectionGraphNode/ProjectionGraphEdge` -- [x] Provider DI 改为直接 Store 注册 -- [x] Document/Graph Provider 命名体系并行化 -- [x] 测试更新到 Dispatcher + Binding 模式 -- [x] CI 架构门禁脚本同步新命名 -- [x] 文档与 README 全量更新 - -## 6. Verification - -执行命令: - -1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -4. `bash tools/ci/architecture_guards.sh` -5. `bash tools/ci/projection_route_mapping_guard.sh` -6. `bash tools/ci/test_stability_guards.sh` - -结果:通过。 diff --git a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md b/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md deleted file mode 100644 index b3d8426d4..000000000 --- a/docs/audit-scorecard/projection-storage-architecture-scorecard-2026-02-24.md +++ /dev/null @@ -1,99 +0,0 @@ -# Projection Store / ReadModel 架构审计与打分(2026-02-24) - -- 范围: - - `src/Aevatar.CQRS.Projection.Stores.Abstractions` - - `src/Aevatar.CQRS.Projection.Core.Abstractions` - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions` - - `src/Aevatar.CQRS.Projection.Runtime` - - `src/Aevatar.CQRS.Projection.Providers.*` - - `src/workflow/Aevatar.Workflow.Projection` - - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` -- 审计目标:确认 `DocumentStore` 与 `GraphStore` 为平行关系,且 `ReadModel -> Stores` 为一对多模型。 - -## 1. 总分 - -- **9.3 / 10** - -扣分点: - -1. Runtime 仍允许通过额外 binding 扩展更多写入目标(通用能力),对首次接入者理解门槛略高。(-0.4) -2. 历史文档存在过旧版本痕迹(已重写主要文档,但仓库仍可能有历史性参考文档)。(-0.3) - -## 2. 分项打分 - -| 维度 | 分数 | 结论 | -|---|---:|---| -| 分层清晰度 | 9.5 | Stores.Abstractions / Runtime.Abstractions / Runtime / Providers 职责边界清晰 | -| 并行一致性(Document vs Graph) | 9.4 | 命名与实现层次已并行(`IProjectionDocumentStore` vs `IProjectionGraphStore`) | -| 一对多模型完整性 | 9.2 | `IProjectionStoreDispatcher + Bindings` 明确支持一个 ReadModel 多 Store 分发 | -| 冗余消除程度 | 9.3 | Fanout/Router/Registration/Marker/双模型已删除 | -| Host 装配正确性 | 9.4 | Workflow Host 强制同类 Provider 仅一个,并支持 Document+Graph 双写 | -| 可测试性 | 9.1 | Dispatcher/GraphBinding/Workflow host tests 覆盖关键路径 | -| 运维可控性 | 9.0 | 策略位与 fail-fast 完整,日志和 guard 已对齐新模型 | - -## 3. 核心审计结论 - -1. `DocumentStore` 与 `GraphStore` 为平行关系,不存在继承或主从关系。 -2. `ReadModel` 与 Store 是 `1:N` 关系:一个 ReadModel 可写入多个 Store。 -3. 当前 Workflow 生产组合为 `N=2`:Document + Graph 同步投影。 -4. 同类 Provider 强制单实现: - - Document: Elasticsearch 或 InMemory(二选一) - - Graph: Neo4j 或 InMemory(二选一) - -## 4. 目标架构图 - -```mermaid -%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% -flowchart LR - RM["WorkflowExecutionReport(ReadModel)"] --> DSP["IProjectionStoreDispatcher"] - DSP --> DB["ProjectionDocumentStoreBinding"] - DSP --> GB["ProjectionGraphStoreBinding"] - DB --> DS["IProjectionDocumentStore"] - GB --> GS["IProjectionGraphStore"] - DS --> DP["Document Provider(Elasticsearch/InMemory)"] - GS --> GP["Graph Provider(Neo4j/InMemory)"] -``` - -## 5. 并行性核对(结论:通过) - -| 层级 | Document | Graph | 结果 | -|---|---|---|---| -| 抽象层 | `IProjectionDocumentStore` | `IProjectionGraphStore` | 平行 | -| Runtime Binding | `ProjectionDocumentStoreBinding` | `ProjectionGraphStoreBinding` | 平行 | -| Runtime 分发 | `IProjectionStoreDispatcher` 统一调度 | `IProjectionStoreDispatcher` 统一调度 | 平行 | -| InMemory Provider | `InMemoryProjectionDocumentStore` | `InMemoryProjectionGraphStore` | 平行 | -| Durable Provider | `ElasticsearchProjectionDocumentStore` | `Neo4jProjectionGraphStore` | 平行 | - -## 6. 冗余审计(已清理) - -已删除冗余层: - -- `IProjectionStoreRegistration` / `DelegateProjectionStoreRegistration` -- `IProjectionMaterializationRouter` / `ProjectionMaterializationRouter` -- `IProjectionGraphMaterializer` / `ProjectionGraphMaterializer` -- `ProjectionDocumentStoreFanout` / `ProjectionGraphStoreFanout` -- `IDocumentReadModel` -- `GraphNodeDescriptor` / `GraphEdgeDescriptor` -- `ProjectionGraphSystemPropertyKeys` - -## 7. 实施核对 - -已落地关键项: - -1. Workflow Projector / Updater 全部使用 `IProjectionStoreDispatcher`。 -2. Workflow Host Provider 装配强制“同类 Provider 仅一个”。 -3. Document/Graph 双写通过 Dispatcher + Binding 实现,不依赖 Router/Fanout。 -4. CI 守卫脚本已更新到新命名(DocumentStore 命名体系)。 - -## 8. 验证记录 - -执行通过: - -1. `dotnet build aevatar.slnx --nologo` -2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` -3. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` -4. `bash tools/ci/architecture_guards.sh` -5. `bash tools/ci/projection_route_mapping_guard.sh` -6. `bash tools/ci/test_stability_guards.sh` - -结论:当前 Projection Store/ReadModel 架构已满足“彻底重构,无兼容性包袱”的目标。 diff --git a/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md b/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md new file mode 100644 index 000000000..b05de27c6 --- /dev/null +++ b/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md @@ -0,0 +1,352 @@ +# Projection Store 全量类审计报告(2026-02-24) + +- 审计日期:2026-02-24 +- 审计范围: + - `src/Aevatar.CQRS.Projection.Stores.Abstractions` + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions` + - `src/Aevatar.CQRS.Projection.Runtime` + - `src/Aevatar.CQRS.Projection.Providers.InMemory` + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j` +- 审计对象:31 个 Projection Store 相关公开类/接口/枚举/记录 +- 审计目标: + 1. 完整核对所有 Projection Store 类职责边界。 + 2. 验证 `DocumentStore` 与 `GraphStore` 是否为平行关系。 + 3. 验证 `1 ReadModel -> N Stores` 是否为当前权威模型。 + 4. 输出结构化打分、问题清单、改进建议。 + +--- + +## 1. 总体结论 + +### 1.1 总分 + +- **9.1 / 10** + +### 1.2 结论摘要 + +1. 当前架构已完成从旧的 Router/Fanout/Registration 体系向 Dispatcher/Binding 体系的收敛。 +2. `DocumentStore` 与 `GraphStore` 在抽象、运行时绑定、Provider 三层均为平行关系,不再存在“语义主从”。 +3. `1 ReadModel -> N Stores` 已落地为稳定主干:Dispatcher 一次写入可同时投影到 Document + Graph。 +4. 主要扣分点集中在两个“超大 Provider 类”的维护复杂度与部分语义冗余。 + +--- + +## 2. 目标架构图(已落地) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + RM["ReadModel(TReadModel)"] --> DSP["IProjectionStoreDispatcher"] + DSP --> QB["IProjectionQueryableStoreBinding"] + DSP --> WB["IProjectionStoreBinding (write-only)"] + + QB --> DS["IProjectionDocumentStore"] + WB --> GS["IProjectionGraphStore"] + + DS --> DP["Document Provider (Elasticsearch/InMemory)"] + GS --> GP["Graph Provider (Neo4j/InMemory)"] +``` + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TD + A["Stores.Abstractions"] --> B["Runtime.Abstractions"] + B --> C["Runtime"] + A --> D["Providers.InMemory"] + A --> E["Providers.Elasticsearch"] + A --> F["Providers.Neo4j"] + B --> D + B --> E + B --> F +``` + +--- + +## 3. 类清单与逐类打分(31/31) + +### 3.1 Stores.Abstractions + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `IProjectionReadModel` | 9.5 | 最小主键契约清晰,无冗余。 | +| `IGraphReadModel` | 9.4 | 直接暴露 `GraphNodes/GraphEdges`,去除了旧 descriptor 双模型。 | +| `IProjectionDocumentStore` | 9.0 | CRUD 语义完整;未约束 `IProjectionReadModel`,灵活但类型语义略宽。 | +| `DocumentIndexMetadata` | 9.2 | 索引元数据结构化良好,避免字符串拼 JSON。 | +| `IProjectionDocumentMetadataProvider` | 9.3 | 元数据来源抽象简洁,职责单一。 | +| `IProjectionGraphStore` | 9.2 | 图写入/查询/owner 清理契约完整。 | +| `ProjectionGraphDirection` | 9.6 | 枚举清晰。 | +| `ProjectionGraphNode` | 9.1 | 结构简明;`Properties` 固定 string->string,通用性受限但稳定。 | +| `ProjectionGraphEdge` | 9.1 | 同上。 | +| `ProjectionGraphQuery` | 9.3 | 方向/边类型/深度/take 语义完整。 | +| `ProjectionGraphSubgraph` | 9.4 | 输出模型简洁。 | + +### 3.2 Runtime.Abstractions + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `IProjectionStoreDispatcher` | 9.4 | 统一入口良好,彻底替代旧 Router。 | +| `IProjectionStoreBinding` | 9.3 | 最小写入 binding 抽象,扩展成本低。 | +| `IProjectionQueryableStoreBinding` | 9.2 | 显式区分查询源和写入源,核心设计正确。 | +| `IProjectionDocumentMetadataResolver` | 9.1 | 解析职责明确。 | +| `ProjectionGraphManagedPropertyKeys` | 8.9 | 运行态系统键集中定义合理;命名可继续收敛。 | + +### 3.3 Runtime + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `ServiceCollectionExtensions` | 9.0 | 默认注入模型清晰;对“唯一 query binding”有约束前提。 | +| `ProjectionDocumentMetadataResolver` | 9.2 | DI 解析简单稳定。 | +| `ProjectionDocumentStoreBinding` | 9.4 | 纯代理类,无冗余逻辑。 | +| `ProjectionGraphStoreBinding` | 8.8 | owner 标记+差集清理逻辑完整;固定 `take:50000` 具规模上限风险。 | +| `ProjectionStoreDispatcher` | 8.9 | 一对多分发模型正确;写入失败时非事务一致性需要明确。 | + +### 3.4 Providers.InMemory + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `ServiceCollectionExtensions` | 9.2 | Document/Graph 注册对称。 | +| `InMemoryProjectionDocumentStore` | 8.8 | 行为完整;序列化 clone 成本较高,但测试语义可接受。 | +| `InMemoryProjectionGraphStore` | 8.6 | 功能完整;子图构建复杂度较高,适合 dev/test,不宜生产事实源。 | + +### 3.5 Providers.Elasticsearch + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `ElasticsearchMissingIndexBehavior` | 9.3 | 缺失索引策略明确。 | +| `ElasticsearchProjectionDocumentStoreOptions` | 9.2 | 配置项完整。 | +| `ServiceCollectionExtensions` | 9.3 | 注册方式标准化。 | +| `ElasticsearchProjectionDocumentStore` | 8.4 | OCC/索引初始化/查询逻辑完整,但类体过大(700+ 行)维护成本高。 | + +### 3.6 Providers.Neo4j + +| 类/接口 | 评分 | 审计结论 | +|---|---:|---| +| `Neo4jProjectionGraphStoreOptions` | 9.1 | 配置完整。 | +| `ServiceCollectionExtensions` | 8.7 | 注入接口清晰,但 `scopeFactory` 语义与实现存在冗余。 | +| `Neo4jProjectionGraphStore` | 8.3 | 图能力完整;类体过大(690+ 行)且 `_scope` 仅用于日志,语义冗余。 | + +--- + +## 4. 关键架构核对项 + +### 4.1 Document 与 Graph 是否平行 + +结论:**是**。 + +证据: + +1. 抽象层:`IProjectionDocumentStore` 与 `IProjectionGraphStore` 同层并列。 +2. Runtime 层:`ProjectionDocumentStoreBinding` 与 `ProjectionGraphStoreBinding` 同层并列。 +3. Provider 层: + - Document:`InMemoryProjectionDocumentStore` / `ElasticsearchProjectionDocumentStore` + - Graph:`InMemoryProjectionGraphStore` / `Neo4jProjectionGraphStore` + +### 4.2 是否是 1 对多(ReadModel -> Stores) + +结论:**是**。 + +证据: + +1. `ProjectionStoreDispatcher` 接收 `IEnumerable>`。 +2. `UpsertAsync` 对所有 binding 顺序写入。 +3. `MutateAsync` 先改 query binding,再回写 write-only bindings。 + +### 4.3 同类 Provider 是否单实现 + +结论:**Workflow Host 侧已强约束**。 + +证据: + +1. Document provider count 必须为 1。 +2. Graph provider count 必须为 1。 +3. 配置冲突时 fail-fast。 + +--- + +## 5. 发现的问题(按严重度) + +### 5.1 High + +无。 + +### 5.2 Medium + +1. 分发写入非事务一致性风险。 +- 位置:`ProjectionStoreDispatcher`。 +- 现象:某 binding 写入失败时,前序 binding 可能已成功写入,存在短时不一致。 +- 影响:Document 与 Graph 同步窗口内可能产生读侧偏差。 +- 建议: + 1. 在 dispatcher 层引入可选补偿策略接口(`IProjectionStoreDispatchCompensator`)。 + 2. 明确一致性等级(at-least-once + eventual consistency)并写入契约文档。 + +2. Graph 清理固定上限可能漏删。 +- 位置:`ProjectionGraphStoreBinding` 的 `take:50000`。 +- 现象:超大 owner 规模下清理差集可能不完整。 +- 影响:陈旧边/节点残留。 +- 建议:分页清理或游标化清理。 + +3. Neo4j scope 语义冗余。 +- 位置:`Neo4jProjectionGraphStore` 的 `_scope` 字段。 +- 现象:构造注入 `scopeFactory`,但 `_scope` 基本不参与行为约束,仅用于日志。 +- 影响:配置心智负担增加,接口语义与实现不一致。 +- 建议: + 1. 若不需要全局 scope 约束,删除 `scopeFactory` 与 `_scope` 字段。 + 2. 若需要约束,则在所有写/读入口强校验 `query.Scope == _scope`。 + +### 5.3 Low + +1. Provider 大类过重(可维护性)。 +- `ElasticsearchProjectionDocumentStore` 712 行。 +- `Neo4jProjectionGraphStore` 691 行。 +- 建议:按职责拆分 `IndexBootstrap / DocumentMutation / QueryAdapter / CypherBuilder`。 + +2. `IProjectionDocumentStore` 类型约束较宽。 +- 当前仅 `where TReadModel : class`。 +- 建议:若长期只服务 Projection ReadModel,可评估收紧为 `IProjectionReadModel`。 + +### 5.4 证据索引(关键问题) + +1. 非事务分发路径: + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:55` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:67` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:71` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:78` +2. Graph 清理固定上限: + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:44` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:53` +3. Neo4j scope 冗余链路: + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:13` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:15` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:642` +4. Runtime 默认 query binding 注入: + - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:12` + - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:13` +5. Document store 类型约束: + - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:3` + - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:4` + +--- + +## 6. 冗余与重复审计结果 + +### 6.1 已清理(通过) + +1. 旧 `Router/Fanout/Registration` 双轨层已移除。 +2. `IDocumentReadModel` marker 已移除。 +3. `GraphNodeDescriptor/GraphEdgeDescriptor` 双模型已移除。 +4. Document 命名已收敛为 `ProjectionDocumentStore` 体系。 + +### 6.2 当前仍需关注(非阻塞) + +1. Neo4j `scopeFactory` 冗余。 +2. 两个 Provider 超大类的职责聚合过多。 + +--- + +## 7. 测试覆盖审计 + +### 7.1 已覆盖 + +1. Dispatcher 核心行为: + - 多 binding 写入 + - query binding 唯一性约束 +2. Graph binding 生命周期行为: + - owner 差集删除 + - 跨 owner 隔离 + - 空 Id fail-fast +3. Elasticsearch 行为: + - 缺失索引策略 + - OCC 重试 + - 索引元数据结构化初始化 +4. Workflow Host 组合: + - Provider 单实现约束 + - 组合注册 idempotent + +### 7.2 建议补测 + +1. `ProjectionStoreDispatcher` 写入失败补偿/告警行为(当前无契约测试)。 +2. `ProjectionGraphStoreBinding` 在超大 owner(>50k)时的清理分页行为。 +3. `Neo4jProjectionGraphStore` scope 约束行为(若决定保留 `_scope`)。 + +--- + +## 8. 分层与依赖反转审计 + +结论:**通过**。 + +1. `Stores.Abstractions` 无额外依赖,保持最小内核。 +2. `Runtime.Abstractions` 仅依赖 `Stores.Abstractions`。 +3. `Runtime` 依赖 Runtime/Stores 抽象,不依赖业务层。 +4. Provider 项目仅依赖抽象与通用基础包,不依赖 Workflow/AI 业务项目。 + +--- + +## 9. 综合评分明细 + +| 维度 | 分数 | 说明 | +|---|---:|---| +| 分层清晰度 | 9.4 | 分层边界清晰,依赖方向正确。 | +| 平行一致性(Document/Graph) | 9.5 | 三层结构平行,命名已统一。 | +| 一对多模型完备度 | 9.2 | Dispatcher/Binding 语义完整。 | +| 冗余清理彻底性 | 9.0 | 旧体系已删,少量语义冗余尚存。 | +| 可维护性 | 8.4 | 两个 Provider 大类过重。 | +| 可扩展性 | 9.1 | Binding 扩展点清晰。 | +| 可测试性 | 9.0 | 核心路径有测试,补偿/规模化场景需补测。 | +| 运行稳定性 | 8.9 | 现有模型可用,但需明确非事务一致性语义。 | + +- **最终得分:9.1 / 10** + +--- + +## 10. 建议实施顺序(P0/P1/P2) + +1. P0:确定 Neo4j scope 语义(删除或强约束)。 +2. P1:为 Dispatcher 增加失败补偿扩展点与可观测日志。 +3. P1:Graph owner 清理改为分页/游标模式,移除固定 50k 上限。 +4. P2:拆分 Elasticsearch/Neo4j 大类,降低维护复杂度。 + +--- + +## 11. 审计结语 + +Projection Store 当前架构已经达到“单主干 + Document/Graph 平行 + ReadModel 一对多投影”的目标态。 +当前最需要处理的不是重做模型,而是收敛少量语义冗余并降低大类复杂度。 + +--- + +## 附录 A:31 个类声明位置(文件:行号) + +```text +src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs:3:public enum ElasticsearchMissingIndexBehavior +src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs:3:public sealed class ElasticsearchProjectionDocumentStoreOptions +src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:8:public static class ServiceCollectionExtensions +src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs:11:public sealed class ElasticsearchProjectionDocumentStore +src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:7:public static class ServiceCollectionExtensions +src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs:7:public sealed class InMemoryProjectionDocumentStore +src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs:5:public sealed class InMemoryProjectionGraphStore +src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs:3:public sealed class Neo4jProjectionGraphStoreOptions +src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:8:public static class ServiceCollectionExtensions +src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:9:public sealed class Neo4jProjectionGraphStore +src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs:3:public static class ProjectionGraphManagedPropertyKeys +src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs:3:public interface IProjectionDocumentMetadataResolver +src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs:3:public interface IProjectionQueryableStoreBinding +src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs:3:public interface IProjectionStoreBinding +src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs:3:public interface IProjectionStoreDispatcher +src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7:public static class ServiceCollectionExtensions +src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5:public sealed class ProjectionDocumentMetadataResolver : IProjectionDocumentMetadataResolver +src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs:3:public sealed class ProjectionDocumentStoreBinding +src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:3:public sealed class ProjectionGraphStoreBinding +src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:6:public sealed class ProjectionStoreDispatcher +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs:3:public interface IProjectionGraphStore +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs:3:public enum ProjectionGraphDirection +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs:3:public sealed class ProjectionGraphEdge +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs:3:public sealed class ProjectionGraphNode +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs:3:public sealed class ProjectionGraphQuery +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs:3:public sealed class ProjectionGraphSubgraph +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs:3:public sealed record DocumentIndexMetadata( +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs:3:public interface IGraphReadModel : IProjectionReadModel +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs:3:public interface IProjectionDocumentMetadataProvider +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:3:public interface IProjectionDocumentStore +src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs:3:public interface IProjectionReadModel +``` From a0b6a78861317c40d025c37f8e854198bb3982b2 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 05:39:54 +0800 Subject: [PATCH 42/46] Enhance Projection ReadModel and Update Dependency Injection - Introduced `IProjectionReadModel` interface to unify the ReadModel structure across different projection stores, ensuring consistency in implementation. - Updated the `ServiceCollectionExtensions` for both InMemory and Elasticsearch providers to enforce the new `IProjectionReadModel` constraint, improving type safety and clarity in service registration. - Added a new project reference for `Aevatar.CQRS.Projection.Stores.Abstractions` to facilitate integration with the updated ReadModel architecture. - Revised documentation to reflect these changes and clarify the new ReadModel interface requirements, ensuring alignment with the latest architectural framework. --- ...r.Demos.CaseProjection.Abstractions.csproj | 1 + .../ReadModels/CaseProjectionReadModel.cs | 6 +- ...ng-elasticsearch-readmodel-requirements.md | 2 +- ...tore-full-architecture-audit-2026-02-24.md | 407 +++++++----------- .../ServiceCollectionExtensions.cs | 2 +- ...icsearchProjectionDocumentStore.Helpers.cs | 320 ++++++++++++++ .../ElasticsearchProjectionDocumentStore.cs | 316 +------------- .../ServiceCollectionExtensions.cs | 2 +- .../Stores/InMemoryProjectionDocumentStore.cs | 2 +- .../Stores/InMemoryProjectionGraphStore.cs | 6 + .../ServiceCollectionExtensions.cs | 5 +- .../Neo4jProjectionGraphStore.Helpers.cs | 318 ++++++++++++++ .../Stores/Neo4jProjectionGraphStore.cs | 327 +------------- .../IProjectionStoreBindingAvailability.cs | 6 + .../IProjectionStoreDispatchCompensator.cs | 9 + ...jectionStoreDispatchCompensationContext.cs | 17 + .../Stores/ProjectionStoreDispatchOptions.cs | 6 + .../ServiceCollectionExtensions.cs | 3 + src/Aevatar.CQRS.Projection.Runtime/README.md | 5 +- ...ggingProjectionStoreDispatchCompensator.cs | 34 ++ .../Runtime/ProjectionDocumentStoreBinding.cs | 27 +- .../Runtime/ProjectionGraphStoreBinding.cs | 121 +++++- .../Runtime/ProjectionStoreDispatcher.cs | 165 ++++++- .../Graphs/IProjectionGraphStore.cs | 2 + .../ReadModels/IProjectionDocumentStore.cs | 2 +- ...tionProviderServiceCollectionExtensions.cs | 6 +- ...rchProjectionDocumentStoreBehaviorTests.cs | 2 +- .../ProjectionGraphStoreBindingTests.cs | 53 +++ .../ProjectionProviderE2EIntegrationTests.cs | 2 +- .../ProjectionStoreDispatcherTests.cs | 151 ++++++- ...lowExecutionProjectionRegistrationTests.cs | 2 - 31 files changed, 1369 insertions(+), 958 deletions(-) create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Runtime/Runtime/LoggingProjectionStoreDispatchCompensator.cs diff --git a/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj b/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj index 2d83ec38d..dcd1aceb7 100644 --- a/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj +++ b/demos/Aevatar.Demos.CaseProjection.Abstractions/Aevatar.Demos.CaseProjection.Abstractions.csproj @@ -9,6 +9,7 @@ + diff --git a/demos/Aevatar.Demos.CaseProjection.Abstractions/ReadModels/CaseProjectionReadModel.cs b/demos/Aevatar.Demos.CaseProjection.Abstractions/ReadModels/CaseProjectionReadModel.cs index 348e27b94..78ef139c2 100644 --- a/demos/Aevatar.Demos.CaseProjection.Abstractions/ReadModels/CaseProjectionReadModel.cs +++ b/demos/Aevatar.Demos.CaseProjection.Abstractions/ReadModels/CaseProjectionReadModel.cs @@ -1,7 +1,11 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + namespace Aevatar.Demos.CaseProjection.Abstractions.ReadModels; -public sealed class CaseProjectionReadModel +public sealed class CaseProjectionReadModel : IProjectionReadModel { + public string Id => RunId; + public string ReadModelVersion { get; set; } = "1.0"; public string RunId { get; set; } = ""; public string RootActorId { get; set; } = ""; diff --git a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md index 9fd252db7..87ab37a7e 100644 --- a/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md +++ b/docs/architecture/generic-event-sourcing-elasticsearch-readmodel-requirements.md @@ -97,7 +97,7 @@ - `src/Aevatar.CQRS.Projection.Providers.Neo4j` - 当前行为: 1. 一个 ReadModel 可绑定多个 Store(Document + Graph)。 - 2. 必须且仅能有一个 queryable binding(用于 Get/List/Mutate)。 + 2. queryable binding 为可选(0..1);若存在则用于 Get/List/Mutate。 3. Provider 写路径日志结构已统一。 ### 6.4 Workflow 接入现状(已完成) diff --git a/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md b/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md index b05de27c6..b5ad1ba9e 100644 --- a/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md +++ b/docs/audit-scorecard/projection-store-full-architecture-audit-2026-02-24.md @@ -1,6 +1,7 @@ -# Projection Store 全量类审计报告(2026-02-24) +# Projection Store 全量架构审计与打分(2026-02-24,Rev.2) - 审计日期:2026-02-24 +- 修订日期:2026-02-24 - 审计范围: - `src/Aevatar.CQRS.Projection.Stores.Abstractions` - `src/Aevatar.CQRS.Projection.Runtime.Abstractions` @@ -8,44 +9,49 @@ - `src/Aevatar.CQRS.Projection.Providers.InMemory` - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` - `src/Aevatar.CQRS.Projection.Providers.Neo4j` -- 审计对象:31 个 Projection Store 相关公开类/接口/枚举/记录 + - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting` +- 审计对象:34 个公开 Projection Store 类型(class/interface/record/enum) - 审计目标: - 1. 完整核对所有 Projection Store 类职责边界。 - 2. 验证 `DocumentStore` 与 `GraphStore` 是否为平行关系。 - 3. 验证 `1 ReadModel -> N Stores` 是否为当前权威模型。 - 4. 输出结构化打分、问题清单、改进建议。 + 1. 核对 Projection Store 体系是否“单主干 + 一对多分发”。 + 2. 严格确认 `DocumentStore` 与 `GraphStore` 是平行关系。 + 3. 严格确认“一个 ReadModel 对应多个 Store”是否在实现层真实成立。 + 4. 输出全量评分、冗余清单、实现一致性结论。 --- -## 1. 总体结论 +## 1. 审计结论(严格) ### 1.1 总分 -- **9.1 / 10** +- **9.6 / 10** -### 1.2 结论摘要 +### 1.2 核心结论 -1. 当前架构已完成从旧的 Router/Fanout/Registration 体系向 Dispatcher/Binding 体系的收敛。 -2. `DocumentStore` 与 `GraphStore` 在抽象、运行时绑定、Provider 三层均为平行关系,不再存在“语义主从”。 -3. `1 ReadModel -> N Stores` 已落地为稳定主干:Dispatcher 一次写入可同时投影到 Document + Graph。 -4. 主要扣分点集中在两个“超大 Provider 类”的维护复杂度与部分语义冗余。 +1. `DocumentStore` 与 `GraphStore` 已在抽象层、Runtime 绑定层、Provider 层形成同层并行模型。 +2. `1 ReadModel -> N Stores` 已稳定落地:`ProjectionStoreDispatcher` 对所有已配置 binding 统一分发写入。 +3. Runtime 已去除“必须存在主查询存储”的硬约束:queryable binding 现在是可选(0..1),不再强制唯一主存储。 +4. Runtime 默认同层装配 `DocumentBinding + GraphBinding`,Graph 在“无 GraphStore 或非 IGraphReadModel”场景自动失活,避免手工拼接不对称。 +5. Workflow Host 层对“同类 Provider 仅一个”保持强约束:Document Provider 必须且仅能一个,Graph Provider 必须且仅能一个。 --- -## 2. 目标架构图(已落地) +## 2. 目标架构图(当前实现) ```mermaid %%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% flowchart LR RM["ReadModel(TReadModel)"] --> DSP["IProjectionStoreDispatcher"] - DSP --> QB["IProjectionQueryableStoreBinding"] - DSP --> WB["IProjectionStoreBinding (write-only)"] - QB --> DS["IProjectionDocumentStore"] - WB --> GS["IProjectionGraphStore"] + DSP --> B1["ProjectionDocumentStoreBinding"] + DSP --> B2["ProjectionGraphStoreBinding"] - DS --> DP["Document Provider (Elasticsearch/InMemory)"] - GS --> GP["Graph Provider (Neo4j/InMemory)"] + B1 --> DS["IProjectionDocumentStore"] + B2 --> GS["IProjectionGraphStore"] + + DS --> DP["Document Provider: InMemory | Elasticsearch"] + GS --> GP["Graph Provider: InMemory | Neo4j"] + + DSP --> Q["Get/List/Mutate: queryable binding(0..1)"] ``` ```mermaid @@ -63,290 +69,173 @@ flowchart TD --- -## 3. 类清单与逐类打分(31/31) - -### 3.1 Stores.Abstractions - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `IProjectionReadModel` | 9.5 | 最小主键契约清晰,无冗余。 | -| `IGraphReadModel` | 9.4 | 直接暴露 `GraphNodes/GraphEdges`,去除了旧 descriptor 双模型。 | -| `IProjectionDocumentStore` | 9.0 | CRUD 语义完整;未约束 `IProjectionReadModel`,灵活但类型语义略宽。 | -| `DocumentIndexMetadata` | 9.2 | 索引元数据结构化良好,避免字符串拼 JSON。 | -| `IProjectionDocumentMetadataProvider` | 9.3 | 元数据来源抽象简洁,职责单一。 | -| `IProjectionGraphStore` | 9.2 | 图写入/查询/owner 清理契约完整。 | -| `ProjectionGraphDirection` | 9.6 | 枚举清晰。 | -| `ProjectionGraphNode` | 9.1 | 结构简明;`Properties` 固定 string->string,通用性受限但稳定。 | -| `ProjectionGraphEdge` | 9.1 | 同上。 | -| `ProjectionGraphQuery` | 9.3 | 方向/边类型/深度/take 语义完整。 | -| `ProjectionGraphSubgraph` | 9.4 | 输出模型简洁。 | - -### 3.2 Runtime.Abstractions - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `IProjectionStoreDispatcher` | 9.4 | 统一入口良好,彻底替代旧 Router。 | -| `IProjectionStoreBinding` | 9.3 | 最小写入 binding 抽象,扩展成本低。 | -| `IProjectionQueryableStoreBinding` | 9.2 | 显式区分查询源和写入源,核心设计正确。 | -| `IProjectionDocumentMetadataResolver` | 9.1 | 解析职责明确。 | -| `ProjectionGraphManagedPropertyKeys` | 8.9 | 运行态系统键集中定义合理;命名可继续收敛。 | - -### 3.3 Runtime - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `ServiceCollectionExtensions` | 9.0 | 默认注入模型清晰;对“唯一 query binding”有约束前提。 | -| `ProjectionDocumentMetadataResolver` | 9.2 | DI 解析简单稳定。 | -| `ProjectionDocumentStoreBinding` | 9.4 | 纯代理类,无冗余逻辑。 | -| `ProjectionGraphStoreBinding` | 8.8 | owner 标记+差集清理逻辑完整;固定 `take:50000` 具规模上限风险。 | -| `ProjectionStoreDispatcher` | 8.9 | 一对多分发模型正确;写入失败时非事务一致性需要明确。 | - -### 3.4 Providers.InMemory - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `ServiceCollectionExtensions` | 9.2 | Document/Graph 注册对称。 | -| `InMemoryProjectionDocumentStore` | 8.8 | 行为完整;序列化 clone 成本较高,但测试语义可接受。 | -| `InMemoryProjectionGraphStore` | 8.6 | 功能完整;子图构建复杂度较高,适合 dev/test,不宜生产事实源。 | - -### 3.5 Providers.Elasticsearch - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `ElasticsearchMissingIndexBehavior` | 9.3 | 缺失索引策略明确。 | -| `ElasticsearchProjectionDocumentStoreOptions` | 9.2 | 配置项完整。 | -| `ServiceCollectionExtensions` | 9.3 | 注册方式标准化。 | -| `ElasticsearchProjectionDocumentStore` | 8.4 | OCC/索引初始化/查询逻辑完整,但类体过大(700+ 行)维护成本高。 | - -### 3.6 Providers.Neo4j - -| 类/接口 | 评分 | 审计结论 | -|---|---:|---| -| `Neo4jProjectionGraphStoreOptions` | 9.1 | 配置完整。 | -| `ServiceCollectionExtensions` | 8.7 | 注入接口清晰,但 `scopeFactory` 语义与实现存在冗余。 | -| `Neo4jProjectionGraphStore` | 8.3 | 图能力完整;类体过大(690+ 行)且 `_scope` 仅用于日志,语义冗余。 | - ---- - -## 4. 关键架构核对项 +## 3. 严格核对项 -### 4.1 Document 与 Graph 是否平行 +### 3.1 Document 与 Graph 是否平行关系 -结论:**是**。 +结论:**是(通过)**。 证据: - -1. 抽象层:`IProjectionDocumentStore` 与 `IProjectionGraphStore` 同层并列。 -2. Runtime 层:`ProjectionDocumentStoreBinding` 与 `ProjectionGraphStoreBinding` 同层并列。 -3. Provider 层: +1. 存储抽象同层并列:`IProjectionDocumentStore` 与 `IProjectionGraphStore`。 +2. Runtime 绑定同层并列:`ProjectionDocumentStoreBinding` 与 `ProjectionGraphStoreBinding`。 +3. Provider 同层并列: - Document:`InMemoryProjectionDocumentStore` / `ElasticsearchProjectionDocumentStore` - Graph:`InMemoryProjectionGraphStore` / `Neo4jProjectionGraphStore` -### 4.2 是否是 1 对多(ReadModel -> Stores) +### 3.2 是否是一对多(一个 ReadModel -> 多 Store) -结论:**是**。 +结论:**是(通过)**。 证据: - 1. `ProjectionStoreDispatcher` 接收 `IEnumerable>`。 -2. `UpsertAsync` 对所有 binding 顺序写入。 -3. `MutateAsync` 先改 query binding,再回写 write-only bindings。 +2. `UpsertAsync` 顺序写入所有已配置 binding。 +3. query 逻辑与 write fan-out 解耦:queryable binding 可选(0..1),写分发不受影响。 -### 4.3 同类 Provider 是否单实现 +### 3.3 同类 Provider 是否只允许一个 -结论:**Workflow Host 侧已强约束**。 +结论:**是(Workflow Host 侧强约束通过)**。 证据: - -1. Document provider count 必须为 1。 -2. Graph provider count 必须为 1。 -3. 配置冲突时 fail-fast。 +1. `WorkflowProjectionProviderServiceCollectionExtensions` 对 Document Provider 计数强约束为 1。 +2. 同文件对 Graph Provider 计数强约束为 1。 +3. 冲突配置启动即 fail-fast。 --- -## 5. 发现的问题(按严重度) - -### 5.1 High - -无。 - -### 5.2 Medium - -1. 分发写入非事务一致性风险。 -- 位置:`ProjectionStoreDispatcher`。 -- 现象:某 binding 写入失败时,前序 binding 可能已成功写入,存在短时不一致。 -- 影响:Document 与 Graph 同步窗口内可能产生读侧偏差。 -- 建议: - 1. 在 dispatcher 层引入可选补偿策略接口(`IProjectionStoreDispatchCompensator`)。 - 2. 明确一致性等级(at-least-once + eventual consistency)并写入契约文档。 - -2. Graph 清理固定上限可能漏删。 -- 位置:`ProjectionGraphStoreBinding` 的 `take:50000`。 -- 现象:超大 owner 规模下清理差集可能不完整。 -- 影响:陈旧边/节点残留。 -- 建议:分页清理或游标化清理。 - -3. Neo4j scope 语义冗余。 -- 位置:`Neo4jProjectionGraphStore` 的 `_scope` 字段。 -- 现象:构造注入 `scopeFactory`,但 `_scope` 基本不参与行为约束,仅用于日志。 -- 影响:配置心智负担增加,接口语义与实现不一致。 -- 建议: - 1. 若不需要全局 scope 约束,删除 `scopeFactory` 与 `_scope` 字段。 - 2. 若需要约束,则在所有写/读入口强校验 `query.Scope == _scope`。 - -### 5.3 Low - -1. Provider 大类过重(可维护性)。 -- `ElasticsearchProjectionDocumentStore` 712 行。 -- `Neo4jProjectionGraphStore` 691 行。 -- 建议:按职责拆分 `IndexBootstrap / DocumentMutation / QueryAdapter / CypherBuilder`。 - -2. `IProjectionDocumentStore` 类型约束较宽。 -- 当前仅 `where TReadModel : class`。 -- 建议:若长期只服务 Projection ReadModel,可评估收紧为 `IProjectionReadModel`。 - -### 5.4 证据索引(关键问题) - -1. 非事务分发路径: - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:55` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:67` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:71` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:78` -2. Graph 清理固定上限: - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:44` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:53` -3. Neo4j scope 冗余链路: - - `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:13` - - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:15` - - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:642` -4. Runtime 默认 query binding 注入: - - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:12` - - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:13` -5. Document store 类型约束: - - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:3` - - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:4` +## 4. 分层评分(严格) ---- +| 维度 | 分数 | 结论 | +|---|---:|---| +| 分层边界 | 9.7 | `Stores.Abstractions -> Runtime.Abstractions -> Runtime -> Providers` 依赖方向正确。 | +| 平行一致性(Document/Graph) | 9.8 | 抽象/绑定/Provider 三层均并列。 | +| 一对多分发能力 | 9.6 | Dispatcher + Binding 主干清晰,写分发稳定。 | +| 配置治理(同类 provider 唯一) | 9.7 | Host fail-fast 规则明确。 | +| 类型契约严谨性 | 9.5 | DocumentStore 已收紧到 `IProjectionReadModel`。 | +| 可维护性 | 9.2 | Provider 大类已拆 partial,但仍有继续下钻空间。 | +| 可测试性 | 9.5 | 核心路径(分页清理、补偿、重试)已有测试。 | -## 6. 冗余与重复审计结果 +- **最终总分:9.6 / 10** -### 6.1 已清理(通过) +--- -1. 旧 `Router/Fanout/Registration` 双轨层已移除。 -2. `IDocumentReadModel` marker 已移除。 -3. `GraphNodeDescriptor/GraphEdgeDescriptor` 双模型已移除。 -4. Document 命名已收敛为 `ProjectionDocumentStore` 体系。 +## 5. 全量类型打分(34/34) -### 6.2 当前仍需关注(非阻塞) +### 5.1 Stores.Abstractions -1. Neo4j `scopeFactory` 冗余。 -2. 两个 Provider 超大类的职责聚合过多。 +| 类型 | 分数 | 结论 | +|---|---:|---| +| `IProjectionReadModel` | 9.6 | 主键语义最小充分。 | +| `IGraphReadModel` | 9.6 | 直接表达图节点/边输入,语义清晰。 | +| `IProjectionDocumentStore` | 9.5 | 文档读模型契约完整,类型边界收敛。 | +| `IProjectionGraphStore` | 9.4 | 图写入/查询/owner 管理接口完整。 | +| `IProjectionDocumentMetadataProvider` | 9.5 | 索引 metadata 来源明确。 | +| `DocumentIndexMetadata` | 9.4 | 结构化索引定义,避免字符串拼装。 | +| `ProjectionGraphNode` | 9.2 | 节点结构清晰。 | +| `ProjectionGraphEdge` | 9.2 | 边结构清晰。 | +| `ProjectionGraphDirection` | 9.7 | 方向语义清晰。 | +| `ProjectionGraphQuery` | 9.4 | 查询参数完整。 | +| `ProjectionGraphSubgraph` | 9.4 | 子图返回模型简洁。 | + +### 5.2 Runtime.Abstractions + +| 类型 | 分数 | 结论 | +|---|---:|---| +| `IProjectionStoreBinding` | 9.5 | 最小写绑定抽象清楚。 | +| `IProjectionQueryableStoreBinding` | 9.3 | query 能力分离清楚。 | +| `IProjectionStoreDispatcher` | 9.5 | 单入口契约稳定。 | +| `IProjectionStoreDispatchCompensator` | 9.4 | 失败补偿扩展点合理。 | +| `ProjectionStoreDispatchCompensationContext` | 9.3 | 补偿上下文字段完整。 | +| `ProjectionStoreDispatchOptions` | 9.3 | 重试策略配置简洁。 | +| `IProjectionStoreBindingAvailability` | 9.5 | 绑定启用态显式化,解决无效 binding 冗余。 | +| `IProjectionDocumentMetadataResolver` | 9.3 | metadata 解析契约清晰。 | +| `ProjectionGraphManagedPropertyKeys` | 9.0 | 系统托管键集中定义,略偏实现细节。 | + +### 5.3 Runtime + +| 类型 | 分数 | 结论 | +|---|---:|---| +| `ProjectionStoreDispatcher` | 9.5 | 一对多分发、补偿、重试、可选 query 绑定完整。 | +| `ProjectionDocumentStoreBinding` | 9.4 | 文档 binding 可配置感知,失配自动失活。 | +| `ProjectionGraphStoreBinding` | 9.4 | 图 binding 可配置感知 + owner 差集清理分页。 | +| `ProjectionDocumentMetadataResolver` | 9.3 | 解析逻辑简洁。 | +| `LoggingProjectionStoreDispatchCompensator` | 9.2 | 默认补偿可观测性到位。 | +| `ServiceCollectionExtensions` | 9.5 | Runtime 默认同层注册 Document/Graph binding。 | ---- +### 5.4 Providers.InMemory -## 7. 测试覆盖审计 +| 类型 | 分数 | 结论 | +|---|---:|---| +| `InMemoryProjectionDocumentStore` | 9.0 | 行为稳定,适合 dev/test。 | +| `InMemoryProjectionGraphStore` | 8.9 | 图能力完整,生产事实源仍建议持久化后端。 | +| `ServiceCollectionExtensions` | 9.3 | 注册语义清晰。 | -### 7.1 已覆盖 +### 5.5 Providers.Elasticsearch -1. Dispatcher 核心行为: - - 多 binding 写入 - - query binding 唯一性约束 -2. Graph binding 生命周期行为: - - owner 差集删除 - - 跨 owner 隔离 - - 空 Id fail-fast -3. Elasticsearch 行为: - - 缺失索引策略 - - OCC 重试 - - 索引元数据结构化初始化 -4. Workflow Host 组合: - - Provider 单实现约束 - - 组合注册 idempotent +| 类型 | 分数 | 结论 | +|---|---:|---| +| `ElasticsearchProjectionDocumentStore` | 9.0 | OCC、索引初始化、query 路径完整;已拆 partial 降复杂度。 | +| `ElasticsearchProjectionDocumentStoreOptions` | 9.3 | 配置项完整。 | +| `ElasticsearchMissingIndexBehavior` | 9.4 | 缺索引策略清晰。 | +| `ServiceCollectionExtensions` | 9.3 | DI 装配一致。 | -### 7.2 建议补测 +### 5.6 Providers.Neo4j -1. `ProjectionStoreDispatcher` 写入失败补偿/告警行为(当前无契约测试)。 -2. `ProjectionGraphStoreBinding` 在超大 owner(>50k)时的清理分页行为。 -3. `Neo4jProjectionGraphStore` scope 约束行为(若决定保留 `_scope`)。 +| 类型 | 分数 | 结论 | +|---|---:|---| +| `Neo4jProjectionGraphStore` | 8.9 | 图能力完整,已拆 partial;维护复杂度仍偏高。 | +| `Neo4jProjectionGraphStoreOptions` | 9.3 | 配置边界清晰。 | +| `ServiceCollectionExtensions` | 9.4 | scope 冗余已清理,装配简化。 | --- -## 8. 分层与依赖反转审计 - -结论:**通过**。 +## 6. 冗余审计结果 -1. `Stores.Abstractions` 无额外依赖,保持最小内核。 -2. `Runtime.Abstractions` 仅依赖 `Stores.Abstractions`。 -3. `Runtime` 依赖 Runtime/Stores 抽象,不依赖业务层。 -4. Provider 项目仅依赖抽象与通用基础包,不依赖 Workflow/AI 业务项目。 +### 6.1 已彻底清理(通过) ---- - -## 9. 综合评分明细 +1. 旧 `Router/Fanout/Registration` 双轨投影存储模型已移除。 +2. Neo4j `scopeFactory` / `_scope` 语义冗余已移除。 +3. Graph owner 清理固定 50k 上限语义已改为分页扫描(`skip/take`)。 +4. Dispatcher 增加失败重试 + 补偿扩展,不再是“失败即中断且无策略”。 +5. Runtime 默认 Document/Graph 同层 binding 注册,Workflow 不再手工拼接 Graph binding。 -| 维度 | 分数 | 说明 | -|---|---:|---| -| 分层清晰度 | 9.4 | 分层边界清晰,依赖方向正确。 | -| 平行一致性(Document/Graph) | 9.5 | 三层结构平行,命名已统一。 | -| 一对多模型完备度 | 9.2 | Dispatcher/Binding 语义完整。 | -| 冗余清理彻底性 | 9.0 | 旧体系已删,少量语义冗余尚存。 | -| 可维护性 | 8.4 | 两个 Provider 大类过重。 | -| 可扩展性 | 9.1 | Binding 扩展点清晰。 | -| 可测试性 | 9.0 | 核心路径有测试,补偿/规模化场景需补测。 | -| 运行稳定性 | 8.9 | 现有模型可用,但需明确非事务一致性语义。 | +### 6.2 当前剩余关注点(低优先级) -- **最终得分:9.1 / 10** +1. `ElasticsearchProjectionDocumentStore` 与 `Neo4jProjectionGraphStore` 虽已拆 partial,但业务复杂度仍高。 +2. Graph query 与 Document query 的统一查询门面暂未抽象(当前是能力有意分离,不是架构错误)。 --- -## 10. 建议实施顺序(P0/P1/P2) +## 7. 并行一致性矩阵(Document vs Graph) -1. P0:确定 Neo4j scope 语义(删除或强约束)。 -2. P1:为 Dispatcher 增加失败补偿扩展点与可观测日志。 -3. P1:Graph owner 清理改为分页/游标模式,移除固定 50k 上限。 -4. P2:拆分 Elasticsearch/Neo4j 大类,降低维护复杂度。 +| 层级 | Document | Graph | 结论 | +|---|---|---|---| +| 抽象层 | `IProjectionDocumentStore` | `IProjectionGraphStore` | 同层并列 | +| ReadModel 契约 | `IProjectionReadModel` + metadata provider | `IGraphReadModel` | 同层并列 | +| Runtime 绑定 | `ProjectionDocumentStoreBinding` | `ProjectionGraphStoreBinding` | 同层并列 | +| Provider 实现 | InMemory / Elasticsearch | InMemory / Neo4j | 同层并列 | +| Host 选择策略 | 同类 provider 必须 1 个 | 同类 provider 必须 1 个 | 对称 | --- -## 11. 审计结语 +## 8. 关键实现证据(摘录) -Projection Store 当前架构已经达到“单主干 + Document/Graph 平行 + ReadModel 一对多投影”的目标态。 -当前最需要处理的不是重做模型,而是收敛少量语义冗余并降低大类复杂度。 +1. Store 并列抽象: + - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs` + - `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs` +2. Runtime 并列绑定注册: + - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs` +3. 一对多分发: + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs` +4. Graph binding 自动失活条件(非图模型/无 graph store): + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs` +5. Workflow 同类 provider 唯一约束: + - `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` --- -## 附录 A:31 个类声明位置(文件:行号) - -```text -src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs:3:public enum ElasticsearchMissingIndexBehavior -src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs:3:public sealed class ElasticsearchProjectionDocumentStoreOptions -src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:8:public static class ServiceCollectionExtensions -src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs:11:public sealed class ElasticsearchProjectionDocumentStore -src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:7:public static class ServiceCollectionExtensions -src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs:7:public sealed class InMemoryProjectionDocumentStore -src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs:5:public sealed class InMemoryProjectionGraphStore -src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs:3:public sealed class Neo4jProjectionGraphStoreOptions -src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:8:public static class ServiceCollectionExtensions -src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:9:public sealed class Neo4jProjectionGraphStore -src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs:3:public static class ProjectionGraphManagedPropertyKeys -src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs:3:public interface IProjectionDocumentMetadataResolver -src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs:3:public interface IProjectionQueryableStoreBinding -src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs:3:public interface IProjectionStoreBinding -src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs:3:public interface IProjectionStoreDispatcher -src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7:public static class ServiceCollectionExtensions -src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5:public sealed class ProjectionDocumentMetadataResolver : IProjectionDocumentMetadataResolver -src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs:3:public sealed class ProjectionDocumentStoreBinding -src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:3:public sealed class ProjectionGraphStoreBinding -src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:6:public sealed class ProjectionStoreDispatcher -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs:3:public interface IProjectionGraphStore -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs:3:public enum ProjectionGraphDirection -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs:3:public sealed class ProjectionGraphEdge -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs:3:public sealed class ProjectionGraphNode -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs:3:public sealed class ProjectionGraphQuery -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs:3:public sealed class ProjectionGraphSubgraph -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs:3:public sealed record DocumentIndexMetadata( -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs:3:public interface IGraphReadModel : IProjectionReadModel -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs:3:public interface IProjectionDocumentMetadataProvider -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:3:public interface IProjectionDocumentStore -src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs:3:public interface IProjectionReadModel -``` +## 9. 最终裁决 + +- `DocumentStore` 与 `GraphStore`:**平行关系,已通过严格审计**。 +- `一个 ReadModel -> 多个 Store`:**主干稳定成立,已通过严格审计**。 +- “同类 Provider 只一个”治理:**Workflow Host 层强约束通过**。 +- 架构状态:**可进入持续演进阶段,当前不再存在阻断级冗余问题**。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index cfb3d4657..5e26c583e 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -13,7 +13,7 @@ public static IServiceCollection AddElasticsearchDocumentProjectionStore metadataFactory, Func keySelector, Func? keyFormatter = null) - where TReadModel : class + where TReadModel : class, IProjectionReadModel { ArgumentNullException.ThrowIfNull(optionsFactory); ArgumentNullException.ThrowIfNull(metadataFactory); diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs new file mode 100644 index 000000000..b733c4e30 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs @@ -0,0 +1,320 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +public sealed partial class ElasticsearchProjectionDocumentStore +{ + private string BuildListPayloadJson(int size) + { + var sort = _listSortField.Length == 0 + ? BuildDefaultSortSpec() + : BuildConfiguredSortSpec(_listSortField); + + return JsonSerializer.Serialize(new + { + size, + sort, + query = new + { + match_all = new { }, + }, + }); + } + + private static object[] BuildConfiguredSortSpec(string sortField) + { + return + [ + new Dictionary + { + [sortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + new Dictionary + { + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + ]; + } + + private static object[] BuildDefaultSortSpec() + { + return + [ + new Dictionary + { + [DefaultListPrimarySortField] = new Dictionary + { + ["order"] = "desc", + ["missing"] = "_last", + ["unmapped_type"] = "date", + }, + }, + new Dictionary + { + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + ]; + } + + private async Task EnsureIndexAsync(CancellationToken ct) + { + if (!_autoCreateIndex || _indexInitialized) + return; + + await _indexInitializationLock.WaitAsync(ct); + try + { + if (_indexInitialized) + return; + + var payload = BuildIndexInitializationPayload(); + using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + _indexInitialized = true; + return; + } + + var responsePayload = await response.Content.ReadAsStringAsync(ct); + if (response.StatusCode == HttpStatusCode.BadRequest && + responsePayload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) + { + _indexInitialized = true; + return; + } + + throw new InvalidOperationException( + $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={responsePayload}"); + } + finally + { + _indexInitializationLock.Release(); + } + } + + private string BuildIndexInitializationPayload() + { + var mappings = _indexMetadata.Mappings.Count == 0 + ? new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + } + : new Dictionary(_indexMetadata.Mappings, StringComparer.Ordinal); + + var root = new Dictionary + { + ["mappings"] = mappings, + }; + + if (_indexMetadata.Settings.Count > 0) + root["settings"] = new Dictionary(_indexMetadata.Settings, StringComparer.Ordinal); + if (_indexMetadata.Aliases.Count > 0) + root["aliases"] = new Dictionary(_indexMetadata.Aliases, StringComparer.Ordinal); + + return JsonSerializer.Serialize(root, _jsonOptions); + } + + private static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + var normalizedMappings = NormalizeObjectMap(metadata.Mappings, "DocumentIndexMetadata.Mappings"); + var normalizedSettings = NormalizeObjectMap(metadata.Settings, "DocumentIndexMetadata.Settings"); + var normalizedAliases = NormalizeObjectMap(metadata.Aliases, "DocumentIndexMetadata.Aliases"); + return new DocumentIndexMetadata( + metadata.IndexName?.Trim() ?? "", + normalizedMappings, + normalizedSettings, + normalizedAliases); + } + + private static Dictionary NormalizeObjectMap( + IReadOnlyDictionary source, + string context) + { + ArgumentNullException.ThrowIfNull(source); + var normalized = new Dictionary(StringComparer.Ordinal); + foreach (var pair in source) + { + var key = pair.Key?.Trim() ?? ""; + if (key.Length == 0) + throw new InvalidOperationException($"{context} contains an empty key."); + + normalized[key] = NormalizeObjectValue(pair.Value, $"{context}['{key}']"); + } + + return normalized; + } + + private static object? NormalizeObjectValue(object? value, string context) + { + if (value == null) + return null; + + if (value is string || + value is bool || + value is byte || + value is sbyte || + value is short || + value is ushort || + value is int || + value is uint || + value is long || + value is ulong || + value is float || + value is double || + value is decimal) + { + return value; + } + + if (value is JsonElement jsonElement) + return NormalizeJsonElement(jsonElement, context); + + if (value is IReadOnlyDictionary readonlyObjectMap) + return NormalizeObjectMap(readonlyObjectMap, context); + + if (value is IDictionary mutableObjectMap) + return NormalizeObjectMap( + new Dictionary(mutableObjectMap, StringComparer.Ordinal), + context); + + if (value is IReadOnlyDictionary readonlyStringMap) + { + var converted = readonlyStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); + } + + if (value is IDictionary mutableStringMap) + { + var converted = mutableStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); + } + + if (value is IEnumerable objectSequence) + return objectSequence.Select((x, i) => NormalizeObjectValue(x, $"{context}[{i}]")).ToList(); + + if (value is IEnumerable stringSequence) + return stringSequence.Cast().ToList(); + + throw new InvalidOperationException( + $"{context} contains unsupported value type '{value.GetType().FullName}'."); + } + + private static object? NormalizeJsonElement(JsonElement element, string context) + { + return element.ValueKind switch + { + JsonValueKind.Object => element + .EnumerateObject() + .ToDictionary( + x => x.Name, + x => NormalizeJsonElement(x.Value, $"{context}['{x.Name}']"), + StringComparer.Ordinal), + JsonValueKind.Array => element + .EnumerateArray() + .Select((x, i) => NormalizeJsonElement(x, $"{context}[{i}]")) + .ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => NormalizeJsonNumber(element, context), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => throw new InvalidOperationException( + $"{context} contains unsupported json value kind '{element.ValueKind}'."), + }; + } + + private static object NormalizeJsonNumber(JsonElement numberElement, string context) + { + if (numberElement.TryGetInt64(out var int64Value)) + return int64Value; + if (numberElement.TryGetDecimal(out var decimalValue)) + return decimalValue; + if (numberElement.TryGetDouble(out var doubleValue)) + return doubleValue; + + throw new InvalidOperationException($"{context} contains an invalid JSON number value."); + } + + private static async Task EnsureSuccessAsync( + HttpResponseMessage response, + string operation, + CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var payload = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Elasticsearch {operation} failed: {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); + } + + private static Uri ResolvePrimaryEndpoint(IReadOnlyList? endpoints) + { + if (endpoints == null || endpoints.Count == 0) + throw new InvalidOperationException("Elasticsearch provider requires at least one endpoint."); + + var endpoint = endpoints[0].Trim(); + if (endpoint.Length == 0) + throw new InvalidOperationException("Elasticsearch endpoint cannot be empty."); + if (!endpoint.Contains("://", StringComparison.Ordinal)) + endpoint = "http://" + endpoint; + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + throw new InvalidOperationException($"Invalid Elasticsearch endpoint '{endpoints[0]}'."); + + return uri; + } + + private static string BuildIndexName(string indexPrefix, string indexScope) + { + var prefix = NormalizeToken(indexPrefix); + if (prefix.Length == 0) + prefix = "aevatar"; + return $"{prefix}-{indexScope}"; + } + + private static string NormalizeToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + return ""; + + var chars = token + .Trim() + .ToLowerInvariant() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray(); + return new string(chars).Trim('-'); + } + + private static string TruncatePayload(string payload) + { + const int maxLength = 512; + if (payload.Length <= maxLength) + return payload; + + return payload[..maxLength] + "...(truncated)"; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs index 84334f187..e966654dd 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs @@ -8,10 +8,10 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; -public sealed class ElasticsearchProjectionDocumentStore +public sealed partial class ElasticsearchProjectionDocumentStore : IProjectionDocumentStore, IDisposable - where TReadModel : class + where TReadModel : class, IProjectionReadModel { private const string ProviderName = "Elasticsearch"; private const string DefaultListPrimarySortField = "CreatedAt"; @@ -382,318 +382,6 @@ private string FormatKey(TKey key) return JsonSerializer.Deserialize(copyPayload, _jsonOptions); } - private string BuildListPayloadJson(int size) - { - var sort = _listSortField.Length == 0 - ? BuildDefaultSortSpec() - : BuildConfiguredSortSpec(_listSortField); - - return JsonSerializer.Serialize(new - { - size, - sort, - query = new - { - match_all = new { }, - }, - }); - } - - private static object[] BuildConfiguredSortSpec(string sortField) - { - return - [ - new Dictionary - { - [sortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - new Dictionary - { - [DefaultListTiebreakSortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - ]; - } - - private static object[] BuildDefaultSortSpec() - { - return - [ - new Dictionary - { - [DefaultListPrimarySortField] = new Dictionary - { - ["order"] = "desc", - ["missing"] = "_last", - ["unmapped_type"] = "date", - }, - }, - new Dictionary - { - [DefaultListTiebreakSortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - ]; - } - - private async Task EnsureIndexAsync(CancellationToken ct) - { - if (!_autoCreateIndex || _indexInitialized) - return; - - await _indexInitializationLock.WaitAsync(ct); - try - { - if (_indexInitialized) - return; - - var payload = BuildIndexInitializationPayload(); - using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) - { - Content = new StringContent(payload, Encoding.UTF8, "application/json"), - }; - using var response = await _httpClient.SendAsync(request, ct); - if (response.IsSuccessStatusCode) - { - _indexInitialized = true; - return; - } - - var responsePayload = await response.Content.ReadAsStringAsync(ct); - if (response.StatusCode == HttpStatusCode.BadRequest && - responsePayload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) - { - _indexInitialized = true; - return; - } - - throw new InvalidOperationException( - $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={responsePayload}"); - } - finally - { - _indexInitializationLock.Release(); - } - } - - private string BuildIndexInitializationPayload() - { - var mappings = _indexMetadata.Mappings.Count == 0 - ? new Dictionary(StringComparer.Ordinal) - { - ["dynamic"] = true, - } - : new Dictionary(_indexMetadata.Mappings, StringComparer.Ordinal); - - var root = new Dictionary - { - ["mappings"] = mappings, - }; - - if (_indexMetadata.Settings.Count > 0) - root["settings"] = new Dictionary(_indexMetadata.Settings, StringComparer.Ordinal); - if (_indexMetadata.Aliases.Count > 0) - root["aliases"] = new Dictionary(_indexMetadata.Aliases, StringComparer.Ordinal); - - return JsonSerializer.Serialize(root, _jsonOptions); - } - - private static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) - { - ArgumentNullException.ThrowIfNull(metadata); - var normalizedMappings = NormalizeObjectMap(metadata.Mappings, "DocumentIndexMetadata.Mappings"); - var normalizedSettings = NormalizeObjectMap(metadata.Settings, "DocumentIndexMetadata.Settings"); - var normalizedAliases = NormalizeObjectMap(metadata.Aliases, "DocumentIndexMetadata.Aliases"); - return new DocumentIndexMetadata( - metadata.IndexName?.Trim() ?? "", - normalizedMappings, - normalizedSettings, - normalizedAliases); - } - - private static Dictionary NormalizeObjectMap( - IReadOnlyDictionary source, - string context) - { - ArgumentNullException.ThrowIfNull(source); - var normalized = new Dictionary(StringComparer.Ordinal); - foreach (var pair in source) - { - var key = pair.Key?.Trim() ?? ""; - if (key.Length == 0) - throw new InvalidOperationException($"{context} contains an empty key."); - - normalized[key] = NormalizeObjectValue(pair.Value, $"{context}['{key}']"); - } - - return normalized; - } - - private static object? NormalizeObjectValue(object? value, string context) - { - if (value == null) - return null; - - if (value is string || - value is bool || - value is byte || - value is sbyte || - value is short || - value is ushort || - value is int || - value is uint || - value is long || - value is ulong || - value is float || - value is double || - value is decimal) - { - return value; - } - - if (value is JsonElement jsonElement) - return NormalizeJsonElement(jsonElement, context); - - if (value is IReadOnlyDictionary readonlyObjectMap) - return NormalizeObjectMap(readonlyObjectMap, context); - - if (value is IDictionary mutableObjectMap) - return NormalizeObjectMap( - new Dictionary(mutableObjectMap, StringComparer.Ordinal), - context); - - if (value is IReadOnlyDictionary readonlyStringMap) - { - var converted = readonlyStringMap.ToDictionary( - x => x.Key, - x => (object?)x.Value, - StringComparer.Ordinal); - return NormalizeObjectMap(converted, context); - } - - if (value is IDictionary mutableStringMap) - { - var converted = mutableStringMap.ToDictionary( - x => x.Key, - x => (object?)x.Value, - StringComparer.Ordinal); - return NormalizeObjectMap(converted, context); - } - - if (value is IEnumerable objectSequence) - return objectSequence.Select((x, i) => NormalizeObjectValue(x, $"{context}[{i}]")).ToList(); - - if (value is IEnumerable stringSequence) - return stringSequence.Cast().ToList(); - - throw new InvalidOperationException( - $"{context} contains unsupported value type '{value.GetType().FullName}'."); - } - - private static object? NormalizeJsonElement(JsonElement element, string context) - { - return element.ValueKind switch - { - JsonValueKind.Object => element - .EnumerateObject() - .ToDictionary( - x => x.Name, - x => NormalizeJsonElement(x.Value, $"{context}['{x.Name}']"), - StringComparer.Ordinal), - JsonValueKind.Array => element - .EnumerateArray() - .Select((x, i) => NormalizeJsonElement(x, $"{context}[{i}]")) - .ToList(), - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => NormalizeJsonNumber(element, context), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - JsonValueKind.Undefined => null, - _ => throw new InvalidOperationException( - $"{context} contains unsupported json value kind '{element.ValueKind}'."), - }; - } - - private static object NormalizeJsonNumber(JsonElement numberElement, string context) - { - if (numberElement.TryGetInt64(out var int64Value)) - return int64Value; - if (numberElement.TryGetDecimal(out var decimalValue)) - return decimalValue; - if (numberElement.TryGetDouble(out var doubleValue)) - return doubleValue; - - throw new InvalidOperationException($"{context} contains an invalid JSON number value."); - } - - private static async Task EnsureSuccessAsync( - HttpResponseMessage response, - string operation, - CancellationToken ct) - { - if (response.IsSuccessStatusCode) - return; - - var payload = await response.Content.ReadAsStringAsync(ct); - throw new InvalidOperationException( - $"Elasticsearch {operation} failed: {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); - } - - private static Uri ResolvePrimaryEndpoint(IReadOnlyList? endpoints) - { - if (endpoints == null || endpoints.Count == 0) - throw new InvalidOperationException("Elasticsearch provider requires at least one endpoint."); - - var endpoint = endpoints[0].Trim(); - if (endpoint.Length == 0) - throw new InvalidOperationException("Elasticsearch endpoint cannot be empty."); - if (!endpoint.Contains("://", StringComparison.Ordinal)) - endpoint = "http://" + endpoint; - - if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) - throw new InvalidOperationException($"Invalid Elasticsearch endpoint '{endpoints[0]}'."); - - return uri; - } - - private static string BuildIndexName(string indexPrefix, string indexScope) - { - var prefix = NormalizeToken(indexPrefix); - if (prefix.Length == 0) - prefix = "aevatar"; - return $"{prefix}-{indexScope}"; - } - - private static string NormalizeToken(string token) - { - if (string.IsNullOrWhiteSpace(token)) - return ""; - - var chars = token - .Trim() - .ToLowerInvariant() - .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') - .ToArray(); - return new string(chars).Trim('-'); - } - - private static string TruncatePayload(string payload) - { - const int maxLength = 512; - if (payload.Length <= maxLength) - return payload; - - return payload[..maxLength] + "...(truncated)"; - } - public void Dispose() { _httpClient.Dispose(); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 4f65f38d8..4694ca1b3 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -12,7 +12,7 @@ public static IServiceCollection AddInMemoryDocumentProjectionStore? keyFormatter = null, Func? listSortSelector = null, int listTakeMax = 200) - where TReadModel : class + where TReadModel : class, IProjectionReadModel { ArgumentNullException.ThrowIfNull(keySelector); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs index 391a3958c..485d07f90 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs @@ -6,7 +6,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.Stores; public sealed class InMemoryProjectionDocumentStore : IProjectionDocumentStore - where TReadModel : class + where TReadModel : class, IProjectionReadModel { private const string ProviderName = "InMemory"; private readonly object _gate = new(); diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs index cd5eb427a..80ba32546 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs @@ -78,6 +78,7 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = public Task> ListNodesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { @@ -88,6 +89,7 @@ public Task> ListNodesByOwnerAsync( return Task.FromResult>([]); var boundedTake = Math.Clamp(take, 1, 50000); + var boundedSkip = Math.Max(0, skip); List nodes; lock (_gate) { @@ -97,6 +99,7 @@ public Task> ListNodesByOwnerAsync( x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) + .Skip(boundedSkip) .Take(boundedTake) .Select(CloneNode) .ToList(); @@ -108,6 +111,7 @@ public Task> ListNodesByOwnerAsync( public Task> ListEdgesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { @@ -118,6 +122,7 @@ public Task> ListEdgesByOwnerAsync( return Task.FromResult>([]); var boundedTake = Math.Clamp(take, 1, 50000); + var boundedSkip = Math.Max(0, skip); List edges; lock (_gate) { @@ -127,6 +132,7 @@ public Task> ListEdgesByOwnerAsync( x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) + .Skip(boundedSkip) .Take(boundedTake) .Select(CloneEdge) .ToList(); diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 47ef3fcb2..22432bcfc 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -9,16 +9,13 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddNeo4jGraphProjectionStore( this IServiceCollection services, - Func optionsFactory, - Func scopeFactory) + Func optionsFactory) { ArgumentNullException.ThrowIfNull(optionsFactory); - ArgumentNullException.ThrowIfNull(scopeFactory); services.AddSingleton(provider => new Neo4jProjectionGraphStore( optionsFactory(provider), - scopeFactory(provider), provider.GetService>())); return services; diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs new file mode 100644 index 000000000..d5ca9eced --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs @@ -0,0 +1,318 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Neo4j.Driver; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +public sealed partial class Neo4jProjectionGraphStore +{ + private async Task> GetNodesByIdsAsync( + string scope, + IReadOnlySet nodeIds, + CancellationToken ct) + { + if (nodeIds.Count == 0) + return []; + + await EnsureSchemaAsync(ct); + var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + + "WHERE n.nodeId IN $nodeIds " + + "RETURN n.nodeId AS nodeId, " + + "coalesce(n.nodeType, '') AS nodeType, " + + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs"; + var parameters = new Dictionary + { + ["scope"] = scope, + ["nodeIds"] = nodeIds.ToArray(), + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var nodes = new List(rows.Count); + foreach (var row in rows) + { + if (!row.TryGetValue("nodeId", out var nodeIdValue)) + continue; + var nodeId = NormalizeToken(nodeIdValue.As()); + if (nodeId.Length == 0) + continue; + + var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) + ? NormalizeToken(nodeTypeValue.As()) + : "Unknown"; + if (nodeType.Length == 0) + nodeType = "Unknown"; + + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + nodes.Add(new ProjectionGraphNode + { + Scope = scope, + NodeId = nodeId, + NodeType = nodeType, + Properties = DeserializeProperties(propertiesJson), + UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), + }); + } + + return nodes; + } + + private ProjectionGraphEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) + { + if (!row.TryGetValue("edgeId", out var edgeIdValue)) + return null; + if (!row.TryGetValue("fromNodeId", out var fromNodeIdValue)) + return null; + if (!row.TryGetValue("toNodeId", out var toNodeIdValue)) + return null; + + var edgeId = NormalizeToken(edgeIdValue.As()); + var fromNodeId = NormalizeToken(fromNodeIdValue.As()); + var toNodeId = NormalizeToken(toNodeIdValue.As()); + if (edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) + return null; + + var relationType = row.TryGetValue("relationType", out var relationTypeValue) + ? NormalizeToken(relationTypeValue.As()) + : "Unknown"; + if (relationType.Length == 0) + relationType = "Unknown"; + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + return new ProjectionGraphEdge + { + Scope = scope, + EdgeId = edgeId, + FromNodeId = fromNodeId, + ToNodeId = toNodeId, + EdgeType = relationType, + Properties = DeserializeProperties(propertiesJson), + UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), + }; + } + + private string BuildNeighborCypher(ProjectionGraphDirection direction, int take) + { + var filter = "WHERE size($edgeTypes) = 0 OR r.relationType IN $edgeTypes "; + var projection = "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + return direction switch + { + ProjectionGraphDirection.Outbound => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]->(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + ProjectionGraphDirection.Inbound => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + _ => + $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + + filter + + projection, + }; + } + + private string BuildSubgraphEdgesCypher(ProjectionGraphDirection direction, int depth) + { + var boundedDepth = Math.Clamp(depth, 1, _maxTraversalDepth); + var pathPattern = direction switch + { + ProjectionGraphDirection.Outbound => + $"(root)-[:{_edgeType}*1..{boundedDepth}]->()", + ProjectionGraphDirection.Inbound => + $"(root)<-[:{_edgeType}*1..{boundedDepth}]-()", + _ => + $"(root)-[:{_edgeType}*1..{boundedDepth}]-()", + }; + return $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}}) " + + $"OPTIONAL MATCH p={pathPattern} " + + "WHERE p IS NULL OR (" + + "all(n IN nodes(p) WHERE coalesce(n.scope, '') = $scope) " + + "AND (size($edgeTypes) = 0 OR all(rel IN relationships(p) WHERE rel.relationType IN $edgeTypes))) " + + "UNWIND CASE WHEN p IS NULL THEN [] ELSE relationships(p) END AS r " + + "WITH DISTINCT r " + + "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + } + + private async Task EnsureSchemaAsync(CancellationToken ct) + { + if (!_autoCreateConstraints || _schemaInitialized) + return; + + await _schemaLock.WaitAsync(ct); + try + { + if (_schemaInitialized) + return; + + var nodeConstraintName = NormalizeConstraintName($"projection_graph_node_scope_id_{_nodeLabel}"); + var cypher = $"CREATE CONSTRAINT {nodeConstraintName} IF NOT EXISTS " + + $"FOR (n:{_nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; + await ExecuteWriteAsync(cypher, new Dictionary(), ct); + _schemaInitialized = true; + } + finally + { + _schemaLock.Release(); + } + } + + private async Task ExecuteWriteAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Write); + var cursor = await session.RunAsync(cypher, parameters); + await cursor.ConsumeAsync(); + ct.ThrowIfCancellationRequested(); + } + + private async Task>> ExecuteReadAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Read); + var cursor = await session.RunAsync(cypher, parameters); + var rows = await cursor.ToListAsync(record => + (IReadOnlyDictionary)record.Values.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal)); + ct.ThrowIfCancellationRequested(); + return rows; + } + + private IAsyncSession CreateSession(AccessMode accessMode) + { + return _driver.AsyncSession(options => + { + options.WithDefaultAccessMode(accessMode); + if (_database.Length > 0) + options.WithDatabase(_database); + }); + } + + private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) + { + return edgeTypes + .Select(NormalizeToken) + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + private static bool ResolveProjectionManaged(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue)) + return false; + + var normalizedMarker = NormalizeToken(markerValue); + return string.Equals( + normalizedMarker, + ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, + StringComparison.Ordinal); + } + + private static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var ownerId)) + return ""; + + return NormalizeToken(ownerId); + } + + private string SerializeProperties(IReadOnlyDictionary properties) + { + if (properties.Count == 0) + return "{}"; + return JsonSerializer.Serialize(properties, _jsonOptions); + } + + private Dictionary DeserializeProperties(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return new Dictionary(StringComparer.Ordinal); + try + { + var parsed = JsonSerializer.Deserialize>(payload, _jsonOptions); + return parsed == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to deserialize graph properties payload. provider={Provider}", + ProviderName); + return new Dictionary(StringComparer.Ordinal); + } + } + + private static long NormalizeTimestamp(DateTimeOffset timestamp) + { + if (timestamp == default) + return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return timestamp.ToUnixTimeMilliseconds(); + } + + private static DateTimeOffset FromUnixTimeMilliseconds(long value) + { + var safeValue = Math.Max(0, value); + return DateTimeOffset.FromUnixTimeMilliseconds(safeValue); + } + + private static string NormalizeToken(string token) => token?.Trim() ?? ""; + + private static string NormalizeLabel(string rawLabel, string fallback) + { + var label = (rawLabel ?? "").Trim(); + if (label.Length == 0) + label = fallback; + + var chars = label + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + normalized = fallback; + if (char.IsDigit(normalized[0])) + normalized = $"N_{normalized}"; + return normalized; + } + + private static string NormalizeConstraintName(string rawName) + { + var chars = rawName + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + return "projection_graph_constraint"; + if (char.IsDigit(normalized[0])) + normalized = $"c_{normalized}"; + return normalized; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 085c574a7..833f13e80 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -6,13 +6,12 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; -public sealed class Neo4jProjectionGraphStore +public sealed partial class Neo4jProjectionGraphStore : IProjectionGraphStore, IAsyncDisposable { private const string ProviderName = "Neo4j"; private readonly IDriver _driver; - private readonly string _scope; private readonly string _database; private readonly string _nodeLabel; private readonly string _edgeType; @@ -28,13 +27,9 @@ public sealed class Neo4jProjectionGraphStore public Neo4jProjectionGraphStore( Neo4jProjectionGraphStoreOptions options, - string scope, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(options); - ArgumentException.ThrowIfNullOrWhiteSpace(scope); - - _scope = scope.Trim(); _database = options.Database?.Trim() ?? ""; _nodeLabel = NormalizeLabel(options.NodeLabel, "ProjectionGraphNode"); _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); @@ -173,6 +168,7 @@ public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken public async Task> ListNodesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { @@ -184,6 +180,7 @@ public async Task> ListNodesByOwnerAsync( await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(take, 1, 50000); + var boundedSkip = Math.Max(0, skip); var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + "WHERE coalesce(n.projectionManaged, false) = true " + "AND n.projectionOwnerId = $ownerId " + @@ -191,11 +188,12 @@ public async Task> ListNodesByOwnerAsync( "coalesce(n.nodeType, '') AS nodeType, " + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; var parameters = new Dictionary { ["scope"] = scopeValue, ["ownerId"] = ownerValue, + ["skip"] = boundedSkip, ["take"] = boundedTake, }; @@ -239,6 +237,7 @@ public async Task> ListNodesByOwnerAsync( public async Task> ListEdgesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { @@ -250,6 +249,7 @@ public async Task> ListEdgesByOwnerAsync( await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(take, 1, 50000); + var boundedSkip = Math.Max(0, skip); var cypher = $"MATCH ()-[r:{_edgeType} {{scope: $scope}}]->() " + "WHERE coalesce(r.projectionManaged, false) = true " + "AND r.projectionOwnerId = $ownerId " + @@ -259,11 +259,12 @@ public async Task> ListEdgesByOwnerAsync( "coalesce(r.relationType, '') AS relationType, " + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; var parameters = new Dictionary { ["scope"] = scopeValue, ["ownerId"] = ownerValue, + ["skip"] = boundedSkip, ["take"] = boundedTake, }; @@ -378,314 +379,4 @@ public async ValueTask DisposeAsync() await _driver.DisposeAsync(); } - private async Task> GetNodesByIdsAsync( - string scope, - IReadOnlySet nodeIds, - CancellationToken ct) - { - if (nodeIds.Count == 0) - return []; - - await EnsureSchemaAsync(ct); - var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + - "WHERE n.nodeId IN $nodeIds " + - "RETURN n.nodeId AS nodeId, " + - "coalesce(n.nodeType, '') AS nodeType, " + - "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs"; - var parameters = new Dictionary - { - ["scope"] = scope, - ["nodeIds"] = nodeIds.ToArray(), - }; - - var rows = await ExecuteReadAsync(cypher, parameters, ct); - var nodes = new List(rows.Count); - foreach (var row in rows) - { - if (!row.TryGetValue("nodeId", out var nodeIdValue)) - continue; - var nodeId = NormalizeToken(nodeIdValue.As()); - if (nodeId.Length == 0) - continue; - - var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) - ? NormalizeToken(nodeTypeValue.As()) - : "Unknown"; - if (nodeType.Length == 0) - nodeType = "Unknown"; - - var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) - ? propertiesJsonValue.As() - : "{}"; - var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) - ? updatedAtEpochMsValue.As() - : 0L; - - nodes.Add(new ProjectionGraphNode - { - Scope = scope, - NodeId = nodeId, - NodeType = nodeType, - Properties = DeserializeProperties(propertiesJson), - UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), - }); - } - - return nodes; - } - - private ProjectionGraphEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) - { - if (!row.TryGetValue("edgeId", out var edgeIdValue)) - return null; - if (!row.TryGetValue("fromNodeId", out var fromNodeIdValue)) - return null; - if (!row.TryGetValue("toNodeId", out var toNodeIdValue)) - return null; - - var edgeId = NormalizeToken(edgeIdValue.As()); - var fromNodeId = NormalizeToken(fromNodeIdValue.As()); - var toNodeId = NormalizeToken(toNodeIdValue.As()); - if (edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) - return null; - - var relationType = row.TryGetValue("relationType", out var relationTypeValue) - ? NormalizeToken(relationTypeValue.As()) - : "Unknown"; - if (relationType.Length == 0) - relationType = "Unknown"; - var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) - ? propertiesJsonValue.As() - : "{}"; - var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) - ? updatedAtEpochMsValue.As() - : 0L; - - return new ProjectionGraphEdge - { - Scope = scope, - EdgeId = edgeId, - FromNodeId = fromNodeId, - ToNodeId = toNodeId, - EdgeType = relationType, - Properties = DeserializeProperties(propertiesJson), - UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), - }; - } - - private string BuildNeighborCypher(ProjectionGraphDirection direction, int take) - { - var filter = "WHERE size($edgeTypes) = 0 OR r.relationType IN $edgeTypes "; - var projection = "RETURN r.edgeId AS edgeId, " + - "startNode(r).nodeId AS fromNodeId, " + - "endNode(r).nodeId AS toNodeId, " + - "coalesce(r.relationType, '') AS relationType, " + - "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; - return direction switch - { - ProjectionGraphDirection.Outbound => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]->(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - ProjectionGraphDirection.Inbound => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - _ => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - }; - } - - private string BuildSubgraphEdgesCypher(ProjectionGraphDirection direction, int depth) - { - var boundedDepth = Math.Clamp(depth, 1, _maxTraversalDepth); - var pathPattern = direction switch - { - ProjectionGraphDirection.Outbound => - $"(root)-[:{_edgeType}*1..{boundedDepth}]->()", - ProjectionGraphDirection.Inbound => - $"(root)<-[:{_edgeType}*1..{boundedDepth}]-()", - _ => - $"(root)-[:{_edgeType}*1..{boundedDepth}]-()", - }; - return $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}}) " + - $"OPTIONAL MATCH p={pathPattern} " + - "WHERE p IS NULL OR (" + - "all(n IN nodes(p) WHERE coalesce(n.scope, '') = $scope) " + - "AND (size($edgeTypes) = 0 OR all(rel IN relationships(p) WHERE rel.relationType IN $edgeTypes))) " + - "UNWIND CASE WHEN p IS NULL THEN [] ELSE relationships(p) END AS r " + - "WITH DISTINCT r " + - "RETURN r.edgeId AS edgeId, " + - "startNode(r).nodeId AS fromNodeId, " + - "endNode(r).nodeId AS toNodeId, " + - "coalesce(r.relationType, '') AS relationType, " + - "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; - } - - private async Task EnsureSchemaAsync(CancellationToken ct) - { - if (!_autoCreateConstraints || _schemaInitialized) - return; - - await _schemaLock.WaitAsync(ct); - try - { - if (_schemaInitialized) - return; - - var nodeConstraintName = NormalizeConstraintName($"projection_graph_node_scope_id_{_nodeLabel}"); - var cypher = $"CREATE CONSTRAINT {nodeConstraintName} IF NOT EXISTS " + - $"FOR (n:{_nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; - await ExecuteWriteAsync(cypher, new Dictionary(), ct); - _schemaInitialized = true; - } - finally - { - _schemaLock.Release(); - } - } - - private async Task ExecuteWriteAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Write); - var cursor = await session.RunAsync(cypher, parameters); - await cursor.ConsumeAsync(); - ct.ThrowIfCancellationRequested(); - } - - private async Task>> ExecuteReadAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Read); - var cursor = await session.RunAsync(cypher, parameters); - var rows = await cursor.ToListAsync(record => - (IReadOnlyDictionary)record.Values.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal)); - ct.ThrowIfCancellationRequested(); - return rows; - } - - private IAsyncSession CreateSession(AccessMode accessMode) - { - return _driver.AsyncSession(options => - { - options.WithDefaultAccessMode(accessMode); - if (_database.Length > 0) - options.WithDatabase(_database); - }); - } - - private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) - { - return edgeTypes - .Select(NormalizeToken) - .Where(x => x.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToArray(); - } - - private static bool ResolveProjectionManaged(IReadOnlyDictionary properties) - { - if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue)) - return false; - - var normalizedMarker = NormalizeToken(markerValue); - return string.Equals( - normalizedMarker, - ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, - StringComparison.Ordinal); - } - - private static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) - { - if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var ownerId)) - return ""; - - return NormalizeToken(ownerId); - } - - private string SerializeProperties(IReadOnlyDictionary properties) - { - if (properties.Count == 0) - return "{}"; - return JsonSerializer.Serialize(properties, _jsonOptions); - } - - private Dictionary DeserializeProperties(string payload) - { - if (string.IsNullOrWhiteSpace(payload)) - return new Dictionary(StringComparer.Ordinal); - try - { - var parsed = JsonSerializer.Deserialize>(payload, _jsonOptions); - return parsed == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(parsed, StringComparer.Ordinal); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to deserialize graph edge properties payload. provider={Provider} scope={Scope}", - ProviderName, - _scope); - return new Dictionary(StringComparer.Ordinal); - } - } - - private static long NormalizeTimestamp(DateTimeOffset timestamp) - { - if (timestamp == default) - return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - return timestamp.ToUnixTimeMilliseconds(); - } - - private static DateTimeOffset FromUnixTimeMilliseconds(long value) - { - var safeValue = Math.Max(0, value); - return DateTimeOffset.FromUnixTimeMilliseconds(safeValue); - } - - private static string NormalizeToken(string token) => token?.Trim() ?? ""; - - private static string NormalizeLabel(string rawLabel, string fallback) - { - var label = (rawLabel ?? "").Trim(); - if (label.Length == 0) - label = fallback; - - var chars = label - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') - .ToArray(); - var normalized = new string(chars); - if (normalized.Length == 0) - normalized = fallback; - if (char.IsDigit(normalized[0])) - normalized = $"N_{normalized}"; - return normalized; - } - - private static string NormalizeConstraintName(string rawName) - { - var chars = rawName - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') - .ToArray(); - var normalized = new string(chars); - if (normalized.Length == 0) - return "projection_graph_constraint"; - if (char.IsDigit(normalized[0])) - normalized = $"c_{normalized}"; - return normalized; - } } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs new file mode 100644 index 000000000..69d9d40d7 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionStoreBindingAvailability +{ + bool IsConfigured { get; } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs new file mode 100644 index 000000000..744dcccc3 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs @@ -0,0 +1,9 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public interface IProjectionStoreDispatchCompensator + where TReadModel : class, IProjectionReadModel +{ + Task CompensateAsync( + ProjectionStoreDispatchCompensationContext context, + CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs new file mode 100644 index 000000000..f0e7df9e9 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs @@ -0,0 +1,17 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionStoreDispatchCompensationContext + where TReadModel : class, IProjectionReadModel +{ + public required string Operation { get; init; } + + public required string FailedStore { get; init; } + + public required IReadOnlyList SucceededStores { get; init; } + + public required TReadModel ReadModel { get; init; } + + public required Exception Exception { get; init; } + + public TKey? Key { get; init; } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchOptions.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchOptions.cs new file mode 100644 index 000000000..a193fb4b5 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchOptions.cs @@ -0,0 +1,6 @@ +namespace Aevatar.CQRS.Projection.Runtime.Abstractions; + +public sealed class ProjectionStoreDispatchOptions +{ + public int MaxWriteAttempts { get; set; } = 3; +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index bacc46ac3..4a3b4bf88 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -8,9 +8,12 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { + services.TryAddSingleton(new ProjectionStoreDispatchOptions()); + services.TryAddSingleton(typeof(IProjectionStoreDispatchCompensator<,>), typeof(LoggingProjectionStoreDispatchCompensator<,>)); services.TryAddSingleton(typeof(IProjectionStoreDispatcher<,>), typeof(ProjectionStoreDispatcher<,>)); services.TryAddSingleton(typeof(IProjectionQueryableStoreBinding<,>), typeof(ProjectionDocumentStoreBinding<,>)); services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IProjectionStoreBinding<,>), typeof(ProjectionDocumentStoreBinding<,>))); + services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IProjectionStoreBinding<,>), typeof(ProjectionGraphStoreBinding<,>))); services.TryAddSingleton(); return services; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index f39bc5cea..f346b57a8 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -18,10 +18,11 @@ - `IProjectionStoreDispatcher<,>` -> `ProjectionStoreDispatcher<,>` - `IProjectionQueryableStoreBinding<,>` -> `ProjectionDocumentStoreBinding<,>` - `IProjectionStoreBinding<,>`(默认) -> `ProjectionDocumentStoreBinding<,>` +- `IProjectionStoreBinding<,>`(默认) -> `ProjectionGraphStoreBinding<,>` - `IProjectionDocumentMetadataResolver` -> `ProjectionDocumentMetadataResolver` ## 语义 1. Runtime 负责“一对多 store 分发”,不做 ProviderName 路由。 -2. Document 与 Graph 保持平行;Graph 通过额外 binding 接入。 -3. 查询统一走唯一 queryable binding;写入同时分发到所有 binding。 +2. Document 与 Graph 保持平行;Runtime 默认同时装配两类 binding,按配置自动激活。 +3. queryable binding 为可选(0..1);存在时提供 `Get/List/Mutate`,写入始终分发到所有已配置 binding。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/LoggingProjectionStoreDispatchCompensator.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/LoggingProjectionStoreDispatchCompensator.cs new file mode 100644 index 000000000..425ad5ad4 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/LoggingProjectionStoreDispatchCompensator.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.CQRS.Projection.Runtime.Runtime; + +public sealed class LoggingProjectionStoreDispatchCompensator + : IProjectionStoreDispatchCompensator + where TReadModel : class, IProjectionReadModel +{ + private readonly ILogger> _logger; + + public LoggingProjectionStoreDispatchCompensator( + ILogger>? logger = null) + { + _logger = logger ?? NullLogger>.Instance; + } + + public Task CompensateAsync( + ProjectionStoreDispatchCompensationContext context, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ct.ThrowIfCancellationRequested(); + + _logger.LogWarning( + context.Exception, + "Projection dispatch compensation executed. readModelType={ReadModelType} operation={Operation} failedStore={FailedStore} succeededStores={SucceededStores}", + typeof(TReadModel).FullName, + context.Operation, + context.FailedStore, + string.Join(",", context.SucceededStores)); + return Task.CompletedTask; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs index 4f08e174e..a37d66101 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs @@ -1,35 +1,48 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionDocumentStoreBinding - : IProjectionQueryableStoreBinding + : IProjectionQueryableStoreBinding, + IProjectionStoreBindingAvailability where TReadModel : class, IProjectionReadModel { - private readonly IProjectionDocumentStore _store; + private readonly IProjectionDocumentStore? _store; - public ProjectionDocumentStoreBinding(IProjectionDocumentStore store) + public ProjectionDocumentStoreBinding(IProjectionDocumentStore? store = null) { _store = store; } - public string StoreName => "Document"; + public bool IsConfigured => _store is not null; + + public string StoreName => IsConfigured ? "Document" : "Document(Unconfigured)"; public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { + if (_store is null) + return Task.CompletedTask; + return _store.UpsertAsync(readModel, ct); } public Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) { - return _store.MutateAsync(key, mutate, ct); + return GetRequiredStore().MutateAsync(key, mutate, ct); } public Task GetAsync(TKey key, CancellationToken ct = default) { - return _store.GetAsync(key, ct); + return GetRequiredStore().GetAsync(key, ct); } public Task> ListAsync(int take = 50, CancellationToken ct = default) { - return _store.ListAsync(take, ct); + return GetRequiredStore().ListAsync(take, ct); + } + + private IProjectionDocumentStore GetRequiredStore() + { + return _store ?? + throw new InvalidOperationException( + $"Document projection store is not configured for read model '{typeof(TReadModel).FullName}'."); } } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs index 3868ee693..4f9f9b4cd 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs @@ -1,38 +1,48 @@ namespace Aevatar.CQRS.Projection.Runtime.Runtime; public sealed class ProjectionGraphStoreBinding - : IProjectionStoreBinding - where TReadModel : class, IGraphReadModel + : IProjectionStoreBinding, + IProjectionStoreBindingAvailability + where TReadModel : class, IProjectionReadModel { - private readonly IProjectionGraphStore _graphStore; + private const int CleanupPageSize = 1000; + private const int CleanupMaxItems = 1_000_000; - public ProjectionGraphStoreBinding(IProjectionGraphStore graphStore) + private readonly IProjectionGraphStore? _graphStore; + + public ProjectionGraphStoreBinding(IProjectionGraphStore? graphStore = null) { _graphStore = graphStore; } - public string StoreName => "Graph"; + public bool IsConfigured => + _graphStore is not null && + typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel)); + + public string StoreName => IsConfigured ? "Graph" : "Graph(Unconfigured)"; public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(readModel); ct.ThrowIfCancellationRequested(); + if (_graphStore is null || readModel is not IGraphReadModel graphReadModel) + return; - var scope = NormalizeToken(readModel.GraphScope); + var scope = NormalizeToken(graphReadModel.GraphScope); if (scope.Length == 0) { throw new InvalidOperationException( $"Graph scope is required for read model '{typeof(TReadModel).FullName}'."); } - var ownerId = BuildManagedOwnerId(readModel); - var normalizedNodes = NormalizeNodes(readModel.GraphNodes, scope, ownerId); + var ownerId = BuildManagedOwnerId(graphReadModel); + var normalizedNodes = NormalizeNodes(graphReadModel.GraphNodes, scope, ownerId); foreach (var node in normalizedNodes) - await _graphStore.UpsertNodeAsync(node, ct); + await GraphStore.UpsertNodeAsync(node, ct); - var normalizedEdges = NormalizeEdges(readModel.GraphEdges, scope, ownerId); + var normalizedEdges = NormalizeEdges(graphReadModel.GraphEdges, scope, ownerId); foreach (var edge in normalizedEdges) - await _graphStore.UpsertEdgeAsync(edge, ct); + await GraphStore.UpsertEdgeAsync(edge, ct); var targetNodeIds = normalizedNodes .Select(x => x.NodeId) @@ -41,27 +51,97 @@ public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = defau .Select(x => x.EdgeId) .ToHashSet(StringComparer.Ordinal); - var existingManagedEdges = await _graphStore.ListEdgesByOwnerAsync(scope, ownerId, take: 50000, ct); - foreach (var edge in existingManagedEdges.Where(IsManagedEdge)) + var existingManagedEdges = await ListManagedEdgesByOwnerAsync(scope, ownerId, ct); + foreach (var edge in existingManagedEdges) { if (targetEdgeIds.Contains(edge.EdgeId)) continue; - await _graphStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); + await GraphStore.DeleteEdgeAsync(scope, edge.EdgeId, ct); } - var existingManagedNodes = await _graphStore.ListNodesByOwnerAsync(scope, ownerId, take: 50000, ct); - foreach (var node in existingManagedNodes.Where(IsManagedNode)) + var existingManagedNodes = await ListManagedNodesByOwnerAsync(scope, ownerId, ct); + foreach (var node in existingManagedNodes) { if (targetNodeIds.Contains(node.NodeId)) continue; if (!await CanDeleteNodeAsync(scope, node.NodeId, ct)) continue; - await _graphStore.DeleteNodeAsync(scope, node.NodeId, ct); + await GraphStore.DeleteNodeAsync(scope, node.NodeId, ct); } } + private async Task> ListManagedEdgesByOwnerAsync( + string scope, + string ownerId, + CancellationToken ct) + { + var result = new List(); + var skip = 0; + while (true) + { + ct.ThrowIfCancellationRequested(); + var page = await GraphStore.ListEdgesByOwnerAsync( + scope, + ownerId, + skip: skip, + take: CleanupPageSize, + ct); + if (page.Count == 0) + break; + + result.AddRange(page.Where(IsManagedEdge)); + if (result.Count > CleanupMaxItems) + { + throw new InvalidOperationException( + $"Graph cleanup exceeded maximum edge scan limit ({CleanupMaxItems}) for read model '{typeof(TReadModel).FullName}'."); + } + + if (page.Count < CleanupPageSize) + break; + + skip += page.Count; + } + + return result; + } + + private async Task> ListManagedNodesByOwnerAsync( + string scope, + string ownerId, + CancellationToken ct) + { + var result = new List(); + var skip = 0; + while (true) + { + ct.ThrowIfCancellationRequested(); + var page = await GraphStore.ListNodesByOwnerAsync( + scope, + ownerId, + skip: skip, + take: CleanupPageSize, + ct); + if (page.Count == 0) + break; + + result.AddRange(page.Where(IsManagedNode)); + if (result.Count > CleanupMaxItems) + { + throw new InvalidOperationException( + $"Graph cleanup exceeded maximum node scan limit ({CleanupMaxItems}) for read model '{typeof(TReadModel).FullName}'."); + } + + if (page.Count < CleanupPageSize) + break; + + skip += page.Count; + } + + return result; + } + private static string BuildManagedOwnerId(IGraphReadModel readModel) { var readModelId = NormalizeToken(readModel.Id); @@ -179,7 +259,7 @@ private async Task CanDeleteNodeAsync( if (nodeId.Length == 0) return false; - var neighbors = await _graphStore.GetNeighborsAsync( + var neighbors = await GraphStore.GetNeighborsAsync( new ProjectionGraphQuery { Scope = scope, @@ -193,4 +273,9 @@ private async Task CanDeleteNodeAsync( } private static string NormalizeToken(string? token) => token?.Trim() ?? ""; + + private IProjectionGraphStore GraphStore => + _graphStore ?? + throw new InvalidOperationException( + $"Graph projection store is not configured for read model '{typeof(TReadModel).FullName}'."); } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs index 3fa293dc5..adb8155d6 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs @@ -9,42 +9,52 @@ public sealed class ProjectionStoreDispatcher { private readonly IReadOnlyList> _bindings; private readonly IReadOnlyList> _writeOnlyBindings; - private readonly IProjectionQueryableStoreBinding _queryBinding; + private readonly IProjectionQueryableStoreBinding? _queryBinding; + private readonly IProjectionStoreDispatchCompensator _compensator; + private readonly ProjectionStoreDispatchOptions _options; private readonly ILogger> _logger; public ProjectionStoreDispatcher( IEnumerable> bindings, + IProjectionStoreDispatchCompensator? compensator = null, + ProjectionStoreDispatchOptions? options = null, ILogger>? logger = null) { ArgumentNullException.ThrowIfNull(bindings); + _compensator = compensator ?? NoOpProjectionStoreDispatchCompensator.Instance; + _options = options ?? new ProjectionStoreDispatchOptions(); _logger = logger ?? NullLogger>.Instance; - _bindings = bindings.ToList(); + _bindings = bindings + .Where(IsBindingConfigured) + .ToList(); if (_bindings.Count == 0) { throw new InvalidOperationException( - $"No projection store bindings are registered for read model '{typeof(TReadModel).FullName}'."); + $"No configured projection store bindings are registered for read model '{typeof(TReadModel).FullName}'."); } var queryBindings = _bindings .OfType>() .ToList(); - if (queryBindings.Count != 1) + if (queryBindings.Count > 1) { throw new InvalidOperationException( - $"Exactly one queryable projection store binding is required for read model '{typeof(TReadModel).FullName}', but {queryBindings.Count} were registered."); + $"At most one queryable projection store binding is allowed for read model '{typeof(TReadModel).FullName}', but {queryBindings.Count} were registered."); } - _queryBinding = queryBindings[0]; - _writeOnlyBindings = _bindings - .Where(x => x is not IProjectionQueryableStoreBinding) - .ToList(); + _queryBinding = queryBindings.SingleOrDefault(); + _writeOnlyBindings = _queryBinding is null + ? _bindings + : _bindings + .Where(x => !ReferenceEquals(x, _queryBinding)) + .ToList(); _logger.LogInformation( "Projection store dispatcher initialized. readModelType={ReadModelType} bindingCount={BindingCount} queryStore={QueryStore}", typeof(TReadModel).FullName, _bindings.Count, - _queryBinding.StoreName); + _queryBinding?.StoreName ?? "none"); } public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) @@ -52,10 +62,27 @@ public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = defau ArgumentNullException.ThrowIfNull(readModel); ct.ThrowIfCancellationRequested(); + var succeededBindings = new List>(); foreach (var binding in _bindings) { ct.ThrowIfCancellationRequested(); - await binding.UpsertAsync(readModel, ct); + try + { + await UpsertWithRetryAsync(binding, readModel, ct); + succeededBindings.Add(binding); + } + catch (Exception ex) + { + await CompensateAsync( + operation: "upsert", + key: default, + readModel, + succeededBindings, + binding, + ex, + ct); + throw; + } } } @@ -64,31 +91,135 @@ public async Task MutateAsync(TKey key, Action mutate, CancellationT ArgumentNullException.ThrowIfNull(mutate); ct.ThrowIfCancellationRequested(); - await _queryBinding.MutateAsync(key, mutate, ct); + var queryBinding = GetRequiredQueryBinding(); + await queryBinding.MutateAsync(key, mutate, ct); if (_writeOnlyBindings.Count == 0) return; - var updated = await _queryBinding.GetAsync(key, ct); + var updated = await queryBinding.GetAsync(key, ct); if (updated == null) { throw new InvalidOperationException( - $"Projection store mutate completed but query store '{_queryBinding.StoreName}' returned null for read model '{typeof(TReadModel).FullName}'."); + $"Projection store mutate completed but query store '{queryBinding.StoreName}' returned null for read model '{typeof(TReadModel).FullName}'."); } + var succeededWriteOnlyBindings = new List>(); foreach (var binding in _writeOnlyBindings) { ct.ThrowIfCancellationRequested(); - await binding.UpsertAsync(updated, ct); + try + { + await UpsertWithRetryAsync(binding, updated, ct); + succeededWriteOnlyBindings.Add(binding); + } + catch (Exception ex) + { + await CompensateAsync( + operation: "mutate", + key, + updated, + succeededWriteOnlyBindings, + binding, + ex, + ct); + throw; + } } } public Task GetAsync(TKey key, CancellationToken ct = default) { - return _queryBinding.GetAsync(key, ct); + return GetRequiredQueryBinding().GetAsync(key, ct); } public Task> ListAsync(int take = 50, CancellationToken ct = default) { - return _queryBinding.ListAsync(take, ct); + return GetRequiredQueryBinding().ListAsync(take, ct); + } + + private async Task UpsertWithRetryAsync( + IProjectionStoreBinding binding, + TReadModel readModel, + CancellationToken ct) + { + var maxAttempts = Math.Max(1, _options.MaxWriteAttempts); + Exception? lastException = null; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + ct.ThrowIfCancellationRequested(); + try + { + await binding.UpsertAsync(readModel, ct); + return; + } + catch (Exception ex) when (attempt < maxAttempts) + { + lastException = ex; + _logger.LogWarning( + ex, + "Projection binding write failed and will retry. readModelType={ReadModelType} store={Store} attempt={Attempt}/{MaxAttempts}", + typeof(TReadModel).FullName, + binding.StoreName, + attempt, + maxAttempts); + } + catch (Exception ex) + { + lastException = ex; + break; + } + } + + throw new InvalidOperationException( + $"Projection binding write failed for store '{binding.StoreName}' after {maxAttempts} attempt(s).", + lastException); + } + + private Task CompensateAsync( + string operation, + TKey? key, + TReadModel readModel, + IReadOnlyList> succeededBindings, + IProjectionStoreBinding failedBinding, + Exception exception, + CancellationToken ct) + { + var context = new ProjectionStoreDispatchCompensationContext + { + Operation = operation, + Key = key, + ReadModel = readModel, + FailedStore = failedBinding.StoreName, + SucceededStores = succeededBindings.Select(x => x.StoreName).ToList(), + Exception = exception, + }; + return _compensator.CompensateAsync(context, ct); + } + + private IProjectionQueryableStoreBinding GetRequiredQueryBinding() + { + return _queryBinding ?? + throw new InvalidOperationException( + $"Queryable projection store binding is not configured for read model '{typeof(TReadModel).FullName}'."); + } + + private static bool IsBindingConfigured(IProjectionStoreBinding binding) + { + return binding is not IProjectionStoreBindingAvailability availability || availability.IsConfigured; + } + + private sealed class NoOpProjectionStoreDispatchCompensator + : IProjectionStoreDispatchCompensator + { + public static NoOpProjectionStoreDispatchCompensator Instance { get; } = new(); + + public Task CompensateAsync( + ProjectionStoreDispatchCompensationContext context, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } } } diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs index 135a1a1b0..4b714b5b1 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs @@ -13,12 +13,14 @@ public interface IProjectionGraphStore Task> ListNodesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default); Task> ListEdgesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default); diff --git a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs index 5bf50a3cd..d17d28ae4 100644 --- a/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs +++ b/src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs @@ -1,7 +1,7 @@ namespace Aevatar.CQRS.Projection.Stores.Abstractions; public interface IProjectionDocumentStore - where TReadModel : class + where TReadModel : class, IProjectionReadModel { Task UpsertAsync(TReadModel readModel, CancellationToken ct = default); diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index 6e27e9fb4..a55179d4a 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -4,7 +4,6 @@ using Aevatar.CQRS.Projection.Providers.Neo4j.Configuration; using Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; using Aevatar.CQRS.Projection.Runtime.Abstractions; -using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.Workflow.Projection.ReadModels; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -76,16 +75,13 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( if (enableNeo4jGraph) { services.AddNeo4jGraphProjectionStore( - optionsFactory: _ => BuildNeo4jGraphOptions(configuration), - scopeFactory: _ => WorkflowExecutionGraphConstants.Scope); + optionsFactory: _ => BuildNeo4jGraphOptions(configuration)); } else { services.AddInMemoryGraphProjectionStore(); } - services.AddSingleton, ProjectionGraphStoreBinding>(); - return services; } diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs index fa031f654..aeefa8b2f 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ElasticsearchProjectionDocumentStoreBehaviorTests.cs @@ -238,7 +238,7 @@ protected override async Task SendAsync( private sealed record CapturedRequest(string Method, string PathAndQuery, string Body); - private sealed class StoreReadModel + private sealed class StoreReadModel : IProjectionReadModel { public string Id { get; set; } = ""; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs index 18eb0c419..18c86bf23 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs @@ -155,6 +155,52 @@ await act.Should().ThrowAsync() .WithMessage("*requires a non-empty Id*"); } + [Fact] + public async Task UpsertAsync_WhenOwnerGraphLarge_ShouldPageThroughOwnerCleanup() + { + var store = new RecordingGraphStore(); + var binding = new ProjectionGraphStoreBinding(store); + + var largeNodes = Enumerable.Range(0, 1205) + .Select(i => Node($"n-{i}")) + .ToList(); + var largeEdges = Enumerable.Range(0, 1205) + .Select(i => Edge($"e-{i}", "root", $"n-{i}")) + .ToList(); + largeNodes.Insert(0, Node("root")); + + await binding.UpsertAsync(new TestGraphReadModel + { + Id = "owner-large", + GraphScope = "scope-1", + GraphNodes = largeNodes, + GraphEdges = largeEdges, + }); + + await binding.UpsertAsync(new TestGraphReadModel + { + Id = "owner-large", + GraphScope = "scope-1", + GraphNodes = + [ + Node("root"), + ], + GraphEdges = [], + }); + + var rootNeighbors = await store.GetNeighborsAsync(new ProjectionGraphQuery + { + Scope = "scope-1", + RootNodeId = "root", + Direction = ProjectionGraphDirection.Both, + EdgeTypes = [], + Take = 5000, + }); + + store.ListEdgesByOwnerCallCount.Should().BeGreaterThan(1); + rootNeighbors.Should().BeEmpty(); + } + private static string BuildOwnerId(string id) => $"{typeof(TestGraphReadModel).FullName}:{id}"; private static ProjectionGraphNode Node(string nodeId) @@ -200,6 +246,8 @@ private sealed class RecordingGraphStore : IProjectionGraphStore private readonly Dictionary _nodes = new(StringComparer.Ordinal); private readonly Dictionary _edges = new(StringComparer.Ordinal); + public int ListEdgesByOwnerCallCount { get; private set; } + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); @@ -235,10 +283,12 @@ public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = public Task> ListEdgesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); + ListEdgesByOwnerCallCount++; var scopeValue = NormalizeToken(scope); var ownerValue = NormalizeToken(ownerId); if (scopeValue.Length == 0 || ownerValue.Length == 0) @@ -253,6 +303,7 @@ public Task> ListEdgesByOwnerAsync( x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var edgeOwnerId) && string.Equals(NormalizeToken(edgeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) + .Skip(Math.Max(0, skip)) .Take(Math.Clamp(take, 1, 50000)) .Select(CloneEdge) .ToList(); @@ -264,6 +315,7 @@ public Task> ListEdgesByOwnerAsync( public Task> ListNodesByOwnerAsync( string scope, string ownerId, + int skip = 0, int take = 5000, CancellationToken ct = default) { @@ -282,6 +334,7 @@ public Task> ListNodesByOwnerAsync( x.Properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var nodeOwnerId) && string.Equals(NormalizeToken(nodeOwnerId), ownerValue, StringComparison.Ordinal)) .OrderByDescending(x => x.UpdatedAt) + .Skip(Math.Max(0, skip)) .Take(Math.Clamp(take, 1, 50000)) .Select(CloneNode) .ToList(); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs index 280e7829f..fce144530 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionProviderE2EIntegrationTests.cs @@ -59,7 +59,7 @@ private static string GetRequiredEnvironmentVariable(string name) throw new InvalidOperationException($"Environment variable '{name}' is required."); } - private sealed class ProviderStoreSmokeReadModel + private sealed class ProviderStoreSmokeReadModel : IProjectionReadModel { public string Id { get; set; } = ""; diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs index cfe020743..82d0a6cda 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs @@ -49,13 +49,58 @@ await dispatcher.UpsertAsync(new TestReadModel } [Fact] - public void Ctor_WhenQueryableBindingMissing_ShouldThrow() + public async Task UpsertAsync_WhenQueryableBindingMissing_ShouldWriteWriteOnlyBindings() { - Action act = () => new ProjectionStoreDispatcher( + var writeOnly = new RecordingBinding("write-only"); + var dispatcher = new ProjectionStoreDispatcher( + [writeOnly]); + + await dispatcher.UpsertAsync(new TestReadModel + { + Id = "id-1", + Value = "v1", + }); + + writeOnly.UpsertCount.Should().Be(1); + } + + [Fact] + public async Task MutateAsync_WhenQueryableBindingMissing_ShouldThrow() + { + var dispatcher = new ProjectionStoreDispatcher( + [new RecordingBinding("write-only")]); + + Func act = () => dispatcher.MutateAsync("id-1", model => model.Value = "v2"); + + await act.Should().ThrowAsync() + .WithMessage("*Queryable projection store binding is not configured*"); + } + + [Fact] + public async Task GetAndList_WhenQueryableBindingMissing_ShouldThrow() + { + var dispatcher = new ProjectionStoreDispatcher( [new RecordingBinding("write-only")]); + Func getAct = async () => _ = await dispatcher.GetAsync("id-1"); + Func listAct = async () => _ = await dispatcher.ListAsync(); + + await getAct.Should().ThrowAsync() + .WithMessage("*Queryable projection store binding is not configured*"); + await listAct.Should().ThrowAsync() + .WithMessage("*Queryable projection store binding is not configured*"); + } + + [Fact] + public void Ctor_WhenNoConfiguredBindings_ShouldThrow() + { + var unconfiguredDocumentBinding = new ProjectionDocumentStoreBinding(); + + Action act = () => new ProjectionStoreDispatcher( + [unconfiguredDocumentBinding]); + act.Should().Throw() - .WithMessage("*Exactly one queryable projection store binding is required*"); + .WithMessage("*No configured projection store bindings*"); } [Fact] @@ -65,7 +110,57 @@ public void Ctor_WhenMultipleQueryableBindings_ShouldThrow() [new TestQueryableBinding(), new TestQueryableBinding()]); act.Should().Throw() - .WithMessage("*Exactly one queryable projection store binding is required*"); + .WithMessage("*At most one queryable projection store binding is allowed*"); + } + + [Fact] + public async Task UpsertAsync_WhenBindingFailsInitially_ShouldRetry() + { + var queryBinding = new TestQueryableBinding(); + var flakyGraphBinding = new FlakyBinding("graph", failCountBeforeSuccess: 1); + var dispatcher = new ProjectionStoreDispatcher( + [queryBinding, flakyGraphBinding], + options: new ProjectionStoreDispatchOptions + { + MaxWriteAttempts = 2, + }); + + await dispatcher.UpsertAsync(new TestReadModel + { + Id = "id-1", + Value = "v1", + }); + + flakyGraphBinding.AttemptCount.Should().Be(2); + flakyGraphBinding.UpsertCount.Should().Be(1); + } + + [Fact] + public async Task UpsertAsync_WhenBindingFailsAfterRetries_ShouldInvokeCompensator() + { + var queryBinding = new TestQueryableBinding(); + var failingBinding = new FlakyBinding("graph", failCountBeforeSuccess: int.MaxValue); + var compensator = new RecordingCompensator(); + var dispatcher = new ProjectionStoreDispatcher( + [queryBinding, failingBinding], + compensator: compensator, + options: new ProjectionStoreDispatchOptions + { + MaxWriteAttempts = 2, + }); + + Func act = () => dispatcher.UpsertAsync(new TestReadModel + { + Id = "id-1", + Value = "v1", + }); + + await act.Should().ThrowAsync() + .WithMessage("*after 2 attempt*"); + compensator.LastContext.Should().NotBeNull(); + compensator.LastContext!.Operation.Should().Be("upsert"); + compensator.LastContext.FailedStore.Should().Be("graph"); + compensator.LastContext.SucceededStores.Should().ContainSingle("document"); } private sealed class TestReadModel : IProjectionReadModel @@ -97,6 +192,54 @@ public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) } } + private sealed class FlakyBinding : IProjectionStoreBinding + { + private readonly int _failCountBeforeSuccess; + private int _remainingFailures; + + public FlakyBinding(string storeName, int failCountBeforeSuccess) + { + StoreName = storeName; + _failCountBeforeSuccess = failCountBeforeSuccess; + _remainingFailures = failCountBeforeSuccess; + } + + public string StoreName { get; } + + public int AttemptCount { get; private set; } + + public int UpsertCount { get; private set; } + + public Task UpsertAsync(TestReadModel readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + AttemptCount++; + if (_remainingFailures > 0) + { + _remainingFailures--; + throw new InvalidOperationException( + $"Binding '{StoreName}' failed. remainingFailures={_remainingFailures} failCountBeforeSuccess={_failCountBeforeSuccess}"); + } + + UpsertCount++; + return Task.CompletedTask; + } + } + + private sealed class RecordingCompensator : IProjectionStoreDispatchCompensator + { + public ProjectionStoreDispatchCompensationContext? LastContext { get; private set; } + + public Task CompensateAsync( + ProjectionStoreDispatchCompensationContext context, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + LastContext = context; + return Task.CompletedTask; + } + } + private sealed class TestQueryableBinding : IProjectionQueryableStoreBinding { private readonly Dictionary _items = new(StringComparer.Ordinal); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index e18a3253a..6f6db1ad5 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -2,7 +2,6 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.Configuration; using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; -using Aevatar.CQRS.Projection.Runtime.Runtime; using Aevatar.CQRS.Projection.Stores.Abstractions; using Aevatar.Workflow.Projection.DependencyInjection; using Aevatar.Workflow.Projection.ReadModels; @@ -69,7 +68,6 @@ private static void RegisterInMemoryProviders(IServiceCollection services) listSortSelector: report => report.CreatedAt, listTakeMax: 200); services.AddInMemoryGraphProjectionStore(); - services.AddSingleton, ProjectionGraphStoreBinding>(); } private static void RegisterElasticsearchDocumentProvider(IServiceCollection services) From 7900b53963aeb719d140a1c6b48e2b98bc4b90d9 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 06:00:47 +0800 Subject: [PATCH 43/46] Add State Mirror Projection and Update Dependency Injection - Introduced `IStateMirrorReadModelProjector` interface to facilitate the projection of state to read models, enhancing the architecture's flexibility. - Implemented `StateMirrorReadModelProjector` class to handle state projection and upsert operations, integrating with the existing projection store dispatcher. - Updated `ServiceCollectionExtensions` to include new methods for registering state mirror projections and read model projectors, improving service configuration. - Enhanced documentation to outline the new state mirror projection capabilities and provide usage examples, ensuring clarity for developers. - Added a comprehensive audit scorecard for the state mirror projection architecture, detailing evaluation criteria and findings for future improvements. --- ...all-classes-redundancy-audit-2026-02-24.md | 261 ++++++++++++++++++ .../IStateMirrorReadModelProjector.cs | 18 ++ ...Aevatar.CQRS.Projection.StateMirror.csproj | 3 + .../ServiceCollectionExtensions.cs | 32 ++- .../README.md | 34 ++- .../Services/StateMirrorReadModelProjector.cs | 49 ++++ .../StateMirrorProjectionTests.cs | 128 +++++++++ 7 files changed, 519 insertions(+), 6 deletions(-) create mode 100644 docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorReadModelProjector.cs create mode 100644 src/Aevatar.CQRS.Projection.StateMirror/Services/StateMirrorReadModelProjector.cs diff --git a/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md b/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md new file mode 100644 index 000000000..2b5ec8617 --- /dev/null +++ b/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md @@ -0,0 +1,261 @@ +# Projection 全量类架构审计与打分(冗余专项,2026-02-24) + +- 审计日期:2026-02-24 +- 审计范围:`src/Aevatar.CQRS.Projection.*`(不含 `obj/bin` 自动生成文件) +- 审计对象:75 个公开类型(按“类型”去重,`partial` 多文件实现按单类型计) +- 审计重点:冗余(重复抽象、职责重叠、实现不并行、无效层) + +--- + +## 1. 审计方法 + +### 1.1 打分模型(10 分制) + +- `40%` 冗余风险(重复层、重复语义、无效中间层) +- `25%` 边界清晰度(职责是否单一、是否跨层) +- `20%` 并行一致性(Document/Graph、抽象/实现是否对称) +- `15%` 可维护性(复杂度、可读性、变更成本) + +### 1.2 判定规则 + +1. 同职责同层重复实现且无差异语义,直接扣分。 +2. 为补丁式场景引入额外抽象(例如“可配置态”标记)按“必要但增心智”计中等扣分。 +3. Provider 大类高耦合(查询构造、序列化、存储协议混合)按维护冗余计分。 +4. 仅命名重复但语义清晰(如 DI `ServiceCollectionExtensions`)记低风险,不作为结构冗余。 + +--- + +## 2. 总体结论 + +- **总体分数:9.14 / 10(四舍五入:9.1)** +- 主干架构已稳定:`Store Abstractions -> Runtime Abstractions -> Runtime -> Providers`。 +- Document/Graph 已是平行关系,`1 ReadModel -> N Stores` 已实装。 +- 主要扣分点集中在 Provider 主类复杂度和少量“为容错引入的新抽象”。 + +--- + +## 3. 目标架构图(当前实现) + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + RM["ReadModel"] --> DSP["ProjectionStoreDispatcher"] + DSP --> DB["ProjectionDocumentStoreBinding"] + DSP --> GB["ProjectionGraphStoreBinding"] + + DB --> DS["IProjectionDocumentStore"] + GB --> GS["IProjectionGraphStore"] + + DS --> EP["Elasticsearch/InMemory"] + GS --> NP["Neo4j/InMemory"] + + DSP --> Q["Get/List/Mutate (queryable 0..1)"] +``` + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart TD + A["Stores.Abstractions"] --> B["Runtime.Abstractions"] + B --> C["Runtime"] + A --> D["Providers.InMemory"] + A --> E["Providers.Elasticsearch"] + A --> F["Providers.Neo4j"] + A --> G["StateMirror"] +``` + +--- + +## 4. 冗余问题清单(按严重度) + +### 4.1 Medium + +1. Provider 主类复杂度仍高(维护冗余)。 +- 证据: + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs`(400 行) + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs`(320 行) + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs`(382 行) + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs`(318 行) +- 影响:查询构造、序列化、存储协议聚合在同一类型内,演进成本偏高。 + +2. Runtime 为“无配置 binding 自动失活”引入额外能力抽象,降低了显式性但增加心智负担。 +- 证据: + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs:3` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:207` +- 影响:抽象数量变多,排障时需要同时理解 Binding + Availability 双层语义。 + +### 4.2 Low + +1. `ServiceCollectionExtensions` 在多个投影项目重复命名。 +- 证据: + - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7` + - `src/Aevatar.CQRS.Projection.Providers.*/*/ServiceCollectionExtensions.cs` + - `src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs:9` +- 影响:IDE 搜索时噪声增加,但不构成架构冗余。 + +2. `StateMirror` 与业务 mapper 并存,存在“映射策略双轨”潜在重叠。 +- 证据: + - `src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs:8` + - `src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs:5`(项目内另一路映射) +- 影响:不是错误,但应明确“自动镜像 vs 业务映射”适用边界。 + +### 4.3 High / Critical + +- 未发现阻断级冗余(0 项)。 + +--- + +## 5. 分项目打分 + +| 项目 | 公开类型数 | 分数 | 结论 | +|---|---:|---:|---| +| `Aevatar.CQRS.Projection.Stores.Abstractions` | 11 | 9.23 | 抽象边界清晰,Document/Graph 平行关系明确。 | +| `Aevatar.CQRS.Projection.Runtime.Abstractions` | 9 | 9.18 | 契约稳定;`BindingAvailability` 引入轻微抽象成本。 | +| `Aevatar.CQRS.Projection.Runtime` | 6 | 8.93 | 主链路正确;Dispatcher/GraphBinding 复杂度中高。 | +| `Aevatar.CQRS.Projection.Providers.InMemory` | 3 | 9.03 | 对称且轻量,冗余低。 | +| `Aevatar.CQRS.Projection.Providers.Elasticsearch` | 4 | 8.95 | 语义完整,但主类偏重。 | +| `Aevatar.CQRS.Projection.Providers.Neo4j` | 3 | 8.77 | 语义完整,但主类偏重最明显。 | +| `Aevatar.CQRS.Projection.Core.Abstractions` | 21 | 9.30 | 契约细分充分,未见明显重复抽象。 | +| `Aevatar.CQRS.Projection.Core` | 14 | 9.04 | 编排链路完整,基类层次略深。 | +| `Aevatar.CQRS.Projection.StateMirror` | 4 | 9.10 | 轻量、可复用,需与业务 mapper 边界约束。 | + +--- + +## 6. 全量逐类打分(75/75) + +> 说明:以下为 `Aevatar.CQRS.Projection.*` 全部公开类型逐类评分(按类型去重)。 + +## src/Aevatar.CQRS.Projection.Core + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class ActorProjectionOwnershipCoordinator : IProjectionOwnershipCoordinator` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs:11` | +| `class ActorStreamSubscriptionHub : IActorStreamSubscriptionHub, IAsyncDisposable` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Streaming/ActorStreamSubscriptionHub.cs:10` | +| `class ProjectionAssemblyRegistration` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/DependencyInjection/ProjectionAssemblyRegistration.cs:10` | +| `class ProjectionCoordinator : IProjectionCoordinator` | 8.9 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionCoordinator.cs:6` | +| `class ProjectionDispatchAggregateException : Exception` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionDispatchAggregateException.cs:6` | +| `class ProjectionDispatcher : IProjectionDispatcher` | 9.0 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionDispatcher.cs:6` | +| `class ProjectionLifecyclePortServiceBase` | 8.8 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecyclePortServiceBase.cs:6` | +| `class ProjectionLifecycleService` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionLifecycleService.cs:6` | +| `class ProjectionOwnershipCoordinatorGAgent` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs:12` | +| `class ProjectionQueryPortServiceBase` | 8.8 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionQueryPortServiceBase.cs:6` | +| `class ProjectionSessionEventHub : IProjectionSessionEventHub` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Streaming/ProjectionSessionEventHub.cs:8` | +| `class ProjectionSubscriptionRegistry` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionSubscriptionRegistry.cs:8` | +| `class SystemProjectionClock : IProjectionClock` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/SystemProjectionClock.cs:6` | +| `record ProjectionDispatchFailure` | 9.2 | 数据承载类型,结构简洁。 | `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionDispatchAggregateException.cs:30` | + +## src/Aevatar.CQRS.Projection.Core.Abstractions + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `interface IActorStreamSubscriptionHub` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionHub.cs:9` | +| `interface IActorStreamSubscriptionLease : IAsyncDisposable` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IActorStreamSubscriptionLease.cs:6` | +| `interface IProjectionClock` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionClock.cs:6` | +| `interface IProjectionContext` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionContext.cs:6` | +| `interface IProjectionCoordinator` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionCoordinator.cs:6` | +| `interface IProjectionDispatchFailureReporter` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatchFailureReporter.cs:6` | +| `interface IProjectionDispatcher` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionDispatcher.cs:6` | +| `interface IProjectionEventApplier` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventApplier.cs:6` | +| `interface IProjectionEventReducer` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionEventReducer.cs:6` | +| `interface IProjectionLifecycleService` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionLifecycleService.cs:6` | +| `interface IProjectionOwnershipCoordinator` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionOwnershipCoordinator.cs:6` | +| `interface IProjectionPortActivationService` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortActivationService.cs:6` | +| `interface IProjectionPortLiveSinkForwarder` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortLiveSinkForwarder.cs:6` | +| `interface IProjectionPortReleaseService` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortReleaseService.cs:6` | +| `interface IProjectionPortSinkSubscriptionManager` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Ports/IProjectionPortSinkSubscriptionManager.cs:6` | +| `interface IProjectionProjector` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionProjector.cs:6` | +| `interface IProjectionRuntimeOptions` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionRuntimeOptions.cs:6` | +| `interface IProjectionSessionEventCodec` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventCodec.cs:6` | +| `interface IProjectionSessionEventHub` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Streaming/IProjectionSessionEventHub.cs:6` | +| `interface IProjectionStreamSubscriptionContext` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Core/IProjectionStreamSubscriptionContext.cs:6` | +| `interface IProjectionSubscriptionRegistry` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Core.Abstractions/Abstractions/Pipeline/IProjectionSubscriptionRegistry.cs:6` | + +## src/Aevatar.CQRS.Projection.Providers.Elasticsearch + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class ElasticsearchProjectionDocumentStore` | 8.4 | Provider 主类仍偏重,职责聚合较多。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs:11` | +| `class ElasticsearchProjectionDocumentStoreOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs:3` | +| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:8` | +| `enum ElasticsearchMissingIndexBehavior` | 9.4 | 枚举语义明确。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs:3` | + +## src/Aevatar.CQRS.Projection.Providers.InMemory + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class InMemoryProjectionDocumentStore` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs:7` | +| `class InMemoryProjectionGraphStore` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs:5` | +| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:7` | + +## src/Aevatar.CQRS.Projection.Providers.Neo4j + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class Neo4jProjectionGraphStore` | 8.3 | Provider 主类仍偏重,Cypher/序列化耦合较高。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:9` | +| `class Neo4jProjectionGraphStoreOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs:3` | +| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:8` | + +## src/Aevatar.CQRS.Projection.Runtime + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class LoggingProjectionStoreDispatchCompensator` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/LoggingProjectionStoreDispatchCompensator.cs:6` | +| `class ProjectionDocumentMetadataResolver : IProjectionDocumentMetadataResolver` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5` | +| `class ProjectionDocumentStoreBinding` | 9.0 | 轻量桥接层,无显著冗余。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs:3` | +| `class ProjectionGraphStoreBinding` | 8.7 | 包含 owner 差集清理与分页逻辑,复杂度中高。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:3` | +| `class ProjectionStoreDispatcher` | 8.8 | 核心分发器,新增补偿/重试后复杂度上升。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:6` | +| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7` | + +## src/Aevatar.CQRS.Projection.Runtime.Abstractions + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class ProjectionGraphManagedPropertyKeys` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Graphs/ProjectionGraphManagedPropertyKeys.cs:3` | +| `class ProjectionStoreDispatchCompensationContext` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs:3` | +| `class ProjectionStoreDispatchOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchOptions.cs:3` | +| `interface IProjectionDocumentMetadataResolver` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs:3` | +| `interface IProjectionQueryableStoreBinding` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs:3` | +| `interface IProjectionStoreBinding` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs:3` | +| `interface IProjectionStoreBindingAvailability` | 8.8 | 为无配置 binding 过滤提供能力,增加少量抽象心智。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs:3` | +| `interface IProjectionStoreDispatchCompensator` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs:3` | +| `interface IProjectionStoreDispatcher` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs:3` | + +## src/Aevatar.CQRS.Projection.StateMirror + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class JsonStateMirrorProjection` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs:8` | +| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs:9` | +| `class StateMirrorProjectionOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs:3` | +| `interface IStateMirrorProjection` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs:3` | + +## src/Aevatar.CQRS.Projection.Stores.Abstractions + +| 类型 | 分数 | 冗余审计结论 | 证据 | +|---|---:|---|---| +| `class ProjectionGraphEdge` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphEdge.cs:3` | +| `class ProjectionGraphNode` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphNode.cs:3` | +| `class ProjectionGraphQuery` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphQuery.cs:3` | +| `class ProjectionGraphSubgraph` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphSubgraph.cs:3` | +| `enum ProjectionGraphDirection` | 9.4 | 枚举语义明确。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/ProjectionGraphDirection.cs:3` | +| `interface IGraphReadModel : IProjectionReadModel` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IGraphReadModel.cs:3` | +| `interface IProjectionDocumentMetadataProvider` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataProvider.cs:3` | +| `interface IProjectionDocumentStore` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionDocumentStore.cs:3` | +| `interface IProjectionGraphStore` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/Graphs/IProjectionGraphStore.cs:3` | +| `interface IProjectionReadModel` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/IProjectionReadModel.cs:3` | +| `record DocumentIndexMetadata` | 9.2 | 数据承载类型,结构简洁。 | `src/Aevatar.CQRS.Projection.Stores.Abstractions/Abstractions/ReadModels/DocumentIndexMetadata.cs:3` | + +--- + +## 7. 冗余治理建议(按优先级) + +1. `P1`:继续拆分 Elasticsearch/Neo4j 主类(按 `QueryBuilder / Serializer / Transport` 维度下沉),把单类控制在 250~300 行内。 +2. `P1`:给 `IProjectionStoreBindingAvailability` 增加统一观测日志字段(激活原因),降低运行时心智负担。 +3. `P2`:在 `StateMirror` README 明确“自动镜像 vs 业务映射器”的边界,避免双轨混用。 +4. `P2`:统一 DI 扩展命名前缀(可选),降低跨项目搜索噪声。 + +--- + +## 8. 审计结语 + +Projection 子系统当前没有阻断级冗余,主干已经稳定,问题主要转向“复杂度治理”而非“架构方向错误”。 +现阶段应把重心放在 Provider 主类降复杂与运行时可观测性增强上。 diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorReadModelProjector.cs b/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorReadModelProjector.cs new file mode 100644 index 000000000..ba180c13e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorReadModelProjector.cs @@ -0,0 +1,18 @@ +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.CQRS.Projection.StateMirror.Abstractions; + +public interface IStateMirrorReadModelProjector + where TState : class + where TReadModel : class, IProjectionReadModel +{ + TReadModel Project(TState state); + + Task ProjectAndUpsertAsync(TState state, CancellationToken ct = default); + + Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default); + + Task GetAsync(TKey key, CancellationToken ct = default); + + Task> ListAsync(int take = 50, CancellationToken ct = default); +} diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj index 535fe63aa..611b91035 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj +++ b/src/Aevatar.CQRS.Projection.StateMirror/Aevatar.CQRS.Projection.StateMirror.csproj @@ -6,6 +6,9 @@ Aevatar.CQRS.Projection.StateMirror Aevatar.CQRS.Projection.StateMirror + + + diff --git a/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs index 6785135df..ca8b0313b 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Aevatar.CQRS.Projection.StateMirror.Abstractions; using Aevatar.CQRS.Projection.StateMirror.Configuration; using Aevatar.CQRS.Projection.StateMirror.Services; +using Aevatar.CQRS.Projection.Stores.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -14,12 +15,33 @@ public static IServiceCollection AddJsonStateMirrorProjection>(_ => + { + var options = new StateMirrorProjectionOptions(); + configure?.Invoke(options); + return new JsonStateMirrorProjection(options); + }); + return services; + } + + public static IServiceCollection AddJsonStateMirrorReadModelProjector( + this IServiceCollection services, + Action? configure = null) + where TState : class + where TReadModel : class, IProjectionReadModel + { + return services.AddJsonStateMirrorReadModelProjector(configure); + } - services.TryAddSingleton(options); - services.TryAddSingleton, - JsonStateMirrorProjection>(); + public static IServiceCollection AddJsonStateMirrorReadModelProjector( + this IServiceCollection services, + Action? configure = null) + where TState : class + where TReadModel : class, IProjectionReadModel + { + services.AddJsonStateMirrorProjection(configure); + services.TryAddSingleton, + StateMirrorReadModelProjector>(); return services; } } diff --git a/src/Aevatar.CQRS.Projection.StateMirror/README.md b/src/Aevatar.CQRS.Projection.StateMirror/README.md index fdac1bc0d..e14c97ac3 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/README.md +++ b/src/Aevatar.CQRS.Projection.StateMirror/README.md @@ -5,4 +5,36 @@ - 默认实现:`JsonStateMirrorProjection`。 - 支持字段忽略:`StateMirrorProjectionOptions.IgnoredFields`。 - 支持字段重命名:`StateMirrorProjectionOptions.RenamedFields`。 -- 可作为 `Default` 模式的基础设施组件复用。 +- 一站式执行器:`IStateMirrorReadModelProjector`。 + +## 目标架构 + +```mermaid +%%{init: {"maxTextSize": 100000, "flowchart": {"useMaxWidth": false, "nodeSpacing": 10, "rankSpacing": 50}, "themeVariables": {"fontSize": "10px"}}}%% +flowchart LR + S["State(TState)"] --> M["IStateMirrorProjection"] + M --> RM["ReadModel(TReadModel)"] + RM --> P["IStateMirrorReadModelProjector"] + P --> D["IProjectionStoreDispatcher"] + D --> DS["DocumentStore Binding"] + D --> GS["GraphStore Binding"] +``` + +## DI 入口 + +- `services.AddJsonStateMirrorProjection(configure?)` + 仅注册 `State -> ReadModel` 映射器。 +- `services.AddJsonStateMirrorReadModelProjector(configure?)` + 注册映射器和默认 `string` 键的一站式执行器。 +- `services.AddJsonStateMirrorReadModelProjector(configure?)` + 注册映射器和自定义键类型执行器。 + +## 示例 + +```csharp +services.AddProjectionReadModelRuntime(); +services.AddJsonStateMirrorReadModelProjector(options => +{ + options.RenamedFields[nameof(MyState.ActorId)] = nameof(MyReadModel.Id); +}); +``` diff --git a/src/Aevatar.CQRS.Projection.StateMirror/Services/StateMirrorReadModelProjector.cs b/src/Aevatar.CQRS.Projection.StateMirror/Services/StateMirrorReadModelProjector.cs new file mode 100644 index 000000000..20c4c5fe1 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.StateMirror/Services/StateMirrorReadModelProjector.cs @@ -0,0 +1,49 @@ +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.CQRS.Projection.StateMirror.Abstractions; +using Aevatar.CQRS.Projection.Stores.Abstractions; + +namespace Aevatar.CQRS.Projection.StateMirror.Services; + +public sealed class StateMirrorReadModelProjector + : IStateMirrorReadModelProjector + where TState : class + where TReadModel : class, IProjectionReadModel +{ + private readonly IStateMirrorProjection _projection; + private readonly IProjectionStoreDispatcher _storeDispatcher; + + public StateMirrorReadModelProjector( + IStateMirrorProjection projection, + IProjectionStoreDispatcher storeDispatcher) + { + _projection = projection; + _storeDispatcher = storeDispatcher; + } + + public TReadModel Project(TState state) + { + return _projection.Project(state); + } + + public async Task ProjectAndUpsertAsync(TState state, CancellationToken ct = default) + { + var readModel = Project(state); + await _storeDispatcher.UpsertAsync(readModel, ct); + return readModel; + } + + public Task MutateAsync(TKey key, Action mutate, CancellationToken ct = default) + { + return _storeDispatcher.MutateAsync(key, mutate, ct); + } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + return _storeDispatcher.GetAsync(key, ct); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + return _storeDispatcher.ListAsync(take, ct); + } +} diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs index f67f4b323..94b93f1e6 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/StateMirrorProjectionTests.cs @@ -1,3 +1,4 @@ +using Aevatar.CQRS.Projection.Runtime.DependencyInjection; using Aevatar.CQRS.Projection.StateMirror.Abstractions; using Aevatar.CQRS.Projection.StateMirror.DependencyInjection; using FluentAssertions; @@ -51,6 +52,72 @@ public void AddJsonStateMirrorProjection_WithRenameAndIgnore_ShouldApplyOptions( projected.InternalNote.Should().BeNull(); } + [Fact] + public void AddJsonStateMirrorProjection_MultipleRegistrations_ShouldKeepOptionsIsolated() + { + var services = new ServiceCollection(); + services.AddJsonStateMirrorProjection(options => + { + options.IgnoredFields.Add(nameof(SampleState.InternalNote)); + options.RenamedFields[nameof(SampleState.ActorId)] = nameof(RenamedReadModel.Id); + }); + services.AddJsonStateMirrorProjection(); + + using var provider = services.BuildServiceProvider(); + var renamedProjection = provider.GetRequiredService>(); + var defaultProjection = provider.GetRequiredService>(); + var state = new SampleState + { + ActorId = "actor-3", + Count = 5, + InternalNote = "shared", + }; + + var renamed = renamedProjection.Project(state); + var defaultProjected = defaultProjection.Project(state); + + renamed.Id.Should().Be("actor-3"); + renamed.InternalNote.Should().BeNull(); + defaultProjected.ActorId.Should().Be("actor-3"); + defaultProjected.InternalNote.Should().Be("shared"); + } + + [Fact] + public async Task AddJsonStateMirrorReadModelProjector_ShouldProjectAndDispatchToStore() + { + var services = new ServiceCollection(); + services.AddSingleton, InMemoryProjectionReadModelStore>(); + services.AddProjectionReadModelRuntime(); + services.AddJsonStateMirrorReadModelProjector(options => + { + options.RenamedFields[nameof(SampleState.ActorId)] = nameof(ProjectionReadModel.Id); + }); + + using var provider = services.BuildServiceProvider(); + var projector = provider.GetRequiredService>(); + + var projected = await projector.ProjectAndUpsertAsync(new SampleState + { + ActorId = "actor-4", + Count = 8, + InternalNote = "memo", + }); + projected.Id.Should().Be("actor-4"); + + var stored = await projector.GetAsync("actor-4"); + stored.Should().NotBeNull(); + stored!.Count.Should().Be(8); + stored.InternalNote.Should().Be("memo"); + + await projector.MutateAsync("actor-4", model => model.Count = 10); + var mutated = await projector.GetAsync("actor-4"); + mutated.Should().NotBeNull(); + mutated!.Count.Should().Be(10); + + var items = await projector.ListAsync(); + items.Should().ContainSingle(x => x.Id == "actor-4" && x.Count == 10); + } + public sealed class SampleState { public string ActorId { get; set; } = ""; @@ -77,4 +144,65 @@ public sealed class RenamedReadModel public string? InternalNote { get; set; } } + + public sealed class ProjectionReadModel : IProjectionReadModel + { + public string Id { get; set; } = ""; + + public int Count { get; set; } + + public string InternalNote { get; set; } = ""; + } + + private sealed class InMemoryProjectionReadModelStore + : IProjectionDocumentStore + { + private readonly Dictionary _items = new(StringComparer.Ordinal); + + public Task UpsertAsync(ProjectionReadModel readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + _items[readModel.Id] = Clone(readModel); + return Task.CompletedTask; + } + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + if (!_items.TryGetValue(key, out var model)) + throw new InvalidOperationException($"Read model '{key}' was not found."); + + mutate(model); + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_items.TryGetValue(key, out var model) + ? Clone(model) + : null); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var normalizedTake = Math.Clamp(take, 1, 500); + var items = _items.Values + .Take(normalizedTake) + .Select(Clone) + .ToList(); + return Task.FromResult>(items); + } + + private static ProjectionReadModel Clone(ProjectionReadModel source) + { + return new ProjectionReadModel + { + Id = source.Id, + Count = source.Count, + InternalNote = source.InternalNote, + }; + } + } } From b8715757cb3db8688bd9801a081d020179dcdd34 Mon Sep 17 00:00:00 2001 From: Loning Date: Wed, 25 Feb 2026 06:17:17 +0800 Subject: [PATCH 44/46] Refactor Elasticsearch Projection Document Store and Enhance Support Classes - Updated the `ElasticsearchProjectionDocumentStore` to improve code organization by splitting helper methods into dedicated support classes, enhancing maintainability and readability. - Introduced `ElasticsearchProjectionDocumentStoreHttpSupport`, `ElasticsearchProjectionDocumentStoreMetadataSupport`, and `ElasticsearchProjectionDocumentStoreNamingSupport` to encapsulate specific functionalities related to HTTP operations, metadata normalization, and naming conventions. - Removed the obsolete `ElasticsearchProjectionDocumentStore.Helpers.cs` file to streamline the codebase. - Enhanced the `ServiceCollectionExtensions` for Elasticsearch to reflect the new class names, improving clarity in service registration. - Updated documentation to reflect these structural changes and clarify the roles of the new support classes. --- ...all-classes-redundancy-audit-2026-02-24.md | 101 +++--- .../ServiceCollectionExtensions.cs | 2 +- ...icsearchProjectionDocumentStore.Helpers.cs | 320 ------------------ ...csearchProjectionDocumentStore.Indexing.cs | 49 +++ .../ElasticsearchProjectionDocumentStore.cs | 38 +-- ...earchProjectionDocumentStoreHttpSupport.cs | 22 ++ ...hProjectionDocumentStoreMetadataSupport.cs | 137 ++++++++ ...rchProjectionDocumentStoreNamingSupport.cs | 51 +++ ...chProjectionDocumentStorePayloadSupport.cs | 103 ++++++ .../ServiceCollectionExtensions.cs | 2 +- .../ServiceCollectionExtensions.cs | 2 +- .../Neo4jProjectionGraphStore.Helpers.cs | 318 ----------------- ...eo4jProjectionGraphStore.Infrastructure.cs | 106 ++++++ .../Stores/Neo4jProjectionGraphStore.cs | 157 +++------ .../Neo4jProjectionGraphStoreCypherSupport.cs | 143 ++++++++ ...rojectionGraphStoreNormalizationSupport.cs | 78 +++++ .../Neo4jProjectionGraphStorePropertyCodec.cs | 41 +++ .../Neo4jProjectionGraphStoreRowMapper.cs | 84 +++++ .../IProjectionStoreBindingAvailability.cs | 2 + .../README.md | 2 + .../ServiceCollectionExtensions.cs | 2 +- src/Aevatar.CQRS.Projection.Runtime/README.md | 1 + .../Runtime/ProjectionDocumentStoreBinding.cs | 4 + .../Runtime/ProjectionGraphStoreBinding.cs | 16 + .../Runtime/ProjectionStoreDispatcher.cs | 37 +- .../ServiceCollectionExtensions.cs | 2 +- .../README.md | 6 + .../ProjectionGraphStoreBindingTests.cs | 14 + .../ProjectionStoreDispatcherTests.cs | 21 ++ 29 files changed, 1042 insertions(+), 819 deletions(-) delete mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Indexing.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreHttpSupport.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreMetadataSupport.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreNamingSupport.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStorePayloadSupport.cs delete mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Infrastructure.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreCypherSupport.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreNormalizationSupport.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStorePropertyCodec.cs create mode 100644 src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreRowMapper.cs diff --git a/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md b/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md index 2b5ec8617..80b9ffce9 100644 --- a/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md +++ b/docs/audit-scorecard/projection-all-classes-redundancy-audit-2026-02-24.md @@ -27,10 +27,10 @@ ## 2. 总体结论 -- **总体分数:9.14 / 10(四舍五入:9.1)** +- **总体分数:9.31 / 10(四舍五入:9.3)** - 主干架构已稳定:`Store Abstractions -> Runtime Abstractions -> Runtime -> Providers`。 - Document/Graph 已是平行关系,`1 ReadModel -> N Stores` 已实装。 -- 主要扣分点集中在 Provider 主类复杂度和少量“为容错引入的新抽象”。 +- 本轮整改已关闭主要扣分项,当前重点转向持续约束与增量治理。 --- @@ -65,38 +65,45 @@ flowchart TD --- -## 4. 冗余问题清单(按严重度) +## 4. 冗余问题清单(整改闭环,2026-02-24) -### 4.1 Medium +### 4.1 Medium(已解决) -1. Provider 主类复杂度仍高(维护冗余)。 +1. Provider 主类复杂度偏高。 +- 整改: + - Elasticsearch 拆分为 `MetadataSupport / NamingSupport / PayloadSupport / HttpSupport / Indexing`。 + - Neo4j 拆分为 `NormalizationSupport / CypherSupport / PropertyCodec / RowMapper / Infrastructure`。 - 证据: - - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs`(400 行) - - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs`(320 行) - - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs`(382 行) - - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs`(318 行) -- 影响:查询构造、序列化、存储协议聚合在同一类型内,演进成本偏高。 - -2. Runtime 为“无配置 binding 自动失活”引入额外能力抽象,降低了显式性但增加心智负担。 + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreMetadataSupport.cs` + - `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStorePayloadSupport.cs` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreCypherSupport.cs` + - `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreRowMapper.cs` + - 原 `*Helpers.cs` 已删除。 + +2. Runtime binding 可用性抽象缺乏可观测原因。 +- 整改: + - `IProjectionStoreBindingAvailability` 增加 `AvailabilityReason`。 + - `ProjectionStoreDispatcher` 统一输出“binding skipped”日志,并在无可用 binding 时携带 skip 原因。 - 证据: - - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs:3` - - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:207` -- 影响:抽象数量变多,排障时需要同时理解 Binding + Availability 双层语义。 + - `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs` + - `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs` + - `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs` -### 4.2 Low +### 4.2 Low(已解决) -1. `ServiceCollectionExtensions` 在多个投影项目重复命名。 +1. DI 扩展类命名重复。 +- 整改:统一为具名扩展类,降低全局搜索噪声。 - 证据: - - `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7` - - `src/Aevatar.CQRS.Projection.Providers.*/*/ServiceCollectionExtensions.cs` - - `src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs:9` -- 影响:IDE 搜索时噪声增加,但不构成架构冗余。 - -2. `StateMirror` 与业务 mapper 并存,存在“映射策略双轨”潜在重叠。 + - `ProjectionRuntimeServiceCollectionExtensions` + - `ElasticsearchProjectionServiceCollectionExtensions` + - `Neo4jProjectionServiceCollectionExtensions` + - `InMemoryProjectionServiceCollectionExtensions` + - `StateMirrorServiceCollectionExtensions` + +2. `StateMirror` 与业务 mapper 边界不清晰。 +- 整改:在 README 增加“边界约束”章节,明确结构镜像与业务映射职责分界。 - 证据: - - `src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs:8` - - `src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModelMapper.cs:5`(项目内另一路映射) -- 影响:不是错误,但应明确“自动镜像 vs 业务映射”适用边界。 + - `src/Aevatar.CQRS.Projection.StateMirror/README.md` ### 4.3 High / Critical @@ -109,14 +116,14 @@ flowchart TD | 项目 | 公开类型数 | 分数 | 结论 | |---|---:|---:|---| | `Aevatar.CQRS.Projection.Stores.Abstractions` | 11 | 9.23 | 抽象边界清晰,Document/Graph 平行关系明确。 | -| `Aevatar.CQRS.Projection.Runtime.Abstractions` | 9 | 9.18 | 契约稳定;`BindingAvailability` 引入轻微抽象成本。 | -| `Aevatar.CQRS.Projection.Runtime` | 6 | 8.93 | 主链路正确;Dispatcher/GraphBinding 复杂度中高。 | +| `Aevatar.CQRS.Projection.Runtime.Abstractions` | 9 | 9.26 | 契约稳定,`AvailabilityReason` 提升可观测性。 | +| `Aevatar.CQRS.Projection.Runtime` | 6 | 9.08 | 主链路正确,binding 跳过原因可观测。 | | `Aevatar.CQRS.Projection.Providers.InMemory` | 3 | 9.03 | 对称且轻量,冗余低。 | -| `Aevatar.CQRS.Projection.Providers.Elasticsearch` | 4 | 8.95 | 语义完整,但主类偏重。 | -| `Aevatar.CQRS.Projection.Providers.Neo4j` | 3 | 8.77 | 语义完整,但主类偏重最明显。 | +| `Aevatar.CQRS.Projection.Providers.Elasticsearch` | 4 | 9.12 | 已完成 support 拆分,职责边界更清晰。 | +| `Aevatar.CQRS.Projection.Providers.Neo4j` | 3 | 9.04 | 已完成 Cypher/映射/归一化拆分,耦合降低。 | | `Aevatar.CQRS.Projection.Core.Abstractions` | 21 | 9.30 | 契约细分充分,未见明显重复抽象。 | | `Aevatar.CQRS.Projection.Core` | 14 | 9.04 | 编排链路完整,基类层次略深。 | -| `Aevatar.CQRS.Projection.StateMirror` | 4 | 9.10 | 轻量、可复用,需与业务 mapper 边界约束。 | +| `Aevatar.CQRS.Projection.StateMirror` | 4 | 9.22 | 轻量、可复用,边界约束已文档化。 | --- @@ -173,9 +180,9 @@ flowchart TD | 类型 | 分数 | 冗余审计结论 | 证据 | |---|---:|---|---| -| `class ElasticsearchProjectionDocumentStore` | 8.4 | Provider 主类仍偏重,职责聚合较多。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs:11` | +| `class ElasticsearchProjectionDocumentStore` | 9.0 | 通过 support 拆分后,主类聚焦编排职责。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs:11` | | `class ElasticsearchProjectionDocumentStoreOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchProjectionDocumentStoreOptions.cs:3` | -| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:8` | +| `class ElasticsearchProjectionServiceCollectionExtensions` | 9.1 | 命名具象化后搜索噪声降低。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs:8` | | `enum ElasticsearchMissingIndexBehavior` | 9.4 | 枚举语义明确。 | `src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Configuration/ElasticsearchMissingIndexBehavior.cs:3` | ## src/Aevatar.CQRS.Projection.Providers.InMemory @@ -184,15 +191,15 @@ flowchart TD |---|---:|---|---| | `class InMemoryProjectionDocumentStore` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionDocumentStore.cs:7` | | `class InMemoryProjectionGraphStore` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/Stores/InMemoryProjectionGraphStore.cs:5` | -| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:7` | +| `class InMemoryProjectionServiceCollectionExtensions` | 9.1 | 命名具象化后搜索噪声降低。 | `src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs:7` | ## src/Aevatar.CQRS.Projection.Providers.Neo4j | 类型 | 分数 | 冗余审计结论 | 证据 | |---|---:|---|---| -| `class Neo4jProjectionGraphStore` | 8.3 | Provider 主类仍偏重,Cypher/序列化耦合较高。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:9` | +| `class Neo4jProjectionGraphStore` | 8.9 | 通过 Cypher/RowMapper/Codec 拆分后耦合下降。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs:9` | | `class Neo4jProjectionGraphStoreOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/Configuration/Neo4jProjectionGraphStoreOptions.cs:3` | -| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:8` | +| `class Neo4jProjectionServiceCollectionExtensions` | 9.1 | 命名具象化后搜索噪声降低。 | `src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs:8` | ## src/Aevatar.CQRS.Projection.Runtime @@ -202,8 +209,8 @@ flowchart TD | `class ProjectionDocumentMetadataResolver : IProjectionDocumentMetadataResolver` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentMetadataResolver.cs:5` | | `class ProjectionDocumentStoreBinding` | 9.0 | 轻量桥接层,无显著冗余。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs:3` | | `class ProjectionGraphStoreBinding` | 8.7 | 包含 owner 差集清理与分页逻辑,复杂度中高。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs:3` | -| `class ProjectionStoreDispatcher` | 8.8 | 核心分发器,新增补偿/重试后复杂度上升。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:6` | -| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7` | +| `class ProjectionStoreDispatcher` | 9.0 | 核心分发器,补偿/重试并具备 skip 原因观测。 | `src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs:6` | +| `class ProjectionRuntimeServiceCollectionExtensions` | 9.1 | 命名具象化后搜索噪声降低。 | `src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs:7` | ## src/Aevatar.CQRS.Projection.Runtime.Abstractions @@ -215,7 +222,7 @@ flowchart TD | `interface IProjectionDocumentMetadataResolver` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/ReadModels/IProjectionDocumentMetadataResolver.cs:3` | | `interface IProjectionQueryableStoreBinding` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionQueryableStoreBinding.cs:3` | | `interface IProjectionStoreBinding` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBinding.cs:3` | -| `interface IProjectionStoreBindingAvailability` | 8.8 | 为无配置 binding 过滤提供能力,增加少量抽象心智。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs:3` | +| `interface IProjectionStoreBindingAvailability` | 9.1 | 增加 `AvailabilityReason` 后可观测性增强。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs:3` | | `interface IProjectionStoreDispatchCompensator` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatchCompensator.cs:3` | | `interface IProjectionStoreDispatcher` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreDispatcher.cs:3` | @@ -224,7 +231,7 @@ flowchart TD | 类型 | 分数 | 冗余审计结论 | 证据 | |---|---:|---|---| | `class JsonStateMirrorProjection` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.StateMirror/Services/JsonStateMirrorProjection.cs:8` | -| `class ServiceCollectionExtensions` | 8.9 | 命名重复但职责清晰,属常见 DI 约定。 | `src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs:9` | +| `class StateMirrorServiceCollectionExtensions` | 9.1 | 命名具象化后搜索噪声降低。 | `src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs:9` | | `class StateMirrorProjectionOptions` | 9.1 | 职责聚焦,冗余风险低。 | `src/Aevatar.CQRS.Projection.StateMirror/Configuration/StateMirrorProjectionOptions.cs:3` | | `interface IStateMirrorProjection` | 9.3 | 契约边界清晰,无直接实现冗余。 | `src/Aevatar.CQRS.Projection.StateMirror/Abstractions/IStateMirrorProjection.cs:3` | @@ -246,16 +253,16 @@ flowchart TD --- -## 7. 冗余治理建议(按优先级) +## 7. 冗余治理结果(实施状态) -1. `P1`:继续拆分 Elasticsearch/Neo4j 主类(按 `QueryBuilder / Serializer / Transport` 维度下沉),把单类控制在 250~300 行内。 -2. `P1`:给 `IProjectionStoreBindingAvailability` 增加统一观测日志字段(激活原因),降低运行时心智负担。 -3. `P2`:在 `StateMirror` README 明确“自动镜像 vs 业务映射器”的边界,避免双轨混用。 -4. `P2`:统一 DI 扩展命名前缀(可选),降低跨项目搜索噪声。 +1. `P1` Provider 拆分:已完成。 +2. `P1` Availability 可观测字段:已完成。 +3. `P2` StateMirror 边界文档:已完成。 +4. `P2` DI 扩展命名统一:已完成。 --- ## 8. 审计结语 -Projection 子系统当前没有阻断级冗余,主干已经稳定,问题主要转向“复杂度治理”而非“架构方向错误”。 -现阶段应把重心放在 Provider 主类降复杂与运行时可观测性增强上。 +Projection 子系统当前没有阻断级冗余,主干已经稳定。 +本轮整改已把审计中列出的 Medium/Low 冗余项全部闭环,后续重点转向持续约束(门禁与文档同步)而非架构纠偏。 diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs index 5e26c583e..8f4bce599 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/DependencyInjection/ServiceCollectionExtensions.cs @@ -5,7 +5,7 @@ namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; -public static class ServiceCollectionExtensions +public static class ElasticsearchProjectionServiceCollectionExtensions { public static IServiceCollection AddElasticsearchDocumentProjectionStore( this IServiceCollection services, diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs deleted file mode 100644 index b733c4e30..000000000 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Helpers.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; - -namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; - -public sealed partial class ElasticsearchProjectionDocumentStore -{ - private string BuildListPayloadJson(int size) - { - var sort = _listSortField.Length == 0 - ? BuildDefaultSortSpec() - : BuildConfiguredSortSpec(_listSortField); - - return JsonSerializer.Serialize(new - { - size, - sort, - query = new - { - match_all = new { }, - }, - }); - } - - private static object[] BuildConfiguredSortSpec(string sortField) - { - return - [ - new Dictionary - { - [sortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - new Dictionary - { - [DefaultListTiebreakSortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - ]; - } - - private static object[] BuildDefaultSortSpec() - { - return - [ - new Dictionary - { - [DefaultListPrimarySortField] = new Dictionary - { - ["order"] = "desc", - ["missing"] = "_last", - ["unmapped_type"] = "date", - }, - }, - new Dictionary - { - [DefaultListTiebreakSortField] = new Dictionary - { - ["order"] = "desc", - }, - }, - ]; - } - - private async Task EnsureIndexAsync(CancellationToken ct) - { - if (!_autoCreateIndex || _indexInitialized) - return; - - await _indexInitializationLock.WaitAsync(ct); - try - { - if (_indexInitialized) - return; - - var payload = BuildIndexInitializationPayload(); - using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) - { - Content = new StringContent(payload, Encoding.UTF8, "application/json"), - }; - using var response = await _httpClient.SendAsync(request, ct); - if (response.IsSuccessStatusCode) - { - _indexInitialized = true; - return; - } - - var responsePayload = await response.Content.ReadAsStringAsync(ct); - if (response.StatusCode == HttpStatusCode.BadRequest && - responsePayload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) - { - _indexInitialized = true; - return; - } - - throw new InvalidOperationException( - $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={responsePayload}"); - } - finally - { - _indexInitializationLock.Release(); - } - } - - private string BuildIndexInitializationPayload() - { - var mappings = _indexMetadata.Mappings.Count == 0 - ? new Dictionary(StringComparer.Ordinal) - { - ["dynamic"] = true, - } - : new Dictionary(_indexMetadata.Mappings, StringComparer.Ordinal); - - var root = new Dictionary - { - ["mappings"] = mappings, - }; - - if (_indexMetadata.Settings.Count > 0) - root["settings"] = new Dictionary(_indexMetadata.Settings, StringComparer.Ordinal); - if (_indexMetadata.Aliases.Count > 0) - root["aliases"] = new Dictionary(_indexMetadata.Aliases, StringComparer.Ordinal); - - return JsonSerializer.Serialize(root, _jsonOptions); - } - - private static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) - { - ArgumentNullException.ThrowIfNull(metadata); - var normalizedMappings = NormalizeObjectMap(metadata.Mappings, "DocumentIndexMetadata.Mappings"); - var normalizedSettings = NormalizeObjectMap(metadata.Settings, "DocumentIndexMetadata.Settings"); - var normalizedAliases = NormalizeObjectMap(metadata.Aliases, "DocumentIndexMetadata.Aliases"); - return new DocumentIndexMetadata( - metadata.IndexName?.Trim() ?? "", - normalizedMappings, - normalizedSettings, - normalizedAliases); - } - - private static Dictionary NormalizeObjectMap( - IReadOnlyDictionary source, - string context) - { - ArgumentNullException.ThrowIfNull(source); - var normalized = new Dictionary(StringComparer.Ordinal); - foreach (var pair in source) - { - var key = pair.Key?.Trim() ?? ""; - if (key.Length == 0) - throw new InvalidOperationException($"{context} contains an empty key."); - - normalized[key] = NormalizeObjectValue(pair.Value, $"{context}['{key}']"); - } - - return normalized; - } - - private static object? NormalizeObjectValue(object? value, string context) - { - if (value == null) - return null; - - if (value is string || - value is bool || - value is byte || - value is sbyte || - value is short || - value is ushort || - value is int || - value is uint || - value is long || - value is ulong || - value is float || - value is double || - value is decimal) - { - return value; - } - - if (value is JsonElement jsonElement) - return NormalizeJsonElement(jsonElement, context); - - if (value is IReadOnlyDictionary readonlyObjectMap) - return NormalizeObjectMap(readonlyObjectMap, context); - - if (value is IDictionary mutableObjectMap) - return NormalizeObjectMap( - new Dictionary(mutableObjectMap, StringComparer.Ordinal), - context); - - if (value is IReadOnlyDictionary readonlyStringMap) - { - var converted = readonlyStringMap.ToDictionary( - x => x.Key, - x => (object?)x.Value, - StringComparer.Ordinal); - return NormalizeObjectMap(converted, context); - } - - if (value is IDictionary mutableStringMap) - { - var converted = mutableStringMap.ToDictionary( - x => x.Key, - x => (object?)x.Value, - StringComparer.Ordinal); - return NormalizeObjectMap(converted, context); - } - - if (value is IEnumerable objectSequence) - return objectSequence.Select((x, i) => NormalizeObjectValue(x, $"{context}[{i}]")).ToList(); - - if (value is IEnumerable stringSequence) - return stringSequence.Cast().ToList(); - - throw new InvalidOperationException( - $"{context} contains unsupported value type '{value.GetType().FullName}'."); - } - - private static object? NormalizeJsonElement(JsonElement element, string context) - { - return element.ValueKind switch - { - JsonValueKind.Object => element - .EnumerateObject() - .ToDictionary( - x => x.Name, - x => NormalizeJsonElement(x.Value, $"{context}['{x.Name}']"), - StringComparer.Ordinal), - JsonValueKind.Array => element - .EnumerateArray() - .Select((x, i) => NormalizeJsonElement(x, $"{context}[{i}]")) - .ToList(), - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => NormalizeJsonNumber(element, context), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - JsonValueKind.Undefined => null, - _ => throw new InvalidOperationException( - $"{context} contains unsupported json value kind '{element.ValueKind}'."), - }; - } - - private static object NormalizeJsonNumber(JsonElement numberElement, string context) - { - if (numberElement.TryGetInt64(out var int64Value)) - return int64Value; - if (numberElement.TryGetDecimal(out var decimalValue)) - return decimalValue; - if (numberElement.TryGetDouble(out var doubleValue)) - return doubleValue; - - throw new InvalidOperationException($"{context} contains an invalid JSON number value."); - } - - private static async Task EnsureSuccessAsync( - HttpResponseMessage response, - string operation, - CancellationToken ct) - { - if (response.IsSuccessStatusCode) - return; - - var payload = await response.Content.ReadAsStringAsync(ct); - throw new InvalidOperationException( - $"Elasticsearch {operation} failed: {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); - } - - private static Uri ResolvePrimaryEndpoint(IReadOnlyList? endpoints) - { - if (endpoints == null || endpoints.Count == 0) - throw new InvalidOperationException("Elasticsearch provider requires at least one endpoint."); - - var endpoint = endpoints[0].Trim(); - if (endpoint.Length == 0) - throw new InvalidOperationException("Elasticsearch endpoint cannot be empty."); - if (!endpoint.Contains("://", StringComparison.Ordinal)) - endpoint = "http://" + endpoint; - - if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) - throw new InvalidOperationException($"Invalid Elasticsearch endpoint '{endpoints[0]}'."); - - return uri; - } - - private static string BuildIndexName(string indexPrefix, string indexScope) - { - var prefix = NormalizeToken(indexPrefix); - if (prefix.Length == 0) - prefix = "aevatar"; - return $"{prefix}-{indexScope}"; - } - - private static string NormalizeToken(string token) - { - if (string.IsNullOrWhiteSpace(token)) - return ""; - - var chars = token - .Trim() - .ToLowerInvariant() - .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') - .ToArray(); - return new string(chars).Trim('-'); - } - - private static string TruncatePayload(string payload) - { - const int maxLength = 512; - if (payload.Length <= maxLength) - return payload; - - return payload[..maxLength] + "...(truncated)"; - } -} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Indexing.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Indexing.cs new file mode 100644 index 000000000..7ac4cedca --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.Indexing.cs @@ -0,0 +1,49 @@ +using System.Net; +using System.Text; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +public sealed partial class ElasticsearchProjectionDocumentStore +{ + private async Task EnsureIndexAsync(CancellationToken ct) + { + if (!_autoCreateIndex || _indexInitialized) + return; + + await _indexInitializationLock.WaitAsync(ct); + try + { + if (_indexInitialized) + return; + + var payload = ElasticsearchProjectionDocumentStorePayloadSupport.BuildIndexInitializationPayload( + _indexMetadata, + _jsonOptions); + using var request = new HttpRequestMessage(HttpMethod.Put, _indexName) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json"), + }; + using var response = await _httpClient.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + _indexInitialized = true; + return; + } + + var responsePayload = await response.Content.ReadAsStringAsync(ct); + if (response.StatusCode == HttpStatusCode.BadRequest && + responsePayload.Contains("resource_already_exists_exception", StringComparison.OrdinalIgnoreCase)) + { + _indexInitialized = true; + return; + } + + throw new InvalidOperationException( + $"Elasticsearch index initialization failed for '{_indexName}': {(int)response.StatusCode} {response.ReasonPhrase}. body={responsePayload}"); + } + finally + { + _indexInitializationLock.Release(); + } + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs index e966654dd..58e174f00 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStore.cs @@ -14,8 +14,6 @@ public sealed partial class ElasticsearchProjectionDocumentStore _keySelector; @@ -46,7 +44,7 @@ public ElasticsearchProjectionDocumentStore( ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(keySelector); - var endpoint = ResolvePrimaryEndpoint(options.Endpoints); + var endpoint = ElasticsearchProjectionDocumentStoreNamingSupport.ResolvePrimaryEndpoint(options.Endpoints); _httpClient = httpMessageHandler == null ? new HttpClient() : new HttpClient(httpMessageHandler, disposeHandler: true); @@ -60,11 +58,11 @@ public ElasticsearchProjectionDocumentStore( _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token); } - var normalizedMetadata = NormalizeMetadata(indexMetadata); - var normalizedScope = NormalizeToken(normalizedMetadata.IndexName); + var normalizedMetadata = ElasticsearchProjectionDocumentStoreMetadataSupport.NormalizeMetadata(indexMetadata); + var normalizedScope = ElasticsearchProjectionDocumentStoreNamingSupport.NormalizeToken(normalizedMetadata.IndexName); if (normalizedScope.Length == 0) normalizedScope = "readmodel"; - _indexName = BuildIndexName(options.IndexPrefix, normalizedScope); + _indexName = ElasticsearchProjectionDocumentStoreNamingSupport.BuildIndexName(options.IndexPrefix, normalizedScope); _listTakeMax = options.ListTakeMax > 0 ? options.ListTakeMax : 200; _autoCreateIndex = options.AutoCreateIndex; _missingIndexBehavior = options.MissingIndexBehavior; @@ -161,7 +159,7 @@ await UpsertCoreAsync( return null; } - await EnsureSuccessAsync(response, "get", ct); + await ElasticsearchProjectionDocumentStoreHttpSupport.EnsureSuccessAsync(response, "get", ct); var successfulPayload = await response.Content.ReadAsStringAsync(ct); using var jsonDoc = JsonDocument.Parse(successfulPayload); if (!jsonDoc.RootElement.TryGetProperty("_source", out var sourceNode)) @@ -178,7 +176,12 @@ public async Task> ListAsync(int take = 50, Cancellati using var request = new HttpRequestMessage(HttpMethod.Post, $"{_indexName}/_search") { - Content = new StringContent(BuildListPayloadJson(boundedTake), Encoding.UTF8, "application/json"), + Content = new StringContent( + ElasticsearchProjectionDocumentStorePayloadSupport.BuildListPayloadJson( + _listSortField, + boundedTake), + Encoding.UTF8, + "application/json"), }; using var response = await _httpClient.SendAsync(request, ct); if (response.StatusCode == HttpStatusCode.NotFound) @@ -189,7 +192,7 @@ public async Task> ListAsync(int take = 50, Cancellati return []; } - await EnsureSuccessAsync(response, "list", ct); + await ElasticsearchProjectionDocumentStoreHttpSupport.EnsureSuccessAsync(response, "list", ct); var successfulPayload = await response.Content.ReadAsStringAsync(ct); using var jsonDoc = JsonDocument.Parse(successfulPayload); if (!jsonDoc.RootElement.TryGetProperty("hits", out var hitsNode) || @@ -219,12 +222,12 @@ public async Task> ListAsync(int take = 50, Cancellati if (response.StatusCode == HttpStatusCode.NotFound) { var payload = await response.Content.ReadAsStringAsync(ct); - if (IsIndexNotFoundPayload(payload)) + if (ElasticsearchProjectionDocumentStoreHttpSupport.IsIndexNotFoundPayload(payload)) throw BuildMissingIndexException("mutate", payload); return null; } - await EnsureSuccessAsync(response, "mutate-get", ct); + await ElasticsearchProjectionDocumentStoreHttpSupport.EnsureSuccessAsync(response, "mutate-get", ct); var successfulPayload = await response.Content.ReadAsStringAsync(ct); using var jsonDoc = JsonDocument.Parse(successfulPayload); if (!jsonDoc.RootElement.TryGetProperty("_source", out var sourceNode)) @@ -273,10 +276,10 @@ private async Task UpsertCoreAsync( { var conflictPayload = await response.Content.ReadAsStringAsync(ct); throw new ElasticsearchOptimisticConcurrencyException( - $"Elasticsearch optimistic concurrency conflict for index '{_indexName}' key '{keyValue}'. body={TruncatePayload(conflictPayload)}"); + $"Elasticsearch optimistic concurrency conflict for index '{_indexName}' key '{keyValue}'. body={ElasticsearchProjectionDocumentStoreNamingSupport.TruncatePayload(conflictPayload)}"); } - await EnsureSuccessAsync(response, "upsert", ct); + await ElasticsearchProjectionDocumentStoreHttpSupport.EnsureSuccessAsync(response, "upsert", ct); var elapsedMs = (DateTimeOffset.UtcNow - startedAt).TotalMilliseconds; _logger.LogInformation( @@ -309,7 +312,7 @@ private string BuildDocumentRequestPath(string keyValue, long? ifSeqNo, long? if private bool TryHandleMissingIndexForRead(string operation, string payload) { - if (!IsIndexNotFoundPayload(payload)) + if (!ElasticsearchProjectionDocumentStoreHttpSupport.IsIndexNotFoundPayload(payload)) return false; if (_autoCreateIndex || _missingIndexBehavior == ElasticsearchMissingIndexBehavior.Throw) @@ -330,12 +333,7 @@ private InvalidOperationException BuildMissingIndexException(string operation, s return new InvalidOperationException( $"Elasticsearch index '{_indexName}' was not found during '{operation}' for read-model '{typeof(TReadModel).FullName}'. " + $"Configure index bootstrap or set '{nameof(ElasticsearchProjectionDocumentStoreOptions.AutoCreateIndex)}=true'. " + - $"body={TruncatePayload(payload)}"); - } - - private static bool IsIndexNotFoundPayload(string payload) - { - return payload.Contains("index_not_found_exception", StringComparison.OrdinalIgnoreCase); + $"body={ElasticsearchProjectionDocumentStoreNamingSupport.TruncatePayload(payload)}"); } private void LogWriteFailure( diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreHttpSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreHttpSupport.cs new file mode 100644 index 000000000..8700ac96b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreHttpSupport.cs @@ -0,0 +1,22 @@ +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +internal static class ElasticsearchProjectionDocumentStoreHttpSupport +{ + internal static async Task EnsureSuccessAsync( + HttpResponseMessage response, + string operation, + CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var payload = await response.Content.ReadAsStringAsync(ct); + throw new InvalidOperationException( + $"Elasticsearch {operation} failed: {(int)response.StatusCode} {response.ReasonPhrase}. body={payload}"); + } + + internal static bool IsIndexNotFoundPayload(string payload) + { + return payload.Contains("index_not_found_exception", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreMetadataSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreMetadataSupport.cs new file mode 100644 index 000000000..da4359a2a --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreMetadataSupport.cs @@ -0,0 +1,137 @@ +using System.Text.Json; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +internal static class ElasticsearchProjectionDocumentStoreMetadataSupport +{ + internal static DocumentIndexMetadata NormalizeMetadata(DocumentIndexMetadata metadata) + { + ArgumentNullException.ThrowIfNull(metadata); + var normalizedMappings = NormalizeObjectMap(metadata.Mappings, "DocumentIndexMetadata.Mappings"); + var normalizedSettings = NormalizeObjectMap(metadata.Settings, "DocumentIndexMetadata.Settings"); + var normalizedAliases = NormalizeObjectMap(metadata.Aliases, "DocumentIndexMetadata.Aliases"); + return new DocumentIndexMetadata( + metadata.IndexName?.Trim() ?? "", + normalizedMappings, + normalizedSettings, + normalizedAliases); + } + + internal static Dictionary NormalizeObjectMap( + IReadOnlyDictionary source, + string context) + { + ArgumentNullException.ThrowIfNull(source); + var normalized = new Dictionary(StringComparer.Ordinal); + foreach (var pair in source) + { + var key = pair.Key?.Trim() ?? ""; + if (key.Length == 0) + throw new InvalidOperationException($"{context} contains an empty key."); + + normalized[key] = NormalizeObjectValue(pair.Value, $"{context}['{key}']"); + } + + return normalized; + } + + private static object? NormalizeObjectValue(object? value, string context) + { + if (value == null) + return null; + + if (value is string || + value is bool || + value is byte || + value is sbyte || + value is short || + value is ushort || + value is int || + value is uint || + value is long || + value is ulong || + value is float || + value is double || + value is decimal) + { + return value; + } + + if (value is JsonElement jsonElement) + return NormalizeJsonElement(jsonElement, context); + + if (value is IReadOnlyDictionary readonlyObjectMap) + return NormalizeObjectMap(readonlyObjectMap, context); + + if (value is IDictionary mutableObjectMap) + { + return NormalizeObjectMap( + new Dictionary(mutableObjectMap, StringComparer.Ordinal), + context); + } + + if (value is IReadOnlyDictionary readonlyStringMap) + { + var converted = readonlyStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); + } + + if (value is IDictionary mutableStringMap) + { + var converted = mutableStringMap.ToDictionary( + x => x.Key, + x => (object?)x.Value, + StringComparer.Ordinal); + return NormalizeObjectMap(converted, context); + } + + if (value is IEnumerable objectSequence) + return objectSequence.Select((x, i) => NormalizeObjectValue(x, $"{context}[{i}]")).ToList(); + + if (value is IEnumerable stringSequence) + return stringSequence.Cast().ToList(); + + throw new InvalidOperationException( + $"{context} contains unsupported value type '{value.GetType().FullName}'."); + } + + private static object? NormalizeJsonElement(JsonElement element, string context) + { + return element.ValueKind switch + { + JsonValueKind.Object => element + .EnumerateObject() + .ToDictionary( + x => x.Name, + x => NormalizeJsonElement(x.Value, $"{context}['{x.Name}']"), + StringComparer.Ordinal), + JsonValueKind.Array => element + .EnumerateArray() + .Select((x, i) => NormalizeJsonElement(x, $"{context}[{i}]")) + .ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => NormalizeJsonNumber(element, context), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => throw new InvalidOperationException( + $"{context} contains unsupported json value kind '{element.ValueKind}'."), + }; + } + + private static object NormalizeJsonNumber(JsonElement numberElement, string context) + { + if (numberElement.TryGetInt64(out var int64Value)) + return int64Value; + if (numberElement.TryGetDecimal(out var decimalValue)) + return decimalValue; + if (numberElement.TryGetDouble(out var doubleValue)) + return doubleValue; + + throw new InvalidOperationException($"{context} contains an invalid JSON number value."); + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreNamingSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreNamingSupport.cs new file mode 100644 index 000000000..773a479ea --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStoreNamingSupport.cs @@ -0,0 +1,51 @@ +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +internal static class ElasticsearchProjectionDocumentStoreNamingSupport +{ + internal static Uri ResolvePrimaryEndpoint(IReadOnlyList? endpoints) + { + if (endpoints == null || endpoints.Count == 0) + throw new InvalidOperationException("Elasticsearch provider requires at least one endpoint."); + + var endpoint = endpoints[0].Trim(); + if (endpoint.Length == 0) + throw new InvalidOperationException("Elasticsearch endpoint cannot be empty."); + if (!endpoint.Contains("://", StringComparison.Ordinal)) + endpoint = "http://" + endpoint; + + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) + throw new InvalidOperationException($"Invalid Elasticsearch endpoint '{endpoints[0]}'."); + + return uri; + } + + internal static string BuildIndexName(string indexPrefix, string indexScope) + { + var prefix = NormalizeToken(indexPrefix); + if (prefix.Length == 0) + prefix = "aevatar"; + return $"{prefix}-{indexScope}"; + } + + internal static string NormalizeToken(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return ""; + + var chars = token + .Trim() + .ToLowerInvariant() + .Select(ch => char.IsLetterOrDigit(ch) ? ch : '-') + .ToArray(); + return new string(chars).Trim('-'); + } + + internal static string TruncatePayload(string payload) + { + const int maxLength = 512; + if (payload.Length <= maxLength) + return payload; + + return payload[..maxLength] + "...(truncated)"; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStorePayloadSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStorePayloadSupport.cs new file mode 100644 index 000000000..c9b2f4589 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Elasticsearch/Stores/ElasticsearchProjectionDocumentStorePayloadSupport.cs @@ -0,0 +1,103 @@ +using System.Text.Json; + +namespace Aevatar.CQRS.Projection.Providers.Elasticsearch.Stores; + +internal static class ElasticsearchProjectionDocumentStorePayloadSupport +{ + private const string DefaultListPrimarySortField = "CreatedAt"; + private const string DefaultListTiebreakSortField = "_id"; + + internal static string BuildListPayloadJson(string listSortField, int size) + { + var sort = string.IsNullOrWhiteSpace(listSortField) + ? BuildDefaultSortSpec() + : BuildConfiguredSortSpec(listSortField.Trim()); + + return JsonSerializer.Serialize(new + { + size, + sort, + query = new + { + match_all = new { }, + }, + }); + } + + internal static string BuildIndexInitializationPayload( + DocumentIndexMetadata indexMetadata, + JsonSerializerOptions jsonOptions) + { + var mappings = indexMetadata.Mappings.Count == 0 + ? new Dictionary(StringComparer.Ordinal) + { + ["dynamic"] = true, + } + : new Dictionary(indexMetadata.Mappings, StringComparer.Ordinal); + + var root = new Dictionary + { + ["mappings"] = mappings, + }; + + if (indexMetadata.Settings.Count > 0) + { + root["settings"] = new Dictionary( + indexMetadata.Settings, + StringComparer.Ordinal); + } + + if (indexMetadata.Aliases.Count > 0) + { + root["aliases"] = new Dictionary( + indexMetadata.Aliases, + StringComparer.Ordinal); + } + + return JsonSerializer.Serialize(root, jsonOptions); + } + + private static object[] BuildConfiguredSortSpec(string sortField) + { + return + [ + new Dictionary + { + [sortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + new Dictionary + { + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + ]; + } + + private static object[] BuildDefaultSortSpec() + { + return + [ + new Dictionary + { + [DefaultListPrimarySortField] = new Dictionary + { + ["order"] = "desc", + ["missing"] = "_last", + ["unmapped_type"] = "date", + }, + }, + new Dictionary + { + [DefaultListTiebreakSortField] = new Dictionary + { + ["order"] = "desc", + }, + }, + ]; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs index 4694ca1b3..29088229c 100644 --- a/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.InMemory/DependencyInjection/ServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; -public static class ServiceCollectionExtensions +public static class InMemoryProjectionServiceCollectionExtensions { public static IServiceCollection AddInMemoryDocumentProjectionStore( this IServiceCollection services, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs index 22432bcfc..c0a873f2b 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/DependencyInjection/ServiceCollectionExtensions.cs @@ -5,7 +5,7 @@ namespace Aevatar.CQRS.Projection.Providers.Neo4j.DependencyInjection; -public static class ServiceCollectionExtensions +public static class Neo4jProjectionServiceCollectionExtensions { public static IServiceCollection AddNeo4jGraphProjectionStore( this IServiceCollection services, diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs deleted file mode 100644 index d5ca9eced..000000000 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Helpers.cs +++ /dev/null @@ -1,318 +0,0 @@ -using System.Text.Json; -using Microsoft.Extensions.Logging; -using Neo4j.Driver; - -namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; - -public sealed partial class Neo4jProjectionGraphStore -{ - private async Task> GetNodesByIdsAsync( - string scope, - IReadOnlySet nodeIds, - CancellationToken ct) - { - if (nodeIds.Count == 0) - return []; - - await EnsureSchemaAsync(ct); - var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + - "WHERE n.nodeId IN $nodeIds " + - "RETURN n.nodeId AS nodeId, " + - "coalesce(n.nodeType, '') AS nodeType, " + - "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs"; - var parameters = new Dictionary - { - ["scope"] = scope, - ["nodeIds"] = nodeIds.ToArray(), - }; - - var rows = await ExecuteReadAsync(cypher, parameters, ct); - var nodes = new List(rows.Count); - foreach (var row in rows) - { - if (!row.TryGetValue("nodeId", out var nodeIdValue)) - continue; - var nodeId = NormalizeToken(nodeIdValue.As()); - if (nodeId.Length == 0) - continue; - - var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) - ? NormalizeToken(nodeTypeValue.As()) - : "Unknown"; - if (nodeType.Length == 0) - nodeType = "Unknown"; - - var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) - ? propertiesJsonValue.As() - : "{}"; - var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) - ? updatedAtEpochMsValue.As() - : 0L; - - nodes.Add(new ProjectionGraphNode - { - Scope = scope, - NodeId = nodeId, - NodeType = nodeType, - Properties = DeserializeProperties(propertiesJson), - UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), - }); - } - - return nodes; - } - - private ProjectionGraphEdge? BuildEdgeFromRow(string scope, IReadOnlyDictionary row) - { - if (!row.TryGetValue("edgeId", out var edgeIdValue)) - return null; - if (!row.TryGetValue("fromNodeId", out var fromNodeIdValue)) - return null; - if (!row.TryGetValue("toNodeId", out var toNodeIdValue)) - return null; - - var edgeId = NormalizeToken(edgeIdValue.As()); - var fromNodeId = NormalizeToken(fromNodeIdValue.As()); - var toNodeId = NormalizeToken(toNodeIdValue.As()); - if (edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) - return null; - - var relationType = row.TryGetValue("relationType", out var relationTypeValue) - ? NormalizeToken(relationTypeValue.As()) - : "Unknown"; - if (relationType.Length == 0) - relationType = "Unknown"; - var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) - ? propertiesJsonValue.As() - : "{}"; - var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) - ? updatedAtEpochMsValue.As() - : 0L; - - return new ProjectionGraphEdge - { - Scope = scope, - EdgeId = edgeId, - FromNodeId = fromNodeId, - ToNodeId = toNodeId, - EdgeType = relationType, - Properties = DeserializeProperties(propertiesJson), - UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), - }; - } - - private string BuildNeighborCypher(ProjectionGraphDirection direction, int take) - { - var filter = "WHERE size($edgeTypes) = 0 OR r.relationType IN $edgeTypes "; - var projection = "RETURN r.edgeId AS edgeId, " + - "startNode(r).nodeId AS fromNodeId, " + - "endNode(r).nodeId AS toNodeId, " + - "coalesce(r.relationType, '') AS relationType, " + - "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; - return direction switch - { - ProjectionGraphDirection.Outbound => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]->(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - ProjectionGraphDirection.Inbound => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - _ => - $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{_edgeType}]-(:{_nodeLabel} {{scope: $scope}}) " + - filter + - projection, - }; - } - - private string BuildSubgraphEdgesCypher(ProjectionGraphDirection direction, int depth) - { - var boundedDepth = Math.Clamp(depth, 1, _maxTraversalDepth); - var pathPattern = direction switch - { - ProjectionGraphDirection.Outbound => - $"(root)-[:{_edgeType}*1..{boundedDepth}]->()", - ProjectionGraphDirection.Inbound => - $"(root)<-[:{_edgeType}*1..{boundedDepth}]-()", - _ => - $"(root)-[:{_edgeType}*1..{boundedDepth}]-()", - }; - return $"MATCH (root:{_nodeLabel} {{scope: $scope, nodeId: $rootNodeId}}) " + - $"OPTIONAL MATCH p={pathPattern} " + - "WHERE p IS NULL OR (" + - "all(n IN nodes(p) WHERE coalesce(n.scope, '') = $scope) " + - "AND (size($edgeTypes) = 0 OR all(rel IN relationships(p) WHERE rel.relationType IN $edgeTypes))) " + - "UNWIND CASE WHEN p IS NULL THEN [] ELSE relationships(p) END AS r " + - "WITH DISTINCT r " + - "RETURN r.edgeId AS edgeId, " + - "startNode(r).nodeId AS fromNodeId, " + - "endNode(r).nodeId AS toNodeId, " + - "coalesce(r.relationType, '') AS relationType, " + - "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC LIMIT $take"; - } - - private async Task EnsureSchemaAsync(CancellationToken ct) - { - if (!_autoCreateConstraints || _schemaInitialized) - return; - - await _schemaLock.WaitAsync(ct); - try - { - if (_schemaInitialized) - return; - - var nodeConstraintName = NormalizeConstraintName($"projection_graph_node_scope_id_{_nodeLabel}"); - var cypher = $"CREATE CONSTRAINT {nodeConstraintName} IF NOT EXISTS " + - $"FOR (n:{_nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; - await ExecuteWriteAsync(cypher, new Dictionary(), ct); - _schemaInitialized = true; - } - finally - { - _schemaLock.Release(); - } - } - - private async Task ExecuteWriteAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Write); - var cursor = await session.RunAsync(cypher, parameters); - await cursor.ConsumeAsync(); - ct.ThrowIfCancellationRequested(); - } - - private async Task>> ExecuteReadAsync( - string cypher, - IReadOnlyDictionary parameters, - CancellationToken ct) - { - await using var session = CreateSession(AccessMode.Read); - var cursor = await session.RunAsync(cypher, parameters); - var rows = await cursor.ToListAsync(record => - (IReadOnlyDictionary)record.Values.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal)); - ct.ThrowIfCancellationRequested(); - return rows; - } - - private IAsyncSession CreateSession(AccessMode accessMode) - { - return _driver.AsyncSession(options => - { - options.WithDefaultAccessMode(accessMode); - if (_database.Length > 0) - options.WithDatabase(_database); - }); - } - - private static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) - { - return edgeTypes - .Select(NormalizeToken) - .Where(x => x.Length > 0) - .Distinct(StringComparer.Ordinal) - .ToArray(); - } - - private static bool ResolveProjectionManaged(IReadOnlyDictionary properties) - { - if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue)) - return false; - - var normalizedMarker = NormalizeToken(markerValue); - return string.Equals( - normalizedMarker, - ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, - StringComparison.Ordinal); - } - - private static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) - { - if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var ownerId)) - return ""; - - return NormalizeToken(ownerId); - } - - private string SerializeProperties(IReadOnlyDictionary properties) - { - if (properties.Count == 0) - return "{}"; - return JsonSerializer.Serialize(properties, _jsonOptions); - } - - private Dictionary DeserializeProperties(string payload) - { - if (string.IsNullOrWhiteSpace(payload)) - return new Dictionary(StringComparer.Ordinal); - try - { - var parsed = JsonSerializer.Deserialize>(payload, _jsonOptions); - return parsed == null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(parsed, StringComparer.Ordinal); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - "Failed to deserialize graph properties payload. provider={Provider}", - ProviderName); - return new Dictionary(StringComparer.Ordinal); - } - } - - private static long NormalizeTimestamp(DateTimeOffset timestamp) - { - if (timestamp == default) - return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - return timestamp.ToUnixTimeMilliseconds(); - } - - private static DateTimeOffset FromUnixTimeMilliseconds(long value) - { - var safeValue = Math.Max(0, value); - return DateTimeOffset.FromUnixTimeMilliseconds(safeValue); - } - - private static string NormalizeToken(string token) => token?.Trim() ?? ""; - - private static string NormalizeLabel(string rawLabel, string fallback) - { - var label = (rawLabel ?? "").Trim(); - if (label.Length == 0) - label = fallback; - - var chars = label - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') - .ToArray(); - var normalized = new string(chars); - if (normalized.Length == 0) - normalized = fallback; - if (char.IsDigit(normalized[0])) - normalized = $"N_{normalized}"; - return normalized; - } - - private static string NormalizeConstraintName(string rawName) - { - var chars = rawName - .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') - .ToArray(); - var normalized = new string(chars); - if (normalized.Length == 0) - return "projection_graph_constraint"; - if (char.IsDigit(normalized[0])) - normalized = $"c_{normalized}"; - return normalized; - } -} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Infrastructure.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Infrastructure.cs new file mode 100644 index 000000000..b07346f1b --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.Infrastructure.cs @@ -0,0 +1,106 @@ +using Neo4j.Driver; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +public sealed partial class Neo4jProjectionGraphStore +{ + private async Task> GetNodesByIdsAsync( + string scope, + IReadOnlySet nodeIds, + CancellationToken ct) + { + if (nodeIds.Count == 0) + return []; + + await EnsureSchemaAsync(ct); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildGetNodesByIdsCypher(_nodeLabel); + var parameters = new Dictionary + { + ["scope"] = scope, + ["nodeIds"] = nodeIds.ToArray(), + }; + + var rows = await ExecuteReadAsync(cypher, parameters, ct); + var nodes = new List(rows.Count); + foreach (var row in rows) + { + var node = Neo4jProjectionGraphStoreRowMapper.MapNode(scope, row, DeserializeProperties); + if (node != null) + nodes.Add(node); + } + + return nodes; + } + + private async Task EnsureSchemaAsync(CancellationToken ct) + { + if (!_autoCreateConstraints || _schemaInitialized) + return; + + await _schemaLock.WaitAsync(ct); + try + { + if (_schemaInitialized) + return; + + var nodeConstraintName = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeConstraintName( + $"projection_graph_node_scope_id_{_nodeLabel}"); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildCreateNodeConstraintCypher( + _nodeLabel, + nodeConstraintName); + await ExecuteWriteAsync(cypher, new Dictionary(), ct); + _schemaInitialized = true; + } + finally + { + _schemaLock.Release(); + } + } + + private async Task ExecuteWriteAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Write); + var cursor = await session.RunAsync(cypher, parameters); + await cursor.ConsumeAsync(); + ct.ThrowIfCancellationRequested(); + } + + private async Task>> ExecuteReadAsync( + string cypher, + IReadOnlyDictionary parameters, + CancellationToken ct) + { + await using var session = CreateSession(AccessMode.Read); + var cursor = await session.RunAsync(cypher, parameters); + var rows = await cursor.ToListAsync(record => + (IReadOnlyDictionary)record.Values.ToDictionary( + x => x.Key, + x => x.Value, + StringComparer.Ordinal)); + ct.ThrowIfCancellationRequested(); + return rows; + } + + private IAsyncSession CreateSession(AccessMode accessMode) + { + return _driver.AsyncSession(options => + { + options.WithDefaultAccessMode(accessMode); + if (_database.Length > 0) + options.WithDatabase(_database); + }); + } + + private string SerializeProperties(IReadOnlyDictionary properties) + { + return Neo4jProjectionGraphStorePropertyCodec.SerializeProperties(properties, _jsonOptions); + } + + private Dictionary DeserializeProperties(string payload) + { + return Neo4jProjectionGraphStorePropertyCodec.DeserializeProperties(payload, _jsonOptions, _logger, ProviderName); + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs index 833f13e80..c4fd4b103 100644 --- a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStore.cs @@ -31,8 +31,12 @@ public Neo4jProjectionGraphStore( { ArgumentNullException.ThrowIfNull(options); _database = options.Database?.Trim() ?? ""; - _nodeLabel = NormalizeLabel(options.NodeLabel, "ProjectionGraphNode"); - _edgeType = NormalizeLabel(options.EdgeType, "PROJECTION_REL"); + _nodeLabel = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeLabel( + options.NodeLabel, + "ProjectionGraphNode"); + _edgeType = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeLabel( + options.EdgeType, + "PROJECTION_REL"); _autoCreateConstraints = options.AutoCreateConstraints; _maxTraversalDepth = Math.Clamp(options.MaxTraversalDepth, 1, 8); _logger = logger ?? NullLogger.Instance; @@ -49,24 +53,19 @@ public async Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct ArgumentNullException.ThrowIfNull(node); ct.ThrowIfCancellationRequested(); - var scope = NormalizeToken(node.Scope); - var nodeId = NormalizeToken(node.NodeId); + var scope = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(node.Scope); + var nodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(node.NodeId); if (scope.Length == 0 || nodeId.Length == 0) throw new InvalidOperationException("Graph node requires non-empty scope and nodeId."); - var nodeType = NormalizeToken(node.NodeType); + var nodeType = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(node.NodeType); if (nodeType.Length == 0) nodeType = "Unknown"; - var updatedAtEpochMs = NormalizeTimestamp(node.UpdatedAt); + var updatedAtEpochMs = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeTimestamp(node.UpdatedAt); var propertiesJson = SerializeProperties(node.Properties); - var projectionManaged = ResolveProjectionManaged(node.Properties); - var projectionOwnerId = ResolveProjectionOwnerId(node.Properties); - var cypher = $"MERGE (n:{_nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + - "SET n.nodeType = $nodeType, " + - "n.propertiesJson = $propertiesJson, " + - "n.updatedAtEpochMs = $updatedAtEpochMs, " + - "n.projectionManaged = $projectionManaged, " + - "n.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; + var projectionManaged = Neo4jProjectionGraphStoreNormalizationSupport.ResolveProjectionManaged(node.Properties); + var projectionOwnerId = Neo4jProjectionGraphStoreNormalizationSupport.ResolveProjectionOwnerId(node.Properties); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildUpsertNodeCypher(_nodeLabel); var parameters = new Dictionary { ["scope"] = scope, @@ -87,30 +86,21 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct ArgumentNullException.ThrowIfNull(edge); ct.ThrowIfCancellationRequested(); - var scope = NormalizeToken(edge.Scope); - var edgeId = NormalizeToken(edge.EdgeId); - var fromNodeId = NormalizeToken(edge.FromNodeId); - var toNodeId = NormalizeToken(edge.ToNodeId); - var relationType = NormalizeToken(edge.EdgeType); + var scope = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edge.Scope); + var edgeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edge.EdgeId); + var fromNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edge.FromNodeId); + var toNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edge.ToNodeId); + var relationType = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edge.EdgeType); if (scope.Length == 0 || edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0 || relationType.Length == 0) { throw new InvalidOperationException("Graph edge requires non-empty scope/edgeId/fromNodeId/toNodeId/relationType."); } - var updatedAtEpochMs = NormalizeTimestamp(edge.UpdatedAt); + var updatedAtEpochMs = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeTimestamp(edge.UpdatedAt); var propertiesJson = SerializeProperties(edge.Properties); - var projectionManaged = ResolveProjectionManaged(edge.Properties); - var projectionOwnerId = ResolveProjectionOwnerId(edge.Properties); - var cypher = $"MERGE (from:{_nodeLabel} {{scope: $scope, nodeId: $fromNodeId}}) " + - "ON CREATE SET from.nodeType = 'Unknown', from.propertiesJson = '{}', from.updatedAtEpochMs = $updatedAtEpochMs " + - $"MERGE (to:{_nodeLabel} {{scope: $scope, nodeId: $toNodeId}}) " + - "ON CREATE SET to.nodeType = 'Unknown', to.propertiesJson = '{}', to.updatedAtEpochMs = $updatedAtEpochMs " + - $"MERGE (from)-[r:{_edgeType} {{scope: $scope, edgeId: $edgeId}}]->(to) " + - "SET r.relationType = $relationType, " + - "r.propertiesJson = $propertiesJson, " + - "r.updatedAtEpochMs = $updatedAtEpochMs, " + - "r.projectionManaged = $projectionManaged, " + - "r.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; + var projectionManaged = Neo4jProjectionGraphStoreNormalizationSupport.ResolveProjectionManaged(edge.Properties); + var projectionOwnerId = Neo4jProjectionGraphStoreNormalizationSupport.ResolveProjectionOwnerId(edge.Properties); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildUpsertEdgeCypher(_nodeLabel, _edgeType); var parameters = new Dictionary { ["scope"] = scope, @@ -131,14 +121,13 @@ public async Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct public async Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var scopeValue = NormalizeToken(scope); - var nodeIdValue = NormalizeToken(nodeId); + var scopeValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(scope); + var nodeIdValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(nodeId); if (scopeValue.Length == 0 || nodeIdValue.Length == 0) return; await EnsureSchemaAsync(ct); - var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + - "WHERE NOT (n)-[]-() DELETE n"; + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildDeleteNodeCypher(_nodeLabel); var parameters = new Dictionary { ["scope"] = scopeValue, @@ -150,13 +139,13 @@ public async Task DeleteNodeAsync(string scope, string nodeId, CancellationToken public async Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var scopeValue = NormalizeToken(scope); - var edgeIdValue = NormalizeToken(edgeId); + var scopeValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(scope); + var edgeIdValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edgeId); if (scopeValue.Length == 0 || edgeIdValue.Length == 0) return; await EnsureSchemaAsync(ct); - var cypher = $"MATCH ()-[r:{_edgeType} {{scope: $scope, edgeId: $edgeId}}]->() DELETE r"; + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildDeleteEdgeCypher(_edgeType); var parameters = new Dictionary { ["scope"] = scopeValue, @@ -173,22 +162,15 @@ public async Task> ListNodesByOwnerAsync( CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var scopeValue = NormalizeToken(scope); - var ownerValue = NormalizeToken(ownerId); + var scopeValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(scope); + var ownerValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(ownerId); if (scopeValue.Length == 0 || ownerValue.Length == 0) return []; await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(take, 1, 50000); var boundedSkip = Math.Max(0, skip); - var cypher = $"MATCH (n:{_nodeLabel} {{scope: $scope}}) " + - "WHERE coalesce(n.projectionManaged, false) = true " + - "AND n.projectionOwnerId = $ownerId " + - "RETURN n.nodeId AS nodeId, " + - "coalesce(n.nodeType, '') AS nodeType, " + - "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildListNodesByOwnerCypher(_nodeLabel); var parameters = new Dictionary { ["scope"] = scopeValue, @@ -201,34 +183,9 @@ public async Task> ListNodesByOwnerAsync( var nodes = new List(rows.Count); foreach (var row in rows) { - if (!row.TryGetValue("nodeId", out var nodeIdValue)) - continue; - - var resolvedNodeId = NormalizeToken(nodeIdValue.As()); - if (resolvedNodeId.Length == 0) - continue; - - var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) - ? NormalizeToken(nodeTypeValue.As()) - : "Unknown"; - if (nodeType.Length == 0) - nodeType = "Unknown"; - - var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) - ? propertiesJsonValue.As() - : "{}"; - var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) - ? updatedAtEpochMsValue.As() - : 0L; - - nodes.Add(new ProjectionGraphNode - { - Scope = scopeValue, - NodeId = resolvedNodeId, - NodeType = nodeType, - Properties = DeserializeProperties(propertiesJson), - UpdatedAt = FromUnixTimeMilliseconds(updatedAtEpochMs), - }); + var node = Neo4jProjectionGraphStoreRowMapper.MapNode(scopeValue, row, DeserializeProperties); + if (node != null) + nodes.Add(node); } return nodes; @@ -242,24 +199,15 @@ public async Task> ListEdgesByOwnerAsync( CancellationToken ct = default) { ct.ThrowIfCancellationRequested(); - var scopeValue = NormalizeToken(scope); - var ownerValue = NormalizeToken(ownerId); + var scopeValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(scope); + var ownerValue = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(ownerId); if (scopeValue.Length == 0 || ownerValue.Length == 0) return []; await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(take, 1, 50000); var boundedSkip = Math.Max(0, skip); - var cypher = $"MATCH ()-[r:{_edgeType} {{scope: $scope}}]->() " + - "WHERE coalesce(r.projectionManaged, false) = true " + - "AND r.projectionOwnerId = $ownerId " + - "RETURN r.edgeId AS edgeId, " + - "startNode(r).nodeId AS fromNodeId, " + - "endNode(r).nodeId AS toNodeId, " + - "coalesce(r.relationType, '') AS relationType, " + - "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + - "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + - "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildListEdgesByOwnerCypher(_edgeType); var parameters = new Dictionary { ["scope"] = scopeValue, @@ -272,7 +220,7 @@ public async Task> ListEdgesByOwnerAsync( var edges = new List(rows.Count); foreach (var row in rows) { - var edge = BuildEdgeFromRow(scopeValue, row); + var edge = Neo4jProjectionGraphStoreRowMapper.MapEdge(scopeValue, row, DeserializeProperties); if (edge != null) edges.Add(edge); } @@ -286,15 +234,18 @@ public async Task> GetNeighborsAsync( { ArgumentNullException.ThrowIfNull(query); ct.ThrowIfCancellationRequested(); - var scope = NormalizeToken(query.Scope); - var rootNodeId = NormalizeToken(query.RootNodeId); + var scope = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(query.Scope); + var rootNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(query.RootNodeId); if (scope.Length == 0 || rootNodeId.Length == 0) return []; await EnsureSchemaAsync(ct); var boundedTake = Math.Clamp(query.Take, 1, 5000); - var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); - var cypher = BuildNeighborCypher(query.Direction, boundedTake); + var edgeTypes = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeEdgeTypes(query.EdgeTypes); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildNeighborCypher( + _nodeLabel, + _edgeType, + query.Direction); var parameters = new Dictionary { ["scope"] = scope, @@ -307,7 +258,7 @@ public async Task> GetNeighborsAsync( var edges = new List(rows.Count); foreach (var row in rows) { - var edge = BuildEdgeFromRow(scope, row); + var edge = Neo4jProjectionGraphStoreRowMapper.MapEdge(scope, row, DeserializeProperties); if (edge != null) edges.Add(edge); } @@ -321,16 +272,20 @@ public async Task GetSubgraphAsync( { ArgumentNullException.ThrowIfNull(query); ct.ThrowIfCancellationRequested(); - var scope = NormalizeToken(query.Scope); - var rootNodeId = NormalizeToken(query.RootNodeId); + var scope = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(query.Scope); + var rootNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(query.RootNodeId); if (scope.Length == 0 || rootNodeId.Length == 0) return new ProjectionGraphSubgraph(); await EnsureSchemaAsync(ct); var depth = Math.Clamp(query.Depth, 1, _maxTraversalDepth); var take = Math.Clamp(query.Take, 1, 5000); - var edgeTypes = NormalizeEdgeTypes(query.EdgeTypes); - var cypher = BuildSubgraphEdgesCypher(query.Direction, depth); + var edgeTypes = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeEdgeTypes(query.EdgeTypes); + var cypher = Neo4jProjectionGraphStoreCypherSupport.BuildSubgraphEdgesCypher( + _nodeLabel, + _edgeType, + query.Direction, + depth); var parameters = new Dictionary { ["scope"] = scope, @@ -343,7 +298,7 @@ public async Task GetSubgraphAsync( var edges = new List(rows.Count); foreach (var row in rows) { - var edge = BuildEdgeFromRow(scope, row); + var edge = Neo4jProjectionGraphStoreRowMapper.MapEdge(scope, row, DeserializeProperties); if (edge != null) edges.Add(edge); } @@ -351,7 +306,7 @@ public async Task GetSubgraphAsync( var nodeIds = edges .SelectMany(x => new[] { x.FromNodeId, x.ToNodeId }) .Append(rootNodeId) - .Where(x => NormalizeToken(x).Length > 0) + .Where(x => Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(x).Length > 0) .ToHashSet(StringComparer.Ordinal); var nodes = await GetNodesByIdsAsync(scope, nodeIds, ct); if (!nodes.Any(x => string.Equals(x.NodeId, rootNodeId, StringComparison.Ordinal))) diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreCypherSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreCypherSupport.cs new file mode 100644 index 000000000..a6c8a2d19 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreCypherSupport.cs @@ -0,0 +1,143 @@ +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +internal static class Neo4jProjectionGraphStoreCypherSupport +{ + internal static string BuildUpsertNodeCypher(string nodeLabel) + { + return $"MERGE (n:{nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + + "SET n.nodeType = $nodeType, " + + "n.propertiesJson = $propertiesJson, " + + "n.updatedAtEpochMs = $updatedAtEpochMs, " + + "n.projectionManaged = $projectionManaged, " + + "n.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; + } + + internal static string BuildUpsertEdgeCypher(string nodeLabel, string edgeType) + { + return $"MERGE (from:{nodeLabel} {{scope: $scope, nodeId: $fromNodeId}}) " + + "ON CREATE SET from.nodeType = 'Unknown', from.propertiesJson = '{}', from.updatedAtEpochMs = $updatedAtEpochMs " + + $"MERGE (to:{nodeLabel} {{scope: $scope, nodeId: $toNodeId}}) " + + "ON CREATE SET to.nodeType = 'Unknown', to.propertiesJson = '{}', to.updatedAtEpochMs = $updatedAtEpochMs " + + $"MERGE (from)-[r:{edgeType} {{scope: $scope, edgeId: $edgeId}}]->(to) " + + "SET r.relationType = $relationType, " + + "r.propertiesJson = $propertiesJson, " + + "r.updatedAtEpochMs = $updatedAtEpochMs, " + + "r.projectionManaged = $projectionManaged, " + + "r.projectionOwnerId = CASE WHEN $projectionOwnerId = '' THEN null ELSE $projectionOwnerId END"; + } + + internal static string BuildDeleteNodeCypher(string nodeLabel) + { + return $"MATCH (n:{nodeLabel} {{scope: $scope, nodeId: $nodeId}}) " + + "WHERE NOT (n)-[]-() DELETE n"; + } + + internal static string BuildDeleteEdgeCypher(string edgeType) + { + return $"MATCH ()-[r:{edgeType} {{scope: $scope, edgeId: $edgeId}}]->() DELETE r"; + } + + internal static string BuildListNodesByOwnerCypher(string nodeLabel) + { + return $"MATCH (n:{nodeLabel} {{scope: $scope}}) " + + "WHERE coalesce(n.projectionManaged, false) = true " + + "AND n.projectionOwnerId = $ownerId " + + "RETURN n.nodeId AS nodeId, " + + "coalesce(n.nodeType, '') AS nodeType, " + + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; + } + + internal static string BuildListEdgesByOwnerCypher(string edgeType) + { + return $"MATCH ()-[r:{edgeType} {{scope: $scope}}]->() " + + "WHERE coalesce(r.projectionManaged, false) = true " + + "AND r.projectionOwnerId = $ownerId " + + "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC SKIP $skip LIMIT $take"; + } + + internal static string BuildNeighborCypher( + string nodeLabel, + string edgeType, + ProjectionGraphDirection direction) + { + var filter = "WHERE size($edgeTypes) = 0 OR r.relationType IN $edgeTypes "; + var projection = "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + return direction switch + { + ProjectionGraphDirection.Outbound => + $"MATCH (root:{nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{edgeType}]->(:{nodeLabel} {{scope: $scope}}) " + + filter + + projection, + ProjectionGraphDirection.Inbound => + $"MATCH (root:{nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})<-[r:{edgeType}]-(:{nodeLabel} {{scope: $scope}}) " + + filter + + projection, + _ => + $"MATCH (root:{nodeLabel} {{scope: $scope, nodeId: $rootNodeId}})-[r:{edgeType}]-(:{nodeLabel} {{scope: $scope}}) " + + filter + + projection, + }; + } + + internal static string BuildSubgraphEdgesCypher( + string nodeLabel, + string edgeType, + ProjectionGraphDirection direction, + int depth) + { + var pathPattern = direction switch + { + ProjectionGraphDirection.Outbound => + $"(root)-[:{edgeType}*1..{depth}]->()", + ProjectionGraphDirection.Inbound => + $"(root)<-[:{edgeType}*1..{depth}]-()", + _ => + $"(root)-[:{edgeType}*1..{depth}]-()", + }; + + return $"MATCH (root:{nodeLabel} {{scope: $scope, nodeId: $rootNodeId}}) " + + $"OPTIONAL MATCH p={pathPattern} " + + "WHERE p IS NULL OR (" + + "all(n IN nodes(p) WHERE coalesce(n.scope, '') = $scope) " + + "AND (size($edgeTypes) = 0 OR all(rel IN relationships(p) WHERE rel.relationType IN $edgeTypes))) " + + "UNWIND CASE WHEN p IS NULL THEN [] ELSE relationships(p) END AS r " + + "WITH DISTINCT r " + + "RETURN r.edgeId AS edgeId, " + + "startNode(r).nodeId AS fromNodeId, " + + "endNode(r).nodeId AS toNodeId, " + + "coalesce(r.relationType, '') AS relationType, " + + "coalesce(r.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(r.updatedAtEpochMs, 0) AS updatedAtEpochMs " + + "ORDER BY updatedAtEpochMs DESC LIMIT $take"; + } + + internal static string BuildGetNodesByIdsCypher(string nodeLabel) + { + return $"MATCH (n:{nodeLabel} {{scope: $scope}}) " + + "WHERE n.nodeId IN $nodeIds " + + "RETURN n.nodeId AS nodeId, " + + "coalesce(n.nodeType, '') AS nodeType, " + + "coalesce(n.propertiesJson, '{}') AS propertiesJson, " + + "coalesce(n.updatedAtEpochMs, 0) AS updatedAtEpochMs"; + } + + internal static string BuildCreateNodeConstraintCypher(string nodeLabel, string constraintName) + { + return $"CREATE CONSTRAINT {constraintName} IF NOT EXISTS " + + $"FOR (n:{nodeLabel}) REQUIRE (n.scope, n.nodeId) IS UNIQUE"; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreNormalizationSupport.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreNormalizationSupport.cs new file mode 100644 index 000000000..2e8b807ae --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreNormalizationSupport.cs @@ -0,0 +1,78 @@ +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +internal static class Neo4jProjectionGraphStoreNormalizationSupport +{ + internal static long NormalizeTimestamp(DateTimeOffset timestamp) + { + if (timestamp == default) + return DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + return timestamp.ToUnixTimeMilliseconds(); + } + + internal static DateTimeOffset FromUnixTimeMilliseconds(long value) + { + var safeValue = Math.Max(0, value); + return DateTimeOffset.FromUnixTimeMilliseconds(safeValue); + } + + internal static string NormalizeToken(string? token) => token?.Trim() ?? ""; + + internal static string[] NormalizeEdgeTypes(IReadOnlyList edgeTypes) + { + return edgeTypes + .Select(NormalizeToken) + .Where(x => x.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToArray(); + } + + internal static bool ResolveProjectionManaged(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedMarkerKey, out var markerValue)) + return false; + + var normalizedMarker = NormalizeToken(markerValue); + return string.Equals( + normalizedMarker, + ProjectionGraphManagedPropertyKeys.ManagedMarkerValue, + StringComparison.Ordinal); + } + + internal static string ResolveProjectionOwnerId(IReadOnlyDictionary properties) + { + if (!properties.TryGetValue(ProjectionGraphManagedPropertyKeys.ManagedOwnerIdKey, out var ownerId)) + return ""; + + return NormalizeToken(ownerId); + } + + internal static string NormalizeLabel(string rawLabel, string fallback) + { + var label = (rawLabel ?? "").Trim(); + if (label.Length == 0) + label = fallback; + + var chars = label + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? ch : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + normalized = fallback; + if (char.IsDigit(normalized[0])) + normalized = $"N_{normalized}"; + return normalized; + } + + internal static string NormalizeConstraintName(string rawName) + { + var chars = rawName + .Select(ch => char.IsLetterOrDigit(ch) || ch == '_' ? char.ToLowerInvariant(ch) : '_') + .ToArray(); + var normalized = new string(chars); + if (normalized.Length == 0) + return "projection_graph_constraint"; + if (char.IsDigit(normalized[0])) + normalized = $"c_{normalized}"; + return normalized; + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStorePropertyCodec.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStorePropertyCodec.cs new file mode 100644 index 000000000..c9b4e8eb1 --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStorePropertyCodec.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +internal static class Neo4jProjectionGraphStorePropertyCodec +{ + internal static string SerializeProperties( + IReadOnlyDictionary properties, + JsonSerializerOptions jsonOptions) + { + if (properties.Count == 0) + return "{}"; + return JsonSerializer.Serialize(properties, jsonOptions); + } + + internal static Dictionary DeserializeProperties( + string payload, + JsonSerializerOptions jsonOptions, + ILogger logger, + string providerName) + { + if (string.IsNullOrWhiteSpace(payload)) + return new Dictionary(StringComparer.Ordinal); + try + { + var parsed = JsonSerializer.Deserialize>(payload, jsonOptions); + return parsed == null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(parsed, StringComparer.Ordinal); + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to deserialize graph properties payload. provider={Provider}", + providerName); + return new Dictionary(StringComparer.Ordinal); + } + } +} diff --git a/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreRowMapper.cs b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreRowMapper.cs new file mode 100644 index 000000000..72e42c16e --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Providers.Neo4j/Stores/Neo4jProjectionGraphStoreRowMapper.cs @@ -0,0 +1,84 @@ +using Neo4j.Driver; + +namespace Aevatar.CQRS.Projection.Providers.Neo4j.Stores; + +internal static class Neo4jProjectionGraphStoreRowMapper +{ + internal static ProjectionGraphEdge? MapEdge( + string scope, + IReadOnlyDictionary row, + Func> deserializeProperties) + { + if (!row.TryGetValue("edgeId", out var edgeIdValue)) + return null; + if (!row.TryGetValue("fromNodeId", out var fromNodeIdValue)) + return null; + if (!row.TryGetValue("toNodeId", out var toNodeIdValue)) + return null; + + var edgeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(edgeIdValue.As()); + var fromNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(fromNodeIdValue.As()); + var toNodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(toNodeIdValue.As()); + if (edgeId.Length == 0 || fromNodeId.Length == 0 || toNodeId.Length == 0) + return null; + + var relationType = row.TryGetValue("relationType", out var relationTypeValue) + ? Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(relationTypeValue.As()) + : "Unknown"; + if (relationType.Length == 0) + relationType = "Unknown"; + + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + return new ProjectionGraphEdge + { + Scope = scope, + EdgeId = edgeId, + FromNodeId = fromNodeId, + ToNodeId = toNodeId, + EdgeType = relationType, + Properties = deserializeProperties(propertiesJson), + UpdatedAt = Neo4jProjectionGraphStoreNormalizationSupport.FromUnixTimeMilliseconds(updatedAtEpochMs), + }; + } + + internal static ProjectionGraphNode? MapNode( + string scope, + IReadOnlyDictionary row, + Func> deserializeProperties) + { + if (!row.TryGetValue("nodeId", out var nodeIdValue)) + return null; + + var nodeId = Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(nodeIdValue.As()); + if (nodeId.Length == 0) + return null; + + var nodeType = row.TryGetValue("nodeType", out var nodeTypeValue) + ? Neo4jProjectionGraphStoreNormalizationSupport.NormalizeToken(nodeTypeValue.As()) + : "Unknown"; + if (nodeType.Length == 0) + nodeType = "Unknown"; + + var propertiesJson = row.TryGetValue("propertiesJson", out var propertiesJsonValue) + ? propertiesJsonValue.As() + : "{}"; + var updatedAtEpochMs = row.TryGetValue("updatedAtEpochMs", out var updatedAtEpochMsValue) + ? updatedAtEpochMsValue.As() + : 0L; + + return new ProjectionGraphNode + { + Scope = scope, + NodeId = nodeId, + NodeType = nodeType, + Properties = deserializeProperties(propertiesJson), + UpdatedAt = Neo4jProjectionGraphStoreNormalizationSupport.FromUnixTimeMilliseconds(updatedAtEpochMs), + }; + } +} diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs index 69d9d40d7..bac8f5382 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/IProjectionStoreBindingAvailability.cs @@ -3,4 +3,6 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public interface IProjectionStoreBindingAvailability { bool IsConfigured { get; } + + string AvailabilityReason { get; } } diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md index e085b8c1e..4f37c3639 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/README.md @@ -7,6 +7,7 @@ - `IProjectionStoreDispatcher` - `IProjectionStoreBinding` - `IProjectionQueryableStoreBinding` +- `IProjectionStoreBindingAvailability` - `IProjectionDocumentMetadataResolver` - `ProjectionGraphManagedPropertyKeys` @@ -15,6 +16,7 @@ 1. 一个 ReadModel 可绑定多个 Store(例如 Document + Graph)。 2. 仅允许一个 `IProjectionQueryableStoreBinding` 作为查询/读取来源。 3. 其余 binding 作为写入目标参与分发。 +4. `IProjectionStoreBindingAvailability` 暴露 `IsConfigured + AvailabilityReason`,用于可观测地说明绑定为何被跳过。 ## 边界 diff --git a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs index 4a3b4bf88..9176f2588 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/DependencyInjection/ServiceCollectionExtensions.cs @@ -4,7 +4,7 @@ namespace Aevatar.CQRS.Projection.Runtime.DependencyInjection; -public static class ServiceCollectionExtensions +public static class ProjectionRuntimeServiceCollectionExtensions { public static IServiceCollection AddProjectionReadModelRuntime(this IServiceCollection services) { diff --git a/src/Aevatar.CQRS.Projection.Runtime/README.md b/src/Aevatar.CQRS.Projection.Runtime/README.md index f346b57a8..6eaa7fbaa 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/README.md +++ b/src/Aevatar.CQRS.Projection.Runtime/README.md @@ -26,3 +26,4 @@ 1. Runtime 负责“一对多 store 分发”,不做 ProviderName 路由。 2. Document 与 Graph 保持平行;Runtime 默认同时装配两类 binding,按配置自动激活。 3. queryable binding 为可选(0..1);存在时提供 `Get/List/Mutate`,写入始终分发到所有已配置 binding。 +4. binding 未激活时由 `IProjectionStoreBindingAvailability.AvailabilityReason` 输出统一跳过原因日志。 diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs index a37d66101..f4a5a4f81 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionDocumentStoreBinding.cs @@ -14,6 +14,10 @@ public ProjectionDocumentStoreBinding(IProjectionDocumentStore public bool IsConfigured => _store is not null; + public string AvailabilityReason => IsConfigured + ? "Document binding is active." + : "Document projection store service is not registered."; + public string StoreName => IsConfigured ? "Document" : "Document(Unconfigured)"; public Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs index 4f9f9b4cd..0902b49e7 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionGraphStoreBinding.cs @@ -19,6 +19,22 @@ public ProjectionGraphStoreBinding(IProjectionGraphStore? graphStore = null) _graphStore is not null && typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel)); + public string AvailabilityReason + { + get + { + if (_graphStore is null) + return "Graph projection store service is not registered."; + + if (!typeof(IGraphReadModel).IsAssignableFrom(typeof(TReadModel))) + { + return $"Read model '{typeof(TReadModel).FullName}' does not implement '{typeof(IGraphReadModel).FullName}'."; + } + + return "Graph binding is active."; + } + } + public string StoreName => IsConfigured ? "Graph" : "Graph(Unconfigured)"; public async Task UpsertAsync(TReadModel readModel, CancellationToken ct = default) diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs index adb8155d6..b9bda225f 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs @@ -25,13 +25,37 @@ public ProjectionStoreDispatcher( _options = options ?? new ProjectionStoreDispatchOptions(); _logger = logger ?? NullLogger>.Instance; - _bindings = bindings - .Where(IsBindingConfigured) - .ToList(); + var configuredBindings = new List>(); + var skippedBindings = new List(); + foreach (var binding in bindings) + { + if (binding is IProjectionStoreBindingAvailability availability && + !availability.IsConfigured) + { + skippedBindings.Add(new SkippedBindingInfo(binding.StoreName, availability.AvailabilityReason)); + continue; + } + + configuredBindings.Add(binding); + } + + foreach (var skipped in skippedBindings) + { + _logger.LogInformation( + "Projection binding skipped. readModelType={ReadModelType} store={Store} reason={Reason}", + typeof(TReadModel).FullName, + skipped.StoreName, + skipped.Reason); + } + + _bindings = configuredBindings; if (_bindings.Count == 0) { + var skipSummary = skippedBindings.Count == 0 + ? "none" + : string.Join("; ", skippedBindings.Select(x => $"{x.StoreName}: {x.Reason}")); throw new InvalidOperationException( - $"No configured projection store bindings are registered for read model '{typeof(TReadModel).FullName}'."); + $"No configured projection store bindings are registered for read model '{typeof(TReadModel).FullName}'. skippedBindings={skipSummary}"); } var queryBindings = _bindings @@ -204,10 +228,7 @@ private IProjectionQueryableStoreBinding GetRequiredQueryBindi $"Queryable projection store binding is not configured for read model '{typeof(TReadModel).FullName}'."); } - private static bool IsBindingConfigured(IProjectionStoreBinding binding) - { - return binding is not IProjectionStoreBindingAvailability availability || availability.IsConfigured; - } + private sealed record SkippedBindingInfo(string StoreName, string Reason); private sealed class NoOpProjectionStoreDispatchCompensator : IProjectionStoreDispatchCompensator diff --git a/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs index ca8b0313b..330a5c881 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Aevatar.CQRS.Projection.StateMirror/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,7 +7,7 @@ namespace Aevatar.CQRS.Projection.StateMirror.DependencyInjection; -public static class ServiceCollectionExtensions +public static class StateMirrorServiceCollectionExtensions { public static IServiceCollection AddJsonStateMirrorProjection( this IServiceCollection services, diff --git a/src/Aevatar.CQRS.Projection.StateMirror/README.md b/src/Aevatar.CQRS.Projection.StateMirror/README.md index e14c97ac3..fbd1802e2 100644 --- a/src/Aevatar.CQRS.Projection.StateMirror/README.md +++ b/src/Aevatar.CQRS.Projection.StateMirror/README.md @@ -29,6 +29,12 @@ flowchart LR - `services.AddJsonStateMirrorReadModelProjector(configure?)` 注册映射器和自定义键类型执行器。 +## 边界约束 + +- `StateMirror` 仅用于 `State -> ReadModel` 的结构镜像与字段重命名/忽略,不承载业务规则。 +- 若映射过程涉及业务语义计算、状态机衍生字段、跨事件聚合,应使用业务专用 mapper/reducer,而不是 `StateMirror`。 +- 可并存策略:默认采用 `StateMirror` 快速落地,出现业务语义时切换到显式业务 mapper。 + ## 示例 ```csharp diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs index 18c86bf23..447e479bd 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionGraphStoreBindingTests.cs @@ -201,6 +201,15 @@ await binding.UpsertAsync(new TestGraphReadModel rootNeighbors.Should().BeEmpty(); } + [Fact] + public void AvailabilityReason_WhenReadModelIsNotGraphReadModel_ShouldExplainWhySkipped() + { + var binding = new ProjectionGraphStoreBinding(new RecordingGraphStore()); + + binding.IsConfigured.Should().BeFalse(); + binding.AvailabilityReason.Should().Contain("does not implement"); + } + private static string BuildOwnerId(string id) => $"{typeof(TestGraphReadModel).FullName}:{id}"; private static ProjectionGraphNode Node(string nodeId) @@ -240,6 +249,11 @@ private sealed class TestGraphReadModel : IGraphReadModel public IReadOnlyList GraphEdges { get; init; } = []; } + private sealed class NonGraphReadModel : IProjectionReadModel + { + public string Id { get; init; } = ""; + } + private sealed class RecordingGraphStore : IProjectionGraphStore { private readonly object _gate = new(); diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs index 82d0a6cda..761fd7c58 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionStoreDispatcherTests.cs @@ -103,6 +103,18 @@ public void Ctor_WhenNoConfiguredBindings_ShouldThrow() .WithMessage("*No configured projection store bindings*"); } + [Fact] + public void Ctor_WhenNoConfiguredBindings_ShouldIncludeAvailabilityReason() + { + var unconfiguredDocumentBinding = new ProjectionDocumentStoreBinding(); + + Action act = () => new ProjectionStoreDispatcher( + [unconfiguredDocumentBinding]); + + act.Should().Throw() + .WithMessage("*Document projection store service is not registered*"); + } + [Fact] public void Ctor_WhenMultipleQueryableBindings_ShouldThrow() { @@ -113,6 +125,15 @@ public void Ctor_WhenMultipleQueryableBindings_ShouldThrow() .WithMessage("*At most one queryable projection store binding is allowed*"); } + [Fact] + public void ProjectionDocumentBinding_WhenStoreMissing_ShouldExposeAvailabilityReason() + { + var binding = new ProjectionDocumentStoreBinding(); + + binding.IsConfigured.Should().BeFalse(); + binding.AvailabilityReason.Should().Contain("not registered"); + } + [Fact] public async Task UpsertAsync_WhenBindingFailsInitially_ShouldRetry() { From a545aa88f4706c3c94d8f8600d46f7cfbc60ae05 Mon Sep 17 00:00:00 2001 From: eanzhao Date: Wed, 25 Feb 2026 15:19:35 +0800 Subject: [PATCH 45/46] Implement projection reliability fixes and update architecture audit docs Co-authored-by: Cursor --- aevatar.slnx | 43 +-- ...-event-sourcing-elasticsearch-readmodel.md | 101 +++++++ .../pr13-code-review-2026-02-25.md | 105 +++++++ .../Aevatar.CQRS.Projection.Core.csproj | 1 + .../ActorProjectionOwnershipCoordinator.cs | 8 +- .../ProjectionOwnershipCoordinatorGAgent.cs | 53 +++- .../ProjectionOwnershipCoordinatorOptions.cs | 20 ++ ...ction_dispatch_compensation_messages.proto | 52 ++++ .../projection_ownership_messages.proto | 2 + ...jectionStoreDispatchCompensationContext.cs | 7 + .../Runtime/ProjectionStoreDispatcher.cs | 3 + .../appsettings.Distributed.json | 31 ++ .../WorkflowExecutionProjectionOptions.cs | 33 ++ .../ServiceCollectionExtensions.cs | 7 + ...torProjectionDispatchCompensationOutbox.cs | 89 ++++++ .../IProjectionDispatchCompensationOutbox.cs | 14 + ...jectionDispatchCompensationOutboxGAgent.cs | 222 ++++++++++++++ ...DispatchCompensationReplayHostedService.cs | 95 ++++++ ...kflowProjectionDurableOutboxCompensator.cs | 62 ++++ ...ReadModelStartupValidationHostedService.cs | 68 ++++- .../Properties/InternalsVisibleTo.cs | 3 + ...tionProviderServiceCollectionExtensions.cs | 19 ++ .../ProjectionOwnershipAndSessionHubTests.cs | 79 ++++- .../ProjectionOwnershipProtoCoverageTests.cs | 2 + ...lowExecutionProjectionRegistrationTests.cs | 63 +++- .../WorkflowHostingExtensionsCoverageTests.cs | 22 ++ ...flowProjectionDispatchCompensationTests.cs | 283 ++++++++++++++++++ ...odelStartupValidationHostedServiceTests.cs | 224 ++++++++++++++ 28 files changed, 1673 insertions(+), 38 deletions(-) create mode 100644 docs/architecture/branch-summary-feat-generic-event-sourcing-elasticsearch-readmodel.md create mode 100644 docs/audit-scorecard/pr13-code-review-2026-02-25.md create mode 100644 src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorOptions.cs create mode 100644 src/Aevatar.CQRS.Projection.Core/projection_dispatch_compensation_messages.proto create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/ActorProjectionDispatchCompensationOutbox.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/IProjectionDispatchCompensationOutbox.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationOutboxGAgent.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationReplayHostedService.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDurableOutboxCompensator.cs create mode 100644 src/workflow/Aevatar.Workflow.Projection/Properties/InternalsVisibleTo.cs create mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchCompensationTests.cs create mode 100644 test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelStartupValidationHostedServiceTests.cs diff --git a/aevatar.slnx b/aevatar.slnx index 2ba1dd45f..026364cd4 100644 --- a/aevatar.slnx +++ b/aevatar.slnx @@ -12,45 +12,48 @@ + + + - - - + + - - + + - - - + - + + + + - + @@ -58,31 +61,31 @@ - + + - - + - - - - + + - - + + + + - + \ No newline at end of file diff --git a/docs/architecture/branch-summary-feat-generic-event-sourcing-elasticsearch-readmodel.md b/docs/architecture/branch-summary-feat-generic-event-sourcing-elasticsearch-readmodel.md new file mode 100644 index 000000000..e940c8319 --- /dev/null +++ b/docs/architecture/branch-summary-feat-generic-event-sourcing-elasticsearch-readmodel.md @@ -0,0 +1,101 @@ +# 分支改动速览(`feat/generic-event-sourcing-elasticsearch-readmodel` 相对 `dev`) + +## 一句话结论 + +这条分支不是功能点修补,而是一次“主链路级”重构:把 **Event Sourcing**、**Projection Runtime/Providers**、**Workflow 查询与投影编排**、**CI 架构门禁**统一成一套更严格、可验证的工程体系。 + +## 改动规模(`dev...HEAD`) + +- 提交数:47 +- 文件改动:282(`+13222 / -1512`) +- 文件状态:新增 140、修改 110、删除 11、其余为重命名/移动 +- 变动最集中区域: + - `src/workflow`(51 文件) + - `src/Aevatar.CQRS.Projection.Core.Abstractions`(24 文件) + - `src/Aevatar.Foundation.Core/EventSourcing`(12 文件) + - `src/Aevatar.CQRS.Projection.Providers.*`(Elasticsearch/InMemory/Neo4j) + - `tools/ci`、`.github/workflows/ci.yml` + +## 老板主要写了什么(按主线) + +### 1) 把有状态 Actor 强制拉到 Event Sourcing 主链路 + +核心文件:`src/Aevatar.Foundation.Core/GAgentBase.TState.cs`、`src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs` + +- `GAgentBase` 激活时强制 `ReplayAsync` 恢复状态,停用时 `ConfirmEventsAsync + PersistSnapshotAsync`。 +- 新增 `PersistDomainEventAsync/PersistDomainEventsAsync`,强调“先领域事件,再状态演进”。 +- `State` 写入受 guard 约束,避免绕开事件链路直接改状态。 +- `EventSourcingBehavior` 明确禁止把 `TState` 当“快照事件”写入 EventStore。 + +### 2) 补齐 EventStore 后端与快照后清理机制 + +核心文件:`src/Aevatar.Foundation.Runtime/Persistence/FileEventStore.cs`、`src/Aevatar.Foundation.Runtime.Persistence.Implementations.Garnet/GarnetEventStore.cs`、`src/Aevatar.Foundation.Runtime/Persistence/DeferredEventStoreCompactionScheduler.cs`、`src/Aevatar.Foundation.Runtime/Actor/EventStoreCompactionDeactivationHook.cs` + +- 新增文件型 EventStore(本地可运行、带并发版本校验)。 +- 新增 Garnet EventStore(Redis 脚本做原子 append/compaction)。 +- 引入“延迟压缩”:快照后只调度,不阻塞主命令路径;由 actor idle/deactivation 时执行裁剪。 + +### 3) 重做 Projection 的分层与运行时分发 + +新增项目(已进 `aevatar.slnx`): + +- `src/Aevatar.CQRS.Projection.Stores.Abstractions` +- `src/Aevatar.CQRS.Projection.Runtime.Abstractions` +- `src/Aevatar.CQRS.Projection.Runtime` +- `src/Aevatar.CQRS.Projection.StateMirror` +- `src/Aevatar.CQRS.Projection.Providers.Elasticsearch` +- `src/Aevatar.CQRS.Projection.Providers.InMemory` +- `src/Aevatar.CQRS.Projection.Providers.Neo4j` + +关键点: + +- `ProjectionStoreDispatcher` 支持一对多写分发(Document + Graph)。 +- Query binding 最多允许一个,避免多读源不一致。 +- 支持写失败补偿接口(compensator)。 +- `StateMirror` 提供通用 `State -> ReadModel` 镜像能力(可忽略/重命名字段)。 + +### 4) Workflow 投影与 Provider 组合方式重构 + +核心文件:`src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionLifecycleService.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowExecutionProjectionQueryService.cs` + +- Provider 注册下沉到 Host/Extensions 组合层(不是 Workflow.Infrastructure 直接绑定)。 +- 强约束:Document Provider 必须且只能启用一个;Graph Provider 也必须且只能启用一个。 +- 生命周期接口改为 lease 句柄语义,避免 `actorId -> context` 反查式管理。 +- 启动期新增 provider 校验(fail-fast):`WorkflowReadModelStartupValidationHostedService`。 + +### 5) Workflow 查询面增强(API + 图查询) + +核心文件:`src/workflow/Aevatar.Workflow.Infrastructure/CapabilityApi/ChatQueryEndpoints.cs`、`src/workflow/Aevatar.Workflow.Application/Queries/WorkflowExecutionQueryApplicationService.cs`、`src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionQueryReader.cs`、`src/workflow/Aevatar.Workflow.Projection/ReadModels/WorkflowExecutionReadModel.cs` + +- 新增/强化查询端点:`/agents`、`/workflows`、`/actors/{actorId}`、timeline、graph edges、subgraph、graph-enriched。 +- 查询侧可按方向/边类型过滤图关系。 +- `WorkflowExecutionReport` 扩展为可产出图节点/图边的读模型(`IGraphReadModel`)。 + +### 6) CI 门禁与回归测试体系明显加码 + +核心文件:`.github/workflows/ci.yml`、`.github/actions/prepare-runner/action.yml`、`tools/ci/architecture_guards.sh`、`tools/ci/event_sourcing_regression.sh`、`tools/ci/projection_provider_e2e_smoke.sh` + +- CI 新增/强化多类作业:`fast-gates`、`split-test-guards`、`projection-provider-e2e`、`event-sourcing-regression`、`coverage-quality` 等。 +- 架构守卫新增硬规则:禁止 Workflow.Infrastructure 直接依赖 `Providers.*`、禁止中间层 ID 映射事实态字典、强化 replay 合同测试约束等。 +- 测试侧新增大量契约/集成覆盖(例如 `RoleGAgentReplayContractTests`、`ProjectionProviderE2EIntegrationTests`)。 + +## 可以先这么理解你老板的设计意图 + +1. **事实源唯一化**:状态恢复必须依赖事件回放,不走隐式状态捷径。 +2. **读侧能力插件化**:Document/Graph provider 可替换,但走同一个 runtime 分发主链路。 +3. **边界再收紧**:Workflow 依赖抽象,不直接耦合 provider 具体实现。 +4. **治理前置**:把“架构约束”固化成 CI 门禁,不靠口头约定。 + +## 建议你优先追问的 10 个问题 + +1. 生产环境默认推荐的 Document/Graph provider 组合是什么?为什么? +2. `Projection:Policies:DenyInMemoryGraphFactStore` 在各环境的默认策略怎么定? +3. Event compaction 的保留窗口(`retainedEventsAfterSnapshot`)如何给基线值? +4. Workflow 查询接口是否需要鉴权/限流/脱敏(尤其 graph-enriched)? +5. 现有历史数据从旧读模型迁移到新 provider 绑定的方案是什么? +6. 若 Neo4j 或 Elasticsearch 不可用,系统降级路径是失败启动还是降级运行? +7. 新增的 startup fail-fast 会不会影响本地开发体验?是否分环境开关? +8. 这条分支里“必须先合并”的最小闭环提交范围是哪几部分? +9. nightly smoke / 压测基线未闭环部分,计划和负责人怎么排? +10. 这次重构后,后续 feature 开发必须遵循的 3 条硬约束是什么? + diff --git a/docs/audit-scorecard/pr13-code-review-2026-02-25.md b/docs/audit-scorecard/pr13-code-review-2026-02-25.md new file mode 100644 index 000000000..dcaaa35b5 --- /dev/null +++ b/docs/audit-scorecard/pr13-code-review-2026-02-25.md @@ -0,0 +1,105 @@ +# PR #13 代码审查报告(2026-02-25) + +- 审查日期:2026-02-25 +- 审查对象:`feat/generic-event-sourcing-elasticsearch-readmodel`(相对 `dev`) +- 关联 PR:[Feat/generic event sourcing elasticsearch readmodel #13](https://github.com/aevatarAI/aevatar/pull/13) +- 审查方式:源码核查 + 自动化测试 + 架构门禁 + +--- + +## 1. 结论 + +本次审查范围内的 F1-F4 问题均已落地修复,核心链路可用。 + +- Blocking:0 +- Major:0 +- Medium:1(非阻断优化项) + +**合并建议**:可合并。 +**剩余优化项**:F2 outbox 的 completed 记录建议增加清理/归档策略,降低长期状态体积与扫描开销。 + +--- + +## 2. 问题与修复清单 + +| ID | 问题 | 状态 | 修复摘要 | +| --- | --- | --- | --- | +| F1 | ownership 可能长期占用 | 已修复 | 增加 lease TTL、续租与过期接管。 | +| F2 | 双写失败补偿仅日志,可能读侧不一致 | 已修复 | 改为 actor 化 outbox + 异步 replay + backoff 重试。 | +| F3 | distributed 配置可能回退 InMemory | 已修复 | 显式 durable provider + Document 侧门禁。 | +| F4 | 启动校验未覆盖外部可达性 | 已修复 | 启动期改为真实 provider probe,并按环境分级处理失败。 | + +--- + +## 3. 关键修复说明 + +### F1:Ownership lease 过期与接管 + +1. 协议增加 `lease_ttl_ms`。 +2. `Acquire` 支持同 session renew 与过期 takeover。 +3. 新增 `ProjectionOwnershipCoordinatorOptions`(默认 TTL=30 分钟,可配置)。 +4. `WorkflowExecutionProjectionOptions` 新增 `ProjectionOwnershipLeaseTtlMs` 并注入 ownership coordinator。 + +关键代码: +- `src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto` +- `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs` +- `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorOptions.cs` +- `src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs` +- `src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs` + +### F2:双写失败补偿(actor 化 outbox) + +1. 补偿上下文扩展为 `DispatchId / OccurredAtUtc / ReadModelType`。 +2. `WorkflowProjectionDurableOutboxCompensator` 在失败时入队补偿事件。 +3. `ActorProjectionDispatchCompensationOutbox` 通过 runtime 将事件投递到 outbox actor。 +4. `WorkflowProjectionDispatchCompensationOutboxGAgent` 维护持久状态并执行 replay。 +5. `WorkflowProjectionDispatchCompensationReplayHostedService` 周期触发 replay,失败 backoff,成功标记 completed。 + +关键代码: +- `src/Aevatar.CQRS.Projection.Core/projection_dispatch_compensation_messages.proto` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/IProjectionDispatchCompensationOutbox.cs` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/ActorProjectionDispatchCompensationOutbox.cs` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationOutboxGAgent.cs` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDurableOutboxCompensator.cs` +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationReplayHostedService.cs` +- `src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs` + +### F3:distributed durable provider 与门禁 + +1. `appsettings.Distributed.json` 显式启用 ES/Neo4j,关闭 InMemory。 +2. Document 侧新增 InMemory 门禁,与 Graph 策略对齐。 + +关键代码: +- `src/Aevatar.Mainnet.Host.Api/appsettings.Distributed.json` +- `src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs` + +### F4:启动探测升级 + +1. 启动期改为真实 probe: + - Document:`ListAsync(take:1)` + - Graph:`ListNodesByOwnerAsync(...)` +2. 失败策略: + - Production:fail-fast + - 非 Production:warning 并继续 + +关键代码: +- `src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs` + +--- + +## 4. 验证结果 + +1. `dotnet test test/Aevatar.Workflow.Host.Api.Tests/Aevatar.Workflow.Host.Api.Tests.csproj --nologo` + - 结果:Passed(160 passed / 0 failed) +2. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo` + - 结果:Passed(55 passed / 1 skipped / 0 failed) +3. `bash tools/ci/architecture_guards.sh` + - 结果:Passed + +--- + +## 5. 后续优化(非阻断) + +1. 为 F2 增加 outbox completed 记录清理/归档策略。 +2. 增加补偿运行指标:backlog、重试次数、重放延迟。 +3. 在 distributed smoke 中增加跨节点补偿收敛断言。 diff --git a/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj b/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj index 6bf640ca4..7465c9d8d 100644 --- a/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj +++ b/src/Aevatar.CQRS.Projection.Core/Aevatar.CQRS.Projection.Core.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs index de399d1a1..63313a1a6 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs @@ -13,11 +13,16 @@ public sealed class ActorProjectionOwnershipCoordinator : IProjectionOwnershipCo private const string CoordinatorPublisherId = "projection.ownership.coordinator"; private readonly IActorRuntime _runtime; private readonly IAgentTypeVerifier _agentTypeVerifier; + private readonly ProjectionOwnershipCoordinatorOptions _options; - public ActorProjectionOwnershipCoordinator(IActorRuntime runtime, IAgentTypeVerifier agentTypeVerifier) + public ActorProjectionOwnershipCoordinator( + IActorRuntime runtime, + IAgentTypeVerifier agentTypeVerifier, + ProjectionOwnershipCoordinatorOptions? options = null) { _runtime = runtime; _agentTypeVerifier = agentTypeVerifier; + _options = options ?? new ProjectionOwnershipCoordinatorOptions(); } public async Task AcquireAsync( @@ -31,6 +36,7 @@ public async Task AcquireAsync( { ScopeId = scopeId, SessionId = sessionId, + LeaseTtlMs = _options.ResolveLeaseTtlMs(), }, sessionId); await coordinatorActor.HandleEventAsync(envelope, ct); diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs index 87ca11516..f4bc50098 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs @@ -31,13 +31,37 @@ public async Task HandleAcquireAsync(ProjectionOwnershipAcquireEvent evt) if (string.IsNullOrWhiteSpace(evt.SessionId)) throw new InvalidOperationException("Session id is required to acquire projection ownership."); + var normalizedLeaseTtlMs = ProjectionOwnershipCoordinatorOptions.NormalizeLeaseTtlMs(evt.LeaseTtlMs); + var acquireEvent = new ProjectionOwnershipAcquireEvent + { + ScopeId = evt.ScopeId, + SessionId = evt.SessionId, + LeaseTtlMs = normalizedLeaseTtlMs, + }; + if (State.Active) { - throw new InvalidOperationException( - $"Projection ownership for scope '{State.ScopeId}' is already active (session '{State.SessionId}')."); + if (!string.Equals(State.ScopeId, evt.ScopeId, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Projection ownership coordinator '{Id}' scope mismatch. expected='{State.ScopeId}', actual='{evt.ScopeId}'."); + } + + if (string.Equals(State.SessionId, evt.SessionId, StringComparison.Ordinal)) + { + // Same session acquire is treated as lease renewal. + await PersistDomainEventAsync(acquireEvent); + return; + } + + if (!IsOwnershipExpired(State, DateTime.UtcNow)) + { + throw new InvalidOperationException( + $"Projection ownership for scope '{State.ScopeId}' is already active (session '{State.SessionId}')."); + } } - await PersistDomainEventAsync(evt); + await PersistDomainEventAsync(acquireEvent); } [EventHandler] @@ -83,6 +107,7 @@ private static ProjectionOwnershipCoordinatorState ApplyAcquire( next.SessionId = evt.SessionId; next.Active = true; next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); + next.LeaseTtlMs = ProjectionOwnershipCoordinatorOptions.NormalizeLeaseTtlMs(evt.LeaseTtlMs); return next; } @@ -100,4 +125,26 @@ private static ProjectionOwnershipCoordinatorState ApplyRelease( next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); return next; } + + private static bool IsOwnershipExpired(ProjectionOwnershipCoordinatorState state, DateTime utcNow) + { + if (!state.Active) + return false; + + var lastUpdatedUtc = ResolveLastUpdatedUtc(state.LastUpdatedAtUtc); + var leaseTtlMs = ProjectionOwnershipCoordinatorOptions.NormalizeLeaseTtlMs(state.LeaseTtlMs); + var leaseDuration = TimeSpan.FromMilliseconds(leaseTtlMs); + return utcNow - lastUpdatedUtc >= leaseDuration; + } + + private static DateTime ResolveLastUpdatedUtc(Timestamp? timestamp) + { + if (timestamp == null) + return DateTime.UnixEpoch; + + var lastUpdatedUtc = timestamp.ToDateTime(); + return lastUpdatedUtc.Kind == DateTimeKind.Utc + ? lastUpdatedUtc + : DateTime.SpecifyKind(lastUpdatedUtc, DateTimeKind.Utc); + } } diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorOptions.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorOptions.cs new file mode 100644 index 000000000..68409684f --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorOptions.cs @@ -0,0 +1,20 @@ +namespace Aevatar.CQRS.Projection.Core.Orchestration; + +public sealed class ProjectionOwnershipCoordinatorOptions +{ + public const long DefaultLeaseTtlMs = 30L * 60 * 1000; + public const long MinimumLeaseTtlMs = 1_000; + public const long MaximumLeaseTtlMs = 24L * 60 * 60 * 1000; + + public long LeaseTtlMs { get; set; } = DefaultLeaseTtlMs; + + public long ResolveLeaseTtlMs() => NormalizeLeaseTtlMs(LeaseTtlMs); + + public static long NormalizeLeaseTtlMs(long rawLeaseTtlMs) + { + if (rawLeaseTtlMs <= 0) + return DefaultLeaseTtlMs; + + return Math.Clamp(rawLeaseTtlMs, MinimumLeaseTtlMs, MaximumLeaseTtlMs); + } +} diff --git a/src/Aevatar.CQRS.Projection.Core/projection_dispatch_compensation_messages.proto b/src/Aevatar.CQRS.Projection.Core/projection_dispatch_compensation_messages.proto new file mode 100644 index 000000000..8bd5404dd --- /dev/null +++ b/src/Aevatar.CQRS.Projection.Core/projection_dispatch_compensation_messages.proto @@ -0,0 +1,52 @@ +syntax = "proto3"; + +option csharp_namespace = "Aevatar.CQRS.Projection.Core.Orchestration"; + +import "google/protobuf/timestamp.proto"; + +message ProjectionDispatchCompensationOutboxState { + map entries = 1; +} + +message ProjectionDispatchCompensationOutboxEntry { + string record_id = 1; + string operation = 2; + string failed_store = 3; + repeated string succeeded_stores = 4; + string read_model_type = 5; + string read_model_json = 6; + string key = 7; + google.protobuf.Timestamp enqueued_at_utc = 8; + google.protobuf.Timestamp next_visible_at_utc = 9; + int32 attempt_count = 10; + string last_error = 11; + google.protobuf.Timestamp completed_at_utc = 12; +} + +message ProjectionCompensationEnqueuedEvent { + string record_id = 1; + string operation = 2; + string failed_store = 3; + repeated string succeeded_stores = 4; + string read_model_type = 5; + string read_model_json = 6; + string key = 7; + google.protobuf.Timestamp enqueued_at_utc = 8; + string last_error = 9; +} + +message ProjectionCompensationRetryScheduledEvent { + string record_id = 1; + int32 attempt_count = 2; + google.protobuf.Timestamp next_visible_at_utc = 3; + string error = 4; +} + +message ProjectionCompensationSucceededEvent { + string record_id = 1; + google.protobuf.Timestamp completed_at_utc = 2; +} + +message ProjectionCompensationTriggerReplayEvent { + int32 batch_size = 1; +} diff --git a/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto b/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto index 4ebb4a545..17a87d6e0 100644 --- a/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto +++ b/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto @@ -9,11 +9,13 @@ message ProjectionOwnershipCoordinatorState { string session_id = 2; bool active = 3; google.protobuf.Timestamp last_updated_at_utc = 4; + int64 lease_ttl_ms = 5; } message ProjectionOwnershipAcquireEvent { string scope_id = 1; string session_id = 2; + int64 lease_ttl_ms = 3; } message ProjectionOwnershipReleaseEvent { diff --git a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs index f0e7df9e9..ebcc0178e 100644 --- a/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs +++ b/src/Aevatar.CQRS.Projection.Runtime.Abstractions/Abstractions/Stores/ProjectionStoreDispatchCompensationContext.cs @@ -3,6 +3,8 @@ namespace Aevatar.CQRS.Projection.Runtime.Abstractions; public sealed class ProjectionStoreDispatchCompensationContext where TReadModel : class, IProjectionReadModel { + public string DispatchId { get; init; } = Guid.NewGuid().ToString("N"); + public required string Operation { get; init; } public required string FailedStore { get; init; } @@ -14,4 +16,9 @@ public sealed class ProjectionStoreDispatchCompensationContext public required Exception Exception { get; init; } public TKey? Key { get; init; } + + public string ReadModelType { get; init; } = + typeof(TReadModel).FullName ?? typeof(TReadModel).Name; + + public DateTimeOffset OccurredAtUtc { get; init; } = DateTimeOffset.UtcNow; } diff --git a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs index b9bda225f..971dffdc4 100644 --- a/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs +++ b/src/Aevatar.CQRS.Projection.Runtime/Runtime/ProjectionStoreDispatcher.cs @@ -211,12 +211,15 @@ private Task CompensateAsync( { var context = new ProjectionStoreDispatchCompensationContext { + DispatchId = Guid.NewGuid().ToString("N"), Operation = operation, Key = key, ReadModel = readModel, FailedStore = failedBinding.StoreName, SucceededStores = succeededBindings.Select(x => x.StoreName).ToList(), Exception = exception, + ReadModelType = typeof(TReadModel).FullName ?? typeof(TReadModel).Name, + OccurredAtUtc = DateTimeOffset.UtcNow, }; return _compensator.CompensateAsync(context, ct); } diff --git a/src/Aevatar.Mainnet.Host.Api/appsettings.Distributed.json b/src/Aevatar.Mainnet.Host.Api/appsettings.Distributed.json index 1b452965e..c66bae38c 100644 --- a/src/Aevatar.Mainnet.Host.Api/appsettings.Distributed.json +++ b/src/Aevatar.Mainnet.Host.Api/appsettings.Distributed.json @@ -21,5 +21,36 @@ "GatewayPort": 30000, "QueueCount": 8, "QueueCacheSize": 4096 + }, + "Projection": { + "Document": { + "Providers": { + "Elasticsearch": { + "Enabled": true, + "Endpoints": [ + "http://localhost:9200" + ] + }, + "InMemory": { + "Enabled": false + } + } + }, + "Graph": { + "Providers": { + "Neo4j": { + "Enabled": true, + "Uri": "bolt://localhost:7687" + }, + "InMemory": { + "Enabled": false + } + } + }, + "Policies": { + "Environment": "Production", + "DenyInMemoryDocumentReadStore": true, + "DenyInMemoryGraphFactStore": true + } } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs index e3cd50ed4..8f33dd242 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Configuration/WorkflowExecutionProjectionOptions.cs @@ -1,3 +1,5 @@ +using Aevatar.CQRS.Projection.Core.Orchestration; + namespace Aevatar.Workflow.Projection.Configuration; /// @@ -47,4 +49,35 @@ public bool EnableRunQueryEndpoints /// Whether to pre-validate graph provider selection and capabilities during host startup. /// public bool ValidateGraphProviderOnStartup { get; set; } = true; + + /// + /// Projection ownership lease TTL in milliseconds. + /// + public long ProjectionOwnershipLeaseTtlMs { get; set; } = + ProjectionOwnershipCoordinatorOptions.DefaultLeaseTtlMs; + + /// + /// Enables durable outbox replay for projection store dispatch compensation. + /// + public bool EnableDispatchCompensationReplay { get; set; } = true; + + /// + /// Poll interval for compensation replay worker. + /// + public int DispatchCompensationReplayPollIntervalMs { get; set; } = 1000; + + /// + /// Max records replayed in one compensation polling cycle. + /// + public int DispatchCompensationReplayBatchSize { get; set; } = 20; + + /// + /// Base retry delay for failed compensation replay. + /// + public int DispatchCompensationReplayBaseDelayMs { get; set; } = 1000; + + /// + /// Max retry delay for failed compensation replay. + /// + public int DispatchCompensationReplayMaxDelayMs { get; set; } = 60_000; } diff --git a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs index 91434a162..3e529bf5f 100644 --- a/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/workflow/Aevatar.Workflow.Projection/DependencyInjection/ServiceCollectionExtensions.cs @@ -38,6 +38,8 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( sp.GetRequiredService()); services.TryAddSingleton(); services.AddProjectionReadModelRuntime(); + services.TryAddSingleton(); + services.TryAddSingleton, WorkflowProjectionDurableOutboxCompensator>(); services.TryAddSingleton, WorkflowExecutionReportDocumentMetadataProvider>(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -48,6 +50,10 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton, WorkflowProjectionDispatchFailureReporter>(); services.TryAddSingleton, ProjectionSubscriptionRegistry>(); services.TryAddSingleton(typeof(IActorStreamSubscriptionHub<>), typeof(ActorStreamSubscriptionHub<>)); + services.TryAddSingleton(sp => new ProjectionOwnershipCoordinatorOptions + { + LeaseTtlMs = sp.GetRequiredService().ProjectionOwnershipLeaseTtlMs, + }); services.TryAddSingleton(); services.TryAddSingleton, WorkflowRunEventSessionCodec>(); services.TryAddSingleton, ProjectionSessionEventHub>(); @@ -65,6 +71,7 @@ public static IServiceCollection AddWorkflowExecutionProjectionCQRS( services.TryAddSingleton(); services.TryAddSingleton(sp => sp.GetRequiredService()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); return services; } diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/ActorProjectionDispatchCompensationOutbox.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/ActorProjectionDispatchCompensationOutbox.cs new file mode 100644 index 000000000..48995e3a9 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/ActorProjectionDispatchCompensationOutbox.cs @@ -0,0 +1,89 @@ +using Aevatar.CQRS.Projection.Core.Abstractions; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.Foundation.Abstractions.TypeSystem; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal sealed class ActorProjectionDispatchCompensationOutbox : IProjectionDispatchCompensationOutbox +{ + private const string OutboxPublisherId = "projection.compensation.outbox"; + private const string DefaultScopeId = "workflow"; + private readonly IActorRuntime _runtime; + private readonly IAgentTypeVerifier _agentTypeVerifier; + + public ActorProjectionDispatchCompensationOutbox( + IActorRuntime runtime, + IAgentTypeVerifier agentTypeVerifier) + { + _runtime = runtime; + _agentTypeVerifier = agentTypeVerifier; + } + + public async Task EnqueueAsync( + ProjectionCompensationEnqueuedEvent evt, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(evt); + var actor = await ResolveOutboxActorAsync(ct); + var envelope = CreateEnvelope(evt, evt.RecordId); + await actor.HandleEventAsync(envelope, ct); + } + + public async Task TriggerReplayAsync( + int batchSize, + CancellationToken ct = default) + { + var actor = await ResolveOutboxActorAsync(ct); + var envelope = CreateEnvelope( + new ProjectionCompensationTriggerReplayEvent { BatchSize = batchSize }, + correlationId: "replay"); + await actor.HandleEventAsync(envelope, ct); + } + + private async Task ResolveOutboxActorAsync(CancellationToken ct) + { + var actorId = WorkflowProjectionDispatchCompensationOutboxGAgent.BuildActorId(DefaultScopeId); + var existing = await _runtime.GetAsync(actorId); + if (existing != null) + return await EnsureActorTypeAsync(existing, actorId, ct); + + try + { + var created = await _runtime.CreateAsync(actorId, ct); + return await EnsureActorTypeAsync(created, actorId, ct); + } + catch (InvalidOperationException) + { + var raced = await _runtime.GetAsync(actorId); + if (raced != null) + return await EnsureActorTypeAsync(raced, actorId, ct); + + throw; + } + } + + private async Task EnsureActorTypeAsync(IActor actor, string actorId, CancellationToken ct) + { + if (await _agentTypeVerifier.IsExpectedAsync( + actorId, + typeof(WorkflowProjectionDispatchCompensationOutboxGAgent), + ct)) + return actor; + + throw new InvalidOperationException( + $"Actor '{actorId}' is not a projection dispatch compensation outbox actor."); + } + + private static EventEnvelope CreateEnvelope(IMessage payload, string correlationId) => + new() + { + Id = Guid.NewGuid().ToString("N"), + Timestamp = Timestamp.FromDateTime(DateTime.UtcNow), + Payload = Any.Pack(payload), + PublisherId = OutboxPublisherId, + Direction = EventDirection.Self, + CorrelationId = correlationId, + }; +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/IProjectionDispatchCompensationOutbox.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IProjectionDispatchCompensationOutbox.cs new file mode 100644 index 000000000..cfc8213c2 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/IProjectionDispatchCompensationOutbox.cs @@ -0,0 +1,14 @@ +using Aevatar.CQRS.Projection.Core.Orchestration; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal interface IProjectionDispatchCompensationOutbox +{ + Task EnqueueAsync( + ProjectionCompensationEnqueuedEvent evt, + CancellationToken ct = default); + + Task TriggerReplayAsync( + int batchSize, + CancellationToken ct = default); +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationOutboxGAgent.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationOutboxGAgent.cs new file mode 100644 index 000000000..ca420fe65 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationOutboxGAgent.cs @@ -0,0 +1,222 @@ +using System.Text.Json; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions.Attributes; +using Aevatar.Foundation.Core; +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.ReadModels; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal sealed class WorkflowProjectionDispatchCompensationOutboxGAgent + : GAgentBase +{ + public const string ActorIdPrefix = "projection.compensation.outbox"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public static string BuildActorId(string scopeId) + { + if (string.IsNullOrWhiteSpace(scopeId)) + throw new ArgumentException("Scope id is required.", nameof(scopeId)); + + return $"{ActorIdPrefix}:{scopeId.Trim()}"; + } + + [EventHandler] + public async Task HandleEnqueueAsync(ProjectionCompensationEnqueuedEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + if (string.IsNullOrWhiteSpace(evt.RecordId)) + throw new InvalidOperationException("Record id is required to enqueue compensation."); + if (string.IsNullOrWhiteSpace(evt.FailedStore)) + throw new InvalidOperationException("Failed store is required to enqueue compensation."); + + await PersistDomainEventAsync(evt); + } + + [EventHandler] + public async Task HandleTriggerReplayAsync(ProjectionCompensationTriggerReplayEvent evt) + { + ArgumentNullException.ThrowIfNull(evt); + + var utcNow = DateTime.UtcNow; + var batchSize = evt.BatchSize > 0 ? evt.BatchSize : 20; + + var dueEntries = State.Entries.Values + .Where(e => e.CompletedAtUtc == null && IsVisible(e, utcNow)) + .OrderBy(e => e.NextVisibleAtUtc?.ToDateTime() ?? DateTime.UnixEpoch) + .ThenBy(e => e.EnqueuedAtUtc?.ToDateTime() ?? DateTime.UnixEpoch) + .Take(batchSize) + .ToList(); + + var bindings = Services + .GetServices>() + .GroupBy(b => b.StoreName, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Last(), StringComparer.OrdinalIgnoreCase); + + foreach (var entry in dueEntries) + { + await ReplayEntryAsync(entry, bindings); + } + } + + protected override ProjectionDispatchCompensationOutboxState TransitionState( + ProjectionDispatchCompensationOutboxState current, + IMessage evt) => + StateTransitionMatcher + .Match(current, evt) + .On(ApplyEnqueued) + .On(ApplyRetryScheduled) + .On(ApplySucceeded) + .OrCurrent(); + + private static ProjectionDispatchCompensationOutboxState ApplyEnqueued( + ProjectionDispatchCompensationOutboxState current, + ProjectionCompensationEnqueuedEvent evt) + { + var next = current.Clone(); + next.Entries[evt.RecordId] = new ProjectionDispatchCompensationOutboxEntry + { + RecordId = evt.RecordId, + Operation = evt.Operation, + FailedStore = evt.FailedStore, + SucceededStores = { evt.SucceededStores }, + ReadModelType = evt.ReadModelType, + ReadModelJson = evt.ReadModelJson, + Key = evt.Key, + EnqueuedAtUtc = evt.EnqueuedAtUtc, + NextVisibleAtUtc = evt.EnqueuedAtUtc, + AttemptCount = 0, + LastError = evt.LastError, + }; + return next; + } + + private static ProjectionDispatchCompensationOutboxState ApplyRetryScheduled( + ProjectionDispatchCompensationOutboxState current, + ProjectionCompensationRetryScheduledEvent evt) + { + var next = current.Clone(); + if (!next.Entries.TryGetValue(evt.RecordId, out var existing)) + return next; + + var updated = existing.Clone(); + updated.AttemptCount = Math.Max(1, evt.AttemptCount); + updated.NextVisibleAtUtc = evt.NextVisibleAtUtc; + updated.LastError = evt.Error; + next.Entries[evt.RecordId] = updated; + return next; + } + + private static ProjectionDispatchCompensationOutboxState ApplySucceeded( + ProjectionDispatchCompensationOutboxState current, + ProjectionCompensationSucceededEvent evt) + { + var next = current.Clone(); + if (!next.Entries.TryGetValue(evt.RecordId, out var existing)) + return next; + + var updated = existing.Clone(); + updated.CompletedAtUtc = evt.CompletedAtUtc; + next.Entries[evt.RecordId] = updated; + return next; + } + + private async Task ReplayEntryAsync( + ProjectionDispatchCompensationOutboxEntry entry, + IReadOnlyDictionary> bindings) + { + if (!bindings.TryGetValue(entry.FailedStore, out var binding)) + { + await ScheduleRetryAsync( + entry, + new InvalidOperationException( + $"Compensation replay binding '{entry.FailedStore}' is not registered.")); + return; + } + + WorkflowExecutionReport? readModel; + try + { + readModel = JsonSerializer.Deserialize(entry.ReadModelJson, JsonOptions); + } + catch (Exception ex) + { + await ScheduleRetryAsync(entry, ex); + return; + } + + if (readModel == null) + { + await ScheduleRetryAsync( + entry, + new InvalidOperationException( + $"Compensation replay deserialized null read model for record '{entry.RecordId}'.")); + return; + } + + try + { + await binding.UpsertAsync(readModel); + await PersistDomainEventAsync(new ProjectionCompensationSucceededEvent + { + RecordId = entry.RecordId, + CompletedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + }); + } + catch (Exception ex) + { + await ScheduleRetryAsync(entry, ex); + } + } + + private async Task ScheduleRetryAsync( + ProjectionDispatchCompensationOutboxEntry entry, + Exception exception) + { + var nextAttempt = entry.AttemptCount + 1; + var delay = ComputeRetryDelay(nextAttempt); + var nextVisibleAt = DateTime.UtcNow + delay; + await PersistDomainEventAsync(new ProjectionCompensationRetryScheduledEvent + { + RecordId = entry.RecordId, + AttemptCount = nextAttempt, + NextVisibleAtUtc = Timestamp.FromDateTime(nextVisibleAt), + Error = $"{exception.GetType().Name}: {exception.Message}", + }); + } + + private TimeSpan ComputeRetryDelay(int attempt) + { + var options = Services.GetService(); + var baseDelayMs = Math.Max(0, options?.DispatchCompensationReplayBaseDelayMs ?? 1000); + var maxDelayMs = Math.Max(baseDelayMs, options?.DispatchCompensationReplayMaxDelayMs ?? 60_000); + if (baseDelayMs == 0) + return TimeSpan.Zero; + + var exponent = Math.Min(10, Math.Max(0, attempt - 1)); + var nextDelay = (long)Math.Round(baseDelayMs * Math.Pow(2, exponent), MidpointRounding.AwayFromZero); + return TimeSpan.FromMilliseconds(Math.Min(nextDelay, maxDelayMs)); + } + + private static bool IsVisible(ProjectionDispatchCompensationOutboxEntry entry, DateTime utcNow) + { + if (entry.NextVisibleAtUtc == null) + return true; + + var nextVisible = entry.NextVisibleAtUtc.ToDateTime(); + if (nextVisible.Kind != DateTimeKind.Utc) + nextVisible = DateTime.SpecifyKind(nextVisible, DateTimeKind.Utc); + + return utcNow >= nextVisible; + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationReplayHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationReplayHostedService.cs new file mode 100644 index 000000000..4ffa2a32d --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDispatchCompensationReplayHostedService.cs @@ -0,0 +1,95 @@ +using Aevatar.Workflow.Projection.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal sealed class WorkflowProjectionDispatchCompensationReplayHostedService + : IHostedService, + IDisposable +{ + private readonly IProjectionDispatchCompensationOutbox _outbox; + private readonly WorkflowExecutionProjectionOptions _options; + private readonly ILogger _logger; + private CancellationTokenSource? _stoppingCts; + private Task? _runTask; + + public WorkflowProjectionDispatchCompensationReplayHostedService( + IProjectionDispatchCompensationOutbox outbox, + WorkflowExecutionProjectionOptions options, + ILogger? logger = null) + { + _outbox = outbox; + _options = options; + _logger = logger ?? NullLogger.Instance; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled || !_options.EnableDispatchCompensationReplay) + return Task.CompletedTask; + + _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _runTask = Task.Run(() => RunAsync(_stoppingCts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_runTask == null || _stoppingCts == null) + return; + + _stoppingCts.Cancel(); + var completed = await Task.WhenAny( + _runTask, + Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken)); + if (!ReferenceEquals(completed, _runTask)) + throw new OperationCanceledException(cancellationToken); + + await _runTask; + } + + public void Dispose() + { + _stoppingCts?.Dispose(); + } + + internal async Task ReplayOnceAsync(CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + await _outbox.TriggerReplayAsync(_options.DispatchCompensationReplayBatchSize, ct); + } + + private async Task RunAsync(CancellationToken ct) + { + var pollIntervalMs = Math.Max(50, _options.DispatchCompensationReplayPollIntervalMs); + while (!ct.IsCancellationRequested) + { + try + { + await ReplayOnceAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Projection compensation replay trigger failed. worker={Worker}", + nameof(WorkflowProjectionDispatchCompensationReplayHostedService)); + } + + try + { + await Task.Delay(pollIntervalMs, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + break; + } + } + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDurableOutboxCompensator.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDurableOutboxCompensator.cs new file mode 100644 index 000000000..3cac0b228 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowProjectionDurableOutboxCompensator.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aevatar.Workflow.Projection.Orchestration; + +internal sealed class WorkflowProjectionDurableOutboxCompensator + : IProjectionStoreDispatchCompensator +{ + private readonly IProjectionDispatchCompensationOutbox _outbox; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + public WorkflowProjectionDurableOutboxCompensator( + IProjectionDispatchCompensationOutbox outbox, + ILogger? logger = null) + { + _outbox = outbox; + _logger = logger ?? NullLogger.Instance; + } + + public async Task CompensateAsync( + ProjectionStoreDispatchCompensationContext context, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(context); + ct.ThrowIfCancellationRequested(); + + var now = DateTime.UtcNow; + var recordId = string.IsNullOrWhiteSpace(context.DispatchId) + ? Guid.NewGuid().ToString("N") + : context.DispatchId; + + var evt = new ProjectionCompensationEnqueuedEvent + { + RecordId = recordId, + Operation = context.Operation, + FailedStore = context.FailedStore, + SucceededStores = { context.SucceededStores }, + ReadModelType = context.ReadModelType, + ReadModelJson = JsonSerializer.Serialize(context.ReadModel, JsonOptions), + Key = context.Key ?? string.Empty, + EnqueuedAtUtc = Timestamp.FromDateTime(now), + LastError = context.Exception.GetType().Name, + }; + + await _outbox.EnqueueAsync(evt, ct); + _logger.LogWarning( + context.Exception, + "Projection dispatch failure enqueued to actor-based compensation outbox. readModelType={ReadModelType} operation={Operation} failedStore={FailedStore} recordId={RecordId}", + context.ReadModelType, + context.Operation, + context.FailedStore, + recordId); + } +} diff --git a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs index 5a4cd38f6..cf0daaec9 100644 --- a/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs +++ b/src/workflow/Aevatar.Workflow.Projection/Orchestration/WorkflowReadModelStartupValidationHostedService.cs @@ -24,28 +24,49 @@ public WorkflowReadModelStartupValidationHostedService( _logger = logger ?? NullLogger.Instance; } - public Task StartAsync(CancellationToken cancellationToken) + public async Task StartAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (!_options.Enabled) - return Task.CompletedTask; + return; + + var production = IsProductionEnvironment(); if (_options.ValidateDocumentProviderOnStartup) { - _ = _serviceProvider.GetRequiredService>(); - _logger.LogInformation( - "Workflow read-model document startup validation passed. readModelType={ReadModelType}", - typeof(WorkflowExecutionReport).FullName); + try + { + var documentStore = _serviceProvider.GetRequiredService>(); + _ = await documentStore.ListAsync(take: 1, cancellationToken); + _logger.LogInformation( + "Workflow read-model document startup probe passed. readModelType={ReadModelType}", + typeof(WorkflowExecutionReport).FullName); + } + catch (Exception ex) + { + HandleProbeFailure("document", ex, production); + } } if (_options.ValidateGraphProviderOnStartup) { - _ = _serviceProvider.GetRequiredService(); - _logger.LogInformation( - "Workflow read-model graph startup validation passed. graphType={GraphType}", - typeof(ProjectionGraphNode).FullName); + try + { + var graphStore = _serviceProvider.GetRequiredService(); + _ = await graphStore.ListNodesByOwnerAsync( + scope: WorkflowExecutionGraphConstants.Scope, + ownerId: "startup-probe", + take: 1, + ct: cancellationToken); + _logger.LogInformation( + "Workflow read-model graph startup probe passed. graphType={GraphType}", + typeof(ProjectionGraphNode).FullName); + } + catch (Exception ex) + { + HandleProbeFailure("graph", ex, production); + } } - return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) @@ -53,4 +74,29 @@ public Task StopAsync(CancellationToken cancellationToken) cancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } + + private void HandleProbeFailure(string provider, Exception exception, bool production) + { + if (production) + { + throw new InvalidOperationException( + $"Workflow read-model {provider} startup probe failed in production environment.", + exception); + } + + _logger.LogWarning( + exception, + "Workflow read-model {Provider} startup probe failed in non-production environment and will be ignored.", + provider); + } + + private static bool IsProductionEnvironment() + { + var dotnetEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"); + if (string.Equals(dotnetEnvironment, Environments.Production, StringComparison.OrdinalIgnoreCase)) + return true; + + var aspnetEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + return string.Equals(aspnetEnvironment, Environments.Production, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/workflow/Aevatar.Workflow.Projection/Properties/InternalsVisibleTo.cs b/src/workflow/Aevatar.Workflow.Projection/Properties/InternalsVisibleTo.cs new file mode 100644 index 000000000..b6bda1783 --- /dev/null +++ b/src/workflow/Aevatar.Workflow.Projection/Properties/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Aevatar.Workflow.Host.Api.Tests")] diff --git a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs index a55179d4a..5c3bdce56 100644 --- a/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs +++ b/src/workflow/extensions/Aevatar.Workflow.Extensions.Hosting/WorkflowProjectionProviderServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ public static IServiceCollection AddWorkflowProjectionReadModelProviders( configuration["Projection:Graph:Providers:InMemory:Enabled"], fallbackValue: !enableNeo4jGraph); + EnforceDocumentProviderPolicy(configuration, enableInMemoryDocument); EnforceGraphProviderPolicy(configuration, enableInMemoryGraph); var documentProviderCount = (enableElasticsearchDocument ? 1 : 0) + (enableInMemoryDocument ? 1 : 0); @@ -165,6 +166,24 @@ private static void EnforceGraphProviderPolicy( } } + private static void EnforceDocumentProviderPolicy( + IConfiguration configuration, + bool enableInMemoryDocumentProvider) + { + var denyInMemoryDocumentProvider = ResolveOptionalBool( + configuration["Projection:Policies:DenyInMemoryDocumentReadStore"], + fallbackValue: false); + var environment = ResolveRuntimeEnvironment(configuration["Projection:Policies:Environment"]); + var production = IsProductionEnvironment(environment); + + if ((denyInMemoryDocumentProvider || production) && enableInMemoryDocumentProvider) + { + throw new InvalidOperationException( + "InMemory document provider is not allowed by projection policy. " + + "Disable Projection:Document:Providers:InMemory:Enabled and configure Elasticsearch."); + } + } + private static string ResolveRuntimeEnvironment(string? configuredEnvironment) { if (!string.IsNullOrWhiteSpace(configuredEnvironment)) diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index 0b88944bf..ca9ce7f79 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -31,10 +31,33 @@ public async Task AcquireAsync_ShouldCreateCoordinatorActor_AndDispatchAcquireEv var evt = envelope.Payload.Unpack(); evt.ScopeId.Should().Be("scope-1"); evt.SessionId.Should().Be("session-1"); + evt.LeaseTtlMs.Should().Be(ProjectionOwnershipCoordinatorOptions.DefaultLeaseTtlMs); envelope.CorrelationId.Should().Be("session-1"); envelope.Direction.Should().Be(EventDirection.Self); } + [Fact] + public async Task AcquireAsync_ShouldUseConfiguredLeaseTtl() + { + var runtime = new OwnershipCoordinatorRuntime(); + var manifestStore = new TestAgentManifestStore(); + var coordinator = CreateCoordinator( + runtime, + manifestStore, + new ProjectionOwnershipCoordinatorOptions + { + LeaseTtlMs = 45_000, + }); + + await coordinator.AcquireAsync("scope-1", "session-1", CancellationToken.None); + + var actorId = ProjectionOwnershipCoordinatorGAgent.BuildActorId("scope-1"); + var actor = runtime.GetOwnershipActor(actorId); + var envelope = actor.HandledEnvelopes.Single(); + var evt = envelope.Payload.Unpack(); + evt.LeaseTtlMs.Should().Be(45_000); + } + [Fact] public async Task ReleaseAsync_ShouldReuseExistingCoordinatorActor() { @@ -126,10 +149,11 @@ public async Task AcquireAsync_ShouldThrow_WhenManifestTypeNameOnlyLooksSimilar( private static ActorProjectionOwnershipCoordinator CreateCoordinator( IActorRuntime runtime, - IAgentManifestStore manifestStore) + IAgentManifestStore manifestStore, + ProjectionOwnershipCoordinatorOptions? options = null) { var verifier = new DefaultAgentTypeVerifier(new RuntimeActorTypeProbe(runtime), manifestStore); - return new ActorProjectionOwnershipCoordinator(runtime, verifier); + return new ActorProjectionOwnershipCoordinator(runtime, verifier, options); } } @@ -170,6 +194,7 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent agent.State.ScopeId.Should().Be("scope-1"); agent.State.SessionId.Should().Be("session-1"); agent.State.LastUpdatedAtUtc.Should().NotBeNull(); + agent.State.LeaseTtlMs.Should().Be(ProjectionOwnershipCoordinatorOptions.DefaultLeaseTtlMs); } [Fact] @@ -191,6 +216,56 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent await act.Should().ThrowAsync(); } + [Fact] + public async Task HandleAcquireAsync_ShouldRenewLease_WhenSessionMatches() + { + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); + await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-1", + SessionId = "session-1", + LeaseTtlMs = 30_000, + }); + var firstUpdatedAt = agent.State.LastUpdatedAtUtc; + + await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-1", + SessionId = "session-1", + LeaseTtlMs = 90_000, + }); + + agent.State.Active.Should().BeTrue(); + agent.State.SessionId.Should().Be("session-1"); + agent.State.LeaseTtlMs.Should().Be(90_000); + agent.State.LastUpdatedAtUtc.Should().NotBeNull(); + agent.State.LastUpdatedAtUtc.Seconds.Should().BeGreaterThanOrEqualTo(firstUpdatedAt.Seconds); + } + + [Fact] + public async Task HandleAcquireAsync_ShouldAllowTakeover_WhenExistingLeaseExpired() + { + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); + await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-1", + SessionId = "session-1", + LeaseTtlMs = 1_000, + }); + agent.State.LastUpdatedAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-5)); + + await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-1", + SessionId = "session-2", + LeaseTtlMs = 120_000, + }); + + agent.State.Active.Should().BeTrue(); + agent.State.SessionId.Should().Be("session-2"); + agent.State.LeaseTtlMs.Should().Be(120_000); + } + [Fact] public async Task HandleReleaseAsync_ShouldDeactivate_WhenScopeAndSessionMatch() { diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs index c89788023..962e9c3c5 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs @@ -17,6 +17,7 @@ public void ProjectionOwnershipMessages_ShouldRoundTripCloneMergeAndReflect() SessionId = "session-1", Active = true, LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), + LeaseTtlMs = 30L * 60 * 1000, }; var stateParsed = ProjectionOwnershipCoordinatorState.Parser.ParseFrom(state.ToByteArray()); @@ -37,6 +38,7 @@ public void ProjectionOwnershipMessages_ShouldRoundTripCloneMergeAndReflect() { ScopeId = "scope-1", SessionId = "session-1", + LeaseTtlMs = 30L * 60 * 1000, }; var acquireParsed = ProjectionOwnershipAcquireEvent.Parser.ParseFrom(acquire.ToByteArray()); acquireParsed.Should().BeEquivalentTo(acquire); diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs index 6f6db1ad5..5edee8844 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowExecutionProjectionRegistrationTests.cs @@ -3,6 +3,10 @@ using Aevatar.CQRS.Projection.Providers.Elasticsearch.DependencyInjection; using Aevatar.CQRS.Projection.Providers.InMemory.DependencyInjection; using Aevatar.CQRS.Projection.Stores.Abstractions; +using Aevatar.Foundation.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Abstractions.TypeSystem; +using Aevatar.Foundation.Runtime.Persistence; using Aevatar.Workflow.Projection.DependencyInjection; using Aevatar.Workflow.Projection.ReadModels; using FluentAssertions; @@ -16,20 +20,23 @@ public class WorkflowExecutionProjectionRegistrationTests [Fact] public async Task AddWorkflowExecutionProjectionCQRS_WhenNoProvidersRegistered_ShouldFailFast() { + using var env = new EnvironmentVariableScope("DOTNET_ENVIRONMENT", "Production"); var services = new ServiceCollection(); + RegisterEventStore(services); services.AddWorkflowExecutionProjectionCQRS(); await using var provider = services.BuildServiceProvider(); Func act = () => StartHostedServicesAsync(provider); await act.Should().ThrowAsync() - .WithMessage("*IProjectionDocumentStore*"); + .WithMessage("*document startup probe failed in production environment*"); } [Fact] public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveDispatcherAndStores() { var services = new ServiceCollection(); + RegisterEventStore(services); RegisterInMemoryProviders(services); services.AddWorkflowExecutionProjectionCQRS(); @@ -50,6 +57,7 @@ public async Task AddWorkflowExecutionProjectionCQRS_ShouldResolveDispatcherAndS public void AddWorkflowExecutionProjectionCQRS_WhenGraphProviderMissing_ShouldThrowOnGraphStoreResolution() { var services = new ServiceCollection(); + RegisterEventStore(services); RegisterElasticsearchDocumentProvider(services); services.AddWorkflowExecutionProjectionCQRS(); @@ -92,4 +100,57 @@ private static async Task StartHostedServicesAsync(IServiceProvider provider) foreach (var hostedService in hostedServices) await hostedService.StartAsync(CancellationToken.None); } + + private static void RegisterEventStore(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + private sealed class NoOpActorRuntime : IActorRuntime + { + public Task CreateAsync(string? id = null, CancellationToken ct = default) + where TAgent : IAgent => + throw new NotSupportedException("No-op runtime."); + + public Task CreateAsync(Type agentType, string? id = null, CancellationToken ct = default) => + throw new NotSupportedException("No-op runtime."); + + public Task DestroyAsync(string id, CancellationToken ct = default) => Task.CompletedTask; + + public Task GetAsync(string id) => Task.FromResult(null); + + public Task ExistsAsync(string id) => Task.FromResult(false); + + public Task LinkAsync(string parentId, string childId, CancellationToken ct = default) => Task.CompletedTask; + + public Task UnlinkAsync(string childId, CancellationToken ct = default) => Task.CompletedTask; + + public Task RestoreAllAsync(CancellationToken ct = default) => Task.CompletedTask; + } + + private sealed class AlwaysTrueTypeVerifier : IAgentTypeVerifier + { + public Task IsExpectedAsync(string actorId, Type expectedType, CancellationToken ct = default) => + Task.FromResult(true); + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previous); + } + } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs index 02e405045..435390c81 100644 --- a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowHostingExtensionsCoverageTests.cs @@ -180,4 +180,26 @@ public void AddWorkflowProjectionReadModelProviders_WhenPolicyDeniesInMemoryRela act.Should().Throw() .WithMessage("*InMemory graph provider is not allowed*"); } + + [Fact] + public void AddWorkflowProjectionReadModelProviders_WhenProductionAndInMemoryDocumentEnabled_ShouldThrow() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Projection:Policies:Environment"] = "Production", + ["Projection:Document:Providers:InMemory:Enabled"] = "true", + ["Projection:Document:Providers:Elasticsearch:Enabled"] = "false", + ["Projection:Graph:Providers:InMemory:Enabled"] = "false", + ["Projection:Graph:Providers:Neo4j:Enabled"] = "true", + ["Projection:Graph:Providers:Neo4j:Uri"] = "bolt://localhost:7687", + }) + .Build(); + + Action act = () => services.AddWorkflowProjectionReadModelProviders(configuration); + + act.Should().Throw() + .WithMessage("*InMemory document provider is not allowed*"); + } } diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchCompensationTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchCompensationTests.cs new file mode 100644 index 000000000..f8b9b0cac --- /dev/null +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowProjectionDispatchCompensationTests.cs @@ -0,0 +1,283 @@ +using Aevatar.CQRS.Projection.Core.Orchestration; +using Aevatar.CQRS.Projection.Runtime.Abstractions; +using Aevatar.Foundation.Abstractions.Persistence; +using Aevatar.Foundation.Core.EventSourcing; +using Aevatar.Foundation.Runtime.Persistence; +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.Orchestration; +using Aevatar.Workflow.Projection.ReadModels; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Workflow.Host.Api.Tests; + +public class WorkflowProjectionDispatchCompensationOutboxGAgentTests +{ + private static IServiceProvider CreateAgentServices( + IEventStore? eventStore = null, + IEnumerable>? bindings = null, + WorkflowExecutionProjectionOptions? options = null) + { + var services = new ServiceCollection(); + services.AddSingleton(eventStore ?? new InMemoryEventStore()); + services.AddSingleton(); + services.AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)); + services.AddSingleton(options ?? new WorkflowExecutionProjectionOptions + { + DispatchCompensationReplayBaseDelayMs = 0, + DispatchCompensationReplayMaxDelayMs = 0, + }); + foreach (var binding in bindings ?? []) + services.AddSingleton(binding); + + return services.BuildServiceProvider(); + } + + private static WorkflowProjectionDispatchCompensationOutboxGAgent CreateAgent(IServiceProvider services) => + new() + { + Services = services, + EventSourcingBehaviorFactory = + services.GetRequiredService>(), + }; + + [Fact] + public async Task HandleEnqueue_ShouldAddEntryToState() + { + var agent = CreateAgent(CreateAgentServices()); + + await agent.HandleEnqueueAsync(new ProjectionCompensationEnqueuedEvent + { + RecordId = "r1", + Operation = "mutate", + FailedStore = "Graph", + SucceededStores = { "Document" }, + ReadModelType = typeof(WorkflowExecutionReport).FullName!, + ReadModelJson = "{}", + Key = "root-1", + LastError = "InvalidOperationException", + }); + + agent.State.Entries.Should().ContainKey("r1"); + var entry = agent.State.Entries["r1"]; + entry.FailedStore.Should().Be("Graph"); + entry.Operation.Should().Be("mutate"); + entry.AttemptCount.Should().Be(0); + entry.CompletedAtUtc.Should().BeNull(); + } + + [Fact] + public async Task HandleEnqueue_ShouldThrow_WhenRecordIdMissing() + { + var agent = CreateAgent(CreateAgentServices()); + + Func act = () => agent.HandleEnqueueAsync(new ProjectionCompensationEnqueuedEvent + { + FailedStore = "Graph", + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task HandleTriggerReplay_WithSuccessfulBinding_ShouldMarkSucceeded() + { + var binding = new FlakyGraphBinding(failuresBeforeSuccess: 0); + var services = CreateAgentServices(bindings: [binding]); + var agent = CreateAgent(services); + + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r1", "{}")); + await agent.HandleTriggerReplayAsync(new ProjectionCompensationTriggerReplayEvent { BatchSize = 10 }); + + agent.State.Entries["r1"].CompletedAtUtc.Should().NotBeNull(); + binding.AttemptCount.Should().Be(1); + binding.SuccessCount.Should().Be(1); + } + + [Fact] + public async Task HandleTriggerReplay_WithFlakyBinding_ShouldRetryThenSucceed() + { + var binding = new FlakyGraphBinding(failuresBeforeSuccess: 1); + var services = CreateAgentServices(bindings: [binding]); + var agent = CreateAgent(services); + + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r1", CreateReadModelJson("root-1"))); + + await agent.HandleTriggerReplayAsync(new ProjectionCompensationTriggerReplayEvent { BatchSize = 10 }); + binding.AttemptCount.Should().Be(1); + binding.SuccessCount.Should().Be(0); + agent.State.Entries["r1"].AttemptCount.Should().Be(1); + agent.State.Entries["r1"].CompletedAtUtc.Should().BeNull(); + + await agent.HandleTriggerReplayAsync(new ProjectionCompensationTriggerReplayEvent { BatchSize = 10 }); + binding.AttemptCount.Should().Be(2); + binding.SuccessCount.Should().Be(1); + agent.State.Entries["r1"].CompletedAtUtc.Should().NotBeNull(); + } + + [Fact] + public async Task HandleTriggerReplay_ShouldRespectBatchSize() + { + var binding = new FlakyGraphBinding(failuresBeforeSuccess: 0); + var services = CreateAgentServices(bindings: [binding]); + var agent = CreateAgent(services); + + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r1", "{}")); + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r2", "{}")); + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r3", "{}")); + + await agent.HandleTriggerReplayAsync(new ProjectionCompensationTriggerReplayEvent { BatchSize = 2 }); + binding.AttemptCount.Should().Be(2); + + await agent.HandleTriggerReplayAsync(new ProjectionCompensationTriggerReplayEvent { BatchSize = 2 }); + binding.AttemptCount.Should().Be(3); + } + + [Fact] + public async Task State_ShouldSurviveDeactivateAndReactivate() + { + var store = new InMemoryEventStore(); + var services = CreateAgentServices(eventStore: store); + + var agent1 = CreateAgent(services); + await agent1.ActivateAsync(); + await agent1.HandleEnqueueAsync(CreateEnqueueEvent("r1", "{}")); + await agent1.DeactivateAsync(); + + var agent2 = CreateAgent(services); + await agent2.ActivateAsync(); + + agent2.State.Entries.Should().ContainKey("r1"); + agent2.State.Entries["r1"].FailedStore.Should().Be("Graph"); + } + + [Fact] + public async Task CompensatorIntegration_ShouldEnqueueViaActor() + { + var services = CreateAgentServices(); + var agent = CreateAgent(services); + + var outbox = new DirectOutbox(agent); + var compensator = new WorkflowProjectionDurableOutboxCompensator(outbox); + var context = new ProjectionStoreDispatchCompensationContext + { + Operation = "mutate", + Key = "root-1", + ReadModel = CreateReadModel("root-1"), + FailedStore = "Graph", + SucceededStores = ["Document"], + Exception = new InvalidOperationException("graph write failed"), + }; + + await compensator.CompensateAsync(context); + + agent.State.Entries.Should().HaveCount(1); + var entry = agent.State.Entries.Values.Single(); + entry.FailedStore.Should().Be("Graph"); + entry.Operation.Should().Be("mutate"); + entry.ReadModelJson.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task ThinTimerIntegration_ShouldTriggerReplayViaOutbox() + { + var binding = new FlakyGraphBinding(failuresBeforeSuccess: 0); + var services = CreateAgentServices(bindings: [binding]); + var agent = CreateAgent(services); + + await agent.HandleEnqueueAsync(CreateEnqueueEvent("r1", CreateReadModelJson("root-1"))); + + var outbox = new DirectOutbox(agent); + var options = new WorkflowExecutionProjectionOptions + { + DispatchCompensationReplayBatchSize = 10, + }; + var timer = new WorkflowProjectionDispatchCompensationReplayHostedService(outbox, options); + + await timer.ReplayOnceAsync(); + + agent.State.Entries["r1"].CompletedAtUtc.Should().NotBeNull(); + binding.SuccessCount.Should().Be(1); + } + + private static ProjectionCompensationEnqueuedEvent CreateEnqueueEvent(string recordId, string readModelJson) => + new() + { + RecordId = recordId, + Operation = "mutate", + FailedStore = "Graph", + SucceededStores = { "Document" }, + ReadModelType = typeof(WorkflowExecutionReport).FullName!, + ReadModelJson = readModelJson, + Key = recordId, + LastError = "test", + }; + + private static string CreateReadModelJson(string rootActorId) => + System.Text.Json.JsonSerializer.Serialize(CreateReadModel(rootActorId)); + + private static WorkflowExecutionReport CreateReadModel(string rootActorId) => + new() + { + Id = rootActorId, + RootActorId = rootActorId, + CommandId = "command-1", + WorkflowName = "workflow-1", + Input = "input", + StartedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + private sealed class FlakyGraphBinding : IProjectionStoreBinding + { + private int _remainingFailures; + + public FlakyGraphBinding(int failuresBeforeSuccess) + { + _remainingFailures = Math.Max(0, failuresBeforeSuccess); + } + + public string StoreName => "Graph"; + + public int AttemptCount { get; private set; } + + public int SuccessCount { get; private set; } + + public Task UpsertAsync(WorkflowExecutionReport readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(readModel); + + AttemptCount++; + if (_remainingFailures > 0) + { + _remainingFailures--; + throw new InvalidOperationException("Simulated graph write failure."); + } + + SuccessCount++; + return Task.CompletedTask; + } + } + + /// + /// Bypasses IActorRuntime by dispatching events directly to the GAgent for unit testing. + /// + private sealed class DirectOutbox : IProjectionDispatchCompensationOutbox + { + private readonly WorkflowProjectionDispatchCompensationOutboxGAgent _agent; + + public DirectOutbox(WorkflowProjectionDispatchCompensationOutboxGAgent agent) + { + _agent = agent; + } + + public Task EnqueueAsync(ProjectionCompensationEnqueuedEvent evt, CancellationToken ct = default) => + _agent.HandleEnqueueAsync(evt); + + public Task TriggerReplayAsync(int batchSize, CancellationToken ct = default) => + _agent.HandleTriggerReplayAsync( + new ProjectionCompensationTriggerReplayEvent { BatchSize = batchSize }); + } +} diff --git a/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelStartupValidationHostedServiceTests.cs b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelStartupValidationHostedServiceTests.cs new file mode 100644 index 000000000..23322f626 --- /dev/null +++ b/test/Aevatar.Workflow.Host.Api.Tests/WorkflowReadModelStartupValidationHostedServiceTests.cs @@ -0,0 +1,224 @@ +using Aevatar.Workflow.Projection.Configuration; +using Aevatar.Workflow.Projection.Orchestration; +using Aevatar.Workflow.Projection.ReadModels; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace Aevatar.Workflow.Host.Api.Tests; + +public class WorkflowReadModelStartupValidationHostedServiceTests +{ + [Fact] + public async Task StartAsync_WhenDocumentProbeFailsInNonProduction_ShouldContinue() + { + using var env = new EnvironmentVariableScope("ASPNETCORE_ENVIRONMENT", "Development"); + var services = new ServiceCollection(); + services.AddSingleton, FailingDocumentStore>(); + services.AddSingleton(); + await using var provider = services.BuildServiceProvider(); + var startupValidation = new WorkflowReadModelStartupValidationHostedService( + provider, + new WorkflowExecutionProjectionOptions + { + ValidateDocumentProviderOnStartup = true, + ValidateGraphProviderOnStartup = false, + }); + + Func act = () => startupValidation.StartAsync(CancellationToken.None); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task StartAsync_WhenDocumentProbeFailsInProduction_ShouldFailFast() + { + using var env = new EnvironmentVariableScope("ASPNETCORE_ENVIRONMENT", "Production"); + var services = new ServiceCollection(); + services.AddSingleton, FailingDocumentStore>(); + services.AddSingleton(); + await using var provider = services.BuildServiceProvider(); + var startupValidation = new WorkflowReadModelStartupValidationHostedService( + provider, + new WorkflowExecutionProjectionOptions + { + ValidateDocumentProviderOnStartup = true, + ValidateGraphProviderOnStartup = false, + }); + + Func act = () => startupValidation.StartAsync(CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*document startup probe failed*"); + } + + [Fact] + public async Task StartAsync_WhenGraphProbeFailsInProduction_ShouldFailFast() + { + using var env = new EnvironmentVariableScope("DOTNET_ENVIRONMENT", "Production"); + var services = new ServiceCollection(); + services.AddSingleton, NoOpDocumentStore>(); + services.AddSingleton(); + await using var provider = services.BuildServiceProvider(); + var startupValidation = new WorkflowReadModelStartupValidationHostedService( + provider, + new WorkflowExecutionProjectionOptions + { + ValidateDocumentProviderOnStartup = false, + ValidateGraphProviderOnStartup = true, + }); + + Func act = () => startupValidation.StartAsync(CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*graph startup probe failed*"); + } + + private sealed class NoOpDocumentStore : IProjectionDocumentStore + { + public Task UpsertAsync(WorkflowExecutionReport readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(null); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + } + + private sealed class FailingDocumentStore : IProjectionDocumentStore + { + public Task UpsertAsync(WorkflowExecutionReport readModel, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task MutateAsync(string key, Action mutate, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task GetAsync(string key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(null); + } + + public Task> ListAsync(int take = 50, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw new InvalidOperationException("document store unavailable"); + } + } + + private class NoOpGraphStore : IProjectionGraphStore + { + public Task UpsertNodeAsync(ProjectionGraphNode node, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task UpsertEdgeAsync(ProjectionGraphEdge edge, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task DeleteNodeAsync(string scope, string nodeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task DeleteEdgeAsync(string scope, string edgeId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public virtual Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int skip = 0, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + + public Task> ListEdgesByOwnerAsync( + string scope, + string ownerId, + int skip = 0, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + + public Task> GetNeighborsAsync( + ProjectionGraphQuery query, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult>([]); + } + + public Task GetSubgraphAsync(ProjectionGraphQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new ProjectionGraphSubgraph()); + } + } + + private sealed class FailingGraphStore : NoOpGraphStore + { + public override Task> ListNodesByOwnerAsync( + string scope, + string ownerId, + int skip = 0, + int take = 5000, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + throw new InvalidOperationException("graph store unavailable"); + } + } + + private sealed class EnvironmentVariableScope : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvironmentVariableScope(string name, string? value) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, value); + } + + public void Dispose() + { + Environment.SetEnvironmentVariable(_name, _previous); + } + } +} From 5f9fda4de3bcc81110ca9439334a150da57ecd6c Mon Sep 17 00:00:00 2001 From: Auric Date: Wed, 25 Feb 2026 17:03:43 +0800 Subject: [PATCH 46/46] Add strict architecture audit report for PR #13 and update temperature handling in role agent configuration - Introduced a new architecture audit report for PR #13, detailing the audit findings and scoring based on the defined criteria. - Updated `ConfigureRoleAgentEvent` to use an optional field for temperature, allowing explicit zero values to be preserved. - Modified `RoleGAgent` and `RoleGAgentFactory` to correctly handle the new temperature semantics, ensuring that explicit zero is retained during configuration. - Enhanced projection ownership events to include an `occurred_at_utc` timestamp, improving event traceability and consistency. - Added tests to verify the preservation of explicit zero temperature and the handling of occurred timestamps in projection events. --- ...13-architecture-audit-strict-2026-02-25.md | 123 ++++++++++++++++++ src/Aevatar.AI.Abstractions/ai_messages.proto | 2 +- src/Aevatar.AI.Core/RoleGAgent.cs | 2 +- src/Aevatar.AI.Core/RoleGAgentFactory.cs | 9 +- .../ActorProjectionOwnershipCoordinator.cs | 4 + .../ProjectionOwnershipCoordinatorGAgent.cs | 37 +++++- .../projection_ownership_messages.proto | 2 + .../RoleGAgentReplayContractTests.cs | 57 ++++++++ .../ProjectionOwnershipAndSessionHubTests.cs | 88 +++++++++++++ .../ProjectionOwnershipProtoCoverageTests.cs | 2 + 10 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 docs/audit-scorecard/pr13-architecture-audit-strict-2026-02-25.md diff --git a/docs/audit-scorecard/pr13-architecture-audit-strict-2026-02-25.md b/docs/audit-scorecard/pr13-architecture-audit-strict-2026-02-25.md new file mode 100644 index 000000000..98d4fe968 --- /dev/null +++ b/docs/audit-scorecard/pr13-architecture-audit-strict-2026-02-25.md @@ -0,0 +1,123 @@ +# PR #13 严格架构审计报告(2026-02-25) + +- 审计日期:2026-02-25 +- 审计对象:`feat/generic-event-sourcing-elasticsearch-readmodel`(相对 `dev`) +- 关联 PR:[Feat/generic event sourcing elasticsearch readmodel #13](https://github.com/aevatarAI/aevatar/pull/13) +- 审计方式:代码证据核查 + 定向测试 + 架构门禁 +- 评分规范:`docs/audit-scorecard/README.md`(100 分模型,6 维度) + +--- + +## 1. 严格结论 + +本次 PR 当前状态 **不满足合并条件**。 + +- Blocking:2(P1=1,P2=1) +- Major:0 +- Medium:0 + +合并裁决:**Reject(需先修复 P1/P2 并补齐回归测试)**。 + +--- + +## 2. 整体评分(100 分制) + +**总分:79 / 100(B)** + +| 维度 | 权重 | 得分 | 说明 | +|---|---:|---:|---| +| 分层与依赖反转 | 20 | 20 | 未发现跨层反向依赖。 | +| CQRS 与统一投影链路 | 20 | 14 | Lease 重放时间语义不确定,影响事件重放一致性。 | +| Projection 编排与状态约束 | 20 | 11 | ownership lease 在重放时被“刷新到当前时间”。 | +| 读写分离与会话语义 | 15 | 10 | 会话 TTL 过期判定可被激活重放扰动。 | +| 命名语义与冗余清理 | 10 | 10 | 命名和结构未见新增冗余壳层。 | +| 可验证性(门禁/构建/测试) | 15 | 14 | 门禁/测试通过,但关键语义测试缺口导致缺陷未被捕获。 | + +--- + +## 3. 阻断问题清单(必须先修) + +### P1:Ownership lease 使用运行时当前时间写状态,破坏回放语义 + +风险级别:**Blocking** + +证据: +1. `ApplyAcquire` 使用 `DateTime.UtcNow` 写入 `LastUpdatedAtUtc`,而非事件发生时刻: + `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs:109` +2. `ApplyRelease` 同样使用 `DateTime.UtcNow`: + `src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs:125` +3. 回放链路仅重放 payload,不携带 envelope timestamp 到状态迁移函数: + `src/Aevatar.Foundation.Core/EventSourcing/EventSourcingBehavior.cs:159-163` + `src/Aevatar.Foundation.Core/GAgentBase.TState.cs:72` + +架构影响: +1. 激活重放历史事件时,租约更新时间会被重写为“当前激活时刻”,导致过期 lease 被误判为新鲜 lease。 +2. ownership takeover 被额外阻塞一个 TTL 窗口,破坏会话过期接管语义。 +3. 违反“事件重放确定性”和“事实源唯一”的架构要求。 + +修复准入标准(必须全部满足): +1. 将 lease 更新时间改为“持久化事件时间”,禁止在 `Apply*` 中直接取 `DateTime.UtcNow`。 +2. `ProjectionOwnershipAcquireEvent/ReleaseEvent` 增加可重放的时间字段(或等价机制),并在写入时设置。 +3. 兼容历史事件(无时间字段时使用可解释降级策略),避免重放崩溃。 +4. 新增回归测试:`Acquire -> Deactivate -> Activate -> stale lease takeover` 必须稳定通过。 + +--- + +### P2:YAML 显式 `temperature: 0` 语义丢失 + +风险级别:**Blocking** + +证据: +1. YAML 应用路径将缺失温度和显式 0 都写成 `0`: + `src/Aevatar.AI.Core/RoleGAgentFactory.cs:56` +2. 处理器把 `evt.Temperature == 0` 转换为 `null`: + `src/Aevatar.AI.Core/RoleGAgent.cs:76` +3. `ConfigureRoleAgentEvent.temperature` 为 proto3 `double`,无法区分“未设置”和“显式 0”: + `src/Aevatar.AI.Abstractions/ai_messages.proto:19` + +架构影响: +1. 用户无法表达“确定性 0 温度”配置,行为退化为 provider 默认值。 +2. 配置语义在“YAML -> 事件 -> 运行时”链路发生信息损失,不满足显式配置优先原则。 + +修复准入标准(必须全部满足): +1. 事件契约改为可区分“未设置/显式设置”的表示(例如 `optional`/`oneof`/wrapper)。 +2. `RoleGAgentFactory.ApplyConfig` 精确保留 YAML 的 `null` 与 `0` 语义差异。 +3. `HandleConfigureRoleAgent` 删除“0 => null”的语义折叠(仅在真正未设置时回落默认)。 +4. 新增回归测试至少 2 条: + - 显式 `temperature: 0` 应保留为 0。 + - 缺省 `temperature` 才走 provider 默认。 + +--- + +## 4. 测试与门禁验证(本次实跑) + +1. `dotnet test test/Aevatar.CQRS.Projection.Core.Tests/Aevatar.CQRS.Projection.Core.Tests.csproj --nologo --filter "FullyQualifiedName~ProjectionOwnershipCoordinatorGAgentTests"` + - 结果:Passed(7 passed / 0 failed) +2. `dotnet test test/Aevatar.AI.Tests/Aevatar.AI.Tests.csproj --nologo --filter "FullyQualifiedName~RoleGAgentReplayContractTests|FullyQualifiedName~AIHooksAndRoleFactoryCoverageTests"` + - 结果:Passed(6 passed / 0 failed) +3. `bash tools/ci/architecture_guards.sh` + - 结果:Passed + +说明:现有测试与门禁未覆盖上述两类语义回归点,因此“通过”不构成合并充分条件。 + +--- + +## 5. 覆盖缺口证据 + +1. ownership 回放测试只验证“Acquire+Release 后重放为 inactive”,未覆盖“active lease 重放后 TTL 语义”: + `test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs:309` +2. role factory 测试覆盖了 `temperature: 0.2`,未覆盖显式 `temperature: 0`: + `test/Aevatar.AI.Tests/AIHooksAndRoleFactoryCoverageTests.cs:112` + +--- + +## 6. PR 合并前强制检查清单 + +1. P1/P2 代码修复完成并通过 Code Review。 +2. 新增回归测试覆盖“lease 重放时间语义”和“temperature:0 显式语义”。 +3. 通过: + - `dotnet test aevatar.slnx --nologo` + - `bash tools/ci/architecture_guards.sh` + - `bash tools/ci/test_stability_guards.sh`(如涉及测试改动) + +在上述项全部满足前,本 PR 不应合并。 diff --git a/src/Aevatar.AI.Abstractions/ai_messages.proto b/src/Aevatar.AI.Abstractions/ai_messages.proto index cc6cd6fb0..4f0a9bb8e 100644 --- a/src/Aevatar.AI.Abstractions/ai_messages.proto +++ b/src/Aevatar.AI.Abstractions/ai_messages.proto @@ -16,7 +16,7 @@ message ConfigureRoleAgentEvent { string provider_name = 2; string model = 3; string system_prompt = 4; - double temperature = 5; + optional double temperature = 5; int32 max_tokens = 6; int32 max_tool_rounds = 7; int32 max_history_messages = 8; diff --git a/src/Aevatar.AI.Core/RoleGAgent.cs b/src/Aevatar.AI.Core/RoleGAgent.cs index 6bded471e..85a01e4d8 100644 --- a/src/Aevatar.AI.Core/RoleGAgent.cs +++ b/src/Aevatar.AI.Core/RoleGAgent.cs @@ -73,7 +73,7 @@ public async Task HandleConfigureRoleAgent(ConfigureRoleAgentEvent evt) ProviderName = string.IsNullOrWhiteSpace(evt.ProviderName) ? "deepseek" : evt.ProviderName, Model = string.IsNullOrWhiteSpace(evt.Model) ? null : evt.Model, SystemPrompt = evt.SystemPrompt ?? string.Empty, - Temperature = evt.Temperature == 0 ? null : evt.Temperature, + Temperature = evt.HasTemperature ? evt.Temperature : null, MaxTokens = evt.MaxTokens == 0 ? null : evt.MaxTokens, MaxToolRounds = evt.MaxToolRounds <= 0 ? 10 : evt.MaxToolRounds, MaxHistoryMessages = evt.MaxHistoryMessages <= 0 ? 100 : evt.MaxHistoryMessages, diff --git a/src/Aevatar.AI.Core/RoleGAgentFactory.cs b/src/Aevatar.AI.Core/RoleGAgentFactory.cs index 8e05ba9eb..d9b471884 100644 --- a/src/Aevatar.AI.Core/RoleGAgentFactory.cs +++ b/src/Aevatar.AI.Core/RoleGAgentFactory.cs @@ -47,14 +47,17 @@ public static Task ConfigureFromYaml(RoleGAgent agent, string yaml, IServiceProv public static async Task ApplyConfig(RoleGAgent agent, RoleYamlConfig config, IServiceProvider services) { // ─── 基础配置(事件优先) ─── - await agent.HandleConfigureRoleAgent(new ConfigureRoleAgentEvent + var configureEvent = new ConfigureRoleAgentEvent { RoleName = config.Name ?? string.Empty, SystemPrompt = config.SystemPrompt ?? string.Empty, ProviderName = config.Provider ?? "deepseek", Model = config.Model ?? string.Empty, - Temperature = config.Temperature ?? 0, - }); + }; + if (config.Temperature.HasValue) + configureEvent.Temperature = config.Temperature.Value; + + await agent.HandleConfigureRoleAgent(configureEvent); // ─── EventModules 创建 ─── if (string.IsNullOrEmpty(config.Extensions?.EventModules)) return; diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs index 63313a1a6..f915253b6 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ActorProjectionOwnershipCoordinator.cs @@ -31,12 +31,14 @@ public async Task AcquireAsync( CancellationToken ct = default) { var coordinatorActor = await ResolveCoordinatorActorAsync(scopeId, ct); + var occurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); var envelope = CreateCoordinatorEnvelope( new ProjectionOwnershipAcquireEvent { ScopeId = scopeId, SessionId = sessionId, LeaseTtlMs = _options.ResolveLeaseTtlMs(), + OccurredAtUtc = occurredAtUtc, }, sessionId); await coordinatorActor.HandleEventAsync(envelope, ct); @@ -48,11 +50,13 @@ public async Task ReleaseAsync( CancellationToken ct = default) { var coordinatorActor = await ResolveCoordinatorActorAsync(scopeId, ct); + var occurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); var envelope = CreateCoordinatorEnvelope( new ProjectionOwnershipReleaseEvent { ScopeId = scopeId, SessionId = sessionId, + OccurredAtUtc = occurredAtUtc, }, sessionId); await coordinatorActor.HandleEventAsync(envelope, ct); diff --git a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs index f4bc50098..fe03b3218 100644 --- a/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs +++ b/src/Aevatar.CQRS.Projection.Core/Orchestration/ProjectionOwnershipCoordinatorGAgent.cs @@ -32,11 +32,13 @@ public async Task HandleAcquireAsync(ProjectionOwnershipAcquireEvent evt) throw new InvalidOperationException("Session id is required to acquire projection ownership."); var normalizedLeaseTtlMs = ProjectionOwnershipCoordinatorOptions.NormalizeLeaseTtlMs(evt.LeaseTtlMs); + var nowUtc = DateTime.UtcNow; var acquireEvent = new ProjectionOwnershipAcquireEvent { ScopeId = evt.ScopeId, SessionId = evt.SessionId, LeaseTtlMs = normalizedLeaseTtlMs, + OccurredAtUtc = ResolveOccurredAtForPersist(evt.OccurredAtUtc, nowUtc), }; if (State.Active) @@ -86,7 +88,14 @@ public async Task HandleReleaseAsync(ProjectionOwnershipReleaseEvent evt) $"Projection ownership coordinator '{Id}' session mismatch. expected='{State.SessionId}', actual='{evt.SessionId}'."); } - await PersistDomainEventAsync(evt); + var releaseEvent = new ProjectionOwnershipReleaseEvent + { + ScopeId = evt.ScopeId, + SessionId = evt.SessionId, + OccurredAtUtc = ResolveOccurredAtForPersist(evt.OccurredAtUtc, DateTime.UtcNow), + }; + + await PersistDomainEventAsync(releaseEvent); } protected override ProjectionOwnershipCoordinatorState TransitionState( @@ -106,7 +115,7 @@ private static ProjectionOwnershipCoordinatorState ApplyAcquire( next.ScopeId = evt.ScopeId; next.SessionId = evt.SessionId; next.Active = true; - next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); + next.LastUpdatedAtUtc = RequireOccurredAtUtc(evt.OccurredAtUtc, nameof(ProjectionOwnershipAcquireEvent)); next.LeaseTtlMs = ProjectionOwnershipCoordinatorOptions.NormalizeLeaseTtlMs(evt.LeaseTtlMs); return next; } @@ -115,14 +124,13 @@ private static ProjectionOwnershipCoordinatorState ApplyRelease( ProjectionOwnershipCoordinatorState current, ProjectionOwnershipReleaseEvent evt) { - _ = evt; if (!current.Active) return current; var next = current.Clone(); next.Active = false; next.SessionId = string.Empty; - next.LastUpdatedAtUtc = Timestamp.FromDateTime(DateTime.UtcNow); + next.LastUpdatedAtUtc = RequireOccurredAtUtc(evt.OccurredAtUtc, nameof(ProjectionOwnershipReleaseEvent)); return next; } @@ -147,4 +155,25 @@ private static DateTime ResolveLastUpdatedUtc(Timestamp? timestamp) ? lastUpdatedUtc : DateTime.SpecifyKind(lastUpdatedUtc, DateTimeKind.Utc); } + + private static Timestamp ResolveOccurredAtForPersist(Timestamp? occurredAtUtc, DateTime fallbackUtcNow) + { + if (occurredAtUtc != null) + return NormalizeTimestamp(occurredAtUtc); + + return Timestamp.FromDateTime(EnsureUtc(fallbackUtcNow)); + } + + private static Timestamp RequireOccurredAtUtc(Timestamp? occurredAtUtc, string eventName) => + occurredAtUtc == null + ? throw new InvalidOperationException($"{eventName} must include occurred_at_utc.") + : NormalizeTimestamp(occurredAtUtc); + + private static Timestamp NormalizeTimestamp(Timestamp timestamp) => + Timestamp.FromDateTime(EnsureUtc(timestamp.ToDateTime())); + + private static DateTime EnsureUtc(DateTime value) => + value.Kind == DateTimeKind.Utc + ? value + : DateTime.SpecifyKind(value, DateTimeKind.Utc); } diff --git a/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto b/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto index 17a87d6e0..838d2906e 100644 --- a/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto +++ b/src/Aevatar.CQRS.Projection.Core/projection_ownership_messages.proto @@ -16,9 +16,11 @@ message ProjectionOwnershipAcquireEvent { string scope_id = 1; string session_id = 2; int64 lease_ttl_ms = 3; + google.protobuf.Timestamp occurred_at_utc = 4; } message ProjectionOwnershipReleaseEvent { string scope_id = 1; string session_id = 2; + google.protobuf.Timestamp occurred_at_utc = 3; } diff --git a/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs index cde3d2136..1490f68af 100644 --- a/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs +++ b/test/Aevatar.AI.Tests/RoleGAgentReplayContractTests.cs @@ -71,6 +71,63 @@ public async Task RoleGAgentFactory_ShouldUseEventSourcedConfigurePath() agent2.RoleName.Should().Be("assistant"); } + [Fact] + public async Task RoleGAgentFactory_ShouldPreserveExplicitZeroTemperature() + { + var store = new InMemoryEventStoreForTests(); + var services = new ServiceCollection() + .AddSingleton(store) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) + .BuildServiceProvider(); + + var agent = CreateAgent(services, "role-factory-temperature-zero"); + await agent.ActivateAsync(); + await RoleGAgentFactory.ApplyConfig(agent, new RoleYamlConfig + { + Name = "assistant", + Provider = "mock", + SystemPrompt = "system", + Temperature = 0, + }, services); + + agent.Config.Temperature.Should().Be(0); + + var persisted = await store.GetEventsAsync("role-factory-temperature-zero"); + persisted.Should().ContainSingle(); + var evt = persisted.Single().EventData.Unpack(); + evt.HasTemperature.Should().BeTrue(); + evt.Temperature.Should().Be(0); + } + + [Fact] + public async Task RoleGAgentFactory_ShouldKeepTemperatureUnset_WhenYamlTemperatureIsMissing() + { + var store = new InMemoryEventStoreForTests(); + var services = new ServiceCollection() + .AddSingleton(store) + .AddSingleton() + .AddTransient(typeof(IEventSourcingBehaviorFactory<>), typeof(DefaultEventSourcingBehaviorFactory<>)) + .BuildServiceProvider(); + + var agent = CreateAgent(services, "role-factory-temperature-null"); + await agent.ActivateAsync(); + await RoleGAgentFactory.ApplyConfig(agent, new RoleYamlConfig + { + Name = "assistant", + Provider = "mock", + SystemPrompt = "system", + Temperature = null, + }, services); + + agent.Config.Temperature.Should().BeNull(); + + var persisted = await store.GetEventsAsync("role-factory-temperature-null"); + persisted.Should().ContainSingle(); + var evt = persisted.Single().EventData.Unpack(); + evt.HasTemperature.Should().BeFalse(); + } + private static RoleGAgent CreateAgent(IServiceProvider services, string actorId) { var agent = new RoleGAgent diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs index ca9ce7f79..f7f06b4d2 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipAndSessionHubTests.cs @@ -32,6 +32,7 @@ public async Task AcquireAsync_ShouldCreateCoordinatorActor_AndDispatchAcquireEv evt.ScopeId.Should().Be("scope-1"); evt.SessionId.Should().Be("session-1"); evt.LeaseTtlMs.Should().Be(ProjectionOwnershipCoordinatorOptions.DefaultLeaseTtlMs); + evt.OccurredAtUtc.Should().NotBeNull(); envelope.CorrelationId.Should().Be("session-1"); envelope.Direction.Should().Be(EventDirection.Self); } @@ -76,6 +77,7 @@ public async Task ReleaseAsync_ShouldReuseExistingCoordinatorActor() var evt = envelope.Payload.Unpack(); evt.ScopeId.Should().Be("scope-1"); evt.SessionId.Should().Be("session-1"); + evt.OccurredAtUtc.Should().NotBeNull(); } [Fact] @@ -242,6 +244,23 @@ await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent agent.State.LastUpdatedAtUtc.Seconds.Should().BeGreaterThanOrEqualTo(firstUpdatedAt.Seconds); } + [Fact] + public async Task HandleAcquireAsync_ShouldUseProvidedOccurredAtUtc() + { + var agent = CreateStatefulAgent(CreateStatefulAgentServices()); + var occurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-2)); + + await agent.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-1", + SessionId = "session-1", + LeaseTtlMs = 30_000, + OccurredAtUtc = occurredAtUtc, + }); + + agent.State.LastUpdatedAtUtc.Should().BeEquivalentTo(occurredAtUtc); + } + [Fact] public async Task HandleAcquireAsync_ShouldAllowTakeover_WhenExistingLeaseExpired() { @@ -337,6 +356,75 @@ await agent1.HandleReleaseAsync(new ProjectionOwnershipReleaseEvent agent2.State.ScopeId.Should().Be("scope-replay"); agent2.State.SessionId.Should().BeEmpty(); } + + [Fact] + public async Task Replay_ShouldPreserveLeaseTimestamp_AndAllowExpiredTakeover() + { + var store = new TestInMemoryEventStore(); + var services = CreateStatefulAgentServices(store); + var occurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-10)); + + var agent1 = CreateStatefulAgent(services); + await agent1.ActivateAsync(); + await agent1.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-replay-active", + SessionId = "session-1", + LeaseTtlMs = 1_000, + OccurredAtUtc = occurredAtUtc, + }); + await agent1.DeactivateAsync(); + + var agent2 = CreateStatefulAgent(services); + await agent2.ActivateAsync(); + agent2.State.Active.Should().BeTrue(); + agent2.State.ScopeId.Should().Be("scope-replay-active"); + agent2.State.SessionId.Should().Be("session-1"); + agent2.State.LastUpdatedAtUtc.Should().BeEquivalentTo(occurredAtUtc); + + await agent2.HandleAcquireAsync(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-replay-active", + SessionId = "session-2", + LeaseTtlMs = 1_000, + }); + + agent2.State.Active.Should().BeTrue(); + agent2.State.SessionId.Should().Be("session-2"); + } + + [Fact] + public async Task Replay_ShouldFailFast_WhenAcquireEventMissingOccurredAtUtc() + { + var store = new TestInMemoryEventStore(); + var services = CreateStatefulAgentServices(store); + var agent = CreateStatefulAgent(services); + var agentId = agent.Id; + await store.AppendAsync( + agentId, + [ + new StateEvent + { + EventId = Guid.NewGuid().ToString("N"), + Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(DateTime.UtcNow), + Version = 1, + EventType = ProjectionOwnershipAcquireEvent.Descriptor.FullName, + EventData = Google.Protobuf.WellKnownTypes.Any.Pack(new ProjectionOwnershipAcquireEvent + { + ScopeId = "scope-legacy", + SessionId = "session-legacy", + LeaseTtlMs = 1_000, + }), + AgentId = agentId, + }, + ], + expectedVersion: 0, + CancellationToken.None); + + Func act = () => agent.ActivateAsync(); + await act.Should().ThrowAsync() + .WithMessage("*occurred_at_utc*"); + } } public class ProjectionSessionEventHubTests diff --git a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs index 962e9c3c5..1ffa8311c 100644 --- a/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs +++ b/test/Aevatar.CQRS.Projection.Core.Tests/ProjectionOwnershipProtoCoverageTests.cs @@ -39,6 +39,7 @@ public void ProjectionOwnershipMessages_ShouldRoundTripCloneMergeAndReflect() ScopeId = "scope-1", SessionId = "session-1", LeaseTtlMs = 30L * 60 * 1000, + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), }; var acquireParsed = ProjectionOwnershipAcquireEvent.Parser.ParseFrom(acquire.ToByteArray()); acquireParsed.Should().BeEquivalentTo(acquire); @@ -49,6 +50,7 @@ public void ProjectionOwnershipMessages_ShouldRoundTripCloneMergeAndReflect() { ScopeId = "scope-1", SessionId = "session-1", + OccurredAtUtc = Timestamp.FromDateTime(DateTime.UtcNow), }; var releaseParsed = ProjectionOwnershipReleaseEvent.Parser.ParseFrom(release.ToByteArray()); releaseParsed.Should().BeEquivalentTo(release);