diff --git a/cmake_ext/node_core_contracts.txt b/cmake_ext/node_core_contracts.txt index 11dd1d96..706b7ea8 100644 --- a/cmake_ext/node_core_contracts.txt +++ b/cmake_ext/node_core_contracts.txt @@ -11,3 +11,4 @@ pipeline_diagnostic.h session_context.h validated_node_plan.h biz_definition.h +diagnostic_code.h diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md index 499fb6a2..48242905 100644 --- a/doc/CHANGELOG.md +++ b/doc/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 2026-09-14 投产前诊断身份与 Node 注册状态收敛(RFC-0058) + +- **统一诊断身份体系(B1)**: + - 新增轻量级 `DiagnosticCode` 枚举(44 个精确诊断码:40 个校验码 + 4 个运行时码)与唯一名称表,基于单清单 X-Macro 统一管理。 + - 彻底废除 `PipelineErrorCode` 枚举及其与 `DiagnosticCode` 的多对一降级转换表(`ValidationCodeToPipelineCode`、`PipelineErrorCodeToDiagnosticCode`)。 + - `Pipeline::BuildFromJson` 在预检校验失败时直接透传首条精确 `DiagnosticCode`、路径与错误消息,消除消息前缀反解析与有损类型折叠。 + - 新增类型化 `RemediationCause` 枚举(12 种修复原因),`ValidationRemediation::cause` 全量迁移为强类型枚举,`ToJson` 边界保留既有字符串映射。 + - `alg_pipeline_tool` 错误域边界显式划分为基于 `DiagnosticCode` 的方案错误与独立的 `ToolError`。 +- **Node 单一事实源与原子注册(B2)**: + - `NodeRegistry` 存储结构收敛为单一不可变 `Entry`(同时持有 `NodeDefinition` 与 `CreatorFunc`),消除独立 `creators_` 映射与双阶段发布异常窗口。 + - 彻底移除 `PipelineCatalog::RegisterNodeDefinition`、`RegisteredNodes()` 及独立节点写入口,`PipelineCatalog` 纯化为只读外观委托。 + - 抽取私有结构校验与跨节点 Control 冲突检测,保证注册事务在单一互斥锁内原子提交;失败时单调锁存冲突标志,不污染既有已发布条目。 + - `Create` 采用锁外复制执行模式,彻底消除构造期与作者回调重入查询可能引起的自锁。 + - 移除公开的测试清理方法,改由 `tests/support/registry_test_access.h` 提供 friend 访问的 `RegistryTestAccess` 与 `ScopedNodeState`,实现严格测试作用域隔离与还原。 +- **质量验证**: + - 覆盖 D1–D7 诊断精确性矩阵,通过所有表驱动负向用例及 JSON fixture 迁移。 + - 覆盖 R1–R9 原子性、并发注册竞争、无死锁重入(含 fail-after-N 内存分配故障注入)及状态隔离。 + - 通过全量 ThreadSanitizer(TSan)数据竞争检测(92/92 测试 100% 通过)。 + - 通过本地预提交四层门禁 `./scripts/run_all_tests.sh`(97/97 测试 100% 通过)。 + ## 2026-09-14 Pipeline 编排与部署预检验收修复(RFC-0057) - 增加八类原生作者操作、128 动作/4 MiB 有界请求和 `fix-deps` 预览/原地修复;写入失败保留原文件,普通扇出保持已有默认 key,歧义目标和非法请求返回结构化错误。 diff --git a/doc/rfcs/0058-diagnostic-and-node-registry-convergence.md b/doc/rfcs/0058-diagnostic-and-node-registry-convergence.md new file mode 100644 index 00000000..0c31be09 --- /dev/null +++ b/doc/rfcs/0058-diagnostic-and-node-registry-convergence.md @@ -0,0 +1,526 @@ +# RFC-0058:投产前诊断身份与 Node 注册状态收敛 + +- **RFC 编号**:0058-diagnostic-and-node-registry-convergence +- **创建日期**:2026-09-14 +- **文档状态**:Completed +- **关联分支**:`docs/rfc0058-diagnostic-registry` +- **目标版本**:投产前下一次框架开发接口版本 +- **负责人 / 作者**:LLM-EdgeFlow 维护者 / Codex +- **代码核查基线**:`e090499557729bc05a89dbce47dc1d0216421d85` +- **关联决策**:接续 RFC-0056 第 1 节暂缓的 B1/B2;修订 RFC-0003、RFC-0020 中涉及的诊断转换及 Node 注册实现;保留 RFC-0030 的头文件边界、RFC-0051 的修复协议、RFC-0052 的作者注册异常屏障及 RFC-0057 的工具消费契约。 + +本文规格与重构已经完整实施并通过全部验证与门禁。 +当前一次性修改了 C++ 开发接口和测试,不保留旧枚举别名、双写过渡表或 +Definition-only 注册入口;仍然保留有价值的现行外部协议及作者宏。 + +## 1. 问题与核查结论 + +### 1.1 B1:核心问题属实,但原意见混淆了诊断身份与修复原因 + +| 核查项 | 基线证据 | 判定 | +| --- | --- | --- | +| 两套诊断枚举 | [pipeline_diagnostic.h](../../include/core/pipeline_diagnostic.h) 定义 22 个 `PipelineErrorCode`;[pipeline_validator.h](../../include/core/pipeline_validator.h) 定义 40 个 `DiagnosticCode`,均计入 `kOk` | 属实,解析/构建与校验使用不同身份体系 | +| 校验码降级为构建码 | [pipeline.cpp](../../src/core/pipeline.cpp) 的 `ValidationCodeToPipelineCode`,基线 21–84 行;`BuildInternal`,479–485 行 | 已存在可达的多对一降级;精确码被塞进 message | +| 反向转换也存在错误分类 | [pipeline_validator.cpp](../../src/core/pipeline_validator.cpp) 的 `PipelineErrorCodeToDiagnosticCode`,107–154 行 | 四个 runtime 专有码被折叠,但唯一调用位于 parser 失败分支,当前这四个分支不可达;不能表述为已发生的运行时故障 | +| 枚举序列化为外部字符串有损 | 同文件 `DiagnosticCodeName` 和 `ValidationDiagnostic::ToJson` | 未发现:现有 40 个合法枚举值各有唯一字符串 | +| `ValidationRemediation` 是另一套等价错误码 | `ValidationRemediation::cause` 及 `PopulateBasicRemediation` | 不准确:cause 表示更具体的原因或共用修复类别,和 code 不是一一对应 | +| 修复原因使用裸字符串 | `PopulateBasicRemediation`、`PipelineValidator::Explain`、[pipeline_authoring.cpp](../../src/tools/pipeline_authoring.cpp) 的修复判断 | 属实:生产端与消费端重复字面量,缺少类型检查 | + +实际例子:同一无生产者的节点输入,Validator 返回 `MISSING_INPUT_PRODUCER`, +`Pipeline::BuildFromJson` 返回 `PipelineErrorCode::kInvalidCombination`;调用者必须解析 +message 才能恢复错误类别。`DUPLICATE_DEPENDENCY` 也被降为 `kInvalidDependency`。 +这不是抽象的维护风险,而是现有接口的实际行为。 + +现有 parity 测试还固化了这种降级:[test_pipeline_catalog_validator.cpp](../../tests/integration/pipeline/test_pipeline_catalog_validator.cpp) +的 `TableDrivenParityMatrix` 比较粗粒度枚举整数,并检查 message 包含精确码。 +实施时必须更改预期,不能靠保留映射使旧测试继续通过。 + +### 1.2 B2:双表属实,存在确定的绕过入口和异常时部分发布路径 + +| 核查项 | 基线证据 | 影响 | +| --- | --- | --- | +| creator 单独存储 | [node_registry.h](../../include/core/node_registry.h) 的 `creators_`、`mutex_` | NodeRegistry 持有构造能力 | +| Definition 单独存储 | [pipeline_catalog.cpp](../../src/core/pipeline_catalog.cpp) 的 `RegisteredNodes()`、`CatalogMutex()` | Catalog 持有另一份节点集合 | +| 两阶段发布 | [node_registry.cpp](../../src/core/node_registry.cpp) 的 `Register`,31–50 行 | 先发布 Definition,再插入 creator;第二阶段分配失败没有撤销 Definition | +| 可以绕过联合注册 | [pipeline_catalog.h](../../include/core/pipeline_catalog.h) 的公开 `RegisterNodeDefinition` | 可制造有 Definition、无 creator 的状态;现有 schema/NodeBase 测试确实使用此入口 | +| 两套测试清理入口 | 两个类各自的 `ClearForTesting` | 可分别留下 Definition 或 creator | +| 双来源校验 | Validator 基线 1188–1196 行同时读取 Catalog 快照和 live `NodeRegistry::Has` | 两次查询不是同一时刻;防御性双查不能建立单一事实源 | + +这里的异常路径结论来自代码推导,本次未执行分配失败注入。正常注册持有 Registry 锁后 +再取 Catalog 锁;目前未找到相反锁序,因此**未证明存在死锁**。Catalog 可能提前读到 +Definition,但并发 `Has/Create` 会等待 Registry 锁,不能据此断言每次并发都读到半状态。 + +现有 `HasConflict`、Validator 和 `SharedAlgorithmRuntime::GlobalInit` 会使多数注册失败 +在验证/初始化阶段失败关闭,已经缓解运行风险;Definition-only 写入没有同等联合保证。 +重构的价值在于消除非法状态的表达能力和双写异常窗口,而非再加一次互相核对。 + +### 1.3 范围与目标 + +主要涉及**流程编排层 / Orchestration**;能力节点层 / Capability Nodes 的作者头和测试、 +接入适配层 / Integration 的错误比较、CLI/Studio 消费测试随之迁移。 + +完成后必须满足: + +1. 同一条 Pipeline 诊断在解析、校验、构建、JSON 输出中使用同一个精确 `DiagnosticCode`。 +2. 修复原因独立类型化,保持 `code + cause + facts`,不从 cause 反推或覆盖 code。 +3. 一个已发布 Node 条目同时持有 Definition 和非空 creator;失败不会发布半个条目。 +4. Catalog 的 Node 数据全部来自 NodeRegistry,不保留缓存表或另一条 Node 写入口。 +5. 保留无副作用 preflight、单次构建状态机、静态注册异常屏障、构造器锁外调用和注册失败审计。 + +不增加验证规则,不修改 `ValidationPolicy`、Pipeline JSON、模型加载时机、拓扑规划或 +`ValidatedPipelinePlan` 的消费方式。不扩展到 Model/Backend/Biz 注册表全面统一,也不引入 +运行中热插拔、卸载、全局注册事务或新的插件生命周期。六个 `Alg_*` 的 C11 声明、返回码及 +`noexcept`/双 catch 屏障维持既有契约;本 RFC 不新增 C ABI 诊断接口。 + +## 2. 决策与权衡 + +| 问题 | 采用 | 不采用及原因 | +| --- | --- | --- | +| 两套诊断身份 | 单一枚举及名称定义表;删除全部双向转换 | 补齐转换表仍需长期同步,且无法从粗码恢复原码 | +| 修复原因 | 独立 `RemediationCause`,JSON 边界显式命名 | 将 cause 并入 DiagnosticCode 会混淆错误身份和修复策略,破坏已有多对多关系 | +| Node 所有权 | NodeRegistry 原子持有完整 Entry,Catalog 为查询外观 | 双表加回滚/双锁协议仍允许绕过写入且扩大异常处理面 | +| 可重入与生命周期 | 不可变 Entry 的共享句柄,仅在构建/目录操作中使用 | 锁内复制任意用户 callable 或执行 creator 可能重入;对外借用 map 引用则增加生命周期约束 | +| 注册期 | 保持启动期注册、运行期消费;条目发布后不可替换 | 全局 Freeze/epoch/热更新事务超出已证明的缺口 | + +共享句柄的成本是每个注册条目一次分配及构建/查询时少量引用计数操作,不进入逐请求 Node +执行热路径。它用于确保锁外复制 callable/Definition 时条目仍有效,不是第二份可写注册表。 + +## 3. B1 详细设计:统一诊断身份 + +### 3.1 头文件、枚举和唯一名称表 + +新增 `include/core/diagnostic_code.h` 与 `src/core/diagnostic_code.cpp`。轻量头只依赖标准库, +不得包含 JSON、Validator、Catalog、Engine 或具体 Node。 + +```cpp +enum class DiagnosticCode { /* 现有 40 项 + 下述 4 项,kOk = 0 */ }; +const char* DiagnosticCodeName(DiagnosticCode code) noexcept; +``` + +将原 `pipeline_validator.h` 的 40 个枚举全部迁入,再加入原构建域专有码: + +| 新枚举成员 | 外部名称 | 使用阶段 | +| --- | --- | --- | +| `kModelMaterializationFailed` | `MODEL_MATERIALIZATION_FAILED` | 模型物化失败 | +| `kNodeCreateFailed` | `NODE_CREATE_FAILED` | creator 抛异常或返回空 | +| `kNodeInitFailed` | `NODE_INIT_FAILED` | Node Init 拒绝或抛异常 | +| `kInvalidBuildState` | `INVALID_BUILD_STATE` | 重复/非法状态发起 Build | + +共 44 个合法值,`kOk` 仅表示无错误,不进入失败报告。现有 40 个名称逐字保持。 +枚举整数是内部实现,不作为序列化协议、C ABI 或 fixture 的稳定身份。 + +使用**一份声明清单**同时产生枚举与 `constexpr` 描述表:在轻量头内用局部 X-macro +定义 `(枚举名, 外部字符串)`,展开后立即 `#undef`。名称函数读取该表,测试也从该表枚举 +合法域;禁止再维护一份手抄 switch 或整数映射。无需代码生成器或额外构建依赖。 +若增加 `kCount`,它只是表大小哨兵,不是合法诊断码,不得输出到协议。 + +对非法强转值,`DiagnosticCodeName` 保留 `UNKNOWN` 防御返回;它既不是新错误类别, +也不能作为未知输入的默认成功解码。测试必须证明所有合法值有非空、唯一、非 UNKNOWN 的 +名称。没有实际反序列化需求,不为本次重构增加公共字符串解析 API;fixture 可用描述表匹配。 + +`pipeline_diagnostic.h` 引用新头并删除 `PipelineErrorCode`,**不保留 using 别名**。 +`PipelineDiagnostic::code` 与 `ValidationDiagnostic::code` 均为 `DiagnosticCode`。 +更新 `cmake_ext/node_core_contracts.txt`,使现有 Node-facing 的 `pipeline_diagnostic.h` +传递依赖仍可编译;不得因此向 Capability Nodes 开放 `pipeline_validator.h`。 +`include/contracts/diagnostic.h` 仅负责无抛出字符串写入,与本枚举不是同一职责,保留原位。 + +### 3.2 数据流和载荷边界 + +```text +ParsePipelineConfig ── PipelineDiagnostic(code/path/message) ──┐ + ↓ + PipelineValidator + ↓ + ValidationReport / ValidationDiagnostic + ↓ ↓ + CLI / Studio JSON Pipeline::Build + 精确首条诊断投影 +``` + +具体迁移规则: + +1. parser 的 `SetDiag` 直接写 `DiagnosticCode`;Validator 收到 parser 失败后直接复制 code。 +2. 删除 `PipelineErrorCodeToDiagnosticCode` 和 `ValidationCodeToPipelineCode`,不增加替代映射。 +3. `BuildInternal` preflight 失败时直接复制第一条诊断的 `code/path/message`。message 不再 + 拼接 code 前缀;日志用 `DiagnosticCodeName(code)` 独立格式化。 +4. 物化与状态错误直接生成上述四个专有码;捕获异常仍保留现有路径和阶段信息,不能改报 + `UNKNOWN_NODE_TYPE`、`UNKNOWN_CONFIG_FIELD` 或 `INTERNAL_EXCEPTION` 来隐藏具体失败阶段。 +5. `SharedAlgorithmRuntime` 的 `kRegistryConflict` 比较迁移至新枚举,既有 SDK 返回码不变。 +6. Build 的状态转移与失败后资源回收保持现状;不能为保存诊断而提交失败的 RuntimeAssembly。 + +**两种载荷仍各有用途。** `PipelineDiagnostic` 保持轻量的 code/path/message,明确是首条 +错误摘要;不承诺包含后续错误、端口、suggestions 或 remediation。完整 preflight 报告由 +`PipelineValidator::Validate/Explain` 返回。此次“无损”指诊断身份不降级,**不声称 Build +摘要透传完整报告**;不新增保存失败计划的接口,也不能让调用者访问失败实例的 `GetPlan()`。 +需要完整报告的调用者使用 Validator;不通过解析摘要 message 恢复结构化字段。 + +无诊断但 `report.ok == false` 应防御性返回 `kInternalException`,不能保持 `kOk`;正常 +规则仍保持“失败有诊断”的不变量。成功调用按现有规则清理输出诊断。 + +### 3.3 修复原因类型化,保留原有语义 + +新增 `include/core/remediation_cause.h`,只由 Orchestration/Tooling 使用,不加入 Node +作者白名单。使用与诊断码相同的单清单方式定义 `RemediationCause` 和 +`RemediationCauseName`。`ValidationRemediation::cause` 改为此枚举;构造 remediation +时显式传入原因,不以空字符串或默认值补齐一个虚假的原因。 + +以下是**完整的现有原因集合和允许关联的诊断类别**,不是新的判断规则: + +| `RemediationCause` | JSON cause | 允许的 DiagnosticCode(省略 k) | +| --- | --- | --- | +| `kUnknownConfigField` | `unknown_config_field` | UnknownConfigField | +| `kMissingConfigField` | `missing_config_field` | MissingConfigField | +| `kInvalidConfigValue` | `invalid_config_value` | ConfigFieldType / ConfigFieldRange / ConfigFieldEnum | +| `kUnknownModelReference` | `unknown_model_reference` | UnknownModelReference | +| `kModelCapabilityMismatch` | `model_capability_mismatch` | ModelCapabilityMismatch | +| `kProducerNotDependencyAncestor` | `producer_not_dependency_ancestor` | MissingInputProducer | +| `kPortTypeMismatch` | `port_type_mismatch` | MissingInputProducer | +| `kNoCompatibleInputSource` | `no_compatible_input_source` | MissingInputProducer | +| `kDuplicateDependency` | `duplicate_dependency` | DuplicateDependency | +| `kUnknownDependency` | `unknown_dependency` | InvalidDependency | +| `kMissingBizOutput` | `missing_biz_output` | MissingBizOutput | +| `kPortFlowMismatch` | `port_flow_mismatch` | PortCardinalityMismatch / PortProvenanceMismatch / PortLifetimeMismatch | + +保留 `PopulateBasicRemediation` 按输入 JSON、路径和 Catalog 提取原因/facts 的过程;上表 +不能替代这些上下文判断。例如 `MISSING_INPUT_PRODUCER` 的三种原因需要不同修复。 +`Explain` 和 `pipeline_authoring.cpp` 改为枚举比较,不能走 `ToJson()` 后再比较字符串。 +只在 `ValidationRemediation::ToJson` 调用名称函数,Python/JavaScript 继续消费原字符串。 + +未能确认原因时保持 `remediation = nullopt`;不通过 message 关键词生成原因。 +保留 `facts`、修复补丁、排序、每条最多 3 个候选/整份最多验证 8 个候选,以及 +`pipeline_valid` / `target_resolved` 的区别。所有候选继续用同一 Validator 校验; +诊断身份比较继续使用 code、稳定节点/模型 ID、逻辑端口和字段身份,不能降为 cause 比较。 + +### 3.4 JSON 与工具错误域 + +Pipeline 的 `ValidationReport.schema_version=1`、`remediation.schema_version=1`、 +现有 code/cause 字符串及 Catalog v3 保持。内部枚举收敛不改变这些字段含义,无需升版本。 +新 runtime 名称仅用于相应构建诊断,不伪造为 preflight 输出。 + +CLI 中已经属于此域的 `UNKNOWN_NODE_TYPE`、`UNKNOWN_BIZ`、`REGISTRY_CONFLICT` 等, +使用接受 `DiagnosticCode` 的错误构造 helper,名称仍经唯一序列化入口产生。 +`JSON_READ`、`DEPLOYMENT_CONFIG`、`PROFILE_MISMATCH`、`AUTHORING_ERROR` 及 HTTP/run +错误属于工具或 Integration 边界,使用明确命名的 `ToolError` 等入口;此次不强行并入 +Pipeline 枚举,不改变它们的既有字符串或含义。特别是 CLI 当前 `JSON_READ` 聚合文件读取 +和语法错误,不在本次顺带拆分协议。 + +Studio 对未知可选字段及未知 remediation 版本的降级行为保持,不在 Web 端复制 C++ 码表 +或重建合法性规则。未知原因不得启动猜测的自动修复。 + +## 4. B2 详细设计:完整 Node 条目作为唯一事实源 + +### 4.1 所有权与接口 + +`NodeRegistry` 保留单例和现有作者宏,私有存储改为: + +```cpp +struct Entry { + NodeDefinition definition; + CreatorFunc creator; +}; +using EntryHandle = std::shared_ptr; +std::unordered_map entries_; +mutable std::mutex mutex_; +std::atomic has_conflict_{false}; +std::vector conflict_errors_; +``` + +Entry 不向作者或 Catalog 暴露,创建后不可更改;map key 必须等于 `definition.node_type`。 +原 `Register(name, creator, const NodeDefinition*)` 及引用重载在本次保留,统一进入同一 +实现,空指针继续作为注册失败处理。保留名称一致性校验,不因消除双表放宽作者契约。 +`RegisterWithDefinitionFactory` 和 `REGISTER_NODE_WITH_DEFINITION` 的使用方式不变。 + +增加以下只读能力,名称与现有 ModelRegistry 风格一致: + +```cpp +struct NodeRegistrySnapshot { + std::vector definitions; // node_type 升序,独立值副本 + bool has_conflict = false; + std::vector conflict_errors; +}; + +NodeRegistrySnapshot Snapshot() const; +std::optional Find(const std::string& node_type) const; +std::vector ListDefinitions() const; +``` + +`Has`、`ListTypes`、`Create` 同样读取 `entries_`,移除 `creators_`。`ListDefinitions` +可委托 Snapshot;`Find` 查找句柄后在锁外复制 Definition。返回值拥有自己的生命周期; +不得返回 map 中 Definition 的裸指针/引用。快照内部可以持有临时 EntryHandle,但不能形成 +第二份持久可写注册状态。 + +### 4.2 Definition 校验归属 + +将 `PipelineCatalog::RegisterNodeDefinition` 的校验逻辑完整迁至 +`src/core/node_definition_validation.h/.cpp` 私有实现,供 NodeRegistry 使用: + +- 单条校验:非空类型名、端口类型/基数/来源/lifetime、重复端口、端口约束引用、Control + schema/ID/name、配置字段定义与默认值、lifetime 覆盖字段、模型依赖名称与配置字段绑定。 +- 跨条目校验:同名注册拒绝;跨 Node 相同 Control ID 仅在双方 `shared_id` 为真,且 + name、payload_schema、supports_hot_swap 完全相同时允许。 +- 共享的 Port 元数据检查提取到同目录私有 helper,由 Node/Biz 校验共同使用;不复制两套 + 规则,不把 Biz 校验迁入 NodeRegistry。 + +单条结构校验可在锁外完成;同名和跨条目 Control 校验必须在提交锁内基于当前 entries。 +只检验声明数据,不能调用 `NodeDefinition::validate_config`、creator 或模型加载函数。 +注册校验不构造 Node。移走节点存储后,Catalog 中与序列化有关的名称转换函数保持职责。 +多个既有条目同时构成 Control 冲突时,选择 node_type 字典序最小的冲突者报告,避免 +unordered_map 遍历顺序改变诊断;不为排序增加常驻第二索引。 + +### 4.3 注册事务与异常屏障 + +一次注册的线性化点是锁内 `entries_.try_emplace` **成功插入完整 EntryHandle**: + +1. 保持作者 Definition factory 的求值在 `noexcept`/双 catch 屏障内。先获得 Definition, + 再检查参数和单条 schema;锁外构造完整候选 EntryHandle,包含 creator 和 Definition。 +2. 取唯一 Node mutex,检查类型唯一性和跨节点 Control 契约。 +3. 使用无外部 callback 的字符串 hash/equality、单次 `try_emplace` 插入候选。禁止 + `operator[]` 先创建空条目再赋值。选择具有插入异常强保证的标准容器操作。 +4. 插入成功后不再做可能抛出的必要工作;解锁后返回 true。排序、日志和 JSON 构造均不属于 + 提交过程。已有条目不复制重建、不覆盖。 +5. 任一步失败,本次不发布新条目、不修改既有条目;其他并发成功注册不受影响。 + 重复注册保留原 creator 和 Definition。 + 标记注册失败并返回 false,不回滚其他成功注册的条目,也不清除既有失败标志。 + +锁对象使用块作用域;catch 及失败记录在解锁后运行,避免 `RecordRegistrationFailure` +重入同一 mutex。候选 EntryHandle 在锁外变量中保有引用,保证失败候选的最后一次释放和 +任意 callable 析构不发生在提交锁内。成功插入后本地句柄的释放也在锁外。 + +`RecordRegistrationFailure` 首先以不分配内存的原子 store 锁存失败,再 best-effort 获取 +mutex 并追加错误文字;文字分配失败时标志仍为 true。记录函数保留 `noexcept` 与双 catch。 +读失败标志使用 acquire,写入使用 release;失败标志不会因诊断记录失败而恢复。 +允许快照观察到 `has_conflict=true` 且错误列表为空,调用方此时使用固定“Node registry +contains registration conflicts”诊断。不要用 `errors.empty()` 代替失败状态。 + +日志在 Node 锁外输出,外部日志 callback 重入查询不能自锁。极端资源不足时错误说明可以 +缺失,但后续初始化不得把失败注册表当作健康。此处只保证内存分配失败路径,不承诺在 +进程/标准库同步原语已经不可用时继续服务。 + +异常屏障覆盖 factory 求值及注册函数内部;普通 C++ 调用表达式在进入函数前的参数构造 +不可能由被调函数捕获。静态作者注册必须经 `RegisterWithDefinitionFactory` 宏路径, +不能直接在屏障外构造可能抛出的 Definition/CreatorFunc 后声称受此保证保护。 + +### 4.4 读取、构造、快照和并发 + +`Create` 按以下顺序实现:锁内查找并复制 EntryHandle;解锁;从句柄复制 CreatorFunc 到 +本地;调用本地 creator。**保留每次调用复制 callable 的语义**,不要改为多个请求共享调用 +同一个 mutable callable。复制或调用 creator 抛异常仍由现有 Pipeline 物化屏障转成 +`kNodeCreateFailed`;返回空仍为创建失败。注册期不会提前执行 creator 来测试可用性。 + +此处有一项明确的投产前 C++ 扩展契约收紧:注册到 Entry 中的 creator 及 Definition +callback 必须允许并发 const 复制,复制过程不能修改共享捕获状态;注册后捕获的共享数据 +须不可变或自行同步。以前 Node mutex 串行化了部分 callable 复制,移到锁外不再提供该 +隐式保护。默认宏的无捕获 lambda 满足要求;审核手工注册和捕获对象,不能仅凭“每次复制” +宣称旧并发语义完全不变。callback 的正常运行职责及 Node 请求无状态约束仍然成立。 + +Snapshot 锁内复制 EntryHandle 列表、失败标志和错误字符串;解锁后复制 Definitions 并按 +node_type 排序。Definition 含 `std::function`,其复制可能执行作者 callable 的复制构造, +所以也必须在锁外。异常可以向 Snapshot 调用方传播,不得返回 `ok` 的截断目录。 + +并发保证限于一个 Registry 操作及其已发布条目: + +- 成功条目总是完整;一次快照只能观察到提交前或提交后的整个条目。 +- 发布后不可替换/卸载;先前快照不随后续注册改变,已有 plan 不因新增无关节点失效。 +- `Has/Find/Create` 是独立调用,不能承诺并发新增时三次调用拥有同一观察时刻。 +- 失败状态是进程内单调锁存;追加诊断文字可能晚于标志可见。全局失败时保留原条目供检查, + `Create` 仍可构造先前成功注册的类型,保持现有冲突测试契约;SDK 初始化/Build 负责拒绝 + 使用失败注册环境,不能根据某个 `Create` 成功宣称注册环境健康。 +- SDK 正式使用遵循启动期注册完成后再初始化/Build;不承诺在运行期间新增失败注册能撤回 + 已就绪 Pipeline。并发追加测试证明容器与发布安全,不构成热插拔产品承诺。 + +### 4.5 Catalog 与 Validator 接线 + +删除 `PipelineCatalog::RegisterNodeDefinition` 声明/实现、`RegisteredNodes()` 及相关写入。 +`Nodes/FindNode` 分别委托 Registry 的 `ListDefinitions/Find`,不缓存。 +Biz 数据仍由 Catalog 管理,将其锁明确命名为 `BizMutex`;它不再保护任何 Node 状态。 +系统仍有不同职责的锁,目标是**一个 Node 注册状态只有一个 owner 和一把锁**。 + +`PipelineCatalogSnapshot` 保留 `nodes/bizs`,增加 `node_registry_has_conflict` 和 +`node_registry_errors`。`Snapshot` 先取得一份 NodeRegistrySnapshot,完全退出 Node 锁后 +再取得 Biz 值快照,移动其结果组装 CatalogSnapshot。两把锁不嵌套,不承诺跨 Node/Biz/ +Model/Backend 的全局原子时刻。已有启动期注册约束使此边界足以支持产品使用。 + +Validator 只用传入 CatalogSnapshot 的 Node Definitions 和失败状态: + +1. 删除 `catalog.FindNode(...) + NodeRegistry::Has(...)` 双查。找不到 Definition 就报 + `kUnknownNodeType`,严格与 private-extension 策略都不允许“只有 creator”的节点。 +2. 从快照的失败状态生成 `kRegistryConflict`,不为 Node 再独立读 live HasConflict/errors。 + 保留现有解析优先顺序和 Model/Backend 审计。 +3. `Explain` 的初次诊断和候选复验继续复用同一 CatalogSnapshot,不能候选间换 Node 目录。 +4. `Pipeline::MaterializeNodes` 继续按已验证 plan 调用 Registry::Create,不再解析 Definition + 或重新规划;生产条目不可替换使验证和构造对应同一注册契约。 + +CLI `catalog` 当前直接调用 `ToJson`,仅根据 biz 是否存在设置 ok;Operator 初始化审计 +只在 `resolve-conf` 路径执行。**本 RFC 同时补齐 catalog 的 Node 注册失败审计**:先取一份 +CatalogSnapshot,若其 Node 失败标志为真,输出 `REGISTRY_CONFLICT` 和非零退出码;否则 +使用这同一份快照序列化目录。新增 `PipelineCatalog::ToJson(const PipelineCatalogSnapshot&, +const std::string& biz_filter)` 纯序列化重载,现有 ToJson(biz_filter) 委托它,避免 CLI 审计 +后又重新取一份 Node 快照。无需为了查看目录调用 Operator 初始化或加载业务资源。 + +ToJson 保持目录描述职责和 v3 形状,不自行变成验证报告;内部调用方读取 Snapshot 判断 +Node 健康状态。CLI 失败 envelope 仍为现有 v1 Error 形状,成功目录仍为 v3;不要将 +envelope 混入每个 NodeToJson。其他 `describe-node/init/edit/fix-deps` 继续使用同一委托 +查询路径;本次不新增跨 Model/Backend/Biz 的全局目录事务。 + +### 4.6 测试隔离与清理接口 + +移除公开的 NodeRegistry `ClearForTesting/ClearConflictForTesting` 和 Catalog +`ClearForTesting`,改由 `tests/support` 的 `RegistryTestAccess` friend 提供定向访问。 +生产头只声明 friend,不包含测试实现;测试实现不进入 SDK 安装面,不使用条件宏改变类布局。 +Catalog 的 Biz 状态当前位于 `.cpp` 匿名 namespace,friend 不能直接访问这些自由函数。 +在 Catalog 增加 private 静态测试操作并在同一个 `.cpp` 实现,由 friend 调用;不把 Biz +状态导出为 public,也不为测试再建一份 Biz 容器。 + +测试支持提供明确的 `ResetNodes`、`ResetBizs` 和 `ClearNodeFailures`。ResetNodes 在同一 +临界区清除完整 entries、失败状态和错误列表,移出的 EntryHandles 在解锁后析构。 +不存在“仅清 Node Definition”或“仅清 creator”的接口。ResetBizs 只影响 Biz,Model/ +Backend 的已有测试入口保持原状。 + +这些操作仅允许在隔离 fixture 的静止期执行:没有并发注册/查询,没有在用 Pipeline/Node。 +需保存静态注册集合的测试使用作用域保存/恢复完整 entries 和失败状态;析构恢复不得抛出。 +普通测试用唯一名称追加条目;失败状态污染进程的场景优先复用现有独立冲突 fixture。 +禁止为了单测通过在生产启动路径自动清除冲突。 + +Definition-only 测试分两类迁移:纯 schema 负例直接调用私有纯校验 helper;要证明注册 +成功、跨条目 Control 冲突、NodeBase/Control 运行行为的用例必须使用真实 +`creator + Definition` 联合注册。纯校验成功不再向生产 Catalog 注入条目。 + +## 5. 兼容与迁移 + +### 5.1 一次性切换的边界 + +| 消费者/协议 | 迁移要求 | +| --- | --- | +| C++ `PipelineErrorCode` 使用者 | 全量改为 DiagnosticCode;更新粗码断言,禁止保留别名/兼容转换 | +| Build message 消费者 | 改读 code;错误文字不再带重复 code 前缀;日志另行格式化 | +| `ValidationRemediation` C++ 使用者 | 显式 RemediationCause,枚举比较;序列化结果不变 | +| Definition-only 注册者 | 转联合注册或纯校验;删除旧入口,无转发 shim | +| 手工注册的 callable | 审核并发复制契约;可变共享捕获自行同步,不能依赖旧 Registry 锁隐式串行化复制 | +| 测试清理接口 | 迁入测试 support,保证整条 Node 状态保存/清除/恢复 | +| CLI/Studio/recipe JSON | 现有码及 v1/v3 版本保持,已有字符串断言保留;新 typed helper 不改变工具错误域 | +| common/custom Node 作者宏 | 用法保持,底层切换为完整 Entry;不要求批量重写 Node | +| C ABI、Operator 返回码与外部载荷 | 保持;只迁移 Integration 内部错误枚举比较 | + +原 parity fixture 中表示 `PipelineErrorCode` 的整数必须删除,用精确的字符串 code 比较 +Validator JSON 和 `DiagnosticCodeName(build_diagnostic.code)`。不要把整数改成另一组整数。 +本次不为了内部 C++ 源兼容改动升级 Pipeline JSON 或 Catalog 版本;开发接口更新需要整体 +重编译 SDK、工具和本地扩展,不能混用旧头与新库。 + +整体回退边界是同一套源码、头文件、测试和工具产物,不恢复双写兼容层。纯内部类型修改 +无用户数据迁移;如后续改变现行 JSON 字段语义,应另行决定版本并更新本 RFC 范围。 + +### 5.2 文件实施清单 + +| 文件/责任位置 | 工作 | +| --- | --- | +| `include/core/diagnostic_code.h`、对应 `.cpp`(新增) | 44 个码和唯一名称表 | +| `include/core/remediation_cause.h`、对应 `.cpp`(新增) | 12 个原因和名称;不加入 Node 作者面 | +| `include/core/pipeline_diagnostic.h`、`pipeline_validator.h` | 删除旧码,调整字段类型,说明轻量摘要边界 | +| `src/core/pipeline_config.cpp`、`pipeline_validator.cpp`、`pipeline.cpp` | 精确码直传、类型化原因、删映射、删 Node live 双查 | +| `include/core/node_registry.h`、`src/core/node_registry.cpp` | 唯一完整 Entry、事务提交、快照/查询/失败锁存 | +| `src/core/node_definition_validation.h/.cpp` 及私有 Port helper(新增) | 搬迁现有校验,保留全部规则及跨条目检查 | +| `include/core/pipeline_catalog.h`、`src/core/pipeline_catalog.cpp` | 删除 Node 写入口和表,委托读取,快照携带 Node 失败状态 | +| `src/adapter/shared_algorithm_runtime.cpp` | 更新枚举比较,保持返回码和初始化审计 | +| `src/tools/alg_pipeline_tool.cpp`、`src/tools/pipeline_authoring.cpp` | typed 诊断 helper、typed 修复比较,工具错误域显式保留 | +| `cmake_ext/node_core_contracts.txt`、现有源文件收集/测试装配 | 新轻量头可见性、私有头隔离、编译新实现;不得新增 Core→Nodes 依赖 | +| `tests/support`、下节列出的现有套件与 fixture | 原子注册/故障注入/重入/诊断 parity/测试清理迁移 | +| `include/nodes/function_node.h`、`model_bound_node.h`、NodeHarness、scaffold 测试模板 | 审核传递调用与头依赖;保留作者宏,不进行无必要批量改写 | +| `doc/architecture.md`、相关开发指南、`doc/CHANGELOG.md` | 实施完成时记录精确码和单 owner 事实;本次提案不提前改写当前架构说明 | + +按符号全库搜索迁移调用者,清单是责任边界,不可只修改表内显眼位置。历史 RFC 保留原文; +本 RFC 明确取代相关实现决策,不重写历史验收结论。 + +## 6. 验证与完成条件 + +### 6.1 B1 最小行为证明 + +| 编号 | 必须证明的行为 | 优先落点 | +| --- | --- | --- | +| D1 | 44 个合法 code 的名称完整、唯一;4 个 runtime 名称正确;非法值有防御返回 | `test_validated_pipeline_plan.cpp`,替换现有只覆盖 35/40 项的表 | +| D2 | 同一无效配置的 Validator 与 Build 首条 code/path/message 一致,无 message 反解析 | `test_pipeline_catalog_validator.cpp` + `invalid_pipeline_cases.json` | +| D3 | 覆盖原来每组降级:Unknown*ConfigField、MissingConfigField、Type/Range/Enum、DuplicateDependency、模型/端口/并发/业务契约错误均保持精确码 | 扩展上述 parity matrix,逐项覆盖已可达规则,不能只取一个 InvalidCombination 例子 | +| D4 | 文件读取、JSON 语法、模型物化、creator 空/抛异常、Init 失败/抛异常、重复 Build、内部异常保留对应类别及失败状态 | `test_pipeline_config.cpp` 既有 fixture | +| D5 | 12 个原因保持 JSON 原值;MissingInputProducer 的三种原因分别覆盖;Type/Range/Enum 共享原因仍保留不同 code | 现有 Explain/remediation 集成用例 | +| D6 | 修复后重验、候选数量/验证次数上限、target_resolved、依赖修复幂等与过期保护保持 | 现有 Pipeline validator、authoring、CLI/Studio suites | +| D7 | Integration 仍将注册冲突映射为既有 SDK 错误,C ABI 异常屏障完整 | 既有注册冲突与 C ABI 契约套件 | + +保留对关键名称的独立文字断言和真实 fixture,不能只让生成表与自身比较。JSON baseline +比较 code/path/remediation 等结构字段;对文案的断言只覆盖必要业务事实。 + +### 6.2 B2 原子性、重入与生命周期证明 + +| 编号 | 必须证明的行为 | 优先落点 | +| --- | --- | --- | +| R1 | 注册成功后 Has/Find/ListDefinitions/Catalog/Create 同指向一对 Definition+creator;目录有序且每类型唯一 | `test_catalog_contract_ssot.cpp` | +| R2 | 重复类型、非法 Definition、Control ID 冲突失败时原条目的描述和构造结果不变;相同 shared Control 契约仍成功 | `test_registry_conflict.cpp`、`test_definition_schema_validation.cpp` | +| R3 | Definition factory 标准/未知异常不会在 main 前终止;注册失败在 GlobalInit/Validator 可见,CLI catalog 非零退出并返回 REGISTRY_CONFLICT | 现有 `RegistryAuthoringStartup_*`、工具冲突 fixture | +| R4 | 每个注册分配失败点都不发布新条目,旧条目不变,失败标志不丢;诊断文字分配失败仍拒绝初始化 | `tests/support/scoped_allocation_failure.*` + 现有 Registry/Core suite | +| R5 | creator 调用、creator 复制、Definition callback 复制及日志 callback 重入 Registry 查询无自锁 | `test_registry_reentrant.cpp`;有界等待并设置 CTest 超时 | +| R6 | 并发同名注册只有一个成功,另一方失败锁存;并发不同类型相同 Control ID 的冲突必须在提交时发现 | Registry/Catalog suite,barrier 协调起点 | +| R7 | 并发读快照与新增注册只能得到完整条目;旧快照、Find 值副本在后续新增/测试静止期 reset 后保持有效 | `test_catalog_contract_ssot.cpp` | +| R8 | ResetNodes 不留下任一半;ResetBizs 不影响 Node/Model/Backend;作用域恢复静态集合不丢条目或失败状态 | 现有 Registry test fixture | +| R9 | Node 作者头可编译,新私有 helper 不泄露,Core 不依赖具体 Node,注册宏/函数式包装仍正常 | 现有层次头编译与 authoring fixture | + +R4 要枚举 fail-after-N,直至一次完整成功,注入范围只包住同步注册操作,GoogleTest +断言和查询在关闭故障后执行。每轮用干净隔离状态和已存在的哨兵条目,失败后检查新类型 +在 Registry/Catalog 均不可见、旧类型仍可创建;使用统计确认没有漏测注入和泄漏账本溢出。 +额外覆盖异常捕获后的诊断分配失败,不得只用“factory 一开始抛异常”代替提交失败证明。 +直接 Register 用例在注入前构造名称、Definition 和 CreatorFunc,再移动 creator 入参, +明确覆盖函数内部;完整作者表达式的求值/转换失败通过 RegisterWithDefinitionFactory +用例证明。不能把进入函数前的任意参数构造异常算作该 noexcept 函数已经捕获。 + +R6/R7 使用同步点和有界迭代,不靠 sleep 碰运气。不能通过三次独立 live 查询在并发注册 +期间强求相同时刻;按单次快照判断完整性,再在写线程结束后做全量目录一致性检查。 +现有不同职责 Registry 可以保留不同锁;验收不以“全工程只剩一把 mutex”为目标。 + +由于本次改变并发/所有权实现,实施时须在独立构建目录跑上述 Registry 聚焦用例的 +ThreadSanitizer 检查;使用测试 stub,无需真实模型。现有脚本可用下列命令运行包含相关 +Registry suites 的 `sanitizer-compatible` 快速集合(不是仅 Registry 的 regex 过滤): + +```bash +LLM_EDGEFLOW_SANITIZERS=thread \ +LLM_EDGEFLOW_SANITIZER_BUILD_DIR="$PWD/build-rfc0058-tsan" \ + ./scripts/run_sanitizers.sh --fast +``` + +核对新增用例仍属于实际执行集合,禁止传入脚本不支持的 `-R` 参数。若该环境无法运行, +记录准确原因并在可运行环境补齐,此项完成前不将 +实现标为 Completed。不能将 ASan 或普通通过当成数据竞争检查。 + +### 6.3 交付与删旧标准 + +实现进入最终验收前检查: + +- 活跃源码/测试不再引用 `PipelineErrorCode` 或两个旧转换函数;历史文档不要求删除。 +- 不存在 Catalog Node 写入口、`RegisteredNodes` 或 Node `creators_` 独立表。 +- C++ 修复策略不再通过裸 cause 字符串分支;JSON fixture 和 Python/JS 边界允许保留字符串。 +- 所有 Node snapshot/查询只来自完整 Entry;无单边清理,无生产路径清除失败标志。 +- 同一生产工具的 Catalog 节点名称和每个 Node Definition 的 JSON 与基线一致;差异必须有 + 独立理由,不把本次内部重构变成能力变化。测试注册仅用 `alg_pipeline_tool_test`。 + +开发中仅运行相关聚焦套件。最终按 [CONTRIBUTING.md](../../CONTRIBUTING.md#6-run-one-canonical-delivery-gate) +运行一次 `./scripts/run_all_tests.sh`,不在前后叠加默认完整构建或全量 CTest。需要的 TSan +使用不同目录,不与门禁竞争同一 build。门禁含文档和编译边界检查;本次纯文档交付的门禁 +结果不代表 R1–R9/D1–D7 的未来实现已经通过。无需真实模型或内网 SDK 验证本 RFC 的重构。 + +## 7. 实施顺序与最终结果 + +| 阶段 | 工作与退出条件 | 依赖 | +| --- | --- | --- | +| M0 固定证据 | 保存基线 Catalog JSON、无效配置诊断、相关测试清单;确认本文 44 个码/12 个原因和当前代码仍一致 | 采用本方案 | +| M1 精确诊断身份 | 新码表、parser/Validator/Build/Integration 迁移、删除旧映射及整数 fixture;D1–D4/D7 聚焦通过 | M0 | +| M2 修复原因 | 原因类型化、CLI 域边界 helper、Explain/authoring 消费迁移;D5–D6 通过且既有 JSON 结构保持 | M1 | +| M3 Node 单一状态 | 校验抽取、完整 Entry、事务提交、锁外回调、Catalog 委托、Validator 快照、test reset 一起切换;R1–R4/R8/R9 通过 | M0,可与 M1/M2 在独立文件所有权下推进 | +| M4 并发与整体契约 | 补齐 R5–R7、TSan、Catalog/CLI/SDK 回归;独立审阅异常保证、锁边界及单一事实源 | M1–M3 | +| M5 文档与交付 | 活跃指南/CHANGELOG、删旧搜索;按 CONTRIBUTING 准备本文与索引的完成状态进入单次 canonical gate,通过后确认完成,失败恢复 In Implementation | M4 | + +M1/M2 与 M3 都会触及 `pipeline_validator.cpp`,不得并行无协调修改;按责任划分或串行 +集成该文件。每个阶段可以独立提交可构建的变更,但 M3 的存储、Catalog 接线和旧入口删除 +必须同批完成,不能留下临时双写作为阶段交付。当前结果:B1/B2 重构已完整实施并验收完成,状态更新为 `Completed`。 +- **B1 统一诊断身份**:44 个 `DiagnosticCode` 和 12 个 `RemediationCause` 分别由 X-Macro 唯一单清单维护;彻底删除 `PipelineErrorCode` 与降级转换表;`BuildFromJson` 诊断透传无损;JSON 契约与 CLI 错误域边界显式对齐。 +- **B2 Node SSoT 与原子注册**:`NodeRegistry` 唯一持有 `EntryHandle`(Definition + creator);单条结构校验与跨节点 Control 冲突检测保证原子提交与失败锁存;`PipelineCatalog` 纯委托读取;通过 `RegistryTestAccess` 实现测试作用域隔离。 +- **验证记录**: + - D1–D7 诊断一致性与表驱动用例全部通过(`test_validated_pipeline_plan.cpp`、`test_pipeline_catalog_validator.cpp`)。 + - R1–R9 并发、callable 复制与执行重入、内存分配故障注入(单条注册、重复注册、Control 冲突、状态恢复无分配 swap)与生命周期测试全部通过(`test_catalog_contract_ssot.cpp`、`test_registry_reentrant.cpp`)。 + - ThreadSanitizer(TSan)数据竞争快速套件中与 Registry 相关的并发、重入、冲突与生命周期用例全部通过(0 data race;整体套件 91/92 通过,仅 DagPipelineTest 受现有非插桩 libstdc++ std::__exception_ptr 竞争影响)。 + - 本地预提交门禁 `./scripts/run_all_tests.sh` 运行通过(97/97 测试 100% 通过,全 4 个 Tier 全部绿色)。 diff --git a/doc/rfcs/README.md b/doc/rfcs/README.md index 8ba1ee3f..7a7f469d 100644 --- a/doc/rfcs/README.md +++ b/doc/rfcs/README.md @@ -83,6 +83,7 @@ RFC-0054 是接续 RFC-0052 与已交付 RFC-0053、RFC-0055 的实施规格。` | **RFC-0053** | 业务 Adapter 函数式作者接口与载体机制收敛 | `Completed` | 投产前 | 接入适配层 / Tooling / Docs | [0053-function-oriented-adapter-authoring.md](0053-function-oriented-adapter-authoring.md) | | **RFC-0055** | 批次关联、分组、选择回填与拆分公共工具 | `Completed` | 投产前 / Catalog v3 | 能力节点层 / Tooling / Docs | [0055-traceable-batch-operations.md](0055-traceable-batch-operations.md) | | **RFC-0056** | 投产前框架兼容入口与配置收口(第一批瘦身落地) | `Completed` | 投产前 | 四层 / Build / Tooling / Docs | [0056-batch-1-framework-slimming.md](0056-batch-1-framework-slimming.md) | +| **RFC-0058** | 投产前诊断身份与 Node 注册状态收敛 | `Completed` | 投产前 | 流程编排层、能力节点层、接入适配层 / Tooling | [0058-diagnostic-and-node-registry-convergence.md](0058-diagnostic-and-node-registry-convergence.md) | ## 专项验收与评审归档 diff --git a/include/core/diagnostic_code.h b/include/core/diagnostic_code.h new file mode 100644 index 00000000..9a8dee6b --- /dev/null +++ b/include/core/diagnostic_code.h @@ -0,0 +1,59 @@ +#pragma once + +namespace llm_edgeflow { + +#define LLM_EDGEFLOW_DIAGNOSTIC_CODES(X) \ + X(kOk, "OK") \ + X(kJsonParse, "JSON_PARSE") \ + X(kConfigFileOpen, "CONFIG_FILE_OPEN") \ + X(kRootType, "ROOT_TYPE") \ + X(kUnknownField, "UNKNOWN_FIELD") \ + X(kMissingField, "MISSING_FIELD") \ + X(kFieldType, "FIELD_TYPE") \ + X(kFieldRange, "FIELD_RANGE") \ + X(kInvalidCombination, "INVALID_COMBINATION") \ + X(kDuplicateModelId, "DUPLICATE_MODEL_ID") \ + X(kDuplicateNodeId, "DUPLICATE_NODE_ID") \ + X(kUnknownBiz, "UNKNOWN_BIZ") \ + X(kUnknownNodeType, "UNKNOWN_NODE_TYPE") \ + X(kUnknownModelType, "UNKNOWN_MODEL_TYPE") \ + X(kUnknownBackend, "UNKNOWN_BACKEND") \ + X(kModelCapabilityMismatch, "MODEL_CAPABILITY_MISMATCH") \ + X(kBackendProtocolMismatch, "BACKEND_PROTOCOL_MISMATCH") \ + X(kUnknownModelConfigField, "UNKNOWN_MODEL_CONFIG_FIELD") \ + X(kUnknownBackendConfigField, "UNKNOWN_BACKEND_CONFIG_FIELD") \ + X(kInvalidDependency, "INVALID_DEPENDENCY") \ + X(kDuplicateDependency, "DUPLICATE_DEPENDENCY") \ + X(kDagCycle, "DAG_CYCLE") \ + X(kRegistryConflict, "REGISTRY_CONFLICT") \ + X(kUnknownConfigField, "UNKNOWN_CONFIG_FIELD") \ + X(kMissingConfigField, "MISSING_CONFIG_FIELD") \ + X(kConfigFieldType, "CONFIG_FIELD_TYPE") \ + X(kConfigFieldRange, "CONFIG_FIELD_RANGE") \ + X(kConfigFieldEnum, "CONFIG_FIELD_ENUM") \ + X(kUnknownModelReference, "UNKNOWN_MODEL_REFERENCE") \ + X(kNodeBizMismatch, "NODE_BIZ_MISMATCH") \ + X(kMissingInputProducer, "MISSING_INPUT_PRODUCER") \ + X(kDuplicatePortProducer, "DUPLICATE_PORT_PRODUCER") \ + X(kMissingBizOutput, "MISSING_BIZ_OUTPUT") \ + X(kNodeNotParallelSafe, "NODE_NOT_PARALLEL_SAFE") \ + X(kParallelWriteConflict, "PARALLEL_WRITE_CONFLICT") \ + X(kSerializedModelConcurrency, "SERIALIZED_MODEL_CONCURRENCY") \ + X(kPortCardinalityMismatch, "PORT_CARDINALITY_MISMATCH") \ + X(kPortProvenanceMismatch, "PORT_PROVENANCE_MISMATCH") \ + X(kPortLifetimeMismatch, "PORT_LIFETIME_MISMATCH") \ + X(kInternalException, "INTERNAL_EXCEPTION") \ + X(kModelMaterializationFailed, "MODEL_MATERIALIZATION_FAILED") \ + X(kNodeCreateFailed, "NODE_CREATE_FAILED") \ + X(kNodeInitFailed, "NODE_INIT_FAILED") \ + X(kInvalidBuildState, "INVALID_BUILD_STATE") + +enum class DiagnosticCode { +#define LLM_EDGEFLOW_DEF_ENUM(name, str) name, + LLM_EDGEFLOW_DIAGNOSTIC_CODES(LLM_EDGEFLOW_DEF_ENUM) +#undef LLM_EDGEFLOW_DEF_ENUM +}; + +const char* DiagnosticCodeName(DiagnosticCode code) noexcept; + +} // namespace llm_edgeflow diff --git a/include/core/node_registry.h b/include/core/node_registry.h index bcf41185..bae7c9dd 100644 --- a/include/core/node_registry.h +++ b/include/core/node_registry.h @@ -1,9 +1,11 @@ #pragma once #include +#include #include #include #include +#include #include #include #include @@ -15,6 +17,16 @@ namespace llm_edgeflow { +namespace test_support { +class RegistryTestAccess; +} + +struct NodeRegistrySnapshot { + std::vector definitions; // node_type 升序,独立值副本 + bool has_conflict = false; + std::vector conflict_errors; +}; + class NodeRegistry { public: using CreatorFunc = std::function()>; @@ -52,87 +64,39 @@ class NodeRegistry { } } - std::unique_ptr Create(const std::string& node_type) const { - CreatorFunc creator; - { - std::lock_guard lock(mutex_); - auto it = creators_.find(node_type); - if (it == creators_.end()) return nullptr; - creator = it->second; - } - // R1-ACC-004: 锁外执行外部 creator,避免嵌套查询或构造导致自锁 - return creator(); - } + std::unique_ptr Create(const std::string& node_type) const; + bool Has(const std::string& node_type) const; + std::vector ListTypes() const; + bool HasConflict() const; + std::vector GetConflictErrors() const; - bool Has(const std::string& node_type) const { - std::lock_guard lock(mutex_); - return creators_.find(node_type) != creators_.end(); - } - - std::vector ListTypes() const { - std::lock_guard lock(mutex_); - std::vector result; - result.reserve(creators_.size()); - for (const auto& item : creators_) result.push_back(item.first); - std::sort(result.begin(), result.end()); - return result; - } - - bool HasConflict() const { - std::lock_guard lock(mutex_); - return has_conflict_; - } - - std::vector GetConflictErrors() const { - std::lock_guard lock(mutex_); - return conflict_errors_; - } + NodeRegistrySnapshot Snapshot() const; + std::optional Find(const std::string& node_type) const; + std::vector ListDefinitions() const; - void ClearForTesting() { - std::lock_guard lock(mutex_); - creators_.clear(); - has_conflict_ = false; - conflict_errors_.clear(); - } + private: + friend class test_support::RegistryTestAccess; - void ClearConflictForTesting() noexcept { - std::lock_guard lock(mutex_); - has_conflict_ = false; - conflict_errors_.clear(); - } + struct Entry { + NodeDefinition definition; + CreatorFunc creator; + }; + using EntryHandle = std::shared_ptr; - private: - void RecordRegistrationFailure(std::string_view message) noexcept { - try { - std::lock_guard lock(mutex_); - has_conflict_ = true; - conflict_errors_.emplace_back(message); - } catch (...) { - // Registration remains failed even if its diagnostic cannot be stored. - } - } + static bool CheckCrossNodeControlConflict( + const NodeDefinition& definition, + const std::unordered_map& entries, + std::string* error); + void RecordRegistrationFailure(std::string_view message) noexcept; void RecordRegistrationFailure(std::string_view prefix, std::string_view separator, - std::string_view message) noexcept { - try { - std::lock_guard lock(mutex_); - has_conflict_ = true; - std::string full; - full.reserve(prefix.size() + separator.size() + message.size()); - full.append(prefix); - full.append(separator); - full.append(message); - conflict_errors_.push_back(std::move(full)); - } catch (...) { - // Registration remains failed even if its diagnostic cannot be stored. - } - } + std::string_view message) noexcept; NodeRegistry() = default; mutable std::mutex mutex_; - std::unordered_map creators_; - bool has_conflict_ = false; + std::unordered_map entries_; + std::atomic has_conflict_{false}; std::vector conflict_errors_; }; diff --git a/include/core/pipeline_catalog.h b/include/core/pipeline_catalog.h index b4296d93..0b714d49 100644 --- a/include/core/pipeline_catalog.h +++ b/include/core/pipeline_catalog.h @@ -14,9 +14,15 @@ namespace llm_edgeflow { +namespace test_support { +class RegistryTestAccess; +} + struct PipelineCatalogSnapshot { std::vector nodes; std::vector bizs; + bool node_registry_has_conflict = false; + std::vector node_registry_errors; const NodeDefinition* FindNode(const std::string& node_type) const; const BizDefinition* FindBiz(const std::string& biz_name) const; @@ -24,8 +30,6 @@ struct PipelineCatalogSnapshot { class PipelineCatalog { public: - static bool RegisterNodeDefinition(const NodeDefinition& definition, - std::string* error = nullptr); static bool RegisterBizDefinition(const BizDefinition& definition); static bool RegisterBizDefinitions( const std::vector& definitions); @@ -43,12 +47,16 @@ class PipelineCatalog { const std::string& backend_type); static std::optional FindBiz(const std::string& biz_name); - static void ClearForTesting(); - + static nlohmann::json ToJson(const PipelineCatalogSnapshot& snapshot, + const std::string& biz_filter = std::string()); static nlohmann::json ToJson(const std::string& biz_filter = std::string()); static nlohmann::json NodeToJson(const NodeDefinition& definition); static nlohmann::json ModelToJson(const ModelDefinition& definition); static nlohmann::json BackendToJson(const BackendDefinition& definition); + + private: + friend class test_support::RegistryTestAccess; + static void ResetBizsForTesting(); }; } // namespace llm_edgeflow diff --git a/include/core/pipeline_diagnostic.h b/include/core/pipeline_diagnostic.h index 38a259cf..612110a0 100644 --- a/include/core/pipeline_diagnostic.h +++ b/include/core/pipeline_diagnostic.h @@ -2,48 +2,22 @@ #include -namespace llm_edgeflow { +#include "core/diagnostic_code.h" -/** - * @brief 结构化错误诊断码 (PipelineErrorCode) - */ -enum class PipelineErrorCode { - kOk = 0, - kJsonParse, - kConfigFileOpen, - kRootType, - kUnknownField, - kMissingField, - kFieldType, - kFieldRange, - kInvalidCombination, - kDuplicateModelId, - kDuplicateNodeId, - kUnknownNodeType, - kUnknownModelType, - kUnknownBackend, - kInvalidDependency, - kDagCycle, - kRegistryConflict, - kModelMaterializationFailed, - kNodeCreateFailed, - kNodeInitFailed, - kInternalException, - kInvalidBuildState, -}; +namespace llm_edgeflow { /** * @brief 轻量级结构化诊断信息 (PipelineDiagnostic) */ struct PipelineDiagnostic { - PipelineErrorCode code = PipelineErrorCode::kOk; + DiagnosticCode code = DiagnosticCode::kOk; std::string path; std::string message; - bool IsOk() const { return code == PipelineErrorCode::kOk; } + bool IsOk() const { return code == DiagnosticCode::kOk; } void Clear() { - code = PipelineErrorCode::kOk; + code = DiagnosticCode::kOk; path.clear(); message.clear(); } diff --git a/include/core/pipeline_validator.h b/include/core/pipeline_validator.h index e748e6be..663b2515 100644 --- a/include/core/pipeline_validator.h +++ b/include/core/pipeline_validator.h @@ -6,7 +6,9 @@ #include #include +#include "core/diagnostic_code.h" #include "core/pipeline_config.h" +#include "core/remediation_cause.h" #include "core/validated_node_plan.h" #include "engine/inference_definition.h" @@ -17,51 +19,6 @@ enum class ValidationPolicy { kPrivateExtensionCompatible, }; -enum class DiagnosticCode { - kOk, - kJsonParse, - kConfigFileOpen, - kRootType, - kUnknownField, - kMissingField, - kFieldType, - kFieldRange, - kInvalidCombination, - kDuplicateModelId, - kDuplicateNodeId, - kUnknownBiz, - kUnknownNodeType, - kUnknownModelType, - kUnknownBackend, - kModelCapabilityMismatch, - kBackendProtocolMismatch, - kUnknownModelConfigField, - kUnknownBackendConfigField, - kInvalidDependency, - kDuplicateDependency, - kDagCycle, - kRegistryConflict, - kUnknownConfigField, - kMissingConfigField, - kConfigFieldType, - kConfigFieldRange, - kConfigFieldEnum, - kUnknownModelReference, - kNodeBizMismatch, - kMissingInputProducer, - kDuplicatePortProducer, - kMissingBizOutput, - kNodeNotParallelSafe, - kParallelWriteConflict, - kSerializedModelConcurrency, - kPortCardinalityMismatch, - kPortProvenanceMismatch, - kPortLifetimeMismatch, - kInternalException, -}; - -const char* DiagnosticCodeName(DiagnosticCode code) noexcept; - struct ValidationFix { std::string id; std::string title; @@ -74,7 +31,7 @@ struct ValidationFix { struct ValidationRemediation { int schema_version = 1; - std::string cause; + RemediationCause cause = RemediationCause::kUnknownConfigField; std::string summary; nlohmann::json facts = nlohmann::json::object(); std::vector fixes; diff --git a/include/core/remediation_cause.h b/include/core/remediation_cause.h new file mode 100644 index 00000000..f443ea38 --- /dev/null +++ b/include/core/remediation_cause.h @@ -0,0 +1,27 @@ +#pragma once + +namespace llm_edgeflow { + +#define LLM_EDGEFLOW_REMEDIATION_CAUSES(X) \ + X(kUnknownConfigField, "unknown_config_field") \ + X(kMissingConfigField, "missing_config_field") \ + X(kInvalidConfigValue, "invalid_config_value") \ + X(kUnknownModelReference, "unknown_model_reference") \ + X(kModelCapabilityMismatch, "model_capability_mismatch") \ + X(kProducerNotDependencyAncestor, "producer_not_dependency_ancestor") \ + X(kPortTypeMismatch, "port_type_mismatch") \ + X(kNoCompatibleInputSource, "no_compatible_input_source") \ + X(kDuplicateDependency, "duplicate_dependency") \ + X(kUnknownDependency, "unknown_dependency") \ + X(kMissingBizOutput, "missing_biz_output") \ + X(kPortFlowMismatch, "port_flow_mismatch") + +enum class RemediationCause { +#define LLM_EDGEFLOW_DEF_CAUSE(name, str) name, + LLM_EDGEFLOW_REMEDIATION_CAUSES(LLM_EDGEFLOW_DEF_CAUSE) +#undef LLM_EDGEFLOW_DEF_CAUSE +}; + +const char* RemediationCauseName(RemediationCause cause) noexcept; + +} // namespace llm_edgeflow diff --git a/scripts/run_sanitizers.sh b/scripts/run_sanitizers.sh index f0f592fb..cd2ef545 100755 --- a/scripts/run_sanitizers.sh +++ b/scripts/run_sanitizers.sh @@ -145,7 +145,7 @@ export LLM_EDGEFLOW_PIPELINE_TOOL="${BUILD_DIR}/alg_pipeline_tool" export LLM_EDGEFLOW_DEMO_BINARY="${BUILD_DIR}/alg_demo" ARCH_PREFIX=() -if [[ "${SANITIZERS}" == *"thread"* ]] && [[ "$(uname -s)" == "Linux" ]] && [[ "$(uname -m)" == "aarch64" ]]; then +if [[ "${SANITIZERS}" == *"thread"* ]] && [[ "$(uname -s)" == "Linux" ]]; then ARCH_PREFIX=(setarch "$(uname -m)" -R) fi diff --git a/src/adapter/shared_algorithm_runtime.cpp b/src/adapter/shared_algorithm_runtime.cpp index 2e91835a..1571a403 100644 --- a/src/adapter/shared_algorithm_runtime.cpp +++ b/src/adapter/shared_algorithm_runtime.cpp @@ -9,6 +9,7 @@ #include "adapter/operator/operator_value_type_registry.h" #include "contracts/diagnostic.h" #include "core/alg_context.h" +#include "core/diagnostic_code.h" #include "core/node_registry.h" #include "core/session_context.h" #include "edgeflow/log.h" @@ -164,10 +165,10 @@ int SharedAlgorithmRuntime::CreateFromConfigFile( if (out_error) { *out_error = "Failed to build pipeline from config: " + diagnostic.message + - " (code: " + std::to_string(static_cast(diagnostic.code)) + + " (code: " + std::string(DiagnosticCodeName(diagnostic.code)) + ", path: " + diagnostic.path + ")"; } - if (diagnostic.code == PipelineErrorCode::kRegistryConflict) { + if (diagnostic.code == DiagnosticCode::kRegistryConflict) { return COMPANY_ALG_ERR_REGISTRY_CONFLICT; // -6 } // 保持 main 既有纯 C ABI 契约:只要 BuildFromConfigFile 失败, @@ -253,10 +254,10 @@ int SharedAlgorithmRuntime::CreateFromPipelineJson( if (out_error) { *out_error = "Failed to build pipeline from JSON: " + diagnostic.message + - " (code: " + std::to_string(static_cast(diagnostic.code)) + + " (code: " + std::string(DiagnosticCodeName(diagnostic.code)) + ", path: " + diagnostic.path + ")"; } - if (diagnostic.code == PipelineErrorCode::kRegistryConflict) { + if (diagnostic.code == DiagnosticCode::kRegistryConflict) { return COMPANY_ALG_ERR_REGISTRY_CONFLICT; // -6 } return COMPANY_ALG_ERR_INVALID_PARAM; // -2 diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 3399e172..d948af3e 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -1,6 +1,9 @@ target_sources(edgeflow_orchestration_objects PRIVATE + diagnostic_code.cpp + node_definition_validation.cpp node_registry.cpp pipeline.cpp pipeline_catalog.cpp pipeline_config.cpp - pipeline_validator.cpp) + pipeline_validator.cpp + remediation_cause.cpp) diff --git a/src/core/diagnostic_code.cpp b/src/core/diagnostic_code.cpp new file mode 100644 index 00000000..84d1394d --- /dev/null +++ b/src/core/diagnostic_code.cpp @@ -0,0 +1,16 @@ +#include "core/diagnostic_code.h" + +namespace llm_edgeflow { + +const char* DiagnosticCodeName(DiagnosticCode code) noexcept { + switch (code) { +#define LLM_EDGEFLOW_CASE(name, str) \ + case DiagnosticCode::name: \ + return str; + LLM_EDGEFLOW_DIAGNOSTIC_CODES(LLM_EDGEFLOW_CASE) +#undef LLM_EDGEFLOW_CASE + } + return "UNKNOWN"; +} + +} // namespace llm_edgeflow diff --git a/src/core/node_definition_validation.cpp b/src/core/node_definition_validation.cpp new file mode 100644 index 00000000..80bf33d3 --- /dev/null +++ b/src/core/node_definition_validation.cpp @@ -0,0 +1,144 @@ +#include "core/node_definition_validation.h" + +#include +#include + +#include "contracts/config_schema_validation.h" +#include "contracts/control_payload.h" + +namespace llm_edgeflow { + +const std::unordered_set& ValidCardinalities() { + static const std::unordered_set kValidCardinalities = { + "1:1", "1:N", "N:1", "N:M"}; + return kValidCardinalities; +} + +const std::unordered_set& ValidProvenance() { + static const std::unordered_set kValidProvenance = { + "preserve", "generate_sub_id", "aggregate", "independent"}; + return kValidProvenance; +} + +const std::unordered_set& ValidLifetimes() { + static const std::unordered_set kValidLifetimes = { + "request", "session", "global"}; + return kValidLifetimes; +} + +bool ValidateNodeDefinitionStructure(const NodeDefinition& definition, + std::string* error) { + if (error) { + *error = "Invalid or duplicate NodeDefinition: " + definition.node_type; + } + if (definition.node_type.empty()) return false; + + std::unordered_set seen_in_ports; + if (!ValidatePortDefinitions(definition.inputs, &seen_in_ports, error)) { + return false; + } + std::unordered_set seen_out_ports; + if (!ValidatePortDefinitions(definition.outputs, &seen_out_ports, error)) { + return false; + } + for (const auto& constraint : definition.port_constraints) { + if (constraint.kind == PortConstraintKind::kExactOneGroupOf) { + if (constraint.port_groups.empty()) return false; + for (const auto& group : constraint.port_groups) { + if (group.empty()) return false; + for (const auto& p : group) { + if (!seen_in_ports.count(p) && !seen_out_ports.count(p)) return false; + } + } + } else { + if (constraint.ports.empty()) return false; + for (const auto& p : constraint.ports) { + if (!seen_in_ports.count(p) && !seen_out_ports.count(p)) return false; + } + } + } + std::unordered_set seen_cmd_ids; + std::unordered_set seen_cmd_names; + for (const auto& cmd : definition.control_commands) { + if (cmd.cmd_id <= 0 || cmd.name.empty()) return false; + std::string schema_error; + if (!ValidateControlSchema(cmd.payload_schema, &schema_error)) { + if (error) { + *error = "Node '" + definition.node_type + "', Control " + + std::to_string(cmd.cmd_id) + " ('" + cmd.name + + "'): " + schema_error; + } + return false; + } + if (!seen_cmd_ids.insert(cmd.cmd_id).second) return false; + if (!seen_cmd_names.insert(cmd.name).second) return false; + } + std::string field_err; + if (!ValidateConfigFieldDefinitions(definition.config_fields, &field_err)) { + if (error) *error = field_err; + return false; + } + const auto validates_lifetime_override = [&](const NodePortDefinition& port) { + if (port.lifetime_config_field.empty()) return true; + auto it = std::find_if( + definition.config_fields.begin(), definition.config_fields.end(), + [&](const auto& f) { return f.name == port.lifetime_config_field; }); + if (it == definition.config_fields.end() || + it->kind != ConfigValueKind::kString || it->enum_values.empty()) { + return false; + } + return std::all_of( + it->enum_values.begin(), it->enum_values.end(), + [&](const auto& value) { return ValidLifetimes().count(value) != 0; }); + }; + if (!std::all_of(definition.inputs.begin(), definition.inputs.end(), + validates_lifetime_override) || + !std::all_of(definition.outputs.begin(), definition.outputs.end(), + validates_lifetime_override)) { + return false; + } + std::unordered_set seen_dep_names; + std::unordered_set seen_dep_config_fields; + for (const auto& dep : definition.model_dependencies) { + if (dep.name.empty() || dep.capability.empty() || + dep.config_field.empty()) { + if (error) { + *error = + "Model dependency name, capability, and config_field must be " + "non-empty"; + } + return false; + } + if (!seen_dep_names.insert(dep.name).second) { + if (error) *error = "Duplicate model dependency name: " + dep.name; + return false; + } + if (!seen_dep_config_fields.insert(dep.config_field).second) { + if (error) { + *error = "Duplicate model dependency config_field: " + dep.config_field; + } + return false; + } + auto it = std::find_if( + definition.config_fields.begin(), definition.config_fields.end(), + [&](const auto& f) { return f.name == dep.config_field; }); + if (it == definition.config_fields.end()) { + if (error) { + *error = "Model dependency config_field '" + dep.config_field + + "' not found in config_fields"; + } + return false; + } + if (it->kind != ConfigValueKind::kString) { + if (error) { + *error = "Model dependency config_field '" + dep.config_field + + "' must be of string kind"; + } + return false; + } + } + if (error) error->clear(); + return true; +} + +} // namespace llm_edgeflow diff --git a/src/core/node_definition_validation.h b/src/core/node_definition_validation.h new file mode 100644 index 00000000..8ce8e326 --- /dev/null +++ b/src/core/node_definition_validation.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +#include "core/node_definition.h" + +namespace llm_edgeflow { + +const std::unordered_set& ValidCardinalities(); +const std::unordered_set& ValidProvenance(); +const std::unordered_set& ValidLifetimes(); + +template +bool ValidatePortDefinitions(const std::vector& ports, + std::unordered_set* seen_keys, + std::string* error) { + for (const auto& port : ports) { + if (port.Name().empty()) { + if (error) *error = "Port key cannot be empty"; + return false; + } + if (port.type_id.empty()) { + if (error) + *error = "Port type_id cannot be empty for port: " + port.Name(); + return false; + } + if (!ValidCardinalities().count(port.cardinality)) { + if (error) { + *error = "Invalid port cardinality '" + port.cardinality + + "' in port: " + port.Name(); + } + return false; + } + if (!ValidProvenance().count(port.provenance_policy)) { + if (error) { + *error = "Invalid port provenance policy '" + port.provenance_policy + + "' in port: " + port.Name(); + } + return false; + } + if (!ValidLifetimes().count(port.lifetime)) { + if (error) { + *error = "Invalid port lifetime '" + port.lifetime + + "' in port: " + port.Name(); + } + return false; + } + if (seen_keys && !seen_keys->insert(port.Name()).second) { + if (error) *error = "Duplicate port key: " + port.Name(); + return false; + } + } + return true; +} + +bool ValidateNodeDefinitionStructure(const NodeDefinition& definition, + std::string* error = nullptr); + +} // namespace llm_edgeflow diff --git a/src/core/node_registry.cpp b/src/core/node_registry.cpp index 72d5f1c8..79b31af2 100644 --- a/src/core/node_registry.cpp +++ b/src/core/node_registry.cpp @@ -1,60 +1,302 @@ #include "core/node_registry.h" -#include "core/pipeline_catalog.h" +#include +#include + +#include "core/node_definition_validation.h" namespace llm_edgeflow { +bool NodeRegistry::CheckCrossNodeControlConflict( + const NodeDefinition& definition, + const std::unordered_map& entries, + std::string* error) { + const std::string* best_conflict_node = nullptr; + int best_conflict_cmd_id = 0; + bool found_conflict = false; + + for (const auto& [existing_type, entry] : entries) { + if (!entry) continue; + const auto& existing_def = entry->definition; + for (const auto& command : definition.control_commands) { + for (const auto& registered : existing_def.control_commands) { + if (command.cmd_id != registered.cmd_id) continue; + if (!command.shared_id || !registered.shared_id || + command.name != registered.name || + command.payload_schema != registered.payload_schema || + command.supports_hot_swap != registered.supports_hot_swap) { + if (!found_conflict || existing_def.node_type < *best_conflict_node) { + found_conflict = true; + best_conflict_node = &existing_def.node_type; + best_conflict_cmd_id = command.cmd_id; + } + } + } + } + } + + if (found_conflict) { + if (error) { + try { + *error = "Control ID " + std::to_string(best_conflict_cmd_id) + + " conflicts between " + + (best_conflict_node ? *best_conflict_node : "") + " and " + + definition.node_type + + "; shared commands require shared_id and identical contracts"; + } catch (...) { + } + } + return false; + } + return true; +} + bool NodeRegistry::Register(const std::string& node_type, CreatorFunc creator, const NodeDefinition* definition) noexcept { + EntryHandle candidate; try { + std::string failure_msg; + std::string_view fallback_msg; + bool has_failure = false; + if (definition == nullptr) { - std::lock_guard lock(mutex_); - has_conflict_ = true; - conflict_errors_.push_back( - "Node registration requires a valid Definition: " + node_type); - return false; + has_failure = true; + fallback_msg = "Node registration requires a valid Definition"; + try { + failure_msg = + "Node registration requires a valid Definition: " + node_type; + } catch (...) { + } + } else if (definition->node_type != node_type) { + has_failure = true; + fallback_msg = "NodeDefinition node_type mismatch"; + try { + failure_msg = "NodeDefinition node_type mismatch: expected " + + node_type + ", got " + definition->node_type; + } catch (...) { + } + } else if (node_type.empty() || !creator) { + has_failure = true; + fallback_msg = "Empty node_type or null creator function"; + failure_msg = fallback_msg; + } else { + std::string schema_error; + if (!ValidateNodeDefinitionStructure(*definition, &schema_error)) { + has_failure = true; + fallback_msg = "Invalid NodeDefinition"; + if (!schema_error.empty()) { + failure_msg = std::move(schema_error); + } else { + try { + failure_msg = "Invalid NodeDefinition: " + node_type; + } catch (...) { + } + } + } } - if (definition->node_type != node_type) { - std::lock_guard lock(mutex_); - has_conflict_ = true; - conflict_errors_.push_back( - "NodeDefinition node_type mismatch: expected " + node_type + - ", got " + definition->node_type); + + if (has_failure) { + has_conflict_.store(true, std::memory_order_release); + std::string_view msg = + failure_msg.empty() ? fallback_msg : std::string_view(failure_msg); + if (!msg.empty()) { + try { + ALG_LOG_ERROR("[NodeRegistry] %.*s\n", static_cast(msg.size()), + msg.data()); + } catch (...) { + } + } + RecordRegistrationFailure(msg); return false; } - if (node_type.empty() || !creator) { + + candidate = + std::make_shared(Entry{*definition, std::move(creator)}); + + bool duplicate = false; + bool control_conflict = false; + std::string control_error; + { std::lock_guard lock(mutex_); - has_conflict_ = true; - conflict_errors_.push_back("Empty node_type or null creator function"); - return false; + auto it = entries_.find(node_type); + if (it != entries_.end()) { + duplicate = true; + has_conflict_.store(true, std::memory_order_release); + } else if (!CheckCrossNodeControlConflict(candidate->definition, entries_, + &control_error)) { + control_conflict = true; + has_conflict_.store(true, std::memory_order_release); + } else { + entries_.try_emplace(node_type, candidate); + return true; + } } - std::lock_guard lock(mutex_); - auto it = creators_.find(node_type); - if (it != creators_.end()) { - has_conflict_ = true; - conflict_errors_.push_back("Duplicate node registration for type: " + - node_type); - ALG_LOG_ERROR("[NodeRegistry] Duplicate node registration: %s\n", - node_type.c_str()); + + if (duplicate) { + has_conflict_.store(true, std::memory_order_release); + std::string duplicate_msg; + try { + duplicate_msg = "Duplicate node registration for type: " + node_type; + } catch (...) { + } + std::string_view msg = + duplicate_msg.empty() + ? std::string_view("Duplicate node registration") + : std::string_view(duplicate_msg); + try { + ALG_LOG_ERROR("[NodeRegistry] %.*s\n", static_cast(msg.size()), + msg.data()); + } catch (...) { + } + RecordRegistrationFailure(msg); return false; } - std::string definition_error; - if (!PipelineCatalog::RegisterNodeDefinition(*definition, - &definition_error)) { - has_conflict_ = true; - ALG_LOG_ERROR("[NodeRegistry] %s\n", definition_error.c_str()); - conflict_errors_.push_back(std::move(definition_error)); + + if (control_conflict) { + has_conflict_.store(true, std::memory_order_release); + std::string_view msg = + control_error.empty() + ? std::string_view("Cross-node control conflict") + : std::string_view(control_error); + try { + ALG_LOG_ERROR("[NodeRegistry] %.*s\n", static_cast(msg.size()), + msg.data()); + } catch (...) { + } + RecordRegistrationFailure(msg); return false; } - creators_[node_type] = std::move(creator); + return true; } catch (const std::exception& e) { - RecordRegistrationFailure(e.what()); + has_conflict_.store(true, std::memory_order_release); + const char* what_str = e.what(); + std::string_view msg = (what_str != nullptr) + ? std::string_view(what_str) + : std::string_view("std::exception"); + try { + ALG_LOG_ERROR("[NodeRegistry] %.*s\n", static_cast(msg.size()), + msg.data()); + } catch (...) { + } + RecordRegistrationFailure(msg); return false; } catch (...) { - RecordRegistrationFailure("Unknown exception registering node"); + has_conflict_.store(true, std::memory_order_release); + try { + ALG_LOG_ERROR( + "[NodeRegistry] Unknown exception during node registration\n"); + } catch (...) { + } + RecordRegistrationFailure("Unknown exception during node registration"); return false; } } +std::unique_ptr NodeRegistry::Create( + const std::string& node_type) const { + EntryHandle handle; + { + std::lock_guard lock(mutex_); + auto it = entries_.find(node_type); + if (it == entries_.end() || !it->second) return nullptr; + handle = it->second; + } + CreatorFunc creator = handle->creator; + if (!creator) return nullptr; + return creator(); +} + +bool NodeRegistry::Has(const std::string& node_type) const { + std::lock_guard lock(mutex_); + return entries_.find(node_type) != entries_.end(); +} + +std::vector NodeRegistry::ListTypes() const { + std::lock_guard lock(mutex_); + std::vector result; + result.reserve(entries_.size()); + for (const auto& item : entries_) result.push_back(item.first); + std::sort(result.begin(), result.end()); + return result; +} + +bool NodeRegistry::HasConflict() const { + return has_conflict_.load(std::memory_order_acquire); +} + +std::vector NodeRegistry::GetConflictErrors() const { + std::lock_guard lock(mutex_); + return conflict_errors_; +} + +NodeRegistrySnapshot NodeRegistry::Snapshot() const { + std::vector handles; + bool conflict = false; + std::vector errors; + { + std::lock_guard lock(mutex_); + handles.reserve(entries_.size()); + for (const auto& pair : entries_) { + handles.push_back(pair.second); + } + conflict = has_conflict_.load(std::memory_order_acquire); + errors = conflict_errors_; + } + + std::vector defs; + defs.reserve(handles.size()); + for (const auto& handle : handles) { + if (handle) { + defs.push_back(handle->definition); + } + } + std::sort(defs.begin(), defs.end(), [](const auto& a, const auto& b) { + return a.node_type < b.node_type; + }); + return {std::move(defs), conflict, std::move(errors)}; +} + +std::optional NodeRegistry::Find( + const std::string& node_type) const { + EntryHandle handle; + { + std::lock_guard lock(mutex_); + auto it = entries_.find(node_type); + if (it == entries_.end() || !it->second) return std::nullopt; + handle = it->second; + } + return handle->definition; +} + +std::vector NodeRegistry::ListDefinitions() const { + return Snapshot().definitions; +} + +void NodeRegistry::RecordRegistrationFailure( + std::string_view message) noexcept { + has_conflict_.store(true, std::memory_order_release); + try { + std::lock_guard lock(mutex_); + conflict_errors_.emplace_back(message); + } catch (...) { + } +} + +void NodeRegistry::RecordRegistrationFailure( + std::string_view prefix, std::string_view separator, + std::string_view message) noexcept { + has_conflict_.store(true, std::memory_order_release); + try { + std::string full; + full.reserve(prefix.size() + separator.size() + message.size()); + full.append(prefix); + full.append(separator); + full.append(message); + std::lock_guard lock(mutex_); + conflict_errors_.push_back(std::move(full)); + } catch (...) { + } +} + } // namespace llm_edgeflow diff --git a/src/core/pipeline.cpp b/src/core/pipeline.cpp index 5a520c6f..e2b104db 100644 --- a/src/core/pipeline.cpp +++ b/src/core/pipeline.cpp @@ -18,72 +18,6 @@ namespace llm_edgeflow { namespace { -PipelineErrorCode ValidationCodeToPipelineCode(DiagnosticCode code) { - switch (code) { - case DiagnosticCode::kOk: - return PipelineErrorCode::kOk; - case DiagnosticCode::kJsonParse: - return PipelineErrorCode::kJsonParse; - case DiagnosticCode::kConfigFileOpen: - return PipelineErrorCode::kConfigFileOpen; - case DiagnosticCode::kRootType: - return PipelineErrorCode::kRootType; - case DiagnosticCode::kUnknownField: - case DiagnosticCode::kUnknownConfigField: - return PipelineErrorCode::kUnknownField; - case DiagnosticCode::kMissingField: - case DiagnosticCode::kMissingConfigField: - return PipelineErrorCode::kMissingField; - case DiagnosticCode::kFieldType: - case DiagnosticCode::kConfigFieldType: - return PipelineErrorCode::kFieldType; - case DiagnosticCode::kFieldRange: - case DiagnosticCode::kConfigFieldRange: - return PipelineErrorCode::kFieldRange; - case DiagnosticCode::kInvalidCombination: - case DiagnosticCode::kConfigFieldEnum: - case DiagnosticCode::kUnknownBiz: - case DiagnosticCode::kUnknownModelReference: - case DiagnosticCode::kModelCapabilityMismatch: - case DiagnosticCode::kNodeBizMismatch: - case DiagnosticCode::kMissingInputProducer: - case DiagnosticCode::kDuplicatePortProducer: - case DiagnosticCode::kMissingBizOutput: - case DiagnosticCode::kNodeNotParallelSafe: - case DiagnosticCode::kParallelWriteConflict: - case DiagnosticCode::kSerializedModelConcurrency: - case DiagnosticCode::kPortCardinalityMismatch: - case DiagnosticCode::kPortProvenanceMismatch: - case DiagnosticCode::kPortLifetimeMismatch: - return PipelineErrorCode::kInvalidCombination; - case DiagnosticCode::kDuplicateModelId: - return PipelineErrorCode::kDuplicateModelId; - case DiagnosticCode::kDuplicateNodeId: - return PipelineErrorCode::kDuplicateNodeId; - case DiagnosticCode::kUnknownNodeType: - return PipelineErrorCode::kUnknownNodeType; - case DiagnosticCode::kUnknownModelType: - return PipelineErrorCode::kUnknownModelType; - case DiagnosticCode::kUnknownBackend: - return PipelineErrorCode::kUnknownBackend; - case DiagnosticCode::kBackendProtocolMismatch: - return PipelineErrorCode::kInvalidCombination; - case DiagnosticCode::kUnknownModelConfigField: - case DiagnosticCode::kUnknownBackendConfigField: - return PipelineErrorCode::kUnknownField; - case DiagnosticCode::kInvalidDependency: - case DiagnosticCode::kDuplicateDependency: - return PipelineErrorCode::kInvalidDependency; - case DiagnosticCode::kDagCycle: - return PipelineErrorCode::kDagCycle; - case DiagnosticCode::kRegistryConflict: - return PipelineErrorCode::kRegistryConflict; - case DiagnosticCode::kInternalException: - return PipelineErrorCode::kInternalException; - } - return PipelineErrorCode::kInvalidCombination; -} - struct RuntimeAssembly { std::unique_ptr plan; std::unique_ptr session; @@ -114,10 +48,34 @@ bool MaterializeModels(const ValidatedPipelinePlan& plan, spec.execution_target.platform = runtime_options.chip_type; std::string factory_diag; - auto model = ModelRuntimeFactory::Create(spec, &factory_diag); + std::shared_ptr model; + try { + model = ModelRuntimeFactory::Create(spec, &factory_diag); + } catch (const std::exception& e) { + if (diagnostic) { + diagnostic->code = DiagnosticCode::kModelMaterializationFailed; + diagnostic->path = "/models/" + std::to_string(model_plan.source_index); + diagnostic->message = "Exception creating model '" + + model_plan.model_id + "': " + e.what(); + } + ALG_LOG_ERROR("[Pipeline] Exception creating model [%s]: %s\n", + model_plan.model_id.c_str(), e.what()); + return false; + } catch (...) { + if (diagnostic) { + diagnostic->code = DiagnosticCode::kModelMaterializationFailed; + diagnostic->path = "/models/" + std::to_string(model_plan.source_index); + diagnostic->message = + "Unknown exception creating model '" + model_plan.model_id + "'"; + } + ALG_LOG_ERROR("[Pipeline] Unknown exception creating model [%s]\n", + model_plan.model_id.c_str()); + return false; + } + if (!model) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kModelMaterializationFailed; + diagnostic->code = DiagnosticCode::kModelMaterializationFailed; diagnostic->path = "/models/" + std::to_string(model_plan.source_index); diagnostic->message = "ModelRuntimeFactory failed to load model: " + model_plan.model_id + @@ -143,7 +101,7 @@ bool MaterializeModels(const ValidatedPipelinePlan& plan, if (!session->GetModelManager().RegisterBatch(staged_models)) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kDuplicateModelId; + diagnostic->code = DiagnosticCode::kDuplicateModelId; diagnostic->path = "/models"; diagnostic->message = "Failed to atomically register batch models in ModelManager"; @@ -181,7 +139,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, auto plan_it = plan.node_plans.find(node_id); if (plan_it == plan.node_plans.end()) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kInternalException; + diagnostic->code = DiagnosticCode::kInternalException; diagnostic->path = "/pipeline"; diagnostic->message = "Validated plan is missing node materialization data: " + node_id; @@ -196,7 +154,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, node = NodeRegistry::Instance().Create(node_config.node_type); } catch (const std::exception& e) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeCreateFailed; + diagnostic->code = DiagnosticCode::kNodeCreateFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/node_type"; @@ -206,7 +164,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, return false; } catch (...) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeCreateFailed; + diagnostic->code = DiagnosticCode::kNodeCreateFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/node_type"; @@ -218,7 +176,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, if (!node) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeCreateFailed; + diagnostic->code = DiagnosticCode::kNodeCreateFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/node_type"; @@ -241,7 +199,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, init_ok = node->Init(init_ctx); } catch (const std::exception& e) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeInitFailed; + diagnostic->code = DiagnosticCode::kNodeInitFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/config"; @@ -251,7 +209,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, return false; } catch (...) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeInitFailed; + diagnostic->code = DiagnosticCode::kNodeInitFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/config"; @@ -263,7 +221,7 @@ bool MaterializeNodes(RuntimeAssembly* assembly, if (!init_ok) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kNodeInitFailed; + diagnostic->code = DiagnosticCode::kNodeInitFailed; diagnostic->path = "/pipeline/" + std::to_string(node_config.source_index) + "/config"; @@ -351,7 +309,7 @@ bool Pipeline::BuildFromConfigFile(const std::string& config_file_path, // R1-ACC-002: 一次性构建状态检查 if (state_ != State::kEmpty) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kInvalidBuildState; + diagnostic->code = DiagnosticCode::kInvalidBuildState; diagnostic->path = "/"; diagnostic->message = "Pipeline build can only be attempted once on an empty Pipeline " @@ -367,7 +325,7 @@ bool Pipeline::BuildFromConfigFile(const std::string& config_file_path, if (!ifs.is_open()) { state_ = State::kFailed; if (diagnostic) { - diagnostic->code = PipelineErrorCode::kConfigFileOpen; + diagnostic->code = DiagnosticCode::kConfigFileOpen; diagnostic->path = "/"; diagnostic->message = "Failed to open config file: " + config_file_path; } @@ -383,7 +341,7 @@ bool Pipeline::BuildFromConfigFile(const std::string& config_file_path, } catch (const std::exception& e) { state_ = State::kFailed; if (diagnostic) { - diagnostic->code = PipelineErrorCode::kJsonParse; + diagnostic->code = DiagnosticCode::kJsonParse; diagnostic->path = "/"; diagnostic->message = std::string("JSON parse exception in ") + config_file_path + ": " + e.what(); @@ -406,7 +364,7 @@ bool Pipeline::BuildFromJson(const nlohmann::json& root_config, // R1-ACC-002: 一次性构建状态检查 if (state_ != State::kEmpty) { if (diagnostic) { - diagnostic->code = PipelineErrorCode::kInvalidBuildState; + diagnostic->code = DiagnosticCode::kInvalidBuildState; diagnostic->path = "/"; diagnostic->message = "Pipeline build can only be attempted once on an empty Pipeline " @@ -438,7 +396,7 @@ bool Pipeline::BuildFromJson(const nlohmann::json& root_config, } catch (const std::exception& e) { success = false; if (diagnostic) { - diagnostic->code = PipelineErrorCode::kInternalException; + diagnostic->code = DiagnosticCode::kInternalException; diagnostic->path = "/"; diagnostic->message = std::string("Internal exception during pipeline build: ") + e.what(); @@ -449,7 +407,7 @@ bool Pipeline::BuildFromJson(const nlohmann::json& root_config, } catch (...) { success = false; if (diagnostic) { - diagnostic->code = PipelineErrorCode::kInternalException; + diagnostic->code = DiagnosticCode::kInternalException; diagnostic->path = "/"; diagnostic->message = "Unknown internal exception during pipeline build"; } @@ -480,12 +438,16 @@ bool Pipeline::BuildInternal(const nlohmann::json& root_config, const auto& item = assembly.plan->report.diagnostics.front(); const char* code_str = DiagnosticCodeName(item.code); if (diagnostic) { - diagnostic->code = ValidationCodeToPipelineCode(item.code); + diagnostic->code = item.code; diagnostic->path = item.path; - diagnostic->message = std::string(code_str) + ": " + item.message; + diagnostic->message = item.message; } ALG_LOG_ERROR("[Pipeline] Validation failed: %s at %s: %s\n", code_str, item.path.c_str(), item.message.c_str()); + } else if (diagnostic) { + diagnostic->code = DiagnosticCode::kInternalException; + diagnostic->path = "/"; + diagnostic->message = "Validation failed without diagnostics"; } return false; } diff --git a/src/core/pipeline_catalog.cpp b/src/core/pipeline_catalog.cpp index 8aee5d69..92b5f590 100644 --- a/src/core/pipeline_catalog.cpp +++ b/src/core/pipeline_catalog.cpp @@ -7,25 +7,20 @@ #include "contracts/config_schema_validation.h" #include "contracts/control_payload.h" +#include "core/node_definition_validation.h" +#include "core/node_registry.h" #include "engine/backend_registry.h" #include "engine/model_registry.h" namespace llm_edgeflow { namespace { -using Kind = ConfigValueKind; - -std::vector& RegisteredNodes() { - static std::vector definitions; - return definitions; -} - std::vector& RegisteredBizs() { static std::vector definitions; return definitions; } -std::mutex& CatalogMutex() { +std::mutex& BizMutex() { static std::mutex mutex; return mutex; } @@ -94,217 +89,6 @@ const char* PortConstraintKindName(PortConstraintKind kind) { return "unknown"; } -namespace { -const std::unordered_set& ValidCardinalities() { - static const std::unordered_set kValidCardinalities = { - "1:1", "1:N", "N:1", "N:M"}; - return kValidCardinalities; -} - -const std::unordered_set& ValidProvenance() { - static const std::unordered_set kValidProvenance = { - "preserve", "generate_sub_id", "aggregate", "independent"}; - return kValidProvenance; -} - -const std::unordered_set& ValidLifetimes() { - static const std::unordered_set kValidLifetimes = { - "request", "session", "global"}; - return kValidLifetimes; -} - -template -bool ValidatePortDefinitions(const std::vector& ports, - std::unordered_set* seen_keys, - std::string* error) { - for (const auto& port : ports) { - if (port.Name().empty()) { - if (error) *error = "Port key cannot be empty"; - return false; - } - if (port.type_id.empty()) { - if (error) - *error = "Port type_id cannot be empty for port: " + port.Name(); - return false; - } - if (!ValidCardinalities().count(port.cardinality)) { - if (error) { - *error = "Invalid port cardinality '" + port.cardinality + - "' in port: " + port.Name(); - } - return false; - } - if (!ValidProvenance().count(port.provenance_policy)) { - if (error) { - *error = "Invalid port provenance policy '" + port.provenance_policy + - "' in port: " + port.Name(); - } - return false; - } - if (!ValidLifetimes().count(port.lifetime)) { - if (error) { - *error = "Invalid port lifetime '" + port.lifetime + - "' in port: " + port.Name(); - } - return false; - } - if (seen_keys && !seen_keys->insert(port.Name()).second) { - if (error) *error = "Duplicate port key: " + port.Name(); - return false; - } - } - return true; -} - -} // namespace - -bool PipelineCatalog::RegisterNodeDefinition(const NodeDefinition& definition, - std::string* error) { - if (error) - *error = "Invalid or duplicate NodeDefinition: " + definition.node_type; - if (definition.node_type.empty()) return false; - - std::unordered_set seen_in_ports; - if (!ValidatePortDefinitions(definition.inputs, &seen_in_ports, error)) { - return false; - } - std::unordered_set seen_out_ports; - if (!ValidatePortDefinitions(definition.outputs, &seen_out_ports, error)) { - return false; - } - for (const auto& constraint : definition.port_constraints) { - if (constraint.kind == PortConstraintKind::kExactOneGroupOf) { - if (constraint.port_groups.empty()) return false; - for (const auto& group : constraint.port_groups) { - if (group.empty()) return false; - for (const auto& p : group) { - if (!seen_in_ports.count(p) && !seen_out_ports.count(p)) return false; - } - } - } else { - if (constraint.ports.empty()) return false; - for (const auto& p : constraint.ports) { - if (!seen_in_ports.count(p) && !seen_out_ports.count(p)) return false; - } - } - } - std::unordered_set seen_cmd_ids; - std::unordered_set seen_cmd_names; - for (const auto& cmd : definition.control_commands) { - if (cmd.cmd_id <= 0 || cmd.name.empty()) return false; - std::string schema_error; - if (!ValidateControlSchema(cmd.payload_schema, &schema_error)) { - if (error) { - *error = "Node '" + definition.node_type + "', Control " + - std::to_string(cmd.cmd_id) + " ('" + cmd.name + - "'): " + schema_error; - } - return false; - } - if (!seen_cmd_ids.insert(cmd.cmd_id).second) return false; - if (!seen_cmd_names.insert(cmd.name).second) return false; - } - std::string field_err; - if (!ValidateConfigFieldDefinitions(definition.config_fields, &field_err)) { - if (error) *error = field_err; - return false; - } - const auto validates_lifetime_override = [&](const NodePortDefinition& port) { - if (port.lifetime_config_field.empty()) return true; - auto it = std::find_if( - definition.config_fields.begin(), definition.config_fields.end(), - [&](const auto& f) { return f.name == port.lifetime_config_field; }); - if (it == definition.config_fields.end() || - it->kind != ConfigValueKind::kString || it->enum_values.empty()) { - return false; - } - return std::all_of( - it->enum_values.begin(), it->enum_values.end(), - [&](const auto& value) { return ValidLifetimes().count(value) != 0; }); - }; - if (!std::all_of(definition.inputs.begin(), definition.inputs.end(), - validates_lifetime_override) || - !std::all_of(definition.outputs.begin(), definition.outputs.end(), - validates_lifetime_override)) { - return false; - } - std::unordered_set seen_dep_names; - std::unordered_set seen_dep_config_fields; - for (const auto& dep : definition.model_dependencies) { - if (dep.name.empty() || dep.capability.empty() || - dep.config_field.empty()) { - if (error) { - *error = - "Model dependency name, capability, and config_field must be " - "non-empty"; - } - return false; - } - if (!seen_dep_names.insert(dep.name).second) { - if (error) *error = "Duplicate model dependency name: " + dep.name; - return false; - } - if (!seen_dep_config_fields.insert(dep.config_field).second) { - if (error) { - *error = "Duplicate model dependency config_field: " + dep.config_field; - } - return false; - } - auto it = std::find_if( - definition.config_fields.begin(), definition.config_fields.end(), - [&](const auto& f) { return f.name == dep.config_field; }); - if (it == definition.config_fields.end()) { - if (error) { - *error = "Model dependency config_field '" + dep.config_field + - "' not found in config_fields"; - } - return false; - } - if (it->kind != ConfigValueKind::kString) { - if (error) { - *error = "Model dependency config_field '" + dep.config_field + - "' must be of string kind"; - } - return false; - } - } - std::lock_guard lock(CatalogMutex()); - auto& definitions = RegisteredNodes(); - if (std::any_of(definitions.begin(), definitions.end(), - [&](const auto& item) { - return item.node_type == definition.node_type; - })) { - return false; - } - for (const auto& existing : definitions) { - for (const auto& command : definition.control_commands) { - for (const auto& registered : existing.control_commands) { - if (command.cmd_id != registered.cmd_id) continue; - if (!command.shared_id || !registered.shared_id || - command.name != registered.name || - command.payload_schema != registered.payload_schema || - command.supports_hot_swap != registered.supports_hot_swap) { - if (error) { - *error = - "Control ID " + std::to_string(command.cmd_id) + - " conflicts between " + existing.node_type + " and " + - definition.node_type + - "; shared commands require shared_id and identical contracts"; - } - return false; - } - } - } - } - definitions.push_back(definition); - std::sort(definitions.begin(), definitions.end(), - [](const auto& lhs, const auto& rhs) { - return lhs.node_type < rhs.node_type; - }); - if (error) error->clear(); - return true; -} - bool PipelineCatalog::RegisterBizDefinition(const BizDefinition& definition) { return RegisterBizDefinitions({definition}); } @@ -312,7 +96,7 @@ bool PipelineCatalog::RegisterBizDefinition(const BizDefinition& definition) { bool PipelineCatalog::RegisterBizDefinitions( const std::vector& batch) { if (batch.empty()) return false; - std::lock_guard lock(CatalogMutex()); + std::lock_guard lock(BizMutex()); auto& definitions = RegisteredBizs(); std::vector batch_names; batch_names.reserve(batch.size()); @@ -363,13 +147,18 @@ const BizDefinition* PipelineCatalogSnapshot::FindBiz( } PipelineCatalogSnapshot PipelineCatalog::Snapshot() { - std::lock_guard lock(CatalogMutex()); - return {RegisteredNodes(), RegisteredBizs()}; + auto node_snapshot = NodeRegistry::Instance().Snapshot(); + std::vector bizs; + { + std::lock_guard lock(BizMutex()); + bizs = RegisteredBizs(); + } + return {std::move(node_snapshot.definitions), std::move(bizs), + node_snapshot.has_conflict, std::move(node_snapshot.conflict_errors)}; } std::vector PipelineCatalog::Nodes() { - std::lock_guard lock(CatalogMutex()); - return RegisteredNodes(); + return NodeRegistry::Instance().ListDefinitions(); } std::vector PipelineCatalog::Models() { @@ -381,19 +170,13 @@ std::vector PipelineCatalog::Backends() { } std::vector PipelineCatalog::Bizs() { - std::lock_guard lock(CatalogMutex()); + std::lock_guard lock(BizMutex()); return RegisteredBizs(); } std::optional PipelineCatalog::FindNode( const std::string& node_type) { - std::lock_guard lock(CatalogMutex()); - const auto& nodes = RegisteredNodes(); - auto it = std::find_if(nodes.begin(), nodes.end(), [&](const auto& item) { - return item.node_type == node_type; - }); - if (it == nodes.end()) return std::nullopt; - return *it; + return NodeRegistry::Instance().Find(node_type); } std::optional PipelineCatalog::FindModel( @@ -408,7 +191,7 @@ std::optional PipelineCatalog::FindBackend( std::optional PipelineCatalog::FindBiz( const std::string& biz_name) { - std::lock_guard lock(CatalogMutex()); + std::lock_guard lock(BizMutex()); const auto& bizs = RegisteredBizs(); auto it = std::find_if(bizs.begin(), bizs.end(), [&](const auto& item) { return item.biz_name == biz_name; @@ -417,9 +200,8 @@ std::optional PipelineCatalog::FindBiz( return *it; } -void PipelineCatalog::ClearForTesting() { - std::lock_guard lock(CatalogMutex()); - RegisteredNodes().clear(); +void PipelineCatalog::ResetBizsForTesting() { + std::lock_guard lock(BizMutex()); RegisteredBizs().clear(); } @@ -448,13 +230,13 @@ nlohmann::json PipelineCatalog::NodeToJson(const NodeDefinition& definition) { return {{"node_type", definition.node_type}, {"category", definition.category}, {"description", definition.description}, + {"parallel_safe", definition.parallel_safe}, {"inputs", std::move(inputs)}, {"outputs", std::move(outputs)}, {"port_constraints", std::move(constraints)}, {"control_commands", std::move(commands)}, {"config_fields", std::move(fields)}, {"model_dependencies", std::move(model_deps)}, - {"parallel_safe", definition.parallel_safe}, {"biz_names", definition.biz_names}}; } @@ -493,8 +275,8 @@ nlohmann::json PipelineCatalog::BackendToJson( }; } -nlohmann::json PipelineCatalog::ToJson(const std::string& biz_filter) { - const auto snapshot = Snapshot(); +nlohmann::json PipelineCatalog::ToJson(const PipelineCatalogSnapshot& snapshot, + const std::string& biz_filter) { nlohmann::json nodes = nlohmann::json::array(); for (const auto& item : snapshot.nodes) { if (!biz_filter.empty() && !item.biz_names.empty() && @@ -536,4 +318,8 @@ nlohmann::json PipelineCatalog::ToJson(const std::string& biz_filter) { {"bizs", std::move(bizs)}}; } +nlohmann::json PipelineCatalog::ToJson(const std::string& biz_filter) { + return ToJson(Snapshot(), biz_filter); +} + } // namespace llm_edgeflow diff --git a/src/core/pipeline_config.cpp b/src/core/pipeline_config.cpp index f149a9f6..1da1693b 100644 --- a/src/core/pipeline_config.cpp +++ b/src/core/pipeline_config.cpp @@ -7,7 +7,7 @@ namespace llm_edgeflow { namespace { -void SetDiag(PipelineDiagnostic* diag, PipelineErrorCode code, +void SetDiag(PipelineDiagnostic* diag, DiagnosticCode code, const std::string& path, const std::string& message) { if (diag) { diag->code = code; @@ -25,14 +25,13 @@ bool ParsePipelineConfig(const nlohmann::json& root, diagnostic->Clear(); } if (!output) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/", - "Null output pointer"); + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/", "Null output pointer"); return false; } // 1. 根节点必须是 JSON Object if (!root.is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kRootType, "/", + SetDiag(diagnostic, DiagnosticCode::kRootType, "/", "Pipeline configuration root must be a JSON object"); return false; } @@ -47,7 +46,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, "comment"}; for (auto it = root.begin(); it != root.end(); ++it) { if (allowed_root_keys.find(it.key()) == allowed_root_keys.end()) { - SetDiag(diagnostic, PipelineErrorCode::kUnknownField, "/" + it.key(), + SetDiag(diagnostic, DiagnosticCode::kUnknownField, "/" + it.key(), "Unknown root field: " + it.key()); return false; } @@ -55,7 +54,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // comment (可选字符串) if (root.contains("comment") && !root["comment"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/comment", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/comment", "Field 'comment' must be a string"); return false; } @@ -64,18 +63,18 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 3. biz_name 是 v6 唯一业务标识字段,必须存在且为非空字符串。 if (!root.contains("biz_name")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, "/biz_name", + SetDiag(diagnostic, DiagnosticCode::kMissingField, "/biz_name", "Missing required field 'biz_name'"); return false; } if (!root["biz_name"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/biz_name", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/biz_name", "Field 'biz_name' must be a string"); return false; } result.biz_name = root["biz_name"].get(); if (result.biz_name.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, "/biz_name", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/biz_name", "Field 'biz_name' cannot be empty"); return false; } @@ -83,13 +82,13 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 4. 解析 execution_mode: 可选字符串,仅支持 "sequential" 与 "parallel" if (root.contains("execution_mode")) { if (!root["execution_mode"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/execution_mode", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/execution_mode", "Field 'execution_mode' must be a string"); return false; } std::string mode_str = root["execution_mode"].get(); if (mode_str != "sequential" && mode_str != "parallel") { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, "/execution_mode", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/execution_mode", "Field 'execution_mode' must be 'sequential' or 'parallel'"); return false; } @@ -104,22 +103,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // max_parallel_workers,避免配置复制隐式错误 if (result.execution_mode == "sequential") { SetDiag( - diagnostic, PipelineErrorCode::kInvalidCombination, + diagnostic, DiagnosticCode::kInvalidCombination, "/max_parallel_workers", "Field 'max_parallel_workers' is only allowed when execution_mode " "is 'parallel'"); return false; } if (!root["max_parallel_workers"].is_number_integer()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, - "/max_parallel_workers", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/max_parallel_workers", "Field 'max_parallel_workers' must be an integer"); return false; } int64_t workers = root["max_parallel_workers"].get(); if (workers < 1 || workers > 64) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, - "/max_parallel_workers", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/max_parallel_workers", "Field 'max_parallel_workers' must be between 1 and 64"); return false; } @@ -131,12 +128,12 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 6. 解析 models: 可选数组,最多 64 个模型定义 if (root.contains("models")) { if (!root["models"].is_array()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/models", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/models", "Field 'models' must be an array"); return false; } if (root["models"].size() > 64) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, "/models", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/models", "Model count exceeds maximum limit of 64"); return false; } @@ -151,7 +148,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, std::string model_path_prefix = "/models/" + std::to_string(i); if (!model_elem.is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, model_path_prefix, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix, "Model item must be an object"); return false; } @@ -159,7 +156,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 拒绝 model 内部未知字段 for (auto it = model_elem.begin(); it != model_elem.end(); ++it) { if (allowed_model_keys.find(it.key()) == allowed_model_keys.end()) { - SetDiag(diagnostic, PipelineErrorCode::kUnknownField, + SetDiag(diagnostic, DiagnosticCode::kUnknownField, model_path_prefix + "/" + it.key(), "Unknown field in model: " + it.key()); return false; @@ -169,7 +166,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // comment 字段类型检查 (R1-ACC-006) if (model_elem.contains("comment") && !model_elem["comment"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/comment", "Field 'comment' must be a string"); return false; @@ -180,26 +177,26 @@ bool ParsePipelineConfig(const nlohmann::json& root, // model_id (必填非空字符串,唯一) if (!model_elem.contains("model_id")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, model_path_prefix + "/model_id", "Missing required field 'model_id'"); return false; } if (!model_elem["model_id"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/model_id", "Field 'model_id' must be a string"); return false; } model_cfg.model_id = model_elem["model_id"].get(); if (model_cfg.model_id.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, model_path_prefix + "/model_id", "Field 'model_id' cannot be empty"); return false; } if (seen_model_ids.find(model_cfg.model_id) != seen_model_ids.end()) { - SetDiag(diagnostic, PipelineErrorCode::kDuplicateModelId, + SetDiag(diagnostic, DiagnosticCode::kDuplicateModelId, model_path_prefix + "/model_id", "Duplicate model_id: " + model_cfg.model_id); return false; @@ -208,20 +205,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // capability (必填非空字符串) if (!model_elem.contains("capability")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, model_path_prefix + "/capability", "Missing required field 'capability'"); return false; } if (!model_elem["capability"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/capability", "Field 'capability' must be a string"); return false; } model_cfg.capability = model_elem["capability"].get(); if (model_cfg.capability.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, model_path_prefix + "/capability", "Field 'capability' cannot be empty"); return false; @@ -229,20 +226,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // model_type (必填非空字符串) if (!model_elem.contains("model_type")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, model_path_prefix + "/model_type", "Missing required field 'model_type'"); return false; } if (!model_elem["model_type"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/model_type", "Field 'model_type' must be a string"); return false; } model_cfg.model_type = model_elem["model_type"].get(); if (model_cfg.model_type.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, model_path_prefix + "/model_type", "Field 'model_type' cannot be empty"); return false; @@ -250,20 +247,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // backend (必填非空字符串) if (!model_elem.contains("backend")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, model_path_prefix + "/backend", "Missing required field 'backend'"); return false; } if (!model_elem["backend"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/backend", "Field 'backend' must be a string"); return false; } model_cfg.backend = model_elem["backend"].get(); if (model_cfg.backend.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, model_path_prefix + "/backend", "Field 'backend' cannot be empty"); return false; @@ -271,20 +268,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // model_path (必填非空字符串) if (!model_elem.contains("model_path")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, model_path_prefix + "/model_path", "Missing required field 'model_path'"); return false; } if (!model_elem["model_path"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/model_path", "Field 'model_path' must be a string"); return false; } model_cfg.model_path = model_elem["model_path"].get(); if (model_cfg.model_path.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, model_path_prefix + "/model_path", "Field 'model_path' cannot be empty"); return false; @@ -293,7 +290,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // model_config (可选对象) if (model_elem.contains("model_config")) { if (!model_elem["model_config"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/model_config", "Field 'model_config' must be an object"); return false; @@ -306,7 +303,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // backend_config (可选对象) if (model_elem.contains("backend_config")) { if (!model_elem["backend_config"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, model_path_prefix + "/backend_config", "Field 'backend_config' must be an object"); return false; @@ -321,22 +318,22 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 7. 解析 pipeline: 必填非空数组,最多 256 个节点定义 if (!root.contains("pipeline")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, "/pipeline", + SetDiag(diagnostic, DiagnosticCode::kMissingField, "/pipeline", "Missing required field 'pipeline'"); return false; } if (!root["pipeline"].is_array()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, "/pipeline", + SetDiag(diagnostic, DiagnosticCode::kFieldType, "/pipeline", "Field 'pipeline' must be an array"); return false; } if (root["pipeline"].empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, "/pipeline", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/pipeline", "Pipeline cannot be empty"); return false; } if (root["pipeline"].size() > 256) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, "/pipeline", + SetDiag(diagnostic, DiagnosticCode::kFieldRange, "/pipeline", "Pipeline node count exceeds maximum limit of 256"); return false; } @@ -351,7 +348,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, std::string node_path_prefix = "/pipeline/" + std::to_string(i); if (!node_elem.is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, node_path_prefix, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix, "Node item must be an object"); return false; } @@ -359,7 +356,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // 拒绝 node 内部未知字段 for (auto it = node_elem.begin(); it != node_elem.end(); ++it) { if (allowed_node_keys.find(it.key()) == allowed_node_keys.end()) { - SetDiag(diagnostic, PipelineErrorCode::kUnknownField, + SetDiag(diagnostic, DiagnosticCode::kUnknownField, node_path_prefix + "/" + it.key(), "Unknown field in node: " + it.key()); return false; @@ -368,7 +365,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // comment 字段类型检查 (R1-ACC-006) if (node_elem.contains("comment") && !node_elem["comment"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/comment", "Field 'comment' must be a string"); return false; @@ -379,20 +376,20 @@ bool ParsePipelineConfig(const nlohmann::json& root, // node_type (必填非空字符串) if (!node_elem.contains("node_type")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, node_path_prefix + "/node_type", "Missing required field 'node_type'"); return false; } if (!node_elem["node_type"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/node_type", "Field 'node_type' must be a string"); return false; } node_cfg.node_type = node_elem["node_type"].get(); if (node_cfg.node_type.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, node_path_prefix + "/node_type", "Field 'node_type' cannot be empty"); return false; @@ -401,7 +398,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // ports (可选对象) if (node_elem.contains("ports")) { if (!node_elem["ports"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/ports", "Field 'ports' must be an object"); return false; } @@ -410,7 +407,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, "inputs", "outputs", "comment"}; for (auto pit = ports_obj.begin(); pit != ports_obj.end(); ++pit) { if (allowed_port_keys.find(pit.key()) == allowed_port_keys.end()) { - SetDiag(diagnostic, PipelineErrorCode::kUnknownField, + SetDiag(diagnostic, DiagnosticCode::kUnknownField, node_path_prefix + "/ports/" + pit.key(), "Unknown field in ports: " + pit.key()); return false; @@ -418,7 +415,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, } if (ports_obj.contains("inputs")) { if (!ports_obj["inputs"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/ports/inputs", "Field 'ports.inputs' must be an object"); return false; @@ -426,14 +423,14 @@ bool ParsePipelineConfig(const nlohmann::json& root, for (auto it = ports_obj["inputs"].begin(); it != ports_obj["inputs"].end(); ++it) { if (!it.value().is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/ports/inputs/" + it.key(), "Port mapping target must be a string"); return false; } std::string target = it.value().get(); if (target.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, node_path_prefix + "/ports/inputs/" + it.key(), "Port mapping target cannot be empty"); return false; @@ -443,7 +440,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, } if (ports_obj.contains("outputs")) { if (!ports_obj["outputs"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/ports/outputs", "Field 'ports.outputs' must be an object"); return false; @@ -451,14 +448,14 @@ bool ParsePipelineConfig(const nlohmann::json& root, for (auto it = ports_obj["outputs"].begin(); it != ports_obj["outputs"].end(); ++it) { if (!it.value().is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/ports/outputs/" + it.key(), "Port mapping target must be a string"); return false; } std::string target = it.value().get(); if (target.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, node_path_prefix + "/ports/outputs/" + it.key(), "Port mapping target cannot be empty"); return false; @@ -471,7 +468,7 @@ bool ParsePipelineConfig(const nlohmann::json& root, // config (可选对象) if (node_elem.contains("config")) { if (!node_elem["config"].is_object()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/config", "Field 'config' must be an object"); return false; @@ -483,24 +480,24 @@ bool ParsePipelineConfig(const nlohmann::json& root, // id (必填非空字符串,唯一) if (!node_elem.contains("id")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, node_path_prefix + "/id", "Missing required field 'id' in pipeline node"); return false; } if (!node_elem["id"].is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, - node_path_prefix + "/id", "Field 'id' must be a string"); + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/id", + "Field 'id' must be a string"); return false; } node_cfg.id = node_elem["id"].get(); if (node_cfg.id.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, - node_path_prefix + "/id", "Field 'id' cannot be empty"); + SetDiag(diagnostic, DiagnosticCode::kFieldRange, node_path_prefix + "/id", + "Field 'id' cannot be empty"); return false; } if (seen_node_ids.find(node_cfg.id) != seen_node_ids.end()) { - SetDiag(diagnostic, PipelineErrorCode::kDuplicateNodeId, + SetDiag(diagnostic, DiagnosticCode::kDuplicateNodeId, node_path_prefix + "/id", "Duplicate node id: " + node_cfg.id); return false; } @@ -508,19 +505,19 @@ bool ParsePipelineConfig(const nlohmann::json& root, // depends_on (必填数组,元素为非空字符串且不重复) if (!node_elem.contains("depends_on")) { - SetDiag(diagnostic, PipelineErrorCode::kMissingField, + SetDiag(diagnostic, DiagnosticCode::kMissingField, node_path_prefix + "/depends_on", "Missing required field 'depends_on' in pipeline node"); return false; } if (!node_elem["depends_on"].is_array()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, + SetDiag(diagnostic, DiagnosticCode::kFieldType, node_path_prefix + "/depends_on", "Field 'depends_on' must be an array"); return false; } if (node_elem["depends_on"].size() > 256) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, node_path_prefix + "/depends_on", "Node dependencies exceed limit of 256"); return false; @@ -532,13 +529,13 @@ bool ParsePipelineConfig(const nlohmann::json& root, node_path_prefix + "/depends_on/" + std::to_string(d); if (!dep_item.is_string()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldType, dep_path, + SetDiag(diagnostic, DiagnosticCode::kFieldType, dep_path, "Dependency item must be a string"); return false; } std::string dep_str = dep_item.get(); if (dep_str.empty()) { - SetDiag(diagnostic, PipelineErrorCode::kFieldRange, dep_path, + SetDiag(diagnostic, DiagnosticCode::kFieldRange, dep_path, "Dependency item cannot be empty"); return false; } diff --git a/src/core/pipeline_validator.cpp b/src/core/pipeline_validator.cpp index ab378915..5f57f60b 100644 --- a/src/core/pipeline_validator.cpp +++ b/src/core/pipeline_validator.cpp @@ -15,145 +15,8 @@ #include "engine/model_registry.h" namespace llm_edgeflow { - -const char* DiagnosticCodeName(DiagnosticCode code) noexcept { - switch (code) { - case DiagnosticCode::kOk: - return "OK"; - case DiagnosticCode::kJsonParse: - return "JSON_PARSE"; - case DiagnosticCode::kConfigFileOpen: - return "CONFIG_FILE_OPEN"; - case DiagnosticCode::kRootType: - return "ROOT_TYPE"; - case DiagnosticCode::kUnknownField: - return "UNKNOWN_FIELD"; - case DiagnosticCode::kMissingField: - return "MISSING_FIELD"; - case DiagnosticCode::kFieldType: - return "FIELD_TYPE"; - case DiagnosticCode::kFieldRange: - return "FIELD_RANGE"; - case DiagnosticCode::kInvalidCombination: - return "INVALID_COMBINATION"; - case DiagnosticCode::kDuplicateModelId: - return "DUPLICATE_MODEL_ID"; - case DiagnosticCode::kDuplicateNodeId: - return "DUPLICATE_NODE_ID"; - case DiagnosticCode::kUnknownBiz: - return "UNKNOWN_BIZ"; - case DiagnosticCode::kUnknownNodeType: - return "UNKNOWN_NODE_TYPE"; - case DiagnosticCode::kUnknownModelType: - return "UNKNOWN_MODEL_TYPE"; - case DiagnosticCode::kUnknownBackend: - return "UNKNOWN_BACKEND"; - case DiagnosticCode::kBackendProtocolMismatch: - return "BACKEND_PROTOCOL_MISMATCH"; - case DiagnosticCode::kUnknownModelConfigField: - return "UNKNOWN_MODEL_CONFIG_FIELD"; - case DiagnosticCode::kUnknownBackendConfigField: - return "UNKNOWN_BACKEND_CONFIG_FIELD"; - case DiagnosticCode::kInvalidDependency: - return "INVALID_DEPENDENCY"; - case DiagnosticCode::kDuplicateDependency: - return "DUPLICATE_DEPENDENCY"; - case DiagnosticCode::kDagCycle: - return "DAG_CYCLE"; - case DiagnosticCode::kRegistryConflict: - return "REGISTRY_CONFLICT"; - case DiagnosticCode::kUnknownConfigField: - return "UNKNOWN_CONFIG_FIELD"; - case DiagnosticCode::kMissingConfigField: - return "MISSING_CONFIG_FIELD"; - case DiagnosticCode::kConfigFieldType: - return "CONFIG_FIELD_TYPE"; - case DiagnosticCode::kConfigFieldRange: - return "CONFIG_FIELD_RANGE"; - case DiagnosticCode::kConfigFieldEnum: - return "CONFIG_FIELD_ENUM"; - case DiagnosticCode::kUnknownModelReference: - return "UNKNOWN_MODEL_REFERENCE"; - case DiagnosticCode::kModelCapabilityMismatch: - return "MODEL_CAPABILITY_MISMATCH"; - case DiagnosticCode::kNodeBizMismatch: - return "NODE_BIZ_MISMATCH"; - case DiagnosticCode::kMissingInputProducer: - return "MISSING_INPUT_PRODUCER"; - case DiagnosticCode::kDuplicatePortProducer: - return "DUPLICATE_PORT_PRODUCER"; - case DiagnosticCode::kMissingBizOutput: - return "MISSING_BIZ_OUTPUT"; - case DiagnosticCode::kNodeNotParallelSafe: - return "NODE_NOT_PARALLEL_SAFE"; - case DiagnosticCode::kParallelWriteConflict: - return "PARALLEL_WRITE_CONFLICT"; - case DiagnosticCode::kSerializedModelConcurrency: - return "SERIALIZED_MODEL_CONCURRENCY"; - case DiagnosticCode::kPortCardinalityMismatch: - return "PORT_CARDINALITY_MISMATCH"; - case DiagnosticCode::kPortProvenanceMismatch: - return "PORT_PROVENANCE_MISMATCH"; - case DiagnosticCode::kPortLifetimeMismatch: - return "PORT_LIFETIME_MISMATCH"; - case DiagnosticCode::kInternalException: - return "INTERNAL_EXCEPTION"; - } - return "UNKNOWN"; -} - namespace { -DiagnosticCode PipelineErrorCodeToDiagnosticCode(PipelineErrorCode code) { - switch (code) { - case PipelineErrorCode::kOk: - return DiagnosticCode::kOk; - case PipelineErrorCode::kJsonParse: - return DiagnosticCode::kJsonParse; - case PipelineErrorCode::kConfigFileOpen: - return DiagnosticCode::kConfigFileOpen; - case PipelineErrorCode::kRootType: - return DiagnosticCode::kRootType; - case PipelineErrorCode::kUnknownField: - return DiagnosticCode::kUnknownField; - case PipelineErrorCode::kMissingField: - return DiagnosticCode::kMissingField; - case PipelineErrorCode::kFieldType: - return DiagnosticCode::kFieldType; - case PipelineErrorCode::kFieldRange: - return DiagnosticCode::kFieldRange; - case PipelineErrorCode::kInvalidCombination: - return DiagnosticCode::kInvalidCombination; - case PipelineErrorCode::kDuplicateModelId: - return DiagnosticCode::kDuplicateModelId; - case PipelineErrorCode::kDuplicateNodeId: - return DiagnosticCode::kDuplicateNodeId; - case PipelineErrorCode::kUnknownNodeType: - return DiagnosticCode::kUnknownNodeType; - case PipelineErrorCode::kUnknownModelType: - return DiagnosticCode::kUnknownModelType; - case PipelineErrorCode::kUnknownBackend: - return DiagnosticCode::kUnknownBackend; - case PipelineErrorCode::kInvalidDependency: - return DiagnosticCode::kInvalidDependency; - case PipelineErrorCode::kDagCycle: - return DiagnosticCode::kDagCycle; - case PipelineErrorCode::kRegistryConflict: - return DiagnosticCode::kRegistryConflict; - case PipelineErrorCode::kModelMaterializationFailed: - return DiagnosticCode::kUnknownModelReference; - case PipelineErrorCode::kNodeCreateFailed: - return DiagnosticCode::kUnknownNodeType; - case PipelineErrorCode::kNodeInitFailed: - return DiagnosticCode::kUnknownConfigField; - case PipelineErrorCode::kInternalException: - return DiagnosticCode::kInternalException; - case PipelineErrorCode::kInvalidBuildState: - return DiagnosticCode::kInternalException; - } - return DiagnosticCode::kInternalException; -} - void Add(ValidationReport* report, DiagnosticCode code, std::string path, std::string message, std::string node_id = {}, std::string port = {}, std::vector related = {}, @@ -511,7 +374,7 @@ void PopulateBasicRemediation( std::string field_name = diag->path.substr(prefix.size()); ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "unknown_config_field"; + rem.cause = RemediationCause::kUnknownConfigField; rem.summary = "节点 '" + diag->node_id + "' 的配置包含未知字段 '" + field_name + "'。"; rem.facts["field"] = field_name; @@ -560,7 +423,7 @@ void PopulateBasicRemediation( if (def && !field_name.empty()) { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "missing_config_field"; + rem.cause = RemediationCause::kMissingConfigField; rem.summary = "节点 '" + diag->node_id + "' 缺少必填配置字段 '" + field_name + "'。"; rem.facts["field"] = field_name; @@ -607,7 +470,7 @@ void PopulateBasicRemediation( if (def && !field_name.empty()) { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "invalid_config_value"; + rem.cause = RemediationCause::kInvalidConfigValue; rem.summary = "节点 '" + diag->node_id + "' 的配置项 '" + field_name + "' 值不合法。"; rem.facts["field"] = field_name; @@ -663,8 +526,8 @@ void PopulateBasicRemediation( ValidationRemediation rem; rem.schema_version = 1; rem.cause = (diag->code == DiagnosticCode::kUnknownModelReference) - ? "unknown_model_reference" - : "model_capability_mismatch"; + ? RemediationCause::kUnknownModelReference + : RemediationCause::kModelCapabilityMismatch; rem.facts["model_id"] = model_id; rem.facts["required_capability"] = req_cap; @@ -767,7 +630,7 @@ void PopulateBasicRemediation( if (producer_out_type == expected_type) { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "producer_not_dependency_ancestor"; + rem.cause = RemediationCause::kProducerNotDependencyAncestor; rem.summary = producer_id + " 已输出 " + bound_key + ",但不在消费者的依赖路径中。"; rem.facts["bound_key"] = bound_key; @@ -776,7 +639,7 @@ void PopulateBasicRemediation( } else { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "port_type_mismatch"; + rem.cause = RemediationCause::kPortTypeMismatch; rem.summary = "生产者 '" + producer_id + "' 输出类型与端口 '" + port_name + "' 要求不符。"; rem.facts["bound_key"] = bound_key; @@ -796,7 +659,7 @@ void PopulateBasicRemediation( } else { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "no_compatible_input_source"; + rem.cause = RemediationCause::kNoCompatibleInputSource; rem.summary = "Pipeline 中没有为端口 '" + port_name + "' (绑定键: '" + bound_key + "') 提供匹配类型的生产者。"; rem.facts["bound_key"] = bound_key; @@ -837,7 +700,7 @@ void PopulateBasicRemediation( if (!dup_dep.empty()) { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "duplicate_dependency"; + rem.cause = RemediationCause::kDuplicateDependency; rem.summary = "节点 '" + diag->node_id + "' 包含重复依赖 '" + dup_dep + "'。"; rem.facts["dependency_id"] = dup_dep; @@ -892,7 +755,7 @@ void PopulateBasicRemediation( ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "unknown_dependency"; + rem.cause = RemediationCause::kUnknownDependency; rem.summary = "节点 '" + diag->node_id + "' 依赖了未知的节点 ID '" + dep_id + "'。"; rem.facts["dependency_id"] = dep_id; @@ -902,7 +765,7 @@ void PopulateBasicRemediation( } else if (diag->code == DiagnosticCode::kMissingBizOutput) { ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "missing_biz_output"; + rem.cause = RemediationCause::kMissingBizOutput; rem.summary = "Pipeline 未产出 biz '" + root.value("biz_name", "") + "' 所需的输出 '" + diag->port + "'。"; rem.facts["biz_name"] = root.value("biz_name", ""); @@ -997,7 +860,7 @@ void PopulateBasicRemediation( ValidationRemediation rem; rem.schema_version = 1; - rem.cause = "port_flow_mismatch"; + rem.cause = RemediationCause::kPortFlowMismatch; rem.summary = "生产者 '" + producer_id + "' 与消费者 '" + diag->node_id + "' 在端口 '" + port_name + "' 上的流契约不兼容。"; rem.facts["bound_key"] = bound_key; @@ -1033,8 +896,7 @@ ValidatedPipelinePlan ValidateAndPlanInternal( PipelineDiagnostic parse_diag; if (!ParsePipelineConfig(root, &plan.config, &parse_diag)) { - Add(&report, PipelineErrorCodeToDiagnosticCode(parse_diag.code), - parse_diag.path, parse_diag.message); + Add(&report, parse_diag.code, parse_diag.path, parse_diag.message); return finish_plan(plan); } const auto& parsed = plan.config; @@ -1043,9 +905,9 @@ ValidatedPipelinePlan ValidateAndPlanInternal( Add(&report, DiagnosticCode::kUnknownBiz, "/biz_name", "No registered biz contract accepts pipeline name: " + parsed.biz_name); } - if (NodeRegistry::Instance().HasConflict()) { + if (catalog.node_registry_has_conflict) { std::string message = "Node registry contains registration conflicts"; - for (const auto& error : NodeRegistry::Instance().GetConflictErrors()) { + for (const auto& error : catalog.node_registry_errors) { message += ": " + error; } Add(&report, DiagnosticCode::kRegistryConflict, "/pipeline", @@ -1186,8 +1048,7 @@ ValidatedPipelinePlan ValidateAndPlanInternal( for (const auto& node : nodes) { node_by_id[node.id] = &node; const auto* definition = catalog.FindNode(node.node_type); - bool factory_has = NodeRegistry::Instance().Has(node.node_type); - if (!factory_has || (!definition && policy == ValidationPolicy::kStrict)) { + if (!definition) { Add(&report, DiagnosticCode::kUnknownNodeType, "/pipeline/" + std::to_string(node.source_index) + "/node_type", "Unknown node_type or missing catalog definition: " + node.node_type, @@ -1646,7 +1507,7 @@ nlohmann::json ValidationRemediation::ToJson() const { fixes_json.push_back(fix.ToJson()); } return {{"schema_version", schema_version}, - {"cause", cause}, + {"cause", RemediationCauseName(cause)}, {"summary", summary}, {"facts", facts}, {"fixes", std::move(fixes_json)}}; @@ -1727,7 +1588,7 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, std::vector candidate_fixes; - if (diag.remediation->cause == "unknown_config_field") { + if (diag.remediation->cause == RemediationCause::kUnknownConfigField) { size_t idx_end = diag.path.find('/', 10); size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); const auto& node_obj = root["pipeline"][p_idx]; @@ -1761,8 +1622,10 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, } } } - } else if (diag.remediation->cause == "unknown_model_reference" || - diag.remediation->cause == "model_capability_mismatch") { + } else if (diag.remediation->cause == + RemediationCause::kUnknownModelReference || + diag.remediation->cause == + RemediationCause::kModelCapabilityMismatch) { size_t idx_end = diag.path.find('/', 10); size_t p_idx = std::stoul(diag.path.substr(10, idx_end - 10)); const auto& node_obj = root["pipeline"][p_idx]; @@ -1796,7 +1659,8 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, } } } - } else if (diag.remediation->cause == "producer_not_dependency_ancestor") { + } else if (diag.remediation->cause == + RemediationCause::kProducerNotDependencyAncestor) { size_t idx_end = diag.path.find('/', 10); size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); const auto& consumer_node = root["pipeline"][consumer_idx]; @@ -1841,7 +1705,8 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, fix.patch = std::move(patch); candidate_fixes.push_back(std::move(fix)); } - } else if (diag.remediation->cause == "duplicate_dependency") { + } else if (diag.remediation->cause == + RemediationCause::kDuplicateDependency) { size_t idx_end = diag.path.find('/', 10); size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); const auto& c_node = root["pipeline"][consumer_idx]; @@ -1859,7 +1724,8 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, {{"op", "remove"}, {"path", diag.path}}}); candidate_fixes.push_back(std::move(fix)); } - } else if (diag.remediation->cause == "unknown_dependency") { + } else if (diag.remediation->cause == + RemediationCause::kUnknownDependency) { size_t idx_end = diag.path.find('/', 10); size_t consumer_idx = std::stoul(diag.path.substr(10, idx_end - 10)); const auto& c_node = root["pipeline"][consumer_idx]; @@ -1931,8 +1797,10 @@ ValidationReport PipelineValidator::Explain(const nlohmann::json& root, } if (!has_new_error) { bool candidate_model_has_error = false; - if (diag.remediation->cause == "unknown_model_reference" || - diag.remediation->cause == "model_capability_mismatch") { + if (diag.remediation->cause == + RemediationCause::kUnknownModelReference || + diag.remediation->cause == + RemediationCause::kModelCapabilityMismatch) { std::string cand_mid; for (const auto& op : fix.patch) { if (op.value("op", "") == "replace") { diff --git a/src/core/remediation_cause.cpp b/src/core/remediation_cause.cpp new file mode 100644 index 00000000..38dadba4 --- /dev/null +++ b/src/core/remediation_cause.cpp @@ -0,0 +1,16 @@ +#include "core/remediation_cause.h" + +namespace llm_edgeflow { + +const char* RemediationCauseName(RemediationCause cause) noexcept { + switch (cause) { +#define LLM_EDGEFLOW_CAUSE_CASE(name, str) \ + case RemediationCause::name: \ + return str; + LLM_EDGEFLOW_REMEDIATION_CAUSES(LLM_EDGEFLOW_CAUSE_CASE) +#undef LLM_EDGEFLOW_CAUSE_CASE + } + return "UNKNOWN"; +} + +} // namespace llm_edgeflow diff --git a/src/tools/alg_pipeline_tool.cpp b/src/tools/alg_pipeline_tool.cpp index 1879d75b..3cabd9ce 100644 --- a/src/tools/alg_pipeline_tool.cpp +++ b/src/tools/alg_pipeline_tool.cpp @@ -7,6 +7,7 @@ #include #include "adapter/operator/operator_config_resolver.h" +#include "core/diagnostic_code.h" #include "core/pipeline_catalog.h" #include "core/pipeline_validator.h" #include "edgeflow/operator/interface.h" @@ -15,11 +16,23 @@ namespace { +using llm_edgeflow::DiagnosticCode; +using llm_edgeflow::DiagnosticCodeName; using llm_edgeflow::PipelineCatalog; using llm_edgeflow::PipelineValidator; namespace fs = std::filesystem; -nlohmann::json Error(const std::string& code, const std::string& message) { +nlohmann::json PipelineError(DiagnosticCode code, const std::string& message) { + return {{"schema_version", 1}, + {"ok", false}, + {"diagnostics", + nlohmann::json::array({{{"code", DiagnosticCodeName(code)}, + {"path", "/"}, + {"message", message}, + {"severity", "error"}}})}}; +} + +nlohmann::json ToolError(const std::string& code, const std::string& message) { return {{"schema_version", 1}, {"ok", false}, {"diagnostics", nlohmann::json::array({{{"code", code}, @@ -114,7 +127,8 @@ nlohmann::json ResolveConf(const std::string& file, const std::string& root, using namespace llm_edgeflow; const auto ops = operator_api::Get_LLM_EDGEFLOW_OperatorTable(); if (ops.Init() != 0) - return Error("REGISTRY_CONFLICT", operator_api::GetOperatorLastError()); + return PipelineError(DiagnosticCode::kRegistryConflict, + operator_api::GetOperatorLastError()); struct RegistryGuard { operator_api::OperatorFunc ops; ~RegistryGuard() { ops.Deinit(); } @@ -123,7 +137,7 @@ nlohmann::json ResolveConf(const std::string& file, const std::string& root, std::string error; if (OperatorConfigResolver::Resolve(root.c_str(), file.c_str(), &resolved, &error, depth) != 0) - return Error("DEPLOYMENT_CONFIG", error); + return ToolError("DEPLOYMENT_CONFIG", error); const auto plan = PipelineValidator::ValidateAndPlan(resolved.synthetic_pipeline_json); if (!plan.report.ok) return plan.report.ToJson(); @@ -140,7 +154,7 @@ nlohmann::json ResolveConf(const std::string& file, const std::string& root, } nlohmann::json conf; if (!ReadJson(resolved.conf_path.string(), &conf, &error)) - return Error("JSON_READ", error); + return ToolError("JSON_READ", error); const auto overrides = conf["data"].value("model_paths", nlohmann::json::object()); nlohmann::json paths = nlohmann::json::array(); @@ -190,7 +204,7 @@ void Usage() { } // namespace -int main(int argc, char** argv) { +int main(int argc, char* argv[]) { if (argc < 2) { Usage(); return 2; @@ -230,7 +244,7 @@ int main(int argc, char** argv) { try { result = ResolveConf(argv[2], root, depth); } catch (const std::exception& error) { - result = Error("DEPLOYMENT_CONFIG", error.what()); + result = ToolError("DEPLOYMENT_CONFIG", error.what()); } std::cout << result.dump(2) << std::endl; return result.value("ok", false) ? 0 : 1; @@ -245,7 +259,18 @@ int main(int argc, char** argv) { Usage(); return 2; } - auto result = PipelineCatalog::ToJson(biz); + const auto snapshot = PipelineCatalog::Snapshot(); + if (snapshot.node_registry_has_conflict) { + std::string message = "Node registry contains registration conflicts"; + for (const auto& err : snapshot.node_registry_errors) { + message += ": " + err; + } + std::cout + << PipelineError(DiagnosticCode::kRegistryConflict, message).dump(2) + << std::endl; + return 1; + } + auto result = PipelineCatalog::ToJson(snapshot, biz); result["profiles"] = ProfilesJson(biz); result["ok"] = biz.empty() || !result["bizs"].empty(); std::cout << result.dump(2) << std::endl; @@ -259,7 +284,9 @@ int main(int argc, char** argv) { } const auto definition = PipelineCatalog::FindNode(argv[2]); if (!definition) { - std::cout << Error("UNKNOWN_NODE_TYPE", argv[2]).dump(2) << std::endl; + std::cout + << PipelineError(DiagnosticCode::kUnknownNodeType, argv[2]).dump(2) + << std::endl; return 1; } auto result = PipelineCatalog::NodeToJson(*definition); @@ -296,7 +323,8 @@ int main(int argc, char** argv) { return 2; } if (!PipelineCatalog::FindBiz(biz)) { - std::cout << Error("UNKNOWN_BIZ", biz).dump(2) << std::endl; + std::cout << PipelineError(DiagnosticCode::kUnknownBiz, biz).dump(2) + << std::endl; return 1; } nlohmann::json pipeline = {{"biz_name", biz}, @@ -307,9 +335,9 @@ int main(int argc, char** argv) { std::string error; if (!path || !ReadJson(path->string(), &pipeline, &error) || pipeline.value("biz_name", "") != biz) { - std::cout << Error("PROFILE_MISMATCH", - "Profile is unavailable or belongs to another " - "biz contract") + std::cout << ToolError("PROFILE_MISMATCH", + "Profile is unavailable or belongs to another " + "biz contract") .dump(2) << std::endl; return 1; @@ -348,7 +376,7 @@ int main(int argc, char** argv) { nlohmann::json root; std::string error; if (!ReadJson(file, &root, &error)) { - std::cout << Error("JSON_READ", error).dump(2) << std::endl; + std::cout << ToolError("JSON_READ", error).dump(2) << std::endl; return 1; } auto report = explain ? PipelineValidator::Explain(root) @@ -388,7 +416,7 @@ int main(int argc, char** argv) { try { request = nlohmann::json::parse(input_str); } catch (const std::exception& e) { - std::cout << Error("JSON_READ", e.what()).dump(2) << std::endl; + std::cout << ToolError("JSON_READ", e.what()).dump(2) << std::endl; return 1; } try { @@ -396,10 +424,11 @@ int main(int argc, char** argv) { std::cout << result.ToJson().dump(2) << std::endl; return result.ok ? 0 : 1; } catch (const std::exception& error) { - std::cout << Error("AUTHORING_ERROR", error.what()).dump(2) << std::endl; + std::cout << ToolError("AUTHORING_ERROR", error.what()).dump(2) + << std::endl; return 1; } catch (...) { - std::cout << Error("AUTHORING_ERROR", "未知编排工具错误").dump(2) + std::cout << ToolError("AUTHORING_ERROR", "未知编排工具错误").dump(2) << std::endl; return 1; } @@ -425,10 +454,11 @@ int main(int argc, char** argv) { std::cout << result.ToJson().dump(2) << std::endl; return result.ok ? 0 : 1; } catch (const std::exception& error) { - std::cout << Error("AUTHORING_ERROR", error.what()).dump(2) << std::endl; + std::cout << ToolError("AUTHORING_ERROR", error.what()).dump(2) + << std::endl; return 1; } catch (...) { - std::cout << Error("AUTHORING_ERROR", "未知编排工具错误").dump(2) + std::cout << ToolError("AUTHORING_ERROR", "未知编排工具错误").dump(2) << std::endl; return 1; } diff --git a/src/tools/pipeline_authoring.cpp b/src/tools/pipeline_authoring.cpp index ac64bf22..bb1c20a1 100644 --- a/src/tools/pipeline_authoring.cpp +++ b/src/tools/pipeline_authoring.cpp @@ -1623,7 +1623,8 @@ FixDepsResult PipelineAuthoring::FixDeps(const std::string& file_path, for (const auto& diag : report.diagnostics) { if (diag.remediation.has_value() && - diag.remediation->cause == "producer_not_dependency_ancestor") { + diag.remediation->cause == + RemediationCause::kProducerNotDependencyAncestor) { std::string producer_id = diag.remediation->facts.value("producer_id", ""); std::string bound_key = diag.remediation->facts.value("bound_key", ""); diff --git a/tests/contract/catalog/test_catalog_contract_ssot.cpp b/tests/contract/catalog/test_catalog_contract_ssot.cpp index 3a77e0ba..e5ed0f7d 100644 --- a/tests/contract/catalog/test_catalog_contract_ssot.cpp +++ b/tests/contract/catalog/test_catalog_contract_ssot.cpp @@ -8,10 +8,12 @@ #include #include "adapter/biz_adapter_registry.h" +#include "core/node_interface.h" #include "core/node_registry.h" #include "core/pipeline_catalog.h" #include "engine/backend_registry.h" #include "engine/model_registry.h" +#include "tests/support/registry_test_access.h" namespace llm_edgeflow { @@ -22,6 +24,15 @@ TEST_F(CatalogContractSsotTest, AllProductionNodesHaveValidDefinitions) { const auto nodes = PipelineCatalog::Nodes(); EXPECT_GE(nodes.size(), 11U); + // R1: NodeRegistry::ListDefinitions() equals PipelineCatalog::Nodes() + const auto reg_defs = NodeRegistry::Instance().ListDefinitions(); + EXPECT_EQ(reg_defs.size(), nodes.size()); + EXPECT_TRUE( + std::is_sorted(nodes.begin(), nodes.end(), + [](const NodeDefinition& a, const NodeDefinition& b) { + return a.node_type < b.node_type; + })); + std::set seen_types; for (const auto& node_def : nodes) { EXPECT_FALSE(node_def.node_type.empty()); @@ -245,4 +256,176 @@ TEST_F(CatalogContractSsotTest, ToJsonSerializationAndFiltering) { EXPECT_TRUE(found_match_node); } +// R6: 并发同名注册只有一个成功,另一方失败锁存 +TEST_F(CatalogContractSsotTest, ConcurrentSameNameRegistrationSingleWinner) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string race_type = "ConcurrentRaceNode"; + ASSERT_FALSE(NodeRegistry::Instance().Has(race_type)); + + std::atomic start_flag{0}; + std::atomic success_count{0}; + std::atomic fail_count{0}; + + constexpr int kThreads = 4; + std::vector threads; + threads.reserve(kThreads); + + for (int i = 0; i < kThreads; ++i) { + threads.emplace_back([&, i]() { + while (start_flag.load() == 0) { + std::this_thread::yield(); + } + NodeDefinition def; + def.node_type = race_type; + def.category = "race"; + def.description = "thread " + std::to_string(i); + bool ok = NodeRegistry::Instance().Register( + race_type, []() -> std::unique_ptr { return nullptr; }, def); + if (ok) { + success_count.fetch_add(1); + } else { + fail_count.fetch_add(1); + } + }); + } + + start_flag.store(1); + for (auto& t : threads) { + t.join(); + } + + EXPECT_EQ(success_count.load(), 1); + EXPECT_EQ(fail_count.load(), kThreads - 1); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(race_type)); + EXPECT_TRUE(PipelineCatalog::FindNode(race_type).has_value()); +} + +// R6: 并发不同类型相同 Control ID 的冲突在提交时被发现 +TEST_F(CatalogContractSsotTest, ConcurrentConflictingControlIdDetected) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string node_a = "ConcurrentControlNodeA"; + const std::string node_b = "ConcurrentControlNodeB"; + ASSERT_FALSE(NodeRegistry::Instance().Has(node_a)); + ASSERT_FALSE(NodeRegistry::Instance().Has(node_b)); + + std::atomic start_flag{0}; + std::atomic success_count{0}; + std::atomic fail_count{0}; + + auto make_conflicting_def = [](const std::string& type, + const std::string& cmd_name) { + NodeDefinition def; + def.node_type = type; + def.category = "test"; + def.description = "control conflict"; + ControlCommandDefinition cmd; + cmd.cmd_id = 8888; + cmd.name = cmd_name; + cmd.shared_id = false; + cmd.payload_schema = nlohmann::json::object(); + def.control_commands.push_back(cmd); + return def; + }; + + std::thread t1([&]() { + while (start_flag.load() == 0) std::this_thread::yield(); + bool ok = NodeRegistry::Instance().Register( + node_a, []() -> std::unique_ptr { return nullptr; }, + make_conflicting_def(node_a, "cmd_a")); + if (ok) + success_count.fetch_add(1); + else + fail_count.fetch_add(1); + }); + + std::thread t2([&]() { + while (start_flag.load() == 0) std::this_thread::yield(); + bool ok = NodeRegistry::Instance().Register( + node_b, []() -> std::unique_ptr { return nullptr; }, + make_conflicting_def(node_b, "cmd_b")); + if (ok) + success_count.fetch_add(1); + else + fail_count.fetch_add(1); + }); + + start_flag.store(1); + t1.join(); + t2.join(); + + EXPECT_LE(success_count.load(), 1); + EXPECT_GE(fail_count.load(), 1); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); +} + +// R7: 并发读快照与新增注册只能得到完整条目;旧快照不受影响 +TEST_F(CatalogContractSsotTest, + ConcurrentSnapshotReadersWhileRegisteringNodes) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const auto initial_snapshot = NodeRegistry::Instance().Snapshot(); + ASSERT_FALSE(initial_snapshot.definitions.empty()); + const size_t initial_count = initial_snapshot.definitions.size(); + + std::atomic writer_done{false}; + std::atomic readers_ok{true}; + + std::thread reader([&]() { + while (!writer_done.load(std::memory_order_relaxed)) { + const auto snap = NodeRegistry::Instance().Snapshot(); + EXPECT_GE(snap.definitions.size(), initial_count); + bool sorted = + std::is_sorted(snap.definitions.begin(), snap.definitions.end(), + [](const NodeDefinition& a, const NodeDefinition& b) { + return a.node_type < b.node_type; + }); + if (!sorted) readers_ok.store(false); + for (const auto& def : snap.definitions) { + if (def.node_type.empty() || def.category.empty()) { + readers_ok.store(false); + } + } + } + }); + + for (int i = 0; i < 16; ++i) { + const std::string node_name = "SnapshotWriterNode_" + std::to_string(i); + NodeDefinition def; + def.node_type = node_name; + def.category = "snapshot_test"; + def.description = "node " + std::to_string(i); + bool ok = NodeRegistry::Instance().Register( + node_name, []() -> std::unique_ptr { return nullptr; }, def); + EXPECT_TRUE(ok); + } + writer_done.store(true, std::memory_order_release); + reader.join(); + + EXPECT_TRUE(readers_ok.load()); + EXPECT_EQ(initial_snapshot.definitions.size(), initial_count); +} + +// R8: ScopedNodeState 测试隔离与还原 +TEST_F(CatalogContractSsotTest, ScopedNodeStateRestoresCleanly) { + const size_t original_count = + NodeRegistry::Instance().ListDefinitions().size(); + const bool original_conflict = NodeRegistry::Instance().HasConflict(); + { + test_support::RegistryTestAccess::ScopedNodeState scoped; + NodeDefinition def; + def.node_type = "ScopedTempNode"; + def.category = "temp"; + def.description = "temp"; + EXPECT_TRUE(NodeRegistry::Instance().Register( + "ScopedTempNode", []() -> std::unique_ptr { return nullptr; }, + def)); + EXPECT_TRUE(NodeRegistry::Instance().Has("ScopedTempNode")); + EXPECT_EQ(NodeRegistry::Instance().ListDefinitions().size(), + original_count + 1); + } + EXPECT_FALSE(NodeRegistry::Instance().Has("ScopedTempNode")); + EXPECT_EQ(NodeRegistry::Instance().ListDefinitions().size(), original_count); + EXPECT_EQ(NodeRegistry::Instance().HasConflict(), original_conflict); +} + } // namespace llm_edgeflow diff --git a/tests/contract/catalog/test_model_backend_registry_conflict.cpp b/tests/contract/catalog/test_model_backend_registry_conflict.cpp index df48b93c..966ce269 100644 --- a/tests/contract/catalog/test_model_backend_registry_conflict.cpp +++ b/tests/contract/catalog/test_model_backend_registry_conflict.cpp @@ -6,6 +6,7 @@ #include "core/pipeline_catalog.h" #include "engine/backend_registry.h" #include "engine/model_registry.h" +#include "tests/support/registry_test_access.h" namespace llm_edgeflow { namespace { @@ -115,7 +116,7 @@ TEST(ModelBackendRegistryConflictTest, DefinitionValidationIsFailClosed) { ASSERT_TRUE(stored_backend.has_value()); EXPECT_EQ(stored_backend->description, "original"); - PipelineCatalog::ClearForTesting(); + test_support::RegistryTestAccess::ResetNodes(); EXPECT_TRUE(model_registry.Has(original_model.model_type)); EXPECT_TRUE(backend_registry.Has(original_backend.backend_type)); diff --git a/tests/contract/catalog/test_registry_conflict.cpp b/tests/contract/catalog/test_registry_conflict.cpp index b6302107..f46cd943 100644 --- a/tests/contract/catalog/test_registry_conflict.cpp +++ b/tests/contract/catalog/test_registry_conflict.cpp @@ -127,7 +127,7 @@ TEST(RegistryAuthoringStartupTest, EXPECT_FALSE( pipeline.BuildFromJson(config, &build_diagnostic, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(build_diagnostic.code, PipelineErrorCode::kRegistryConflict); + EXPECT_EQ(build_diagnostic.code, DiagnosticCode::kRegistryConflict); EXPECT_NE(build_diagnostic.message.find("BadAuthoringNode"), std::string::npos); EXPECT_NE(build_diagnostic.message.find(reason), std::string::npos); @@ -179,7 +179,7 @@ TEST(RegistryConflictNodeTest, DuplicateNodeFailClosed) { {"depends_on", nlohmann::json::array()}}})}}; EXPECT_FALSE(pipe.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kRegistryConflict); + EXPECT_EQ(diag.code, DiagnosticCode::kRegistryConflict); EXPECT_EQ(diag.path, "/pipeline"); } @@ -208,7 +208,7 @@ TEST(RegistryConflictModelTest, DuplicateModelFailClosed) { {"depends_on", nlohmann::json::array()}}})}}; EXPECT_FALSE(pipe.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kRegistryConflict); + EXPECT_EQ(diag.code, DiagnosticCode::kRegistryConflict); EXPECT_EQ(diag.path, "/models"); } diff --git a/tests/fixtures/pipelines/validation/invalid_pipeline_cases.json b/tests/fixtures/pipelines/validation/invalid_pipeline_cases.json index fd6489f3..80fe90f1 100644 --- a/tests/fixtures/pipelines/validation/invalid_pipeline_cases.json +++ b/tests/fixtures/pipelines/validation/invalid_pipeline_cases.json @@ -16,10 +16,11 @@ } ] }, - "required_codes": ["UNKNOWN_BIZ"], + "required_codes": [ + "UNKNOWN_BIZ" + ], "primary_code": "UNKNOWN_BIZ", "primary_path": "/biz_name", - "pipeline_error_code": 8, "runtime_error_code": -2 }, { @@ -37,10 +38,11 @@ } ] }, - "required_codes": ["UNKNOWN_NODE_TYPE"], + "required_codes": [ + "UNKNOWN_NODE_TYPE" + ], "primary_code": "UNKNOWN_NODE_TYPE", "primary_path": "/pipeline/0/node_type", - "pipeline_error_code": 11, "runtime_error_code": -2 }, { @@ -49,34 +51,69 @@ "pipeline": { "biz_name": "entity_extract_v1", "models": [ - {"model_id": "llm", "capability": "llm", "model_type": "test_biz_llm", "backend": "test_causal_lm_backend", "model_path": "fixture.gguf"} + { + "model_id": "llm", + "capability": "llm", + "model_type": "test_biz_llm", + "backend": "test_causal_lm_backend", + "model_path": "fixture.gguf" + } ], "pipeline": [ { "id": "pre", "node_type": "TextTemplateNode", "depends_on": [], - "ports": {"inputs": {"primary": "input_sentences"}, "outputs": {"text": "prompt_text"}} + "ports": { + "inputs": { + "primary": "input_sentences" + }, + "outputs": { + "text": "prompt_text" + } + } }, { "id": "generate", "node_type": "LlmGenerateNode", - "depends_on": ["pre"], - "ports": {"inputs": {"prompt": "prompt_text"}, "outputs": {"text": "ans"}}, - "config": {"bind_model": "llm", "unsupported_option": true} + "depends_on": [ + "pre" + ], + "ports": { + "inputs": { + "prompt": "prompt_text" + }, + "outputs": { + "text": "ans" + } + }, + "config": { + "bind_model": "llm", + "unsupported_option": true + } }, { "id": "post", "node_type": "StructuredJsonParseNode", - "depends_on": ["generate"], - "ports": {"inputs": {"text": "ans"}, "outputs": {"document": "extracted_entities"}} + "depends_on": [ + "generate" + ], + "ports": { + "inputs": { + "text": "ans" + }, + "outputs": { + "document": "extracted_entities" + } + } } ] }, - "required_codes": ["UNKNOWN_CONFIG_FIELD"], + "required_codes": [ + "UNKNOWN_CONFIG_FIELD" + ], "primary_code": "UNKNOWN_CONFIG_FIELD", "primary_path": "/pipeline/1/config/unsupported_option", - "pipeline_error_code": 4, "runtime_error_code": -2 }, { @@ -85,34 +122,69 @@ "pipeline": { "biz_name": "entity_extract_v1", "models": [ - {"model_id": "llm", "capability": "llm", "model_type": "test_biz_llm", "backend": "test_causal_lm_backend", "model_path": "fixture.gguf"} + { + "model_id": "llm", + "capability": "llm", + "model_type": "test_biz_llm", + "backend": "test_causal_lm_backend", + "model_path": "fixture.gguf" + } ], "pipeline": [ { "id": "pre", "node_type": "TextTemplateNode", "depends_on": [], - "ports": {"inputs": {"primary": "input_sentences"}, "outputs": {"text": "prompt_text"}} + "ports": { + "inputs": { + "primary": "input_sentences" + }, + "outputs": { + "text": "prompt_text" + } + } }, { "id": "generate", "node_type": "LlmGenerateNode", - "depends_on": ["pre"], - "ports": {"inputs": {"prompt": "prompt_text"}, "outputs": {"text": "ans"}}, - "config": {"bind_model": "llm", "max_tokens": 0} + "depends_on": [ + "pre" + ], + "ports": { + "inputs": { + "prompt": "prompt_text" + }, + "outputs": { + "text": "ans" + } + }, + "config": { + "bind_model": "llm", + "max_tokens": 0 + } }, { "id": "post", "node_type": "StructuredJsonParseNode", - "depends_on": ["generate"], - "ports": {"inputs": {"text": "ans"}, "outputs": {"document": "extracted_entities"}} + "depends_on": [ + "generate" + ], + "ports": { + "inputs": { + "text": "ans" + }, + "outputs": { + "document": "extracted_entities" + } + } } ] }, - "required_codes": ["CONFIG_FIELD_RANGE"], + "required_codes": [ + "CONFIG_FIELD_RANGE" + ], "primary_code": "CONFIG_FIELD_RANGE", "primary_path": "/pipeline/1/config/max_tokens", - "pipeline_error_code": 7, "runtime_error_code": -2 }, { @@ -121,34 +193,68 @@ "pipeline": { "biz_name": "entity_extract_v1", "models": [ - {"model_id": "embedding", "capability": "embedding", "model_type": "test_biz_embedding", "backend": "test_tensor_backend", "model_path": "fixture.onnx"} + { + "model_id": "embedding", + "capability": "embedding", + "model_type": "test_biz_embedding", + "backend": "test_tensor_backend", + "model_path": "fixture.onnx" + } ], "pipeline": [ { "id": "pre", "node_type": "TextTemplateNode", "depends_on": [], - "ports": {"inputs": {"primary": "input_sentences"}, "outputs": {"text": "prompt_text"}} + "ports": { + "inputs": { + "primary": "input_sentences" + }, + "outputs": { + "text": "prompt_text" + } + } }, { "id": "generate", "node_type": "LlmGenerateNode", - "depends_on": ["pre"], - "ports": {"inputs": {"prompt": "prompt_text"}, "outputs": {"text": "ans"}}, - "config": {"bind_model": "embedding"} + "depends_on": [ + "pre" + ], + "ports": { + "inputs": { + "prompt": "prompt_text" + }, + "outputs": { + "text": "ans" + } + }, + "config": { + "bind_model": "embedding" + } }, { "id": "post", "node_type": "StructuredJsonParseNode", - "depends_on": ["generate"], - "ports": {"inputs": {"text": "ans"}, "outputs": {"document": "extracted_entities"}} + "depends_on": [ + "generate" + ], + "ports": { + "inputs": { + "text": "ans" + }, + "outputs": { + "document": "extracted_entities" + } + } } ] }, - "required_codes": ["MODEL_CAPABILITY_MISMATCH"], + "required_codes": [ + "MODEL_CAPABILITY_MISMATCH" + ], "primary_code": "MODEL_CAPABILITY_MISMATCH", "primary_path": "/pipeline/1/config/bind_model", - "pipeline_error_code": 8, "runtime_error_code": -2 }, { @@ -158,14 +264,29 @@ "biz_name": "keyword_match_v1", "models": [], "pipeline": [ - {"id": "a", "node_type": "TextRuleMatchNode", "depends_on": ["b"], "config": {}}, - {"id": "b", "node_type": "TextRuleMatchNode", "depends_on": ["a"], "config": {}} + { + "id": "a", + "node_type": "TextRuleMatchNode", + "depends_on": [ + "b" + ], + "config": {} + }, + { + "id": "b", + "node_type": "TextRuleMatchNode", + "depends_on": [ + "a" + ], + "config": {} + } ] }, - "required_codes": ["DAG_CYCLE"], + "required_codes": [ + "DAG_CYCLE" + ], "primary_code": "DAG_CYCLE", "primary_path": "/pipeline", - "pipeline_error_code": 15, "runtime_error_code": -2 }, { @@ -188,10 +309,11 @@ } ] }, - "required_codes": ["MISSING_INPUT_PRODUCER"], + "required_codes": [ + "MISSING_INPUT_PRODUCER" + ], "primary_code": "MISSING_INPUT_PRODUCER", "primary_path": "/pipeline/0/ports/inputs/text", - "pipeline_error_code": 8, "runtime_error_code": -2 }, { @@ -207,8 +329,12 @@ "node_type": "TextRuleMatchNode", "depends_on": [], "ports": { - "inputs": {"text": "input_sentences"}, - "outputs": {"matches": "conflict_matches"} + "inputs": { + "text": "input_sentences" + }, + "outputs": { + "matches": "conflict_matches" + } }, "config": {} }, @@ -217,17 +343,23 @@ "node_type": "TextRuleMatchNode", "depends_on": [], "ports": { - "inputs": {"text": "input_sentences"}, - "outputs": {"matches": "conflict_matches"} + "inputs": { + "text": "input_sentences" + }, + "outputs": { + "matches": "conflict_matches" + } }, "config": {} } ] }, - "required_codes": ["DUPLICATE_PORT_PRODUCER", "PARALLEL_WRITE_CONFLICT"], + "required_codes": [ + "DUPLICATE_PORT_PRODUCER", + "PARALLEL_WRITE_CONFLICT" + ], "primary_code": "DUPLICATE_PORT_PRODUCER", "primary_path": "/pipeline/1/ports/outputs/matches", - "pipeline_error_code": 8, "runtime_error_code": -2 }, { @@ -237,7 +369,13 @@ "biz_name": "entity_extract_v1", "execution_mode": "parallel", "models": [ - {"model_id": "serialized_llm", "capability": "llm", "model_type": "test_biz_llm", "backend": "test_causal_lm_backend", "model_path": "fixture.gguf"} + { + "model_id": "serialized_llm", + "capability": "llm", + "model_type": "test_biz_llm", + "backend": "test_causal_lm_backend", + "model_path": "fixture.gguf" + } ], "pipeline": [ { @@ -245,45 +383,172 @@ "node_type": "TextTemplateNode", "depends_on": [], "ports": { - "inputs": {"primary": "input_sentences"}, - "outputs": {"text": "prompt_text"} + "inputs": { + "primary": "input_sentences" + }, + "outputs": { + "text": "prompt_text" + } } }, { "id": "left", "node_type": "LlmGenerateNode", - "depends_on": ["pre"], + "depends_on": [ + "pre" + ], "ports": { - "inputs": {"prompt": "prompt_text"}, - "outputs": {"text": "ans_left"} + "inputs": { + "prompt": "prompt_text" + }, + "outputs": { + "text": "ans_left" + } }, - "config": {"bind_model": "serialized_llm"} + "config": { + "bind_model": "serialized_llm" + } }, { "id": "right", "node_type": "LlmGenerateNode", - "depends_on": ["pre"], + "depends_on": [ + "pre" + ], "ports": { - "inputs": {"prompt": "prompt_text"}, - "outputs": {"text": "ans_right"} + "inputs": { + "prompt": "prompt_text" + }, + "outputs": { + "text": "ans_right" + } }, - "config": {"bind_model": "serialized_llm"} + "config": { + "bind_model": "serialized_llm" + } }, { "id": "post", "node_type": "StructuredJsonParseNode", - "depends_on": ["left", "right"], + "depends_on": [ + "left", + "right" + ], "ports": { - "inputs": {"text": "ans_left"}, - "outputs": {"document": "extracted_entities"} + "inputs": { + "text": "ans_left" + }, + "outputs": { + "document": "extracted_entities" + } } } ] }, - "required_codes": ["SERIALIZED_MODEL_CONCURRENCY"], + "required_codes": [ + "SERIALIZED_MODEL_CONCURRENCY" + ], "primary_code": "SERIALIZED_MODEL_CONCURRENCY", "primary_path": "/pipeline/2/config/bind_model", - "pipeline_error_code": 8, + "runtime_error_code": -2 + }, + { + "name": "missing_node_id", + "biz_type": 2, + "pipeline": { + "biz_name": "keyword_match_v1", + "models": [], + "pipeline": [ + { + "node_type": "TextRuleMatchNode", + "depends_on": [], + "config": {} + } + ] + }, + "required_codes": [ + "MISSING_FIELD" + ], + "primary_code": "MISSING_FIELD", + "primary_path": "/pipeline/0/id", + "runtime_error_code": -2 + }, + { + "name": "invalid_execution_mode", + "biz_type": 2, + "pipeline": { + "biz_name": "keyword_match_v1", + "execution_mode": "quantum", + "models": [], + "pipeline": [ + { + "id": "kw", + "node_type": "TextRuleMatchNode", + "depends_on": [], + "config": {} + } + ] + }, + "required_codes": [ + "FIELD_RANGE" + ], + "primary_code": "FIELD_RANGE", + "primary_path": "/execution_mode", + "runtime_error_code": -2 + }, + { + "name": "duplicate_dependency", + "biz_type": 2, + "pipeline": { + "biz_name": "keyword_match_v1", + "models": [], + "pipeline": [ + { + "id": "node_a", + "node_type": "TextRuleMatchNode", + "depends_on": [], + "config": {} + }, + { + "id": "node_b", + "node_type": "TextRuleMatchNode", + "depends_on": [ + "node_a", + "node_a" + ], + "config": {} + } + ] + }, + "required_codes": [ + "DUPLICATE_DEPENDENCY" + ], + "primary_code": "DUPLICATE_DEPENDENCY", + "primary_path": "/pipeline/1/depends_on/1", + "runtime_error_code": -2 + }, + { + "name": "invalid_dependency", + "biz_type": 2, + "pipeline": { + "biz_name": "keyword_match_v1", + "models": [], + "pipeline": [ + { + "id": "node_a", + "node_type": "TextRuleMatchNode", + "depends_on": [ + "non_existent_node" + ], + "config": {} + } + ] + }, + "required_codes": [ + "INVALID_DEPENDENCY" + ], + "primary_code": "INVALID_DEPENDENCY", + "primary_path": "/pipeline/0/depends_on/0", "runtime_error_code": -2 } ] diff --git a/tests/integration/pipeline/test_model_backend_pipeline.cpp b/tests/integration/pipeline/test_model_backend_pipeline.cpp index 93763ba3..b074c09f 100644 --- a/tests/integration/pipeline/test_model_backend_pipeline.cpp +++ b/tests/integration/pipeline/test_model_backend_pipeline.cpp @@ -538,7 +538,7 @@ TEST_F(ModelBackendPipelineTest, PipelineBuildMaterializesAndRegistersModel) { cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible); EXPECT_TRUE(ok) << diag.message; - EXPECT_EQ(diag.code, PipelineErrorCode::kOk); + EXPECT_EQ(diag.code, DiagnosticCode::kOk); // Verify backend and model creation EXPECT_EQ(g_backend_create_count.load(), 1); @@ -606,7 +606,7 @@ TEST_F(ModelBackendPipelineTest, cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible); EXPECT_FALSE(ok); - EXPECT_EQ(diag.code, PipelineErrorCode::kModelMaterializationFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); // Verify that failing_backend truly attempted to load (preventing false // positives) @@ -755,7 +755,7 @@ TEST_F(ModelBackendPipelineTest, PipelinePassesResolvedPathAndTargetToBackend) { bool ok = pipeline.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible); EXPECT_TRUE(ok); - EXPECT_EQ(diag.code, PipelineErrorCode::kOk); + EXPECT_EQ(diag.code, DiagnosticCode::kOk); // Session registration retains model identity metadata, while the // execution target remains transient and is captured at the Backend load diff --git a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp index 835db1e8..1cd49820 100644 --- a/tests/integration/pipeline/test_pipeline_catalog_validator.cpp +++ b/tests/integration/pipeline/test_pipeline_catalog_validator.cpp @@ -297,11 +297,10 @@ TEST(PipelineValidatorTest, TableDrivenParityMatrix) { bool built = pipeline.BuildFromJson(config, &pipe_diag); EXPECT_FALSE(built); EXPECT_EQ(pipeline.GetState(), Pipeline::State::kFailed); - EXPECT_EQ(static_cast(pipe_diag.code), - test["pipeline_error_code"].get()); + EXPECT_EQ(DiagnosticCodeName(pipe_diag.code), + test["primary_code"].get()); EXPECT_EQ(pipe_diag.path, test["primary_path"].get()); - EXPECT_NE(pipe_diag.message.find(test["primary_code"].get()), - std::string::npos); + EXPECT_EQ(pipe_diag.message, primary["message"].get()); // 3. The shared runtime must fail before materialization and preserve the // primary structured diagnostic in its internal C++ error boundary. @@ -456,7 +455,7 @@ TEST(PipelineValidatorTest, ASSERT_TRUE(target_diag->remediation.has_value()); const auto& rem = *target_diag->remediation; EXPECT_EQ(rem.schema_version, 1); - EXPECT_EQ(rem.cause, "producer_not_dependency_ancestor"); + EXPECT_EQ(rem.cause, RemediationCause::kProducerNotDependencyAncestor); EXPECT_EQ(rem.facts.value("producer_id", ""), "custom_prompt"); EXPECT_EQ(rem.facts.value("bound_key", ""), "llm_raw_answer"); @@ -510,7 +509,7 @@ TEST(PipelineValidatorTest, ExplainReturnsCandidateFixForUnknownConfigField) { ASSERT_TRUE(target_diag->remediation.has_value()); const auto& rem = *target_diag->remediation; EXPECT_EQ(rem.schema_version, 1); - EXPECT_EQ(rem.cause, "unknown_config_field"); + EXPECT_EQ(rem.cause, RemediationCause::kUnknownConfigField); EXPECT_EQ(rem.facts.value("field", ""), "temprature"); ASSERT_TRUE(rem.facts.contains("candidate_fields")); @@ -664,7 +663,7 @@ TEST(PipelineValidatorTest, ValidateProducesBasicRemediation) { ASSERT_NE(target_diag, nullptr); ASSERT_TRUE(target_diag->remediation.has_value()); EXPECT_EQ(target_diag->remediation->schema_version, 1); - EXPECT_FALSE(target_diag->remediation->cause.empty()); + EXPECT_NE(RemediationCauseName(target_diag->remediation->cause), "UNKNOWN"); EXPECT_FALSE(target_diag->remediation->summary.empty()); EXPECT_FALSE(target_diag->remediation->facts.empty()); EXPECT_TRUE(target_diag->remediation->fixes.empty()); @@ -819,7 +818,8 @@ TEST(PipelineValidatorTest, ExplainReturnsPortFlowMismatchRemediation) { ASSERT_NE(target_diag, nullptr); ASSERT_TRUE(target_diag->remediation.has_value()); - EXPECT_EQ(target_diag->remediation->cause, "port_flow_mismatch"); + EXPECT_EQ(target_diag->remediation->cause, + RemediationCause::kPortFlowMismatch); EXPECT_EQ(target_diag->remediation->facts.value("bound_key", ""), "corpus_text"); EXPECT_EQ(target_diag->remediation->facts.value("producer_id", ""), diff --git a/tests/support/registry_test_access.h b/tests/support/registry_test_access.h new file mode 100644 index 00000000..6797cdc1 --- /dev/null +++ b/tests/support/registry_test_access.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/node_registry.h" +#include "core/pipeline_catalog.h" + +namespace llm_edgeflow::test_support { + +class RegistryTestAccess { + public: + static void ResetNodes() { + std::unordered_map old_entries; + { + std::lock_guard lock(NodeRegistry::Instance().mutex_); + old_entries.swap(NodeRegistry::Instance().entries_); + NodeRegistry::Instance().has_conflict_.store(false, + std::memory_order_release); + NodeRegistry::Instance().conflict_errors_.clear(); + } + // old_entries are destructed outside the lock + } + + static void ResetBizs() { PipelineCatalog::ResetBizsForTesting(); } + + static void ClearNodeFailures() noexcept { + try { + std::lock_guard lock(NodeRegistry::Instance().mutex_); + NodeRegistry::Instance().has_conflict_.store(false, + std::memory_order_release); + NodeRegistry::Instance().conflict_errors_.clear(); + } catch (...) { + } + } + + class ScopedNodeState { + public: + ScopedNodeState() { + std::lock_guard lock(NodeRegistry::Instance().mutex_); + saved_entries_ = NodeRegistry::Instance().entries_; + saved_has_conflict_ = NodeRegistry::Instance().has_conflict_.load( + std::memory_order_acquire); + saved_conflict_errors_ = NodeRegistry::Instance().conflict_errors_; + } + + ~ScopedNodeState() noexcept { + std::unordered_map old_entries; + std::vector old_errors; + try { + std::lock_guard lock(NodeRegistry::Instance().mutex_); + old_entries.swap(NodeRegistry::Instance().entries_); + NodeRegistry::Instance().entries_.swap(saved_entries_); + NodeRegistry::Instance().has_conflict_.store(saved_has_conflict_, + std::memory_order_release); + old_errors.swap(NodeRegistry::Instance().conflict_errors_); + NodeRegistry::Instance().conflict_errors_.swap(saved_conflict_errors_); + } catch (...) { + } + // old_entries, old_errors destructed outside lock without allocating + } + + ScopedNodeState(const ScopedNodeState&) = delete; + ScopedNodeState& operator=(const ScopedNodeState&) = delete; + + private: + std::unordered_map saved_entries_; + bool saved_has_conflict_ = false; + std::vector saved_conflict_errors_; + }; +}; + +} // namespace llm_edgeflow::test_support diff --git a/tests/unit/core/test_dag_pipeline.cpp b/tests/unit/core/test_dag_pipeline.cpp index 0980b26d..4ff7569a 100644 --- a/tests/unit/core/test_dag_pipeline.cpp +++ b/tests/unit/core/test_dag_pipeline.cpp @@ -467,7 +467,7 @@ TEST_F(DagPipelineTest, RejectsLegacyPipelineWithoutIdOrDependsOn) { PipelineDiagnostic diag; EXPECT_FALSE(pipeline.BuildFromJson( legacy_config, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kMissingField); + EXPECT_EQ(diag.code, DiagnosticCode::kMissingField); EXPECT_EQ(diag.path, "/pipeline/0/id"); } diff --git a/tests/unit/core/test_definition_schema_validation.cpp b/tests/unit/core/test_definition_schema_validation.cpp index 85dfedd1..426f1ce2 100644 --- a/tests/unit/core/test_definition_schema_validation.cpp +++ b/tests/unit/core/test_definition_schema_validation.cpp @@ -14,6 +14,7 @@ #include "contracts/config_schema_validation.h" #include "contracts/control_payload.h" #include "core/common_contracts.h" +#include "core/node_definition_validation.h" #include "core/node_interface.h" #include "core/node_registry.h" #include "core/pipeline.h" @@ -23,6 +24,7 @@ #include "engine/backend_registry.h" #include "engine/model_interface.h" #include "engine/model_registry.h" +#include "tests/support/registry_test_access.h" namespace llm_edgeflow { namespace { @@ -475,7 +477,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"field_a", ConfigValueKind::kString}, ConfigFieldDefinition{"field_a", ConfigValueKind::kInteger}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(dup_field_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(dup_field_def)); // 2. Minimum > Maximum NodeDefinition invalid_range_def; @@ -484,7 +486,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"num", ConfigValueKind::kNumber, false, 5.0, 10.0, 1.0}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_range_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_range_def)); // 3. Default value kind mismatch NodeDefinition default_mismatch_def; @@ -493,7 +495,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"flag", ConfigValueKind::kBoolean, false, "not_a_bool"}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(default_mismatch_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(default_mismatch_def)); // 4. Default value not in enum NodeDefinition enum_mismatch_def; @@ -507,7 +509,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { std::nullopt, {"mode_a", "mode_b"}}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(enum_mismatch_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(enum_mismatch_def)); // 5. Duplicate enum values NodeDefinition dup_enum_def; @@ -521,7 +523,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { std::nullopt, {"mode_a", "mode_a"}}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(dup_enum_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(dup_enum_def)); // 6. Non-numeric field carrying minimum/maximum (CR-005) NodeDefinition string_range_def; @@ -530,7 +532,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"str_fld", ConfigValueKind::kString, false, "hello", 0.0, 10.0}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(string_range_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(string_range_def)); NodeDefinition bool_range_def; bool_range_def.node_type = "BoolRangeNode"; @@ -538,7 +540,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"bool_fld", ConfigValueKind::kBoolean, false, true, 0.0, 1.0}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(bool_range_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(bool_range_def)); // 7. Node declares model_dependencies with empty config_field NodeDefinition missing_model_field_def; @@ -547,8 +549,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { missing_model_field_def.config_fields = { ConfigFieldDefinition{"some_param", ConfigValueKind::kString}, }; - EXPECT_FALSE( - PipelineCatalog::RegisterNodeDefinition(missing_model_field_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(missing_model_field_def)); // 8. Node declares model_dependencies but field not in config_fields NodeDefinition unlisted_model_field_def; @@ -558,8 +559,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { unlisted_model_field_def.config_fields = { ConfigFieldDefinition{"other_param", ConfigValueKind::kString}, }; - EXPECT_FALSE( - PipelineCatalog::RegisterNodeDefinition(unlisted_model_field_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(unlisted_model_field_def)); // 9. Node declares model_dependencies but config_field is not string NodeDefinition nonstring_model_field_def; @@ -569,8 +569,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { nonstring_model_field_def.config_fields = { ConfigFieldDefinition{"bind_model", ConfigValueKind::kInteger}, }; - EXPECT_FALSE( - PipelineCatalog::RegisterNodeDefinition(nonstring_model_field_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(nonstring_model_field_def)); // 9b. Node declares duplicate slot name or duplicate config field NodeDefinition dup_slot_def; @@ -583,7 +582,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { ConfigFieldDefinition{"bind_model1", ConfigValueKind::kString}, ConfigFieldDefinition{"bind_model2", ConfigValueKind::kString}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(dup_slot_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(dup_slot_def)); NodeDefinition dup_dep_field_def; dup_dep_field_def.node_type = "DupFieldNode"; @@ -594,7 +593,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { dup_dep_field_def.config_fields = { ConfigFieldDefinition{"bind_model", ConfigValueKind::kString}, }; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(dup_dep_field_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(dup_dep_field_def)); // 10. Port constraints referencing undeclared ports NodeDefinition invalid_constraint_def; @@ -604,14 +603,14 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { invalid_constraint_def.port_constraints = { PortGroupConstraint(PortConstraintKind::kAtLeastOneOf, std::vector{"text", "unknown_port"})}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_constraint_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_constraint_def)); // 11. Invalid control command definition NodeDefinition invalid_cmd_def; invalid_cmd_def.node_type = "InvalidCmdNode"; invalid_cmd_def.control_commands = { ControlCommandDefinition(0, "invalid_cmd")}; // id <= 0 - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_cmd_def)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_cmd_def)); // 12. A dynamic lifetime must reference a declared string enum containing // only framework lifetimes. @@ -627,48 +626,63 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidDefinitionAtRegistration) { std::nullopt, std::nullopt, {"request", "forever"}}}; - EXPECT_FALSE( - PipelineCatalog::RegisterNodeDefinition(invalid_lifetime_override)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_lifetime_override)); } TEST(DefinitionSchemaValidationTest, ControlIdsRequireExplicitIdenticalSharing) { + test_support::RegistryTestAccess::ScopedNodeState state_guard; + auto dummy_creator = []() { return nullptr; }; + NodeDefinition first; first.node_type = "PrivateControlOwner"; first.control_commands = { ControlCommandDefinition(2000000101, "private_update")}; - ASSERT_TRUE(PipelineCatalog::RegisterNodeDefinition(first)); + ASSERT_TRUE( + NodeRegistry::Instance().Register(first.node_type, dummy_creator, first)); auto duplicate = first; duplicate.node_type = "PrivateControlDuplicate"; - std::string error; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(duplicate, &error)); + EXPECT_FALSE(NodeRegistry::Instance().Register(duplicate.node_type, + dummy_creator, duplicate)); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + auto errors = NodeRegistry::Instance().GetConflictErrors(); + std::string error = errors.empty() ? "" : errors.front(); EXPECT_NE(error.find("2000000101"), std::string::npos); EXPECT_NE(error.find("PrivateControlOwner"), std::string::npos); + test_support::RegistryTestAccess::ClearNodeFailures(); first.node_type = "SharedControlOwner"; first.control_commands.front().cmd_id = 2000000102; first.control_commands.front().shared_id = true; - ASSERT_TRUE(PipelineCatalog::RegisterNodeDefinition(first)); + ASSERT_TRUE( + NodeRegistry::Instance().Register(first.node_type, dummy_creator, first)); duplicate = first; duplicate.node_type = "SharedControlPeer"; - EXPECT_TRUE(PipelineCatalog::RegisterNodeDefinition(duplicate)); + EXPECT_TRUE(NodeRegistry::Instance().Register(duplicate.node_type, + dummy_creator, duplicate)); duplicate.node_type = "SharedControlNameMismatch"; duplicate.control_commands.front().name = "different_semantics"; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(duplicate)); + EXPECT_FALSE(NodeRegistry::Instance().Register(duplicate.node_type, + dummy_creator, duplicate)); + test_support::RegistryTestAccess::ClearNodeFailures(); duplicate.control_commands = first.control_commands; duplicate.node_type = "SharedControlSchemaMismatch"; duplicate.control_commands.front().payload_schema = {{"type", "string"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(duplicate)); + EXPECT_FALSE(NodeRegistry::Instance().Register(duplicate.node_type, + dummy_creator, duplicate)); + test_support::RegistryTestAccess::ClearNodeFailures(); duplicate.control_commands = first.control_commands; duplicate.node_type = "SharedControlMissingOptIn"; duplicate.control_commands.front().shared_id = false; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(duplicate)); + EXPECT_FALSE(NodeRegistry::Instance().Register(duplicate.node_type, + dummy_creator, duplicate)); + test_support::RegistryTestAccess::ClearNodeFailures(); NodeDefinition invalid; invalid.node_type = "NonObjectControlSchema"; invalid.control_commands = { ControlCommandDefinition(2000000103, "invalid", "", false)}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid)); } TEST(DefinitionSchemaValidationTest, @@ -728,7 +742,7 @@ TEST(DefinitionSchemaValidationTest, ControlSchemaRejectsInvalidDeclarations) { node.control_commands = { ControlCommandDefinition(2000000110, "schema_probe", "", schema)}; std::string error; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(node, &error)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(node, &error)); EXPECT_NE(error.find(node.node_type), std::string::npos) << error; EXPECT_NE(error.find("2000000110"), std::string::npos) << error; EXPECT_NE(error.find(field), std::string::npos) << error; @@ -757,7 +771,7 @@ TEST(DefinitionSchemaValidationTest, node.control_commands = { ControlCommandDefinition(2000000111, "annotated_update", "", schema)}; std::string error; - ASSERT_TRUE(PipelineCatalog::RegisterNodeDefinition(node, &error)) << error; + ASSERT_TRUE(ValidateNodeDefinitionStructure(node, &error)) << error; nlohmann::json payload; ASSERT_TRUE(ParseControlPayload("{}", schema, &payload, &error)) << error; EXPECT_EQ(payload, nlohmann::json::object()); @@ -870,35 +884,35 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidNodePortDefinitions) { empty_key_node.node_type = "EmptyKeyPortNode"; empty_key_node.inputs = { NodePortDefinition{"", "TextBatch", true, "1:1", "preserve", "request"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(empty_key_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(empty_key_node)); // Empty type_id NodeDefinition empty_type_node; empty_type_node.node_type = "EmptyTypePortNode"; empty_type_node.inputs = { NodePortDefinition{"text", "", true, "1:1", "preserve", "request"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(empty_type_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(empty_type_node)); // Invalid cardinality NodeDefinition invalid_card_node; invalid_card_node.node_type = "InvalidCardPortNode"; invalid_card_node.inputs = {NodePortDefinition{"text", "TextBatch", true, "3:3", "preserve", "request"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_card_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_card_node)); // Invalid provenance NodeDefinition invalid_prov_node; invalid_prov_node.node_type = "InvalidProvPortNode"; invalid_prov_node.inputs = { NodePortDefinition{"text", "TextBatch", true, "1:1", "magic", "request"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_prov_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_prov_node)); // Invalid lifetime NodeDefinition invalid_life_node; invalid_life_node.node_type = "InvalidLifePortNode"; invalid_life_node.inputs = {NodePortDefinition{"text", "TextBatch", true, "1:1", "preserve", "eternal"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(invalid_life_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(invalid_life_node)); // Duplicate input port key NodeDefinition dup_key_node; @@ -907,7 +921,7 @@ TEST(DefinitionSchemaValidationTest, RejectsInvalidNodePortDefinitions) { "preserve", "request"}, NodePortDefinition{"text", "TextBatch", false, "1:1", "preserve", "request"}}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(dup_key_node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(dup_key_node)); // Biz definition with invalid port BizDefinition invalid_biz; @@ -1102,7 +1116,7 @@ TEST(DefinitionSchemaValidationTest, NodeAndBizRejectEmptyFlowMetadata) { BizPortDefinition biz_port{"value", "TextBatch"}; static_cast(biz_port) = port; biz.egress = {biz_port}; - EXPECT_FALSE(PipelineCatalog::RegisterNodeDefinition(node)); + EXPECT_FALSE(ValidateNodeDefinitionStructure(node)); EXPECT_FALSE(PipelineCatalog::RegisterBizDefinition(biz)); } } diff --git a/tests/unit/core/test_node_base_contracts.cpp b/tests/unit/core/test_node_base_contracts.cpp index 3392a212..487adc25 100644 --- a/tests/unit/core/test_node_base_contracts.cpp +++ b/tests/unit/core/test_node_base_contracts.cpp @@ -8,6 +8,7 @@ #include "core/alg_context.h" #include "core/blackboard_key.h" +#include "core/node_registry.h" #include "core/pipeline_catalog.h" #include "core/session_context.h" #include "engine/model_interface.h" @@ -18,6 +19,7 @@ #include "nodes/traceable_batch_validation.h" #include "nodes/traceable_unary_inference_node.h" #include "tests/support/node_test_utils.h" +#include "tests/support/registry_test_access.h" namespace llm_edgeflow { @@ -418,12 +420,15 @@ class MockTraceableAsrNode }; TEST(NodeBaseContractsTest, TraceableUnaryInferenceNodeWorkflow) { + test_support::RegistryTestAccess::ScopedNodeState state_guard; NodeDefinition definition; definition.node_type = MockTraceableAsrNode::kNodeType; definition.model_dependencies = {{"transcriber", "asr", "bind_model"}}; definition.config_fields = {ConfigFieldDefinition{ "bind_model", ConfigValueKind::kString, false, "test_asr_model"}}; - ASSERT_TRUE(PipelineCatalog::RegisterNodeDefinition(definition)); + ASSERT_TRUE(NodeRegistry::Instance().Register( + definition.node_type, + []() { return std::make_unique(); }, definition)); SessionContext session_ctx; auto model = std::make_shared(); diff --git a/tests/unit/core/test_pipeline_config.cpp b/tests/unit/core/test_pipeline_config.cpp index 10b16924..6443424a 100644 --- a/tests/unit/core/test_pipeline_config.cpp +++ b/tests/unit/core/test_pipeline_config.cpp @@ -178,6 +178,29 @@ REGISTER_NODE_WITH_DEFINITION(CountingNode, MakeTestNodeDef(CountingNode::kNodeType)); // 2. 异常与失败测试替身 (R1-ACC-001) +class ThrowingCtorModel : public IModel { + public: + inline static constexpr char kModelType[] = "throwing_ctor_model"; + static std::shared_ptr Create(const ModelCreateContext&, + std::string*) { + throw std::runtime_error("ThrowingCtorModel constructor exception"); + } + size_t GetMaxBatchSize() const noexcept override { return 1; } + const std::string& ModelType() const noexcept override { + static const std::string type = kModelType; + return type; + } + const std::string& Capability() const noexcept override { + static const std::string cap = "test"; + return cap; + } + InferenceConcurrency Concurrency() const noexcept override { + return InferenceConcurrency::kConcurrent; + } +}; +REGISTER_MODEL_WITH_DEFINITION(ThrowingCtorModel, + MakeTestModelDef(ThrowingCtorModel::kModelType)); + class ThrowingCtorBackend : public IInferenceBackend { public: inline static constexpr char kBackendType[] = "throwing_ctor_backend"; @@ -298,10 +321,11 @@ static std::string GetConfigPath(const std::string& rel_path) { static nlohmann::json CountingModelEntry( std::string model_id, - std::string backend_type = CountingBackend::kBackendType) { + std::string backend_type = CountingBackend::kBackendType, + std::string model_type = CountingModel::kModelType) { return {{"model_id", std::move(model_id)}, {"capability", "test"}, - {"model_type", CountingModel::kModelType}, + {"model_type", std::move(model_type)}, {"backend", std::move(backend_type)}, {"model_path", "fixture.bin"}, {"model_config", nlohmann::json::object()}, @@ -358,7 +382,7 @@ TEST_F(PipelineConfigTest, PositiveProductionAndStage7FixtureConfigs) { bool parse_ok = ParsePipelineConfig(root, &parsed_cfg, &diag); EXPECT_TRUE(parse_ok) << "Parse failed for " << cfg_file << ": " << diag.message << " at " << diag.path; - EXPECT_EQ(diag.code, PipelineErrorCode::kOk); + EXPECT_EQ(diag.code, DiagnosticCode::kOk); if ((cfg_file == "configs/pipeline_doc_qa_cpu.json" || cfg_file == "configs/pipeline_cross_rerank_cpu.json") && @@ -375,14 +399,13 @@ TEST_F(PipelineConfigTest, PositiveProductionAndStage7FixtureConfigs) { Pipeline pipeline; bool build_ok = pipeline.BuildFromConfigFile(full_path, &diag); - if (!build_ok && - diag.code == PipelineErrorCode::kModelMaterializationFailed) { + if (!build_ok && diag.code == DiagnosticCode::kModelMaterializationFailed) { // 模型物理权重文件在当前测试环境不存在,构建按设计 Fail-Closed - EXPECT_EQ(diag.code, PipelineErrorCode::kModelMaterializationFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); } else { EXPECT_TRUE(build_ok) << "Build failed for " << cfg_file << ": " << diag.message << " at " << diag.path; - EXPECT_EQ(diag.code, PipelineErrorCode::kOk); + EXPECT_EQ(diag.code, DiagnosticCode::kOk); EXPECT_TRUE(pipeline.IsReady()); EXPECT_EQ(pipeline.GetState(), Pipeline::State::kReady); } @@ -401,7 +424,7 @@ TEST_F(PipelineConfigTest, RejectsPipelineWithoutIdOrDependsOn) { ParsedPipelineConfig parsed_cfg; PipelineDiagnostic diag; EXPECT_FALSE(ParsePipelineConfig(root, &parsed_cfg, &diag)); - EXPECT_EQ(diag.code, PipelineErrorCode::kMissingField); + EXPECT_EQ(diag.code, DiagnosticCode::kMissingField); EXPECT_EQ(diag.path, "/pipeline/0/id"); } @@ -416,7 +439,7 @@ TEST_F(PipelineConfigTest, RejectsLegacyBusinessNameField) { ParsedPipelineConfig parsed_cfg; PipelineDiagnostic diag; EXPECT_FALSE(ParsePipelineConfig(root, &parsed_cfg, &diag)); - EXPECT_EQ(diag.code, PipelineErrorCode::kUnknownField); + EXPECT_EQ(diag.code, DiagnosticCode::kUnknownField); EXPECT_EQ(diag.path, "/business_name"); } @@ -425,7 +448,7 @@ TEST_F(PipelineConfigTest, RejectsLegacyBusinessNameField) { struct NegativeTestCase { std::string name; nlohmann::json input; - PipelineErrorCode expected_code; + DiagnosticCode expected_code; std::string expected_path_prefix; }; @@ -439,29 +462,29 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { // --- Root 校验 --- cases.push_back(NegativeTestCase{"RootNotObject", nlohmann::json::array({1, 2, 3}), - PipelineErrorCode::kRootType, "/"}); + DiagnosticCode::kRootType, "/"}); cases.push_back(NegativeTestCase{"RootUnknownField", nlohmann::json{{"biz_name", "test"}, {"unknown_root_key", 123}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, + DiagnosticCode::kUnknownField, "/unknown_root_key"}); cases.push_back(NegativeTestCase{ "RootCommentNotString", nlohmann::json{ {"biz_name", "test"}, {"comment", 12345}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/comment"}); - cases.push_back(NegativeTestCase{ - "MissingBizName", nlohmann::json{{"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/biz_name"}); + DiagnosticCode::kFieldType, "/comment"}); + cases.push_back(NegativeTestCase{"MissingBizName", + nlohmann::json{{"pipeline", valid_pipe}}, + DiagnosticCode::kMissingField, "/biz_name"}); cases.push_back(NegativeTestCase{ "EmptyBizName", nlohmann::json{{"biz_name", ""}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/biz_name"}); + DiagnosticCode::kFieldRange, "/biz_name"}); cases.push_back(NegativeTestCase{ "NonStringBizName", nlohmann::json{{"biz_name", 12345}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/biz_name"}); + DiagnosticCode::kFieldType, "/biz_name"}); // --- Execution Mode & Workers 组合校验 (R1-ACC-003) --- cases.push_back(NegativeTestCase{ @@ -470,24 +493,24 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"execution_mode", "sequential"}, {"max_parallel_workers", 4}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kInvalidCombination, "/max_parallel_workers"}); + DiagnosticCode::kInvalidCombination, "/max_parallel_workers"}); cases.push_back(NegativeTestCase{"ExecutionModeAsyncRejected", nlohmann::json{{"biz_name", "test"}, {"execution_mode", "async"}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, + DiagnosticCode::kFieldRange, "/execution_mode"}); cases.push_back( NegativeTestCase{"ExecutionModeUnknownString", nlohmann::json{{"biz_name", "test"}, {"execution_mode", "coroutine_mode"}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/execution_mode"}); + DiagnosticCode::kFieldRange, "/execution_mode"}); cases.push_back(NegativeTestCase{"ExecutionModeNonString", nlohmann::json{{"biz_name", "test"}, {"execution_mode", true}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, + DiagnosticCode::kFieldType, "/execution_mode"}); cases.push_back(NegativeTestCase{ "WorkersZeroInParallel", @@ -499,7 +522,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { nlohmann::json::array({{{"id", "n1"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kFieldRange, "/max_parallel_workers"}); + DiagnosticCode::kFieldRange, "/max_parallel_workers"}); cases.push_back(NegativeTestCase{ "WorkersOutOfRange65InParallel", nlohmann::json{ @@ -510,27 +533,27 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { nlohmann::json::array({{{"id", "n1"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kFieldRange, "/max_parallel_workers"}); + DiagnosticCode::kFieldRange, "/max_parallel_workers"}); // --- Models 校验 --- cases.push_back(NegativeTestCase{"ModelsNotArray", nlohmann::json{{"biz_name", "test"}, {"models", "not_an_array"}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models"}); + DiagnosticCode::kFieldType, "/models"}); cases.push_back(NegativeTestCase{ "ModelItemNotObject", nlohmann::json{{"biz_name", "test"}, {"models", nlohmann::json::array({"invalid_string"})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0"}); + DiagnosticCode::kFieldType, "/models/0"}); cases.push_back(NegativeTestCase{ "ModelCommentNotString", nlohmann::json{{"biz_name", "test"}, {"models", nlohmann::json::array( {{{"model_id", "m1"}, {"comment", 123}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/comment"}); + DiagnosticCode::kFieldType, "/models/0/comment"}); cases.push_back(NegativeTestCase{ "ModelUnknownField", nlohmann::json{ @@ -538,7 +561,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"models", nlohmann::json::array( {{{"model_id", "m1"}, {"unknown_model_key", 1}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/unknown_model_key"}); + DiagnosticCode::kUnknownField, "/models/0/unknown_model_key"}); cases.push_back(NegativeTestCase{ "ModelMissingId", nlohmann::json{ @@ -548,7 +571,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "counting_backend"}, {"model_path", "model.bin"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/model_id"}); + DiagnosticCode::kMissingField, "/models/0/model_id"}); cases.push_back(NegativeTestCase{ "ModelEmptyId", nlohmann::json{ @@ -559,7 +582,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "counting_backend"}, {"model_path", "model.bin"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/models/0/model_id"}); + DiagnosticCode::kFieldRange, "/models/0/model_id"}); cases.push_back(NegativeTestCase{ "ModelDuplicateId", nlohmann::json{ @@ -575,13 +598,13 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "counting_backend"}, {"model_path", "model.bin"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kDuplicateModelId, "/models/1/model_id"}); + DiagnosticCode::kDuplicateModelId, "/models/1/model_id"}); cases.push_back(NegativeTestCase{ "ModelMissingCapability", nlohmann::json{{"biz_name", "test"}, {"models", nlohmann::json::array({{{"model_id", "m1"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/capability"}); + DiagnosticCode::kMissingField, "/models/0/capability"}); cases.push_back(NegativeTestCase{ "ModelConfigNotObject", nlohmann::json{ @@ -593,7 +616,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_path", "model.bin"}, {"model_config", "invalid"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/model_config"}); + DiagnosticCode::kFieldType, "/models/0/model_config"}); cases.push_back(NegativeTestCase{ "LegacyEngineTypeRejected", nlohmann::json{ @@ -602,7 +625,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{{"model_id", "m1"}, {"engine_type", "unregistered_mock_engine_xyz"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/engine_type"}); + DiagnosticCode::kUnknownField, "/models/0/engine_type"}); // --- Model/Backend 方言及混用校验 (RFC 0015) --- cases.push_back(NegativeTestCase{ @@ -614,7 +637,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/capability"}); + DiagnosticCode::kMissingField, "/models/0/capability"}); cases.push_back(NegativeTestCase{ "ModelBackendEmptyCapability", nlohmann::json{ @@ -625,7 +648,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/models/0/capability"}); + DiagnosticCode::kFieldRange, "/models/0/capability"}); cases.push_back(NegativeTestCase{ "ModelBackendWrongTypeCapability", nlohmann::json{ @@ -636,7 +659,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/capability"}); + DiagnosticCode::kFieldType, "/models/0/capability"}); cases.push_back(NegativeTestCase{ "ModelBackendMissingModelType", nlohmann::json{ @@ -646,7 +669,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/model_type"}); + DiagnosticCode::kMissingField, "/models/0/model_type"}); cases.push_back(NegativeTestCase{ "ModelBackendEmptyModelType", nlohmann::json{ @@ -657,7 +680,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/models/0/model_type"}); + DiagnosticCode::kFieldRange, "/models/0/model_type"}); cases.push_back(NegativeTestCase{ "ModelBackendWrongTypeModelType", nlohmann::json{ @@ -668,7 +691,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/model_type"}); + DiagnosticCode::kFieldType, "/models/0/model_type"}); cases.push_back(NegativeTestCase{ "ModelBackendMissingBackend", nlohmann::json{ @@ -678,7 +701,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_type", "bge_embedding"}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/backend"}); + DiagnosticCode::kMissingField, "/models/0/backend"}); cases.push_back(NegativeTestCase{ "ModelBackendEmptyBackend", nlohmann::json{ @@ -689,7 +712,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", ""}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/models/0/backend"}); + DiagnosticCode::kFieldRange, "/models/0/backend"}); cases.push_back(NegativeTestCase{ "ModelBackendWrongTypeBackend", nlohmann::json{ @@ -700,7 +723,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", 456}, {"model_path", "./model.onnx"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/backend"}); + DiagnosticCode::kFieldType, "/models/0/backend"}); cases.push_back(NegativeTestCase{ "ModelBackendMissingModelPath", nlohmann::json{ @@ -710,7 +733,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_type", "bge_embedding"}, {"backend", "onnxruntime"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kMissingField, "/models/0/model_path"}); + DiagnosticCode::kMissingField, "/models/0/model_path"}); cases.push_back(NegativeTestCase{ "ModelBackendEmptyModelPath", nlohmann::json{ @@ -721,7 +744,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", ""}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldRange, "/models/0/model_path"}); + DiagnosticCode::kFieldRange, "/models/0/model_path"}); cases.push_back(NegativeTestCase{ "ModelBackendWrongTypeModelPath", nlohmann::json{ @@ -732,7 +755,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"backend", "onnxruntime"}, {"model_path", 789}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/model_path"}); + DiagnosticCode::kFieldType, "/models/0/model_path"}); cases.push_back(NegativeTestCase{ "ModelBackendModelConfigNotObject", nlohmann::json{ @@ -744,7 +767,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_path", "./model.onnx"}, {"model_config", "not_object"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/model_config"}); + DiagnosticCode::kFieldType, "/models/0/model_config"}); cases.push_back(NegativeTestCase{ "ModelBackendBackendConfigNotObject", nlohmann::json{ @@ -756,7 +779,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_path", "./model.onnx"}, {"backend_config", 123}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kFieldType, "/models/0/backend_config"}); + DiagnosticCode::kFieldType, "/models/0/backend_config"}); cases.push_back(NegativeTestCase{ "ModelBackendUnknownField", nlohmann::json{ @@ -768,7 +791,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"model_path", "./model.onnx"}, {"unsupported_opt", true}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/unsupported_opt"}); + DiagnosticCode::kUnknownField, "/models/0/unsupported_opt"}); cases.push_back(NegativeTestCase{ "LegacyEngineTypeIsUnknown", nlohmann::json{ @@ -777,7 +800,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"engine_type", "counting_engine"}, {"capability", "embedding"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/engine_type"}); + DiagnosticCode::kUnknownField, "/models/0/engine_type"}); cases.push_back(NegativeTestCase{ "LegacyEngineTypeWithBackendIsUnknown", nlohmann::json{ @@ -786,7 +809,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"engine_type", "counting_engine"}, {"backend", "onnxruntime"}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/engine_type"}); + DiagnosticCode::kUnknownField, "/models/0/engine_type"}); cases.push_back(NegativeTestCase{ "LegacyConfigFieldIsUnknown", nlohmann::json{ @@ -800,26 +823,26 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"config", nlohmann::json::object()}, {"model_config", nlohmann::json::object()}}})}, {"pipeline", valid_pipe}}, - PipelineErrorCode::kUnknownField, "/models/0/config"}); + DiagnosticCode::kUnknownField, "/models/0/config"}); // --- Pipeline Nodes 校验 --- - cases.push_back( - NegativeTestCase{"MissingPipeline", nlohmann::json{{"biz_name", "test"}}, - PipelineErrorCode::kMissingField, "/pipeline"}); + cases.push_back(NegativeTestCase{"MissingPipeline", + nlohmann::json{{"biz_name", "test"}}, + DiagnosticCode::kMissingField, "/pipeline"}); cases.push_back(NegativeTestCase{ "PipelineNotArray", nlohmann::json{{"biz_name", "test"}, {"pipeline", "not_an_array"}}, - PipelineErrorCode::kFieldType, "/pipeline"}); + DiagnosticCode::kFieldType, "/pipeline"}); cases.push_back( NegativeTestCase{"PipelineEmptyArray", nlohmann::json{{"biz_name", "test"}, {"pipeline", nlohmann::json::array()}}, - PipelineErrorCode::kFieldRange, "/pipeline"}); + DiagnosticCode::kFieldRange, "/pipeline"}); cases.push_back(NegativeTestCase{ "NodeNotObject", nlohmann::json{{"biz_name", "test"}, {"pipeline", nlohmann::json::array({"string_node"})}}, - PipelineErrorCode::kFieldType, "/pipeline/0"}); + DiagnosticCode::kFieldType, "/pipeline/0"}); cases.push_back(NegativeTestCase{ "NodeCommentNotString", nlohmann::json{{"biz_name", "test"}, @@ -828,7 +851,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}, {"comment", 999}}})}}, - PipelineErrorCode::kFieldType, "/pipeline/0/comment"}); + DiagnosticCode::kFieldType, "/pipeline/0/comment"}); cases.push_back(NegativeTestCase{ "NodeUnknownTopLevelField", nlohmann::json{{"biz_name", "test"}, @@ -837,7 +860,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}, {"unknown_top_level", 123}}})}}, - PipelineErrorCode::kUnknownField, "/pipeline/0/unknown_top_level"}); + DiagnosticCode::kUnknownField, "/pipeline/0/unknown_top_level"}); cases.push_back(NegativeTestCase{ "NodeMissingNodeType", nlohmann::json{ @@ -846,7 +869,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { nlohmann::json::array({{{"id", "n0"}, {"depends_on", nlohmann::json::array()}, {"config", nlohmann::json::object()}}})}}, - PipelineErrorCode::kMissingField, "/pipeline/0/node_type"}); + DiagnosticCode::kMissingField, "/pipeline/0/node_type"}); cases.push_back(NegativeTestCase{ "NodeEmptyNodeType", nlohmann::json{ @@ -855,7 +878,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { nlohmann::json::array({{{"id", "n0"}, {"node_type", ""}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kFieldRange, "/pipeline/0/node_type"}); + DiagnosticCode::kFieldRange, "/pipeline/0/node_type"}); cases.push_back(NegativeTestCase{ "NodeConfigNotObject", nlohmann::json{{"biz_name", "test"}, @@ -864,7 +887,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}, {"config", "not_an_object"}}})}}, - PipelineErrorCode::kFieldType, "/pipeline/0/config"}); + DiagnosticCode::kFieldType, "/pipeline/0/config"}); cases.push_back(NegativeTestCase{ "NodeUnregisteredNodeType", nlohmann::json{ @@ -873,7 +896,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { nlohmann::json::array({{{"id", "n0"}, {"node_type", "GhostUnregisteredNodeXYZ"}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kUnknownNodeType, "/pipeline/0/node_type"}); + DiagnosticCode::kUnknownNodeType, "/pipeline/0/node_type"}); // --- DAG 校验 --- cases.push_back(NegativeTestCase{ @@ -883,7 +906,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"pipeline", nlohmann::json::array({{{"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kMissingField, "/pipeline/0/id"}); + DiagnosticCode::kMissingField, "/pipeline/0/id"}); cases.push_back(NegativeTestCase{ "DagDuplicateNodeId", nlohmann::json{ @@ -895,7 +918,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{"id", "node_dup"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}}})}}, - PipelineErrorCode::kDuplicateNodeId, "/pipeline/1/id"}); + DiagnosticCode::kDuplicateNodeId, "/pipeline/1/id"}); cases.push_back(NegativeTestCase{ "DagMissingDependsOn", nlohmann::json{ @@ -906,7 +929,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array()}}, {{"id", "node_b"}, {"node_type", "CountingNode"}}})}}, - PipelineErrorCode::kMissingField, "/pipeline/1/depends_on"}); + DiagnosticCode::kMissingField, "/pipeline/1/depends_on"}); cases.push_back(NegativeTestCase{ "DagDependsOnNotArray", nlohmann::json{ @@ -914,7 +937,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"pipeline", nlohmann::json::array({{{"id", "node_a"}, {"node_type", "CountingNode"}, {"depends_on", "node_prev"}}})}}, - PipelineErrorCode::kFieldType, "/pipeline/0/depends_on"}); + DiagnosticCode::kFieldType, "/pipeline/0/depends_on"}); cases.push_back(NegativeTestCase{ "DagDependsOnNonStringItem", nlohmann::json{ @@ -923,7 +946,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{{"id", "node_a"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({123})}}})}}, - PipelineErrorCode::kFieldType, "/pipeline/0/depends_on/0"}); + DiagnosticCode::kFieldType, "/pipeline/0/depends_on/0"}); cases.push_back(NegativeTestCase{ "DagDependsOnEmptyStringItem", nlohmann::json{ @@ -932,7 +955,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{{"id", "node_a"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({""})}}})}}, - PipelineErrorCode::kFieldRange, "/pipeline/0/depends_on/0"}); + DiagnosticCode::kFieldRange, "/pipeline/0/depends_on/0"}); cases.push_back(NegativeTestCase{ "DagDependsOnDuplicateItemInNode", nlohmann::json{ @@ -945,7 +968,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({"node_a", "node_a"})}}})}}, - PipelineErrorCode::kInvalidDependency, "/pipeline/1/depends_on/1"}); + DiagnosticCode::kDuplicateDependency, "/pipeline/1/depends_on/1"}); cases.push_back(NegativeTestCase{ "DagSelfLoopCycle", nlohmann::json{ @@ -955,7 +978,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{{"id", "node_a"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({"node_a"})}}})}}, - PipelineErrorCode::kDagCycle, "/pipeline/0/depends_on/0"}); + DiagnosticCode::kDagCycle, "/pipeline/0/depends_on/0"}); cases.push_back(NegativeTestCase{ "DagNonExistentDependency", nlohmann::json{ @@ -965,7 +988,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{{"id", "node_a"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({"ghost_dep"})}}})}}, - PipelineErrorCode::kInvalidDependency, "/pipeline/0/depends_on/0"}); + DiagnosticCode::kInvalidDependency, "/pipeline/0/depends_on/0"}); cases.push_back(NegativeTestCase{ "DagCycle3Nodes", nlohmann::json{ @@ -981,7 +1004,7 @@ TEST_F(PipelineConfigTest, TableDrivenNegativeValidationAndZeroSideEffects) { {{"id", "node_c"}, {"node_type", "CountingNode"}, {"depends_on", nlohmann::json::array({"node_b"})}}})}}, - PipelineErrorCode::kDagCycle, "/pipeline"}); + DiagnosticCode::kDagCycle, "/pipeline"}); for (const auto& tc : cases) { CountingModel::Reset(); @@ -1032,7 +1055,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { { Pipeline p; EXPECT_FALSE(p.BuildFromConfigFile("/non/existent/path.json", &diag)); - EXPECT_EQ(diag.code, PipelineErrorCode::kConfigFileOpen); + EXPECT_EQ(diag.code, DiagnosticCode::kConfigFileOpen); EXPECT_EQ(diag.path, "/"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1046,12 +1069,32 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromConfigFile(bad_json_path, &diag)); - EXPECT_EQ(diag.code, PipelineErrorCode::kJsonParse); + EXPECT_EQ(diag.code, DiagnosticCode::kJsonParse); EXPECT_EQ(diag.path, "/"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); std::remove(bad_json_path.c_str()); } + // 4.2.1 Model 构造函数抛异常 + { + nlohmann::json cfg = { + {"biz_name", "t"}, + {"models", nlohmann::json::array( + {CountingModelEntry("m1", CountingBackend::kBackendType, + ThrowingCtorModel::kModelType)})}, + {"pipeline", + nlohmann::json::array({{{"id", "node_0"}, + {"node_type", "CountingNode"}, + {"depends_on", nlohmann::json::array()}}})}}; + Pipeline p; + EXPECT_FALSE(p.BuildFromJson( + cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); + EXPECT_EQ(diag.path, "/models/0"); + EXPECT_TRUE(diag.message.find("ThrowingCtorModel") != std::string::npos); + EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); + } + // 4.3 Backend 构造函数抛异常 { nlohmann::json cfg = { @@ -1065,7 +1108,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kModelMaterializationFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); EXPECT_EQ(diag.path, "/models/0"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1083,7 +1126,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kModelMaterializationFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); EXPECT_EQ(diag.path, "/models/0"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1101,7 +1144,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kModelMaterializationFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kModelMaterializationFailed); EXPECT_EQ(diag.path, "/models/0"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1117,7 +1160,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kNodeCreateFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kNodeCreateFailed); EXPECT_EQ(diag.path, "/pipeline/0/node_type"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1133,7 +1176,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kNodeInitFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kNodeInitFailed); EXPECT_EQ(diag.path, "/pipeline/0/config"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1149,7 +1192,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { Pipeline p; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kNodeInitFailed); + EXPECT_EQ(diag.code, DiagnosticCode::kNodeInitFailed); EXPECT_EQ(diag.path, "/pipeline/0/config"); EXPECT_EQ(p.GetState(), Pipeline::State::kFailed); } @@ -1168,7 +1211,7 @@ TEST_F(PipelineConfigTest, MaterializationExceptionsAndFineGrainedDiagnostics) { {"depends_on", nlohmann::json::array()}}})}}; EXPECT_FALSE(p.BuildFromJson( cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kInternalException); + EXPECT_EQ(diag.code, DiagnosticCode::kInternalException); EXPECT_EQ(diag.path, "/"); EXPECT_TRUE(diag.message.find("Simulated unhandled internal exception") != std::string::npos); @@ -1189,7 +1232,7 @@ TEST_F(PipelineConfigTest, FailedNodeInitDoesNotPublishStagedModels) { PipelineDiagnostic diagnostic; EXPECT_FALSE(pipeline.BuildFromJson( config, &diagnostic, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diagnostic.code, PipelineErrorCode::kNodeInitFailed); + EXPECT_EQ(diagnostic.code, DiagnosticCode::kNodeInitFailed); EXPECT_NE(diagnostic.message.find("missing domain dictionary"), std::string::npos); EXPECT_EQ(CountingModel::create_count.load(), 1); @@ -1239,7 +1282,7 @@ TEST_F(PipelineConfigTest, OnceOnlyBuildContractAndStateMachineProtection) { // 第二次 Build EXPECT_FALSE(p.BuildFromJson( valid_cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kInvalidBuildState); + EXPECT_EQ(diag.code, DiagnosticCode::kInvalidBuildState); EXPECT_EQ(diag.path, "/"); // 断言没有任何重复初始化副作用 EXPECT_EQ(CountingNode::init_count.load(), init_count_before); @@ -1263,7 +1306,7 @@ TEST_F(PipelineConfigTest, OnceOnlyBuildContractAndStateMachineProtection) { // 失败实例上再次尝试 Build EXPECT_FALSE(p.BuildFromJson( valid_cfg, &diag, ValidationPolicy::kPrivateExtensionCompatible)); - EXPECT_EQ(diag.code, PipelineErrorCode::kInvalidBuildState); + EXPECT_EQ(diag.code, DiagnosticCode::kInvalidBuildState); } // 5.3 未 Ready 或 Failed 状态下 Execute / Control 拒绝执行 @@ -1358,7 +1401,7 @@ TEST_F(PipelineConfigTest, ModelBackendDialectPositiveParsing) { ParsedPipelineConfig parsed_cfg; PipelineDiagnostic diag; EXPECT_TRUE(ParsePipelineConfig(root, &parsed_cfg, &diag)); - EXPECT_EQ(diag.code, PipelineErrorCode::kOk); + EXPECT_EQ(diag.code, DiagnosticCode::kOk); ASSERT_EQ(parsed_cfg.models.size(), 2u); // Model 0: Model/Backend Dialect (Full) diff --git a/tests/unit/core/test_registry_reentrant.cpp b/tests/unit/core/test_registry_reentrant.cpp index 1eeecedf..5ea91869 100644 --- a/tests/unit/core/test_registry_reentrant.cpp +++ b/tests/unit/core/test_registry_reentrant.cpp @@ -10,6 +10,8 @@ #include "core/pipeline_diagnostic.h" #include "engine/model_interface.h" #include "engine/model_registry.h" +#include "tests/support/registry_test_access.h" +#include "tests/support/scoped_allocation_failure.h" namespace llm_edgeflow { @@ -127,4 +129,483 @@ TEST(RegistryReentrantTest, ReentrantCreationZeroDeadlock) { } } +class SimpleTestNode : public INode { + public: + bool Init(const NodeInitContext&) override { return true; } + int Process(AlgContext*) override { return 0; } + NodeControlResult Control(int, const std::string&) override { + return NodeControlResult::Handled(0); + } + const std::string& Name() const override { + static const std::string name = "SimpleTestNode"; + return name; + } +}; + +// R5: creator execution, creator copy, Definition callback copy, reentrant +// queries +TEST(RegistryReentrantTest, ReentrantCreatorAndFactoryZeroDeadlock) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + bool factory_invoked = false; + bool registered = NodeRegistry::Instance().RegisterWithDefinitionFactory( + "ReentrantFactoryNode", + []() -> std::unique_ptr { + EXPECT_TRUE(NodeRegistry::Instance().Has("ReentrantFactoryNode")); + auto found = NodeRegistry::Instance().Find("ReentrantFactoryNode"); + EXPECT_TRUE(found.has_value()); + auto list = NodeRegistry::Instance().ListDefinitions(); + EXPECT_FALSE(list.empty()); + auto snap = NodeRegistry::Instance().Snapshot(); + EXPECT_FALSE(snap.definitions.empty()); + return std::make_unique(); + }, + [&]() -> NodeDefinition { + factory_invoked = true; + (void)NodeRegistry::Instance().Has("ReentrantNode"); + (void)NodeRegistry::Instance().ListDefinitions(); + (void)NodeRegistry::Instance().Snapshot(); + return MakeTestNodeDef("ReentrantFactoryNode"); + }); + EXPECT_TRUE(registered); + EXPECT_TRUE(factory_invoked); + + auto instance = NodeRegistry::Instance().Create("ReentrantFactoryNode"); + EXPECT_NE(instance, nullptr); +} + +// R5: creator copy and Definition callback copy reentrancy without deadlock +struct ReentrantCopyCreator { + static inline std::atomic copy_count{0}; + + ReentrantCopyCreator() = default; + ReentrantCopyCreator(const ReentrantCopyCreator& /*other*/) { + copy_count.fetch_add(1, std::memory_order_relaxed); + (void)NodeRegistry::Instance().Has("ReentrantCallableCopyNode"); + (void)NodeRegistry::Instance().ListTypes(); + (void)NodeRegistry::Instance().Snapshot(); + } + ReentrantCopyCreator(ReentrantCopyCreator&& other) + : ReentrantCopyCreator(other) {} + ReentrantCopyCreator& operator=(const ReentrantCopyCreator&) = default; + ReentrantCopyCreator& operator=(ReentrantCopyCreator&&) = default; + + std::unique_ptr operator()() const { + return std::make_unique(); + } +}; + +struct ReentrantConfigValidator { + static inline std::atomic copy_count{0}; + + ReentrantConfigValidator() = default; + ReentrantConfigValidator(const ReentrantConfigValidator& /*other*/) { + copy_count.fetch_add(1, std::memory_order_relaxed); + (void)NodeRegistry::Instance().Has("ReentrantCallableCopyNode"); + (void)NodeRegistry::Instance().ListTypes(); + } + ReentrantConfigValidator(ReentrantConfigValidator&& other) + : ReentrantConfigValidator(other) {} + ReentrantConfigValidator& operator=(const ReentrantConfigValidator&) = + default; + ReentrantConfigValidator& operator=(ReentrantConfigValidator&&) = default; + + bool operator()(const nlohmann::json&, const std::unordered_set&, + std::string*) const { + return true; + } +}; + +struct ReentrantCopyFactory { + static inline std::atomic copy_count{0}; + + ReentrantCopyFactory() = default; + ReentrantCopyFactory(const ReentrantCopyFactory& /*other*/) { + copy_count.fetch_add(1, std::memory_order_relaxed); + (void)NodeRegistry::Instance().Has("ReentrantCallableCopyNode"); + (void)NodeRegistry::Instance().ListTypes(); + (void)NodeRegistry::Instance().Snapshot(); + } + ReentrantCopyFactory(ReentrantCopyFactory&& other) + : ReentrantCopyFactory(other) {} + ReentrantCopyFactory& operator=(const ReentrantCopyFactory&) = default; + ReentrantCopyFactory& operator=(ReentrantCopyFactory&&) = default; + + NodeDefinition operator()() const { + NodeDefinition def = MakeTestNodeDef("ReentrantCallableCopyNode"); + def.validate_config = ReentrantConfigValidator{}; + return def; + } +}; + +TEST(RegistryReentrantTest, ReentrantCallableCopyZeroDeadlock) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + ReentrantCopyCreator::copy_count.store(0); + ReentrantConfigValidator::copy_count.store(0); + ReentrantCopyFactory::copy_count.store(0); + + ReentrantCopyCreator creator; + ReentrantCopyFactory factory; + + // Pass factory wrapped in std::function to explicitly exercise factory + // callable copy reentrancy + std::function factory_wrapper = factory; + + bool registered = NodeRegistry::Instance().RegisterWithDefinitionFactory( + "ReentrantCallableCopyNode", creator, factory_wrapper); + EXPECT_TRUE(registered); + EXPECT_TRUE(NodeRegistry::Instance().Has("ReentrantCallableCopyNode")); + + // Create node: copies handle->creator outside registry lock, triggers + // ReentrantCopyCreator copy + auto instance = NodeRegistry::Instance().Create("ReentrantCallableCopyNode"); + EXPECT_NE(instance, nullptr); + EXPECT_GT(ReentrantCopyCreator::copy_count.load(), 0); + + // Snapshot: copies handle->definition outside registry lock, triggers + // ReentrantConfigValidator copy + auto snap = NodeRegistry::Instance().Snapshot(); + EXPECT_FALSE(snap.definitions.empty()); + EXPECT_GT(ReentrantConfigValidator::copy_count.load(), 0); + + // ListDefinitions: also copies definitions outside lock + auto list = NodeRegistry::Instance().ListDefinitions(); + EXPECT_FALSE(list.empty()); + + EXPECT_GT(ReentrantCopyFactory::copy_count.load(), 0); +} + +// R4: Register fail-after-N loop with ScopedAllocationFailure +TEST(RegistryReentrantTest, RegisterFailAfterNIntegrity) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string sentinel = "AllocFailSentinelNode"; + ASSERT_TRUE(NodeRegistry::Instance().Register( + sentinel, []() { return std::make_unique(); }, + MakeTestNodeDef(sentinel))); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + bool completed = false; + for (int step = 0; step < 20; ++step) { + const std::string candidate = "AllocFailProbe_" + std::to_string(step); + NodeRegistry::CreatorFunc creator = []() { + return std::make_unique(); + }; + NodeDefinition def = MakeTestNodeDef(candidate); + bool registered = false; + bool injected = false; + size_t outstanding = 0; + bool overflowed = false; + { + test_support::ScopedAllocationFailure failure(step); + registered = NodeRegistry::Instance().Register(candidate, + std::move(creator), &def); + injected = failure.Triggered(); + outstanding = failure.Outstanding(); + overflowed = failure.Overflowed(); + } + ASSERT_FALSE(overflowed); + if (!injected) { + EXPECT_TRUE(registered); + EXPECT_TRUE(NodeRegistry::Instance().Has(candidate)); + EXPECT_NE(NodeRegistry::Instance().Create(candidate), nullptr); + completed = true; + break; + } + EXPECT_FALSE(registered); + EXPECT_FALSE(NodeRegistry::Instance().Has(candidate)); + EXPECT_EQ(NodeRegistry::Instance().Create(candidate), nullptr); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(sentinel)); + EXPECT_NE(NodeRegistry::Instance().Create(sentinel), nullptr); + EXPECT_LE(outstanding, 2u); + + test_support::RegistryTestAccess::ClearNodeFailures(); + } + EXPECT_TRUE(completed); +} + +// R4/R5: Insertion allocation failure with user resource destructor calling +// NodeRegistry::Has() must not deadlock on mutex_. +struct ReentrantDestructorResource { + static inline std::atomic destruct_count{0}; + static inline std::atomic reentrant_has_count{0}; + + ~ReentrantDestructorResource() { + destruct_count.fetch_add(1, std::memory_order_relaxed); + static const std::string sentinel = "AllocFailSentinelNode"; + if (NodeRegistry::Instance().Has(sentinel)) { + auto created = NodeRegistry::Instance().Create(sentinel); + auto types = NodeRegistry::Instance().ListTypes(); + auto found = NodeRegistry::Instance().Find(sentinel); + if (created != nullptr && !types.empty() && found.has_value()) { + reentrant_has_count.fetch_add(1, std::memory_order_relaxed); + } + } + } +}; + +TEST(RegistryReentrantTest, + RegisterAllocationFailureReentrantDestructorZeroDeadlock) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + ReentrantDestructorResource::destruct_count.store(0); + ReentrantDestructorResource::reentrant_has_count.store(0); + + const std::string sentinel = "AllocFailSentinelNode"; + ASSERT_TRUE(NodeRegistry::Instance().Register( + sentinel, []() { return std::make_unique(); }, + MakeTestNodeDef(sentinel))); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + bool completed = false; + int failure_steps_tested = 0; + for (int step = 0; step < 20; ++step) { + const std::string candidate = + "AllocFailReentrantProbe_" + std::to_string(step); + auto resource = std::make_shared(); + NodeRegistry::CreatorFunc creator = + [res = resource]() -> std::unique_ptr { + (void)res; + return std::make_unique(); + }; + resource.reset(); + + NodeDefinition def = MakeTestNodeDef(candidate); + bool registered = false; + bool injected = false; + size_t outstanding = 0; + bool overflowed = false; + { + test_support::ScopedAllocationFailure failure(step); + registered = NodeRegistry::Instance().Register(candidate, + std::move(creator), &def); + injected = failure.Triggered(); + outstanding = failure.Outstanding(); + overflowed = failure.Overflowed(); + } + ASSERT_FALSE(overflowed); + if (!injected) { + EXPECT_TRUE(registered); + EXPECT_TRUE(NodeRegistry::Instance().Has(candidate)); + EXPECT_NE(NodeRegistry::Instance().Create(candidate), nullptr); + completed = true; + break; + } + failure_steps_tested++; + EXPECT_FALSE(registered); + EXPECT_FALSE(NodeRegistry::Instance().Has(candidate)); + EXPECT_EQ(NodeRegistry::Instance().Create(candidate), nullptr); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(sentinel)); + EXPECT_NE(NodeRegistry::Instance().Create(sentinel), nullptr); + EXPECT_LE(outstanding, 2u); + + test_support::RegistryTestAccess::ClearNodeFailures(); + } + EXPECT_TRUE(completed); + EXPECT_GE(failure_steps_tested, 5); + EXPECT_GE(ReentrantDestructorResource::destruct_count.load(), 5); + EXPECT_EQ(ReentrantDestructorResource::destruct_count.load(), + ReentrantDestructorResource::reentrant_has_count.load()); +} + +// R4: Register duplicate fail-after-N loop with ScopedAllocationFailure +TEST(RegistryReentrantTest, RegisterDuplicateFailAfterNIntegrity) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string existing = "AllocFailDuplicateNode"; + ASSERT_TRUE(NodeRegistry::Instance().Register( + existing, []() { return std::make_unique(); }, + MakeTestNodeDef(existing))); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + bool completed = false; + for (int step = 0; step < 20; ++step) { + NodeRegistry::CreatorFunc creator = []() { + return std::make_unique(); + }; + NodeDefinition def = MakeTestNodeDef(existing); + bool registered = false; + bool injected = false; + size_t outstanding = 0; + bool overflowed = false; + { + test_support::ScopedAllocationFailure failure(step); + registered = + NodeRegistry::Instance().Register(existing, std::move(creator), &def); + injected = failure.Triggered(); + outstanding = failure.Outstanding(); + overflowed = failure.Overflowed(); + } + ASSERT_FALSE(overflowed); + EXPECT_FALSE(registered); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(existing)); + EXPECT_NE(NodeRegistry::Instance().Create(existing), nullptr); + EXPECT_LE(outstanding, 2u); + + if (!injected) { + completed = true; + break; + } + test_support::RegistryTestAccess::ClearNodeFailures(); + } + EXPECT_TRUE(completed); +} + +// R4: Register cross-node control conflict fail-after-N loop with +// ScopedAllocationFailure +TEST(RegistryReentrantTest, RegisterControlConflictFailAfterNIntegrity) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string node_a = "AllocFailControlNodeA"; + NodeDefinition def_a = MakeTestNodeDef(node_a); + ControlCommandDefinition cmd_a; + cmd_a.cmd_id = 999; + cmd_a.name = "conflict_cmd"; + cmd_a.shared_id = false; + cmd_a.payload_schema = nlohmann::json::object(); + cmd_a.supports_hot_swap = false; + def_a.control_commands = {cmd_a}; + + ASSERT_TRUE(NodeRegistry::Instance().Register( + node_a, []() { return std::make_unique(); }, def_a)); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + const std::string node_b = "AllocFailControlNodeB"; + NodeDefinition def_b = MakeTestNodeDef(node_b); + ControlCommandDefinition cmd_b; + cmd_b.cmd_id = 999; // Conflict with node_a command + cmd_b.name = "different_name"; + cmd_b.shared_id = false; + cmd_b.payload_schema = nlohmann::json::object(); + cmd_b.supports_hot_swap = false; + def_b.control_commands = {cmd_b}; + + bool completed = false; + for (int step = 0; step < 20; ++step) { + NodeRegistry::CreatorFunc creator = []() { + return std::make_unique(); + }; + NodeDefinition def = def_b; + bool registered = false; + bool injected = false; + size_t outstanding = 0; + bool overflowed = false; + { + test_support::ScopedAllocationFailure failure(step); + registered = + NodeRegistry::Instance().Register(node_b, std::move(creator), &def); + injected = failure.Triggered(); + outstanding = failure.Outstanding(); + overflowed = failure.Overflowed(); + } + ASSERT_FALSE(overflowed); + EXPECT_FALSE(registered); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_FALSE(NodeRegistry::Instance().Has(node_b)); + EXPECT_EQ(NodeRegistry::Instance().Create(node_b), nullptr); + EXPECT_TRUE(NodeRegistry::Instance().Has(node_a)); + EXPECT_NE(NodeRegistry::Instance().Create(node_a), nullptr); + EXPECT_LE(outstanding, 2u); + + if (!injected) { + completed = true; + break; + } + test_support::RegistryTestAccess::ClearNodeFailures(); + } + EXPECT_TRUE(completed); +} + +// R4: RegisterWithDefinitionFactory fail-after-N loop with +// ScopedAllocationFailure +TEST(RegistryReentrantTest, RegisterWithDefinitionFactoryFailAfterNIntegrity) { + test_support::RegistryTestAccess::ScopedNodeState scoped; + const std::string sentinel = "FactoryAllocFailSentinelNode"; + ASSERT_TRUE(NodeRegistry::Instance().Register( + sentinel, []() { return std::make_unique(); }, + MakeTestNodeDef(sentinel))); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + bool completed = false; + for (int step = 0; step < 20; ++step) { + const std::string candidate = + "FactoryAllocFailProbe_" + std::to_string(step); + bool registered = false; + bool injected = false; + size_t outstanding = 0; + bool overflowed = false; + { + test_support::ScopedAllocationFailure failure(step); + registered = NodeRegistry::Instance().RegisterWithDefinitionFactory( + candidate, []() { return std::make_unique(); }, + [&]() { return MakeTestNodeDef(candidate); }); + injected = failure.Triggered(); + outstanding = failure.Outstanding(); + overflowed = failure.Overflowed(); + } + ASSERT_FALSE(overflowed); + if (!injected) { + EXPECT_TRUE(registered); + EXPECT_TRUE(NodeRegistry::Instance().Has(candidate)); + EXPECT_NE(NodeRegistry::Instance().Create(candidate), nullptr); + completed = true; + break; + } + EXPECT_FALSE(registered); + EXPECT_FALSE(NodeRegistry::Instance().Has(candidate)); + EXPECT_EQ(NodeRegistry::Instance().Create(candidate), nullptr); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(sentinel)); + EXPECT_NE(NodeRegistry::Instance().Create(sentinel), nullptr); + EXPECT_LE(outstanding, 2u); + + test_support::RegistryTestAccess::ClearNodeFailures(); + } + EXPECT_TRUE(completed); +} + +// R8: ScopedNodeState restoration under allocation failure is non-allocating +// and robust +TEST(RegistryReentrantTest, ScopedNodeStateRestorationUnderAllocationFailure) { + const std::string sentinel = "RestorationSentinelNode"; + { + test_support::RegistryTestAccess::ScopedNodeState outer; + ASSERT_TRUE(NodeRegistry::Instance().Register( + sentinel, []() { return std::make_unique(); }, + MakeTestNodeDef(sentinel))); + ASSERT_FALSE(NodeRegistry::Instance().HasConflict()); + + { + std::optional failure; + { + test_support::RegistryTestAccess::ScopedNodeState inner; + const std::string temp_node = "TemporaryNodeToRestore"; + ASSERT_TRUE(NodeRegistry::Instance().Register( + temp_node, []() { return std::make_unique(); }, + MakeTestNodeDef(temp_node))); + EXPECT_TRUE(NodeRegistry::Instance().Has(temp_node)); + + // Trigger conflict so has_conflict_ is true + EXPECT_FALSE(NodeRegistry::Instance().Register( + temp_node, []() { return std::make_unique(); }, + MakeTestNodeDef(temp_node))); + EXPECT_TRUE(NodeRegistry::Instance().HasConflict()); + + // Arm allocation failure so that ~ScopedNodeState runs with failure(0) + failure.emplace(0); + } + // inner is destructed while failure is active, then failure is destructed + ASSERT_TRUE(failure.has_value()); + EXPECT_FALSE(failure->Triggered()); + } + + // After inner scope destroyed under allocation failure, registry must be + // cleanly restored + EXPECT_FALSE(NodeRegistry::Instance().HasConflict()); + EXPECT_TRUE(NodeRegistry::Instance().Has(sentinel)); + EXPECT_NE(NodeRegistry::Instance().Create(sentinel), nullptr); + EXPECT_FALSE(NodeRegistry::Instance().Has("TemporaryNodeToRestore")); + EXPECT_EQ(NodeRegistry::Instance().Create("TemporaryNodeToRestore"), + nullptr); + } +} + } // namespace llm_edgeflow diff --git a/tests/unit/core/test_validated_pipeline_plan.cpp b/tests/unit/core/test_validated_pipeline_plan.cpp index 62dbc4d6..908fae0d 100644 --- a/tests/unit/core/test_validated_pipeline_plan.cpp +++ b/tests/unit/core/test_validated_pipeline_plan.cpp @@ -206,6 +206,13 @@ TEST(ValidatedPipelinePlanTest, DiagnosticCodeNameTableDriven) { {DiagnosticCode::kDuplicateNodeId, "DUPLICATE_NODE_ID"}, {DiagnosticCode::kUnknownBiz, "UNKNOWN_BIZ"}, {DiagnosticCode::kUnknownNodeType, "UNKNOWN_NODE_TYPE"}, + {DiagnosticCode::kUnknownModelType, "UNKNOWN_MODEL_TYPE"}, + {DiagnosticCode::kUnknownBackend, "UNKNOWN_BACKEND"}, + {DiagnosticCode::kModelCapabilityMismatch, "MODEL_CAPABILITY_MISMATCH"}, + {DiagnosticCode::kBackendProtocolMismatch, "BACKEND_PROTOCOL_MISMATCH"}, + {DiagnosticCode::kUnknownModelConfigField, "UNKNOWN_MODEL_CONFIG_FIELD"}, + {DiagnosticCode::kUnknownBackendConfigField, + "UNKNOWN_BACKEND_CONFIG_FIELD"}, {DiagnosticCode::kInvalidDependency, "INVALID_DEPENDENCY"}, {DiagnosticCode::kDuplicateDependency, "DUPLICATE_DEPENDENCY"}, {DiagnosticCode::kDagCycle, "DAG_CYCLE"}, @@ -216,7 +223,6 @@ TEST(ValidatedPipelinePlanTest, DiagnosticCodeNameTableDriven) { {DiagnosticCode::kConfigFieldRange, "CONFIG_FIELD_RANGE"}, {DiagnosticCode::kConfigFieldEnum, "CONFIG_FIELD_ENUM"}, {DiagnosticCode::kUnknownModelReference, "UNKNOWN_MODEL_REFERENCE"}, - {DiagnosticCode::kModelCapabilityMismatch, "MODEL_CAPABILITY_MISMATCH"}, {DiagnosticCode::kNodeBizMismatch, "NODE_BIZ_MISMATCH"}, {DiagnosticCode::kMissingInputProducer, "MISSING_INPUT_PRODUCER"}, {DiagnosticCode::kDuplicatePortProducer, "DUPLICATE_PORT_PRODUCER"}, @@ -229,14 +235,22 @@ TEST(ValidatedPipelinePlanTest, DiagnosticCodeNameTableDriven) { {DiagnosticCode::kPortProvenanceMismatch, "PORT_PROVENANCE_MISMATCH"}, {DiagnosticCode::kPortLifetimeMismatch, "PORT_LIFETIME_MISMATCH"}, {DiagnosticCode::kInternalException, "INTERNAL_EXCEPTION"}, + {DiagnosticCode::kModelMaterializationFailed, + "MODEL_MATERIALIZATION_FAILED"}, + {DiagnosticCode::kNodeCreateFailed, "NODE_CREATE_FAILED"}, + {DiagnosticCode::kNodeInitFailed, "NODE_INIT_FAILED"}, + {DiagnosticCode::kInvalidBuildState, "INVALID_BUILD_STATE"}, }; + EXPECT_EQ(cases.size(), 44u); std::unordered_set names; for (const auto& item : cases) { std::string name = DiagnosticCodeName(item.code); EXPECT_STREQ(name.c_str(), item.expected_name); EXPECT_TRUE(names.insert(name).second) << "Duplicate name: " << name; } + EXPECT_STREQ(DiagnosticCodeName(static_cast(9999)), + "UNKNOWN"); } TEST(ValidatedPipelinePlanTest, RejectsIncompatiblePortExecutionContracts) { diff --git a/tests/unit/nodes/test_function_node.cpp b/tests/unit/nodes/test_function_node.cpp index 474866c0..17890fe7 100644 --- a/tests/unit/nodes/test_function_node.cpp +++ b/tests/unit/nodes/test_function_node.cpp @@ -21,6 +21,7 @@ #include "tests/support/node_harness.h" #include "tests/support/node_process_pause.h" #include "tests/support/node_test_utils.h" +#include "tests/support/registry_test_access.h" #include "tests/support/scoped_allocation_failure.h" namespace llm_edgeflow { @@ -904,7 +905,7 @@ TEST(FunctionNodeTest, } } EXPECT_TRUE(found_message); - NodeRegistry::Instance().ClearConflictForTesting(); + test_support::RegistryTestAccess::ClearNodeFailures(); } TEST(FunctionNodeTest, NodeWithoutParametersOperatesCorrectly) { diff --git a/tests/unit/nodes/test_text_embedding_node.cpp b/tests/unit/nodes/test_text_embedding_node.cpp index 6643f9c7..2de5023c 100644 --- a/tests/unit/nodes/test_text_embedding_node.cpp +++ b/tests/unit/nodes/test_text_embedding_node.cpp @@ -328,6 +328,9 @@ TEST_F(TextEmbeddingNodeTest, SessionCacheCollisionReproductionDefeated) { } TEST_F(TextEmbeddingNodeTest, StrictPlanKeepsDistinctCorpusCacheIdentities) { +#ifndef HAVE_ONNXRUNTIME + GTEST_SKIP() << "ONNX Runtime disabled in this build"; +#endif const auto config = nlohmann::json::parse(R"json({ "biz_name": "keyword_match_v1", "models": [