From b215d4431a699180af48fe4d3b9306e28ed54248 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 00:58:26 +0800 Subject: [PATCH 01/58] =?UTF-8?q?feat(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=8C=96=E8=B0=83=E5=BA=A6=E5=9C=B0=E5=9F=BA=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20experiment=20=E8=B0=83=E5=BA=A6=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=20+=20=E5=8D=95=20item=20=E6=B6=88=E8=80=97=E5=90=91=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为中心化实验 item 调度铺地基,本提交只加字段与校验,不改任何派发行为: legacy 路径读到的仍是 legacy/1,行为与改动前一致。 IDL: - domain/expt.thrift: 加 ExptTriggerType Evalx 常量(该 typedef 是 string 而非 enum,追加常量无兼容风险);新增 ExpectedResourceConsumption / ExpectedQuotaConsumption 结构;Experiment 读视图开 116~119 段位回显 priority_level / scheduler_mode - coze.loop.evaluation.expt.thrift: Create/Submit 两个 Request 各加 92 priority_level、93 expected_quota_consumption、94 scheduler_mode。 94 故意不加 api.body —— 与既有 trigger_type 同为服务端内部覆写字段, 公网调用方不得自行指定 enforce,只允许 commercial wrapper 按灰度白名单填入。 DDL(四个文件,非 spec 原述的三处): - docker-compose init-sql + patch-sql、helm init-sql + init-sql/xxx_alter.sql 各加 priority_level / scheduler_mode 两列与 idx_scheduler_queue - 索引不以 space_id 打头(跨空间调度队列扫描不应带 space_id),与现有 12 个 以 space_id 开头的索引形态不同,已在 SQL 注释写明理由 - priority_level DESC 需 MySQL 8.0+,低版本会静默退化为升序,注释中标注上线前 须确认实例版本并 EXPLAIN 验证 - 新列同时写入 CREATE TABLE 与 alter 两处;注意仓库既有漂移(eval_set_space_id / target_space_id / eval_set_access_level 仅在 alter、不在 CREATE TABLE), 本次不顺带修复以免混淆 diff Go: - entity/expt_dispatch_mode.go: ExptDispatchMode 常量与 Normalize/IsValid/ IsCentralDispatch 收敛函数。未知模式一律按 legacy 处理 —— 安全侧是走旧链路, 而非让实验既跳过 legacy 闸又拿不到 reservation - entity/expt_quota_consumption.go: Validate 校验非空/amount>0/键唯一/禁 wildcard, Normalize 去空白(带空白的 key 在调度期拼 constraint key 时会匹配不上上限配置, 静默降级成「未登记资源」而被放行) - Experiment DO 加 PriorityLevel / ExptDispatchMode。字段名刻意避开 SchedulerMode —— entity.ExptSchedulerMode 已被「实验跑法调度器」占用, 同名会让两个无关概念在阅读时混淆 - eval_conf 走 json.Marshal(非 thrift binary),故 ExpectedQuotaConsumption 挂在 EvaluationConfiguration 上零 DDL、零代码生成,老数据反序列化后为 nil 顺带修一处会咬到后续开发的隐患: - QuotaSpaceExpt 新增 Clone() 并让 repo 改用它。原 repo 手工构造 &QuotaSpaceExpt{ExptID2RunTime: maps.Clone(...)} 只覆盖当时唯一的字段, 一旦该 struct 加第二个字段就会在每次 CreateOrUpdate 被零值静默写回 Redis。 GORM model/query 手工补两列(gen 程序 UseDB 从真实库反查,需先应用 DDL 才能跑); query gen 本就缺三个跨空间列、与 model 已不同步,本次只保证新列两侧对齐。 测试: entity 新增两个测试文件覆盖全部分支; go build ./modules/evaluation/... 通过,相关包 go test 全绿,gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/expt.go | 31 ++- .../domain/entity/expt_dispatch_mode.go | 72 +++++++ .../domain/entity/expt_dispatch_mode_test.go | 88 +++++++++ .../domain/entity/expt_quota_consumption.go | 82 ++++++++ .../entity/expt_quota_consumption_test.go | 183 ++++++++++++++++++ .../evaluation/domain/entity/expt_run.go | 16 ++ .../repo/experiment/mysql/convert/expt.go | 6 + .../mysql/gorm_gen/model/experiment.gen.go | 2 + .../mysql/gorm_gen/query/experiment.gen.go | 8 +- .../evaluation/infra/repo/experiment/quota.go | 6 +- .../coze.loop.evaluation.expt.thrift | 20 ++ .../coze/loop/evaluation/domain/expt.thrift | 20 ++ .../mysql-init/init-sql/experiment.sql | 5 +- .../mysql-init/patch-sql/experiment_alter.sql | 11 ++ .../init/mysql/init-sql/experiment.sql | 5 +- .../init/mysql/init-sql/experiment_alter.sql | 11 ++ 16 files changed, 558 insertions(+), 8 deletions(-) create mode 100644 backend/modules/evaluation/domain/entity/expt_dispatch_mode.go create mode 100644 backend/modules/evaluation/domain/entity/expt_dispatch_mode_test.go create mode 100644 backend/modules/evaluation/domain/entity/expt_quota_consumption.go create mode 100644 backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index 94e97e4a15..73e4317553 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -202,7 +202,7 @@ type Experiment struct { MaxAliveTime int64 SourceType SourceType SourceID string - // TriggerType 实验触发方式,与表字段 trigger_type 一致:manual / openapi / schedule + // TriggerType 实验触发方式,与表字段 trigger_type 一致:manual / openapi / schedule / evalx TriggerType string // ExptSource 查询时填充:与一级字段 source_type/source_id 一致;Workflow 时由 Pipeline 补充 span_filter / scheduler / sampler ExptSource *ExptSource @@ -216,6 +216,15 @@ type Experiment struct { Visibility Visibility // 实验模板可见性,默认为空,可见 ThreadID *string // 关联的智能评测会话ID NotificationConf *ExptNotificationConf // 通知配置(JSON序列化存储) + + // PriorityLevel 调度优先级,1-99,数值越大越优先;与表字段 priority_level 一致,历史数据为 1。 + // 仅中心化调度模式参与排序 (priority DESC, created_at ASC, id ASC),legacy 模式不读该值。 + PriorityLevel int32 + // ExptDispatchMode 执行模式,与表字段 scheduler_mode 一致:legacy / enforce。 + // 该 DB 列是唯一权威源:创建时按灰度白名单一次性冻结,Run/Retry/consumer 一律回查此列裁决, + // 配置热变更不得让存量实验在中心调度与旧 daemon 之间切换。 + // 命名注意:本字段与 entity.ExptSchedulerMode (实验跑法调度器 interface) 是完全不同的概念,勿混用。 + ExptDispatchMode string } func (e *Experiment) ToEvaluatorRefDO() []*ExptEvaluatorRef { @@ -348,6 +357,26 @@ type EvaluationConfiguration struct { // RunModeConfig 实验级跑法配置 (仅 SandboxAgent 评测对象 + MultiSetConfig 实验生效)。 // 序列化进 experiment.eval_conf; 提交时展开到各 item 的 ItemTargetConf.RunConf 兜底默认值。 RunModeConfig *RunModeConfig `json:"run_mode_config,omitempty"` + + // ExpectedQuotaConsumption 单 item 预期资源消耗向量 (中心化调度用)。 + // 创建期一次性冻结进 experiment.eval_conf, 之后只读: 调度预占、释放、账本重建全部读这同一份快照, + // 运行期不再回查外部 RPC/缓存, 避免同一实验在不同时刻按不同规格扣额度。 + // enforce 模式必填; legacy 模式为 nil。指针类型用于区分「未申报」与「申报了空列表」。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption `json:"expected_quota_consumption,omitempty"` +} + +// ExpectedResourceConsumption 单 item 对一种具体资源的预期占用量。 +// amount 的单位由额度上限配置的 unit 定义 (seat / token_per_min / query_per_min ...), 调用方不申报 unit, +// 避免伪造或与上限配置漂移。 +type ExpectedResourceConsumption struct { + Category string `json:"category"` + ResourceKey string `json:"resource_key"` + Amount int64 `json:"amount"` +} + +// ExpectedQuotaConsumption 单 item 的多资源消耗向量。 +type ExpectedQuotaConsumption struct { + Resources []*ExpectedResourceConsumption `json:"resources,omitempty"` } // RunMode 实验级评测模式 (跑法)。与 runtime domain RunMode / IDL ExptRunMode 对齐。 diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go new file mode 100644 index 0000000000..c42cdde6e6 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go @@ -0,0 +1,72 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +// ExptDispatchMode 实验的 item 派发模式,对应 experiment.scheduler_mode 列。 +// +// 该列是唯一权威源:创建实验时按灰度白名单一次性写入并冻结,Run / Retry / MQ consumer 一律回查此列裁决, +// 灰度配置热变更只影响后续新建实验,不得让存量实验在中心调度与旧 daemon 之间切换 —— 否则同一 run 会 +// 出现两个派发驱动,既绕过全局优先级也绕过额度账本。 +// +// 命名注意:本类型与 ExptSchedulerMode(实验跑法调度器 interface,见 expt_run.go)是完全不同的概念。 +// 前者回答「谁来派发这个实验的 item」,后者回答「这个实验按什么跑法执行」。 +type ExptDispatchMode = string + +const ( + // ExptDispatchModeLegacy 旧链路:每实验一条 MQ 自循环,按配置并发自主补 item,受空间运行实验数闸约束。 + // 历史数据与非灰度空间的新实验都是该值(DB 列默认值)。 + ExptDispatchModeLegacy ExptDispatchMode = "legacy" + // ExptDispatchModeEnforce 中心调度:由中心调度器按全局优先级 + 资源额度决定派发,跳过空间运行实验数闸。 + // 旧 per-experiment tick 对该模式实验只做初始化与生命周期维护,不得启动新 item。 + ExptDispatchModeEnforce ExptDispatchMode = "enforce" +) + +const ( + // DefaultExptPriorityLevel 未申报优先级时的缺省值,与 DB 列默认值一致。 + DefaultExptPriorityLevel int32 = 1 + // MinExptPriorityLevel / MaxExptPriorityLevel 优先级合法区间(闭区间),数值越大越优先。 + MinExptPriorityLevel int32 = 1 + MaxExptPriorityLevel int32 = 99 +) + +// IsValidExptDispatchMode 判断落库前的模式取值是否合法。空串视为非法: +// 写入路径必须显式给出 legacy 或 enforce,避免把空值写进 NOT NULL 列后再靠 DB 默认值兜底。 +func IsValidExptDispatchMode(mode ExptDispatchMode) bool { + switch mode { + case ExptDispatchModeLegacy, ExptDispatchModeEnforce: + return true + default: + return false + } +} + +// IsCentralDispatch 判断该实验是否由中心调度器派发 item。 +// 读取侧统一走本函数,不要散落 == "enforce" 的字面比较。 +func IsCentralDispatch(mode ExptDispatchMode) bool { + return mode == ExptDispatchModeEnforce +} + +// NormalizeExptDispatchMode 把历史/异常取值收敛为 legacy。 +// 用于读路径:老数据该列虽有 NOT NULL DEFAULT 'legacy',但 PO2DO 之外的构造路径可能留空, +// 而「未知模式」按 legacy 处理是安全侧 —— 最坏是走旧链路,不会让实验绕过额度账本执行。 +func NormalizeExptDispatchMode(mode ExptDispatchMode) ExptDispatchMode { + if IsValidExptDispatchMode(mode) { + return mode + } + return ExptDispatchModeLegacy +} + +// NormalizeExptPriorityLevel 把越界/未设置的优先级收敛到合法区间。 +// 0 值(未申报)收敛为缺省 1;越界值按边界截断而非报错,因为读路径不应因历史脏数据中断调度。 +// 写入路径的合法性校验在 application 层做,会显式返回参数错误。 +func NormalizeExptPriorityLevel(priority int32) int32 { + switch { + case priority < MinExptPriorityLevel: + return DefaultExptPriorityLevel + case priority > MaxExptPriorityLevel: + return MaxExptPriorityLevel + default: + return priority + } +} diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode_test.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_test.go new file mode 100644 index 0000000000..95885bd027 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidExptDispatchMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode ExptDispatchMode + want bool + }{ + {name: "legacy 合法", mode: ExptDispatchModeLegacy, want: true}, + {name: "enforce 合法", mode: ExptDispatchModeEnforce, want: true}, + {name: "空串非法:写入路径必须显式给值,不靠 DB 默认值兜底", mode: "", want: false}, + {name: "未知模式非法", mode: "central", want: false}, + {name: "大小写敏感:Enforce 非法", mode: "Enforce", want: false}, + {name: "已废弃的 shadow 非法", mode: "shadow", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, IsValidExptDispatchMode(tt.mode)) + }) + } +} + +func TestIsCentralDispatch(t *testing.T) { + t.Parallel() + + assert.True(t, IsCentralDispatch(ExptDispatchModeEnforce)) + assert.False(t, IsCentralDispatch(ExptDispatchModeLegacy)) + // 未知模式不得被当成中心调度:那样会让实验绕过 legacy 闸又拿不到 reservation + assert.False(t, IsCentralDispatch("")) + assert.False(t, IsCentralDispatch("shadow")) +} + +func TestNormalizeExptDispatchMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode ExptDispatchMode + want ExptDispatchMode + }{ + {name: "合法值原样返回", mode: ExptDispatchModeEnforce, want: ExptDispatchModeEnforce}, + {name: "legacy 原样返回", mode: ExptDispatchModeLegacy, want: ExptDispatchModeLegacy}, + {name: "空串收敛为 legacy", mode: "", want: ExptDispatchModeLegacy}, + {name: "脏数据收敛为 legacy(安全侧:走旧链路而非绕过额度)", mode: "bogus", want: ExptDispatchModeLegacy}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, NormalizeExptDispatchMode(tt.mode)) + }) + } +} + +func TestNormalizeExptPriorityLevel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + priority int32 + want int32 + }{ + {name: "0(未申报)收敛为缺省 1", priority: 0, want: DefaultExptPriorityLevel}, + {name: "负数收敛为缺省 1", priority: -5, want: DefaultExptPriorityLevel}, + {name: "下界 1 保持", priority: 1, want: 1}, + {name: "区间内保持", priority: 50, want: 50}, + {name: "上界 99 保持", priority: 99, want: 99}, + {name: "超上界截断为 99", priority: 100, want: MaxExptPriorityLevel}, + {name: "极大值截断为 99", priority: 1 << 20, want: MaxExptPriorityLevel}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, NormalizeExptPriorityLevel(tt.priority)) + }) + } +} diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go new file mode 100644 index 0000000000..721786701a --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go @@ -0,0 +1,82 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "fmt" + "strings" +) + +// WildcardResourceKey 通配资源键。只允许出现在额度**上限配置**里(表示"该 category 下所有资源"), +// 调用方申报单 item 消耗时不得使用 —— 否则无法确定实际占用的是哪个具体资源,账本也无从按 key 释放。 +const WildcardResourceKey = "*" + +// Validate 校验单 item 消耗向量的结构合法性。 +// +// 在创建/提交阶段同步校验并返回参数错误,不把问题拖到异步调度阶段:调度期发现向量非法只能让实验干等或 +// 误置失败,用户既看不到原因也无法修正。 +// +// 校验项与 DB/账本的前置条件一一对应: +// - resources 非空:enforce 实验必须有可预占的资源,空向量意味着无限制占用 +// - category / resource_key 去空白后非空:空串会污染 constraint key,导致账本 key 冲撞 +// - resource_key != "*":见 WildcardResourceKey +// - amount > 0:0 或负数会让 maxGrant 的木桶计算失去意义(除零 / 负额度) +// - (category, resource_key) 唯一:重复键在按 constraint key 聚合时会双计,释放时又只释放一份 +func (c *ExpectedQuotaConsumption) Validate() error { + if c == nil || len(c.Resources) == 0 { + return fmt.Errorf("expected_quota_consumption is required and must not be empty") + } + + seen := make(map[string]struct{}, len(c.Resources)) + for i, r := range c.Resources { + if r == nil { + return fmt.Errorf("expected_quota_consumption.resources[%d] is nil", i) + } + + category := strings.TrimSpace(r.Category) + resourceKey := strings.TrimSpace(r.ResourceKey) + + if category == "" { + return fmt.Errorf("expected_quota_consumption.resources[%d].category must not be empty", i) + } + if resourceKey == "" { + return fmt.Errorf("expected_quota_consumption.resources[%d].resource_key must not be empty", i) + } + if resourceKey == WildcardResourceKey { + return fmt.Errorf("expected_quota_consumption.resources[%d].resource_key must not be %q; wildcard is only allowed in quota limit config", i, WildcardResourceKey) + } + if r.Amount <= 0 { + return fmt.Errorf("expected_quota_consumption.resources[%d].amount must be positive, got %d", i, r.Amount) + } + + dedupKey := category + "|" + resourceKey + if _, dup := seen[dedupKey]; dup { + return fmt.Errorf("expected_quota_consumption has duplicated (category,resource_key): %s", dedupKey) + } + seen[dedupKey] = struct{}{} + } + + return nil +} + +// Normalize 返回一份 category/resource_key 已去空白的副本,供落库前调用。 +// 冻结进 eval_conf 的值必须是规范形态:调度期按 category|resource_key 拼 constraint key 时不再 trim, +// 若带前后空白会与上限配置中的同名资源匹配不上,静默变成"未登记资源"而被放行。 +func (c *ExpectedQuotaConsumption) Normalize() *ExpectedQuotaConsumption { + if c == nil { + return nil + } + normalized := &ExpectedQuotaConsumption{Resources: make([]*ExpectedResourceConsumption, 0, len(c.Resources))} + for _, r := range c.Resources { + if r == nil { + continue + } + normalized.Resources = append(normalized.Resources, &ExpectedResourceConsumption{ + Category: strings.TrimSpace(r.Category), + ResourceKey: strings.TrimSpace(r.ResourceKey), + Amount: r.Amount, + }) + } + return normalized +} diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go new file mode 100644 index 0000000000..e2581a8f42 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExpectedQuotaConsumption_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + conf *ExpectedQuotaConsumption + wantErr string // 空表示期望通过 + }{ + { + name: "合法多资源向量", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + {Category: "model", ResourceKey: "gpt5.5", Amount: 2000}, + {Category: "evaluator", ResourceKey: "评估器A", Amount: 3}, + }}, + }, + { + name: "nil 拒绝", + conf: nil, + wantErr: "required", + }, + { + name: "空 resources 拒绝:enforce 实验必须有可预占的资源", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{}}, + wantErr: "required", + }, + { + name: "nil 元素拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{nil}}, + wantErr: "is nil", + }, + { + name: "空 category 拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: " ", ResourceKey: "default", Amount: 1}, + }}, + wantErr: "category must not be empty", + }, + { + name: "空 resource_key 拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "", Amount: 1}, + }}, + wantErr: "resource_key must not be empty", + }, + { + name: "wildcard 拒绝:通配只允许出现在上限配置", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "*", Amount: 1}, + }}, + wantErr: "wildcard is only allowed in quota limit config", + }, + { + name: "amount=0 拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 0}, + }}, + wantErr: "must be positive", + }, + { + name: "amount 负数拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: -1}, + }}, + wantErr: "must be positive", + }, + { + name: "重复键拒绝:聚合时会双计但释放只释放一份", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "gpt5.5", Amount: 1}, + {Category: "model", ResourceKey: "gpt5.5", Amount: 2}, + }}, + wantErr: "duplicated", + }, + { + name: "trim 后重复也拒绝", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "gpt5.5", Amount: 1}, + {Category: " model ", ResourceKey: " gpt5.5 ", Amount: 2}, + }}, + wantErr: "duplicated", + }, + { + name: "同 category 不同 resource_key 允许", + conf: &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "gpt5.5", Amount: 1}, + {Category: "model", ResourceKey: "doubao_pro", Amount: 2}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.conf.Validate() + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestExpectedQuotaConsumption_Normalize(t *testing.T) { + t.Parallel() + + t.Run("nil 返回 nil", func(t *testing.T) { + t.Parallel() + var conf *ExpectedQuotaConsumption + assert.Nil(t, conf.Normalize()) + }) + + t.Run("去空白:否则调度期拼 constraint key 匹配不上上限配置", func(t *testing.T) { + t.Parallel() + conf := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: " model ", ResourceKey: "\tgpt5.5\n", Amount: 100}, + }} + got := conf.Normalize() + require.Len(t, got.Resources, 1) + assert.Equal(t, "model", got.Resources[0].Category) + assert.Equal(t, "gpt5.5", got.Resources[0].ResourceKey) + assert.Equal(t, int64(100), got.Resources[0].Amount) + }) + + t.Run("跳过 nil 元素且不修改原对象", func(t *testing.T) { + t.Parallel() + conf := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: " sandbox ", ResourceKey: "default", Amount: 1}, + nil, + }} + got := conf.Normalize() + require.Len(t, got.Resources, 1) + assert.Equal(t, "sandbox", got.Resources[0].Category) + // 原对象未被就地改写 + assert.Equal(t, " sandbox ", conf.Resources[0].Category) + }) +} + +func TestQuotaSpaceExpt_Clone(t *testing.T) { + t.Parallel() + + t.Run("nil 接收者返回可用空对象", func(t *testing.T) { + t.Parallel() + var q *QuotaSpaceExpt + got := q.Clone() + require.NotNil(t, got) + require.NotNil(t, got.ExptID2RunTime) + got.ExptID2RunTime[1] = 100 // 可直接写入,不 panic + }) + + t.Run("nil map 也返回可写 map", func(t *testing.T) { + t.Parallel() + got := (&QuotaSpaceExpt{}).Clone() + require.NotNil(t, got.ExptID2RunTime) + got.ExptID2RunTime[1] = 100 + }) + + t.Run("深拷贝:改副本不影响原对象", func(t *testing.T) { + t.Parallel() + orig := &QuotaSpaceExpt{ExptID2RunTime: map[int64]int64{1: 100, 2: 200}} + cloned := orig.Clone() + + cloned.ExptID2RunTime[3] = 300 + delete(cloned.ExptID2RunTime, 1) + + assert.Equal(t, map[int64]int64{1: 100, 2: 200}, orig.ExptID2RunTime) + assert.Equal(t, map[int64]int64{2: 200, 3: 300}, cloned.ExptID2RunTime) + }) +} diff --git a/backend/modules/evaluation/domain/entity/expt_run.go b/backend/modules/evaluation/domain/entity/expt_run.go index 471ff6a149..4ca53bfc98 100644 --- a/backend/modules/evaluation/domain/entity/expt_run.go +++ b/backend/modules/evaluation/domain/entity/expt_run.go @@ -5,6 +5,7 @@ package entity import ( "context" + "maps" "strings" "time" @@ -446,6 +447,21 @@ type QuotaSpaceExpt struct { ExptID2RunTime map[int64]int64 // id -> unix } +// Clone 深拷贝全部字段,供 repo 层的 read-modify-write 构造 updater 入参。 +// 之前 repo 直接手工构造 &QuotaSpaceExpt{ExptID2RunTime: maps.Clone(...)},只覆盖了当时唯一的字段; +// 一旦本 struct 新增字段而 repo 未同步补上,新字段会在每次 CreateOrUpdate 时被零值静默覆盖回 Redis。 +// 统一走本方法后,新增字段只需在此处补一行,不必再记得改 repo。 +func (q *QuotaSpaceExpt) Clone() *QuotaSpaceExpt { + if q == nil { + return &QuotaSpaceExpt{ExptID2RunTime: make(map[int64]int64)} + } + cloned := &QuotaSpaceExpt{ExptID2RunTime: maps.Clone(q.ExptID2RunTime)} + if cloned.ExptID2RunTime == nil { + cloned.ExptID2RunTime = make(map[int64]int64) + } + return cloned +} + func (q *QuotaSpaceExpt) Serialize() ([]byte, error) { bytes, err := json.Marshal(q) if err != nil { diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go index 6b189ca9a4..e968e3d2cc 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go @@ -58,6 +58,9 @@ func (ExptConverter) DO2PO(experiment *entity.Experiment) (*model.Experiment, er EvalSetSpaceID: experiment.EvalSetSpaceID, // ★ 跨空间共享: 评测集来源空间 TargetSpaceID: experiment.TargetSpaceID, // ★ 跨空间共享: 评测对象来源空间 EvalSetAccessLevel: experiment.EvalSetAccessLevel, // ★ 跨空间共享: 冻结访问级别 + // ★ 中心化调度: 两列都是 NOT NULL, 写入前收敛为合法值 —— 上游若漏传, 落 legacy/1 而非空串/0 + PriorityLevel: entity.NormalizeExptPriorityLevel(experiment.PriorityLevel), + SchedulerMode: entity.NormalizeExptDispatchMode(experiment.ExptDispatchMode), } if experiment.MaxAliveTime != 0 { @@ -143,6 +146,9 @@ func (ExptConverter) PO2DO(expt *model.Experiment, refs []*model.ExptEvaluatorRe EvalSetSpaceID: expt.EvalSetSpaceID, // ★ 跨空间共享: 评测集来源空间 TargetSpaceID: expt.TargetSpaceID, // ★ 跨空间共享: 评测对象来源空间 EvalSetAccessLevel: expt.EvalSetAccessLevel, // ★ 跨空间共享: 冻结访问级别 + // ★ 中心化调度: 历史行虽有 DB 默认值兜底, 仍过一遍 Normalize —— 脏数据按 legacy/1 处理而非中断读路径 + PriorityLevel: entity.NormalizeExptPriorityLevel(expt.PriorityLevel), + ExptDispatchMode: entity.NormalizeExptDispatchMode(expt.SchedulerMode), } // 反序列化 notification_conf diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go index 83903943c3..1332245264 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go @@ -50,6 +50,8 @@ type Experiment struct { EvalSetSpaceID int64 `gorm:"column:eval_set_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测集来源空间(跨空间共享,0=同空间)" json:"eval_set_space_id"` // 评测集来源空间(跨空间共享,0=同空间) TargetSpaceID int64 `gorm:"column:target_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测对象来源空间(跨空间共享,0=同空间)" json:"target_space_id"` // 评测对象来源空间(跨空间共享,0=同空间) EvalSetAccessLevel string `gorm:"column:eval_set_access_level;type:varchar(32) character set utf8mb4;not null;default:'';comment:发起冻结的评测集访问级别(execute/readable/空)" json:"eval_set_access_level"` // 发起冻结的评测集访问级别 + PriorityLevel int32 `gorm:"column:priority_level;type:int(11) unsigned;not null;index:idx_scheduler_queue,priority:4;default:1;comment:实验调度优先级,1-99,数值越大越优先" json:"priority_level"` // 实验调度优先级,1-99,数值越大越优先 + SchedulerMode string `gorm:"column:scheduler_mode;type:varchar(16) character set utf8mb4;not null;index:idx_scheduler_queue,priority:1;default:legacy;comment:调度模式:legacy(旧per-experiment链路)/enforce(中心调度)" json:"scheduler_mode"` // 调度模式:legacy(旧per-experiment链路)/enforce(中心调度) } // TableName Experiment's table name diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/experiment.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/experiment.gen.go index 04a96713b7..c9d64a6b78 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/experiment.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/experiment.gen.go @@ -60,6 +60,8 @@ func newExperiment(db *gorm.DB, opts ...gen.DOOption) experiment { _experiment.TrialRunItemCount = field.NewInt64(tableName, "trial_run_item_count") _experiment.OfflineExptAnalysisStatus = field.NewInt32(tableName, "offline_expt_analysis_status") _experiment.NotificationConf = field.NewBytes(tableName, "notification_conf") + _experiment.PriorityLevel = field.NewInt32(tableName, "priority_level") + _experiment.SchedulerMode = field.NewString(tableName, "scheduler_mode") _experiment.fillFieldMap() @@ -104,6 +106,8 @@ type experiment struct { TrialRunItemCount field.Int64 // 试运行行数 OfflineExptAnalysisStatus field.Int32 // 离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded) NotificationConf field.Bytes // 通知配置,json格式存储webhook/飞书通知配置 + PriorityLevel field.Int32 // 实验调度优先级,1-99,数值越大越优先 + SchedulerMode field.String // 调度模式:legacy(旧per-experiment链路)/enforce(中心调度) fieldMap map[string]field.Expr } @@ -179,7 +183,7 @@ func (e *experiment) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } func (e *experiment) fillFieldMap() { - e.fieldMap = make(map[string]field.Expr, 32) + e.fieldMap = make(map[string]field.Expr, 35) e.fieldMap["id"] = e.ID e.fieldMap["space_id"] = e.SpaceID e.fieldMap["created_by"] = e.CreatedBy @@ -213,6 +217,8 @@ func (e *experiment) fillFieldMap() { e.fieldMap["trial_run_item_count"] = e.TrialRunItemCount e.fieldMap["offline_expt_analysis_status"] = e.OfflineExptAnalysisStatus e.fieldMap["notification_conf"] = e.NotificationConf + e.fieldMap["priority_level"] = e.PriorityLevel + e.fieldMap["scheduler_mode"] = e.SchedulerMode } func (e experiment) clone(db *gorm.DB) experiment { diff --git a/backend/modules/evaluation/infra/repo/experiment/quota.go b/backend/modules/evaluation/infra/repo/experiment/quota.go index 3f2875659d..833077e694 100644 --- a/backend/modules/evaluation/infra/repo/experiment/quota.go +++ b/backend/modules/evaluation/infra/repo/experiment/quota.go @@ -6,7 +6,6 @@ package experiment import ( "context" "fmt" - "maps" "time" "github.com/samber/lo" @@ -62,9 +61,8 @@ func (q *QuotaRepoImpl) createOrUpdate(ctx context.Context, spaceID int64, updat oldVal = lo.Ternary(oldVal != nil, oldVal, &entity.QuotaSpaceExpt{ExptID2RunTime: make(map[int64]int64)}) - newVal, update, err := updater(&entity.QuotaSpaceExpt{ - ExptID2RunTime: maps.Clone(oldVal.ExptID2RunTime), - }) + // 走 entity.Clone 而非手工构造:手工只拷当时已知的字段,QuotaSpaceExpt 新增字段时会被零值静默覆盖回 Redis。 + newVal, update, err := updater(oldVal.Clone()) if err != nil { return err } diff --git a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift index 837f5827f8..bfb2c036db 100644 --- a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift +++ b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift @@ -62,6 +62,16 @@ struct CreateExperimentRequest { // 引用分组实验 id:填写时校验其为当前空间内的实验 id 91: optional i64 ref_group_experiment_id (api.js_conv = 'true', api.body = 'ref_group_experiment_id', go.tag='json:"ref_group_experiment_id"') + // ★ 中心化调度入参 92~94 + // 调度优先级:1-99,数值越大越优先;缺省 1。仅在中心调度模式下参与排序,legacy 模式忽略 + 92: optional i32 priority_level (api.body = 'priority_level') + // 单 item 预期资源消耗向量:enforce 模式必填且非空,legacy 模式可选 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*",随后冻结进 eval_conf;Retry 继承不可覆盖 + 93: optional expt.ExpectedQuotaConsumption expected_quota_consumption (api.body = 'expected_quota_consumption') + // 执行模式:legacy / enforce。故意不加 api.body —— 公网调用方不得自行指定,仅允许 commercial wrapper 按灰度白名单填入 + // 与 trigger_type 同为服务端内部覆写字段;落库后 experiment.scheduler_mode 列是唯一权威源 + 94: optional string scheduler_mode + // 通知配置 110: optional expt.ExptNotificationConf notification_conf (api.body = 'notification_conf') @@ -139,6 +149,16 @@ struct SubmitExperimentRequest { // 引用分组实验 id:填写时校验其为当前空间内的实验 id 91: optional i64 ref_group_experiment_id (api.js_conv = 'true', api.body = 'ref_group_experiment_id', go.tag='json:"ref_group_experiment_id"') + // ★ 中心化调度入参 92~94 + // 调度优先级:1-99,数值越大越优先;缺省 1。仅在中心调度模式下参与排序,legacy 模式忽略 + 92: optional i32 priority_level (api.body = 'priority_level') + // 单 item 预期资源消耗向量:enforce 模式必填且非空,legacy 模式可选 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*",随后冻结进 eval_conf;Retry 继承不可覆盖 + 93: optional expt.ExpectedQuotaConsumption expected_quota_consumption (api.body = 'expected_quota_consumption') + // 执行模式:legacy / enforce。故意不加 api.body —— 公网调用方不得自行指定,仅允许 commercial wrapper 按灰度白名单填入 + // 与 trigger_type 同为服务端内部覆写字段;落库后 experiment.scheduler_mode 列是唯一权威源 + 94: optional string scheduler_mode + // 通知配置 110: optional expt.ExptNotificationConf notification_conf (api.body = 'notification_conf') diff --git a/idl/thrift/coze/loop/evaluation/domain/expt.thrift b/idl/thrift/coze/loop/evaluation/domain/expt.thrift index 426b4187e8..c704950b75 100644 --- a/idl/thrift/coze/loop/evaluation/domain/expt.thrift +++ b/idl/thrift/coze/loop/evaluation/domain/expt.thrift @@ -128,6 +128,20 @@ typedef string ExptTriggerType (ts.enum="true") const ExptTriggerType Manual = "manual" const ExptTriggerType OpenAPI = "openapi" const ExptTriggerType Schedule = "schedule" +const ExptTriggerType Evalx = "evalx" + +// ★ 中心化调度:单 item 预期资源消耗 +// 由创建方在 Create/Submit 时申报,服务端冻结进 eval_conf,供中心调度器预占额度使用。 +// category/resource_key 语义与额度上限配置一致;amount 的单位由上限配置的 unit 定义,调用方不传 unit。 +struct ExpectedResourceConsumption { + 1: required string category // 资源类别:sandbox / agent_account / model / evaluator + 2: required string resource_key // 具体资源:default / doubao_pro / gpt5.5 ...;不允许传 "*"(通配仅用于上限配置) + 3: required i64 amount // 单 item 的预期占用量,必须 > 0 +} + +struct ExpectedQuotaConsumption { + 1: required list resources +} struct Experiment { 1: optional i64 id (api.js_conv='true', go.tag='json:"id"') @@ -202,6 +216,12 @@ struct Experiment { // 实验级多轮/SUA 跑法配置回显: 从 experiment.eval_conf.run_mode_config 反序列化, 与 Create/Submit 入参 run_mode_config 同构。 // 仅 SandboxAgent + MultiSetConfig 实验非空。SUA 模型的 api_key/base_url 是运行时从 TCC 解析注入 case-file, 绝不回显。 115: optional RunModeConfig run_mode_config + + // ★ 新增段位 116~119: 中心化调度读视图 + // 调度优先级回显 (1-99, 越大越优先); 直读 experiment 表同名列, 历史数据为 1 + 116: optional i32 priority_level + // 执行模式回显: legacy(旧 per-experiment 链路) / enforce(中心调度); 直读 experiment 表同名列, 该列是唯一权威源 + 117: optional string scheduler_mode } // 实验模板基础信息 diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql index 5e7bf26a6c..f106fb1680 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql @@ -33,6 +33,8 @@ CREATE TABLE IF NOT EXISTS `experiment` `trial_run_item_count` bigint unsigned DEFAULT NULL COMMENT '试运行行数', `offline_expt_analysis_status` int unsigned NOT NULL DEFAULT '0' COMMENT '离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded)', `notification_conf` blob COMMENT '通知配置,json格式存储webhook/飞书通知配置', + `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先', + `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_item_idx` (`space_id`, `name`, `deleted_at`), KEY `idx_space_deleted_created_by` (`space_id`, `created_by`, `deleted_at`), @@ -46,7 +48,8 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_source_type_source_id` (`source_type`, `source_id`), KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), - KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`) + KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), + KEY `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql index db1b2883ed..3f04ffc05b 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql @@ -37,3 +37,14 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `eval_set_access_level` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '发起冻结的评测集访问级别(execute/readable/空=同空间)' AFTER `target_space_id`; + +ALTER TABLE `experiment` + ADD COLUMN `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先' AFTER `notification_conf`; + +ALTER TABLE `experiment` + ADD COLUMN `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)' AFTER `priority_level`; + +-- 中心调度主扫描索引:scheduler_mode + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 +-- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 +ALTER TABLE `experiment` + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql index 5e7bf26a6c..f106fb1680 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql @@ -33,6 +33,8 @@ CREATE TABLE IF NOT EXISTS `experiment` `trial_run_item_count` bigint unsigned DEFAULT NULL COMMENT '试运行行数', `offline_expt_analysis_status` int unsigned NOT NULL DEFAULT '0' COMMENT '离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded)', `notification_conf` blob COMMENT '通知配置,json格式存储webhook/飞书通知配置', + `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先', + `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_item_idx` (`space_id`, `name`, `deleted_at`), KEY `idx_space_deleted_created_by` (`space_id`, `created_by`, `deleted_at`), @@ -46,7 +48,8 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_source_type_source_id` (`source_type`, `source_id`), KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), - KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`) + KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), + KEY `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql index db1b2883ed..3f04ffc05b 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql @@ -37,3 +37,14 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `eval_set_access_level` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '发起冻结的评测集访问级别(execute/readable/空=同空间)' AFTER `target_space_id`; + +ALTER TABLE `experiment` + ADD COLUMN `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先' AFTER `notification_conf`; + +ALTER TABLE `experiment` + ADD COLUMN `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)' AFTER `priority_level`; + +-- 中心调度主扫描索引:scheduler_mode + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 +-- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 +ALTER TABLE `experiment` + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); From 9af835fdc3992ea43c8d9a020db6b9a588af3ff6 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 03:31:40 +0800 Subject: [PATCH 02/58] =?UTF-8?q?feat(evaluation):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B7=A8=E7=A9=BA=E9=97=B4=E8=B0=83=E5=BA=A6=E9=98=9F=E5=88=97?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=20ScanSchedulerQueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中心化调度需要按全局优先级跨空间取候选实验,而既有 IExperimentRepo.List 全部要求 space_id。若按空间分别扫描,低优空间的实验会先于高优空间被处理,全局优先级语义即失效, 因此新增一个不带 space_id 的扫描方法。 - entity/expt_scheduler_queue.go:扫描参数与 keyset 游标 用 keyset 而非 offset 分页:候选集合在扫描期间会被并发写入(新实验提交、实验进终态), offset 在这种场景下会漏掉或重复元素 - mysql/expt.go:DAO 实现,走 idx_scheduler_queue 用裸 gorm 而非 gen DSL —— keyset 的三元组比较是带括号 OR 的复合条件,gen 链式 API 要嵌套多层 Or(),可读性差得多 未加 FORCE INDEX:status IN (...) 是 range 条件,MySQL 可能因此放弃用索引满足 ORDER BY 而走 filesort;灰度期数据量小可接受,留给 EXPLAIN 实测再定 —— 过早 FORCE 会在数据分布 变化后选到更差的计划 条件含 latest_run_id > 0,排除"只 Create 尚未 Run"的实验(没有 run 可派 item,扫进来白跑) - expt_repo_impl.go:PO2DO 转换。单条实验 eval_conf 损坏时跳过并告警,不让整拍失败 —— 一条脏数据不应永久阻塞所有实验的调度。evaluator refs 传 nil,调度不需要,避免 N+1 查询 mock 手工补 ScanSchedulerQueue 而非重跑 go generate:本地 mockgen 版本较新,全量重生成会 带来 ~1000 行无关 diff(参数名重命名 + isgomock 字段),淹没本次真实改动。 go build ./modules/evaluation/... 通过,相关包 go test 全绿,gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/entity/expt_scheduler_queue.go | 32 +++++++++++ .../modules/evaluation/domain/repo/expt.go | 4 ++ .../evaluation/domain/repo/mocks/expt.go | 15 +++++ .../infra/repo/experiment/expt_repo_impl.go | 24 ++++++++ .../infra/repo/experiment/mysql/expt.go | 55 +++++++++++++++++++ .../infra/repo/experiment/mysql/mocks/expt.go | 15 +++++ 6 files changed, 145 insertions(+) create mode 100644 backend/modules/evaluation/domain/entity/expt_scheduler_queue.go diff --git a/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go b/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go new file mode 100644 index 0000000000..fb33b7927e --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go @@ -0,0 +1,32 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +// SchedulerQueueScanParam 中心调度候选实验的跨空间扫描参数。 +// +// 排序固定为 priority_level DESC, created_at ASC, id ASC: +// - priority 越大越优先; +// - 同优先级按实验创建时间先到先得(Retry 沿用原 created_at,不额外维护入队时间); +// - 再相同以 id 兜底,保证顺序确定 —— 否则翻页会重复或漏掉实验。 +type SchedulerQueueScanParam struct { + // DispatchMode 目标调度模式,通常为 enforce。 + DispatchMode string + // Statuses 候选状态,通常为 Pending + Processing。用单条 status IN (...) 查询而非按状态分别扫描, + // 避免双流归并游标与未消费 lookahead。 + Statuses []int32 + // Cursor keyset 游标,nil 表示从队头开始。 + Cursor *SchedulerQueueCursor + // Limit 单页条数。 + Limit int32 +} + +// SchedulerQueueCursor keyset 游标,与排序键一一对应。 +// +// 用 keyset 而非 offset 分页:候选集合在扫描过程中会被并发写入(新实验提交、实验进终态), +// offset 分页在这种场景下会漏掉或重复元素。 +type SchedulerQueueCursor struct { + PriorityLevel int32 + CreatedAtUnix int64 + ExptID int64 +} diff --git a/backend/modules/evaluation/domain/repo/expt.go b/backend/modules/evaluation/domain/repo/expt.go index d8b3b9b3c0..371d0155d8 100644 --- a/backend/modules/evaluation/domain/repo/expt.go +++ b/backend/modules/evaluation/domain/repo/expt.go @@ -27,6 +27,10 @@ type IExperimentRepo interface { // ExistGroupKey 判断 group key 是否已被“其它空间”占用(跨空间隔离), 用于创建实验时校验。 ExistGroupKey(ctx context.Context, groupKey string, spaceID int64) (bool, error) GetEvaluatorRefByExptIDs(ctx context.Context, exptID []int64, spaceID int64) ([]*entity.ExptEvaluatorRef, error) + + // ScanSchedulerQueue 跨空间扫描中心调度候选实验(按 priority DESC, created_at ASC, id ASC)。 + // 不接受 spaceID —— 中心调度按全局优先级排序,按空间分别扫描会使全局优先级失效。 + ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*entity.Experiment, error) } type IExptStatsRepo interface { diff --git a/backend/modules/evaluation/domain/repo/mocks/expt.go b/backend/modules/evaluation/domain/repo/mocks/expt.go index 1523e8dcde..9c83b8fa57 100644 --- a/backend/modules/evaluation/domain/repo/mocks/expt.go +++ b/backend/modules/evaluation/domain/repo/mocks/expt.go @@ -130,6 +130,21 @@ func (mr *MockIExperimentRepoMockRecorder) GetEvaluatorRefByExptIDs(arg0, arg1, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvaluatorRefByExptIDs", reflect.TypeOf((*MockIExperimentRepo)(nil).GetEvaluatorRefByExptIDs), arg0, arg1, arg2) } +// ScanSchedulerQueue mocks base method. +func (m *MockIExperimentRepo) ScanSchedulerQueue(arg0 context.Context, arg1 *entity.SchedulerQueueScanParam) ([]*entity.Experiment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScanSchedulerQueue", arg0, arg1) + ret0, _ := ret[0].([]*entity.Experiment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ScanSchedulerQueue indicates an expected call of ScanSchedulerQueue. +func (mr *MockIExperimentRepoMockRecorder) ScanSchedulerQueue(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanSchedulerQueue", reflect.TypeOf((*MockIExperimentRepo)(nil).ScanSchedulerQueue), arg0, arg1) +} + // GetIDsByGroupKey mocks base method. func (m *MockIExperimentRepo) GetIDsByGroupKey(arg0 context.Context, arg1 int64, arg2 string, arg3, arg4 int32) ([]int64, int64, error) { m.ctrl.T.Helper() diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go index 7287814d2f..a7954c3cf3 100644 --- a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go +++ b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go @@ -215,3 +215,27 @@ func (e *exptRepoImpl) GetEvaluatorRefByExptIDs(ctx context.Context, exptIDs []i return convert.NewExptEvaluatorRefConverter().PO2DO(pos), nil } + +// ScanSchedulerQueue 跨空间扫描中心调度候选实验。 +func (e *exptRepoImpl) ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*entity.Experiment, error) { + pos, err := e.exptDAO.ScanSchedulerQueue(ctx, param) + if err != nil { + return nil, err + } + + converter := convert.NewExptConverter() + dos := make([]*entity.Experiment, 0, len(pos)) + for _, po := range pos { + // evaluator refs 传 nil:调度只需要 id/space/priority/mode/run_id 与冻结的 eval_conf, + // 逐条实验再查 refs 会把一次扫描放大成 N+1 次查询。 + do, err := converter.PO2DO(po, nil) + if err != nil { + // 单条实验的 eval_conf 损坏不应让整拍调度失败:跳过该条继续, + // 否则一条脏数据会永久阻塞所有实验的调度。 + logs.CtxWarn(ctx, "[SchedulerQueue] skip experiment with broken payload, expt_id: %v, err: %v", po.ID, err) + continue + } + dos = append(dos, do) + } + return dos, nil +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go index e21c1777cb..f0ea5205c6 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go @@ -47,6 +47,13 @@ type IExptDAO interface { GetIDsByGroupKey(ctx context.Context, spaceID int64, groupKey string, page, pageSize int32) ([]int64, int64, error) ExistGroupKey(ctx context.Context, groupKey string, spaceID int64) (bool, error) + + // ScanSchedulerQueue 跨空间扫描中心调度候选实验。 + // + // 与 List 的关键差别:不带 space_id 条件 —— 中心调度按全局优先级排序,若按空间分别扫描, + // 低优空间的实验会先于高优空间被处理,全局优先级语义即失效。 + // 走 idx_scheduler_queue,keyset 分页保证翻页不重不漏。 + ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*model.Experiment, error) } func NewExptDAO(db db.Provider) IExptDAO { @@ -462,3 +469,51 @@ func (d *exptDAOImpl) ExistGroupKey(ctx context.Context, groupKey string, spaceI } return cnt > 0, nil } + +// ScanSchedulerQueue 跨空间扫描中心调度候选实验,走 idx_scheduler_queue。 +// +// 用裸 gorm 而非 gen DSL:keyset 的三元组比较是一段带括号 OR 的复合条件, +// gen 的链式 API 表达它需要嵌套多层 Or(...),可读性远差于一条 SQL 片段。 +// +// FORCE INDEX 的取舍:status IN (...) 是 range 条件,MySQL 可能因此放弃用索引满足 ORDER BY 而 +// 走 filesort;灰度期数据量小可接受,故此处不 FORCE,留给 EXPLAIN 实测后再决定 —— +// 过早 FORCE INDEX 会在数据分布变化后反而选到更差的计划。 +func (d *exptDAOImpl) ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*model.Experiment, error) { + if param == nil { + return nil, nil + } + limit := int(param.Limit) + if limit <= 0 { + limit = defaultLimit + } + + tx := d.db.NewSession(ctx).Model(&model.Experiment{}). + Where("scheduler_mode = ?", param.DispatchMode). + Where("deleted_at IS NULL"). + // latest_run_id > 0 排除"只 Create 尚未 Run"的实验:它们没有 run 可供派发 item, + // 扫进来只会让每拍白跑一遍。 + Where("latest_run_id > 0") + + if len(param.Statuses) > 0 { + tx = tx.Where("status IN ?", param.Statuses) + } + + if c := param.Cursor; c != nil { + // keyset:严格小于游标(按 priority DESC, created_at ASC, id ASC 的字典序) + tx = tx.Where( + "(priority_level < ?) OR (priority_level = ? AND created_at > FROM_UNIXTIME(?)) OR (priority_level = ? AND created_at = FROM_UNIXTIME(?) AND id > ?)", + c.PriorityLevel, + c.PriorityLevel, c.CreatedAtUnix, + c.PriorityLevel, c.CreatedAtUnix, c.ExptID, + ) + } + + var pos []*model.Experiment + if err := tx. + Order("priority_level DESC, created_at ASC, id ASC"). + Limit(limit). + Find(&pos).Error; err != nil { + return nil, errorx.Wrapf(err, "mysql scan scheduler queue fail, mode: %v, statuses: %v", param.DispatchMode, param.Statuses) + } + return pos, nil +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt.go index ba4dd1b370..68ab468bf3 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt.go @@ -84,6 +84,21 @@ func (mr *MockIExptDAOMockRecorder) ExistGroupKey(arg0, arg1, arg2 any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistGroupKey", reflect.TypeOf((*MockIExptDAO)(nil).ExistGroupKey), arg0, arg1, arg2) } +// ScanSchedulerQueue mocks base method. +func (m *MockIExptDAO) ScanSchedulerQueue(arg0 context.Context, arg1 *entity.SchedulerQueueScanParam) ([]*model.Experiment, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScanSchedulerQueue", arg0, arg1) + ret0, _ := ret[0].([]*model.Experiment) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ScanSchedulerQueue indicates an expected call of ScanSchedulerQueue. +func (mr *MockIExptDAOMockRecorder) ScanSchedulerQueue(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanSchedulerQueue", reflect.TypeOf((*MockIExptDAO)(nil).ScanSchedulerQueue), arg0, arg1) +} + // GetByID mocks base method. func (m *MockIExptDAO) GetByID(arg0 context.Context, arg1 int64) (*model.Experiment, error) { m.ctrl.T.Helper() From ccc3def4cfc1b21e2f9e5153f5860dc6b46dab9c Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 10:40:38 +0800 Subject: [PATCH 03/58] =?UTF-8?q?feat(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E5=8C=96=E8=B0=83=E5=BA=A6=20OSS=20=E6=8E=A5=E7=BC=9D=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=A2=9D=E5=BA=A6=E9=97=B8=20port=20+=20?= =?UTF-8?q?=E6=97=A7=20daemon=20=E9=98=B2=E5=8F=8C=E9=A9=B1=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处最小接缝,legacy 实验行为完全不变(读到的 mode 是 legacy,两条分支都短路)。 1. domain/component/central_reservation_guard.go:窄 port ICentralReservationGuard 只有 ConfirmRunning / Release 两个方法。完整账本、调度算法、Adapter 都在商业版, 由 Wire 注入;开源部署注入 noop。 noop 的 ConfirmRunning 刻意 fail-closed 返回 false:本方法只会被 enforce 实验触达, 而 enforce 意味着"额度由中心账本管控"。没有账本却放行等于零约束跑 item —— 这种失败是 静默的(资源打爆才发现);拒绝执行则可见(实验不动会被察觉)。 Release 永不返回错误:终态收口不应因额度模块缺席而失败。 2. item consumer 新增 HandleCentralReservation 中间件 插在 HandleEventCheck 之后、HandleEventLock 之前:Check 已排除终态 run(无需额度校验), 放在 Lock 之前可避免为一条注定丢弃的消息去抢 item 锁。 **模式判定回查 experiment.scheduler_mode DB 列,不看 event 上的任何标记** —— 若模式随 event 传递,字段丢失或取零值时 central 消息会被当作 legacy 处理,跳过校验、静默绕过额度; 这个方向的失败无声,比多查一次 DB 危险得多。 Guard 用 setter 注入而非构造参数:该构造函数已有一个 variadic 参数(Go 不允许第二个), 且它是可选依赖。 reservation 缺失 → 丢弃消息;账本报错 → 返回 error 让 MQ 重试(item 已被预占, 丢弃会让它停在 Queueing 白等一轮 reservation 超时)。 3. 旧 per-experiment daemon 加 enforce 薄分支(防双驱动) enforce 实验在此丢弃 toSubmit,但**保留其余全部职责**:完成 item 归档、zombie / sandbox terminated 处理、run/实验终态收口、NextTick 续跳。若直接 return,实验会因为 没人收口而永远停在 Processing。 必须丢弃而非"让它也派":旧链路按实验自身配置并发补 item,既不看全局优先级也不经额度账本, 两个驱动并存会直接超发。 go build ./modules/evaluation/... 通过;domain/component 与 domain/service 全部测试通过 (后者 227s,覆盖既有全量用例,确认接缝未破坏 legacy 行为);gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/central_reservation_guard.go | 52 ++++++++++++++++++ .../central_reservation_guard_test.go | 34 ++++++++++++ .../service/expt_run_item_event_impl.go | 55 +++++++++++++++++++ .../service/expt_run_scheduler_event_impl.go | 17 +++++- 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 backend/modules/evaluation/domain/component/central_reservation_guard.go create mode 100644 backend/modules/evaluation/domain/component/central_reservation_guard_test.go diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go new file mode 100644 index 0000000000..2dea311493 --- /dev/null +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -0,0 +1,52 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package component + +import "context" + +//go:generate mockgen -destination=mocks/central_reservation_guard.go -package=mocks . ICentralReservationGuard + +// ICentralReservationGuard 中心化调度的额度预占校验闸。 +// +// OSS 只定义这个最窄的 port,完整的额度账本、调度算法与 Adapter 都在商业版实现并由 Wire 注入。 +// 开源部署下注入 noop 实现:legacy 消息完全不受影响,而意外出现的 central 消息 fail-closed +// (拒绝执行),避免无账本环境下绕过额度跑 item。 +// +// 为什么 item consumer 必须经过它:中心调度先原子预占额度、再投递 item MQ。若 consumer 不校验 +// reservation 就执行,那么迟到的、重复的、账本已重建过的消息都会变成"无额度执行",全局额度 +// 保护即失效。 +type ICentralReservationGuard interface { + // ConfirmRunning 取得该 item 的一次性执行权。 + // + // 返回 false 表示 reservation 不存在 —— 调用方**必须放弃执行并丢弃消息**,不得继续跑 item。 + // 已是 Running 的重复投递返回 true:同一 item 的合法原地重试要继续持有原额度, + // 不重新预占也不重复扣减。 + ConfirmRunning(ctx context.Context, exptRunID, itemID int64) (bool, error) + + // Release 在 item 进入终态(成功/失败/终止/僵尸清理)时幂等释放额度。 + // 重复调用为 no-op。 + Release(ctx context.Context, exptRunID, itemID int64, reason string) error +} + +// NewNoopCentralReservationGuard 返回开源部署使用的 noop 实现。 +func NewNoopCentralReservationGuard() ICentralReservationGuard { + return noopCentralReservationGuard{} +} + +type noopCentralReservationGuard struct{} + +// ConfirmRunning 在无账本环境下一律拒绝。 +// +// 选择 fail-closed 而非放行:本方法只会被判定为 enforce 的实验触达,而 enforce 意味着 +// "该实验的额度由中心账本管控"。没有账本却放行,等于让实验在无任何额度约束下跑, +// 这比让它停下来等待更危险 —— 后者可见(实验不动会被发现),前者静默(资源被打爆才发现)。 +func (noopCentralReservationGuard) ConfirmRunning(ctx context.Context, exptRunID, itemID int64) (bool, error) { + return false, nil +} + +// Release 无账本可释放,直接成功返回。 +// 不返回错误:终态收口路径不应因为额度模块缺席而失败。 +func (noopCentralReservationGuard) Release(ctx context.Context, exptRunID, itemID int64, reason string) error { + return nil +} diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go new file mode 100644 index 0000000000..7a4f0cf90a --- /dev/null +++ b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package component + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNoopCentralReservationGuard_ConfirmRunningFailsClosed(t *testing.T) { + t.Parallel() + + guard := NewNoopCentralReservationGuard() + + // 关键语义:无账本环境下一律拒绝执行。 + // 放行会让 enforce 实验在零额度约束下跑(静默、事后才发现);拒绝则可见(实验不动会被察觉)。 + ok, err := guard.ConfirmRunning(context.Background(), 1, 2) + require.NoError(t, err) + assert.False(t, ok, "noop guard 必须 fail-closed,不得放行 enforce item") +} + +func TestNoopCentralReservationGuard_ReleaseNeverErrors(t *testing.T) { + t.Parallel() + + guard := NewNoopCentralReservationGuard() + + // 终态收口路径不应因为额度模块缺席而失败 + require.NoError(t, guard.Release(context.Background(), 1, 2, "item success")) + require.NoError(t, guard.Release(context.Background(), 1, 2, "duplicate terminal event")) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 7130232f1a..5b28ed748d 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -56,6 +56,16 @@ type ExptItemEventEvalServiceImpl struct { itemCompletePublisher component.IItemCompletePublisher sandboxAgentNotifier ISandboxAgentNotifier // 传递给 ExptItemEvalCtxExecutor 用于失败行飞书通知 sandboxAgentMetrics metrics.SandboxAgentMetrics // 沙箱 agent 端到端 (turn 粒度) 打点; 可空 → 走 noop + // centralGuard 中心化调度的额度预占校验闸。开源部署为 noop(enforce 消息 fail-closed), + // 商业版由 Wire 注入真实账本适配器。用 setter 而非构造参数注入:本构造函数已有一个 + // variadic 参数,Go 不允许第二个,且它是可选依赖 —— 不注入时 legacy 行为完全不变。 + centralGuard component.ICentralReservationGuard +} + +// WithCentralReservationGuard 注入中心化调度额度闸。返回自身便于在 Wire provider 中链式调用。 +func (e *ExptItemEventEvalServiceImpl) WithCentralReservationGuard(guard component.ICentralReservationGuard) *ExptItemEventEvalServiceImpl { + e.centralGuard = guard + return e } func NewExptRecordEvalService( @@ -116,6 +126,9 @@ func NewExptRecordEvalService( i.endpoints = RecordEvalChain( i.HandleEventErr, i.HandleEventCheck, + // 额度闸放在 Check 之后、Lock 之前:Check 已排除掉终态 run(那些消息不需要额度校验), + // 而放在 Lock 之前可避免为一条注定要丢弃的消息去抢 item 锁。 + i.HandleCentralReservation, i.HandleEventLock, i.HandleEventExec, )(func(_ context.Context, _ *entity.ExptItemEvalEvent) error { return nil }) @@ -163,6 +176,48 @@ func (e *ExptItemEventEvalServiceImpl) HandleEventCheck(next RecordEvalEndPoint) } } +// HandleCentralReservation 对中心调度纳管的实验校验额度预占。 +// +// 模式判定**回查 experiment.scheduler_mode DB 列**,不看 event 上的任何标记: +// 若模式随 event 传递,字段丢失或取默认零值时,一条实际为 central 的消息会被当作 legacy 处理, +// 从而跳过本校验、静默绕过额度执行 —— 这个方向的失败是无声的,比多查一次 DB 危险得多。 +func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalEndPoint) RecordEvalEndPoint { + return func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + expt, err := e.experimentRepo.GetByID(ctx, event.ExptID, event.SpaceID) + if err != nil { + return err + } + if !entity.IsCentralDispatch(expt.ExptDispatchMode) { + // legacy 实验:完全不经额度闸,行为与改动前一致 + return next(ctx, event) + } + + guard := e.centralGuard + if guard == nil { + // 判定为 enforce 却没有注入闸门:fail-closed。 + // 放行等于让实验在无额度约束下跑,静默且难以发现;停下来则可见。 + logs.CtxWarn(ctx, "[CentralReservation] enforce experiment without guard, drop event, expt_id: %v, item_id: %v", + event.ExptID, event.EvalSetItemID) + return nil + } + + ok, err := guard.ConfirmRunning(ctx, event.ExptRunID, event.EvalSetItemID) + if err != nil { + // 账本暂时不可用:返回错误让 MQ 重试,而不是丢弃 —— item 已被预占, + // 丢弃会让它停在 Queueing 直到 reservation 超时清理,白等一轮。 + return err + } + if !ok { + // reservation 不存在:迟到消息、账本已重建、或已被释放。丢弃不执行。 + logs.CtxInfo(ctx, "[CentralReservation] reservation absent, discard event, expt_run_id: %v, item_id: %v", + event.ExptRunID, event.EvalSetItemID) + return nil + } + + return next(ctx, event) + } +} + func (e *ExptItemEventEvalServiceImpl) HandleEventErr(next RecordEvalEndPoint) RecordEvalEndPoint { return func(ctx context.Context, event *entity.ExptItemEvalEvent) error { nextErr := func(ctx context.Context, event *entity.ExptItemEvalEvent) (err error) { diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go index 6b81e22b3e..d3d3790162 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go @@ -417,7 +417,22 @@ func (e *ExptSchedulerImpl) schedule(ctx context.Context, event *entity.ExptSche return err } - if err = e.handleToSubmits(ctx, event, toSubmit); err != nil { + // ★ 中心化调度防双驱动:enforce 实验的新 item 派发权归中心调度器独有。 + // + // 旧 per-experiment tick 在此**丢弃 toSubmit**,但保留其余全部职责 —— + // 完成 item 归档(上面的 recordEvalItemRunLogs 已执行)、zombie/sandbox terminated 处理、 + // run/实验终态收口、NextTick 续跳。若这里直接 return,实验会因为没人收口而永远停在 Processing。 + // + // 为什么必须丢弃而不是"让它也派":旧链路按实验自己的配置并发补 item,既不看全局优先级 + // 也不经额度账本,两个驱动并存会直接超发。 + dispatchByCentral := entity.IsCentralDispatch(exptDetail.ExptDispatchMode) + if dispatchByCentral { + if len(toSubmit) > 0 { + logs.CtxInfo(ctx, "[CentralDispatch] legacy tick suppressed %d to-submit item(s) for enforce experiment, expt_id: %v, expt_run_id: %v", + len(toSubmit), event.ExptID, event.ExptRunID) + } + toSubmit = nil + } else if err = e.handleToSubmits(ctx, event, toSubmit); err != nil { return err } From 8c94d7e63480b95ef2a793458370eb6f08870845 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 10:49:02 +0800 Subject: [PATCH 04/58] =?UTF-8?q?refactor(evaluation):=20Guard=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=B8=B8=E8=A7=84=E6=9E=84=E9=80=A0=E5=8F=82=E6=95=B0?= =?UTF-8?q?=20+=20OSS=20wire=20=E6=B3=A8=E5=86=8C=20noop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版把 centralGuard 做成 setter 注入,理由是"构造函数已有 variadic 参数"。 但这让 Wire 无法自动装配:Guard 被构造出来却没人调 setter,闭环实际是断的 (wire_gen 里能看到 NewCentralReservationGuardAdapter 被调用,但 consumer 的 centralGuard 字段始终为 nil,enforce 消息会走"无 guard → fail-closed"分支被全部丢弃)。 改为放在 variadic 之前的常规参数:Go 允许(variadic 只需是最后一个),Wire 能自动填, 调用点只有 wire_gen(生成物)与一个单测。同时删掉 WithCentralReservationGuard setter, 避免两条注入路径并存造成"到底哪条生效"的歧义。 domain/service/wire.go 注册 component.NewNoopCentralReservationGuard: 该 set 被商业版复用,商业版在自己的 set 里注入真实适配器覆盖它 —— 与既有 NewSandboxAgentNotifier(开源 no-op 桩 / 商业版真实 Lark send)同一模式。 go build ./modules/evaluation/... 通过,domain/component 测试通过,gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- backend/modules/evaluation/application/wire_gen.go | 7 +++++-- .../domain/service/expt_run_item_event_impl.go | 13 ++++--------- .../domain/service/expt_run_item_event_impl_test.go | 2 ++ backend/modules/evaluation/domain/service/wire.go | 3 +++ 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 283fb9b54f..e4a54905a9 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -32,6 +32,7 @@ import ( "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/observability/observabilitytraceservice" "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/observability/task/taskservice" "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/prompt/promptmanageservice" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/rpc" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/userinfo" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service" @@ -180,7 +181,8 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, componentIConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, v4...) + iCentralReservationGuard := component.NewNoopCentralReservationGuard() + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) @@ -451,7 +453,8 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, iConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, v4...) + iCentralReservationGuard := component.NewNoopCentralReservationGuard() + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 5b28ed748d..47efaf2797 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -56,18 +56,11 @@ type ExptItemEventEvalServiceImpl struct { itemCompletePublisher component.IItemCompletePublisher sandboxAgentNotifier ISandboxAgentNotifier // 传递给 ExptItemEvalCtxExecutor 用于失败行飞书通知 sandboxAgentMetrics metrics.SandboxAgentMetrics // 沙箱 agent 端到端 (turn 粒度) 打点; 可空 → 走 noop - // centralGuard 中心化调度的额度预占校验闸。开源部署为 noop(enforce 消息 fail-closed), - // 商业版由 Wire 注入真实账本适配器。用 setter 而非构造参数注入:本构造函数已有一个 - // variadic 参数,Go 不允许第二个,且它是可选依赖 —— 不注入时 legacy 行为完全不变。 + // centralGuard 中心化调度的额度预占校验闸。开源部署注入 noop(enforce 消息 fail-closed), + // 商业版由 Wire 注入真实账本适配器。legacy 实验不经过它,行为与引入前一致。 centralGuard component.ICentralReservationGuard } -// WithCentralReservationGuard 注入中心化调度额度闸。返回自身便于在 Wire provider 中链式调用。 -func (e *ExptItemEventEvalServiceImpl) WithCentralReservationGuard(guard component.ICentralReservationGuard) *ExptItemEventEvalServiceImpl { - e.centralGuard = guard - return e -} - func NewExptRecordEvalService( manager IExptManager, configer component.IConfiger, @@ -92,6 +85,7 @@ func NewExptRecordEvalService( evalAsyncRepo repo.IEvalAsyncRepo, itemCompletePublisher component.IItemCompletePublisher, sandboxAgentMetrics metrics.SandboxAgentMetrics, // 沙箱 agent 端到端 turn 打点; 可空 (走 noop) + centralGuard component.ICentralReservationGuard, sandboxAgentNotifier ...ISandboxAgentNotifier, // variadic 兼容 wire_gen 未接入通知器 ) ExptItemEvalEvent { i := &ExptItemEventEvalServiceImpl{ @@ -118,6 +112,7 @@ func NewExptRecordEvalService( evalAsyncRepo: evalAsyncRepo, itemCompletePublisher: itemCompletePublisher, sandboxAgentMetrics: sandboxAgentMetrics, + centralGuard: centralGuard, } if len(sandboxAgentNotifier) > 0 { i.sandboxAgentNotifier = sandboxAgentNotifier[0] diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go index a3e40ba35d..eee7866f7d 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go @@ -14,6 +14,7 @@ import ( benefitmocks "github.com/coze-dev/coze-loop/backend/infra/external/benefit/mocks" idgenmocks "github.com/coze-dev/coze-loop/backend/infra/idgen/mocks" lockmocks "github.com/coze-dev/coze-loop/backend/infra/lock/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" idemmocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/idem/mocks" metricsmocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/metrics/mocks" componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" @@ -51,6 +52,7 @@ func TestNewExptRecordEvalService(t *testing.T) { repoMocks.NewMockIEvalAsyncRepo(ctrl), nil, // itemCompletePublisher nil, // sandboxAgentMetrics + component.NewNoopCentralReservationGuard(), ) assert.NotNil(t, service) } diff --git a/backend/modules/evaluation/domain/service/wire.go b/backend/modules/evaluation/domain/service/wire.go index a392b53cba..f049b73d6b 100644 --- a/backend/modules/evaluation/domain/service/wire.go +++ b/backend/modules/evaluation/domain/service/wire.go @@ -47,6 +47,9 @@ var ExperimentDomainServiceSet = wire.NewSet( // ExptResultService 的 exptItemRefRepo 是 variadic(为兼容既有调用方), wire 需要显式的 // slice provider 才能注入。 ProvideExptItemRefRepos, + // 中心化调度额度闸:开源侧提供 noop(enforce 消息 fail-closed,legacy 不受影响)。 + // 商业版在自己的 wire set 里注入真实账本适配器覆盖它。 + component.NewNoopCentralReservationGuard, // Infrastructure Sets taskrpc.TaskRPCSet, pipeline.PipelineRPCSet, From c389ef0be2d55731ae553082b33603c753aa4def Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 11:42:59 +0800 Subject: [PATCH 05/58] =?UTF-8?q?chore(evaluation):=20=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=20kitex=20codegen=20=E7=94=9F=E6=88=90=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=B8=8E=E6=B6=88=E8=80=97=E5=90=91=E9=87=8F?= =?UTF-8?q?=E7=9A=84=20Go=20=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 只改了 thrift 未跑 codegen,导致 kitex_gen 里没有 ExpectedResourceConsumption / ExpectedQuotaConsumption / Evalx 等符号。下游 cozeloop-gen-commercial 的 expt-ref.go 会 ref_expt 引用这些类型,于是 commercial 编译报 undefined —— 根因在本仓漏了这一步。 生成命令:GOPATH=$(go env GOPATH) bash script/cloudwego/code_gen.sh (脚本用 ${GOPATH}/bin 作为 loopgen 安装目标;环境未导出 GOPATH 时会解析成 /bin 而权限失败, 故需显式带上。脚本内部 NO_PUSH_REMOTE=true,纯本地生成不推远端。) 仅 6 个生成文件变更,全部对应本次改动的两个 thrift。 Co-Authored-By: Claude Opus 5 (1M context) --- .../coze/loop/evaluation/domain/expt/expt.go | 673 ++++++++++++++++++ .../evaluation/domain/expt/expt_validator.go | 6 + .../loop/evaluation/domain/expt/k-expt.go | 490 +++++++++++++ .../expt/coze.loop.evaluation.expt.go | 458 ++++++++++++ .../coze.loop.evaluation.expt_validator.go | 10 + .../expt/k-coze.loop.evaluation.expt.go | 328 +++++++++ 6 files changed, 1965 insertions(+) diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go index 5434ecd1f3..9257a18b9c 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go @@ -28,6 +28,8 @@ const ( Schedule = "schedule" + Evalx = "evalx" + FrequencyEveryday = "every_day" FrequencyMonday = "monday" @@ -2206,6 +2208,520 @@ func (p *RunModeConfig) Field11DeepEqual(src *string) bool { return true } +// ★ 中心化调度:单 item 预期资源消耗 +// 由创建方在 Create/Submit 时申报,服务端冻结进 eval_conf,供中心调度器预占额度使用。 +// category/resource_key 语义与额度上限配置一致;amount 的单位由上限配置的 unit 定义,调用方不传 unit。 +type ExpectedResourceConsumption struct { + // 资源类别:sandbox / agent_account / model / evaluator + Category string `thrift:"category,1,required" frugal:"1,required,string" form:"category,required" json:"category,required" query:"category,required"` + // 具体资源:default / doubao_pro / gpt5.5 ...;不允许传 "*"(通配仅用于上限配置) + ResourceKey string `thrift:"resource_key,2,required" frugal:"2,required,string" form:"resource_key,required" json:"resource_key,required" query:"resource_key,required"` + // 单 item 的预期占用量,必须 > 0 + Amount int64 `thrift:"amount,3,required" frugal:"3,required,i64" form:"amount,required" json:"amount,required" query:"amount,required"` +} + +func NewExpectedResourceConsumption() *ExpectedResourceConsumption { + return &ExpectedResourceConsumption{} +} + +func (p *ExpectedResourceConsumption) InitDefault() { +} + +func (p *ExpectedResourceConsumption) GetCategory() (v string) { + if p != nil { + return p.Category + } + return +} + +func (p *ExpectedResourceConsumption) GetResourceKey() (v string) { + if p != nil { + return p.ResourceKey + } + return +} + +func (p *ExpectedResourceConsumption) GetAmount() (v int64) { + if p != nil { + return p.Amount + } + return +} +func (p *ExpectedResourceConsumption) SetCategory(val string) { + p.Category = val +} +func (p *ExpectedResourceConsumption) SetResourceKey(val string) { + p.ResourceKey = val +} +func (p *ExpectedResourceConsumption) SetAmount(val int64) { + p.Amount = val +} + +var fieldIDToName_ExpectedResourceConsumption = map[int16]string{ + 1: "category", + 2: "resource_key", + 3: "amount", +} + +func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { + var fieldTypeId thrift.TType + var fieldId int16 + var issetCategory bool = false + var issetResourceKey bool = false + var issetAmount bool = false + + if _, err = iprot.ReadStructBegin(); err != nil { + goto ReadStructBeginError + } + + for { + _, fieldTypeId, fieldId, err = iprot.ReadFieldBegin() + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + + switch fieldId { + case 1: + if fieldTypeId == thrift.STRING { + if err = p.ReadField1(iprot); err != nil { + goto ReadFieldError + } + issetCategory = true + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 2: + if fieldTypeId == thrift.STRING { + if err = p.ReadField2(iprot); err != nil { + goto ReadFieldError + } + issetResourceKey = true + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 3: + if fieldTypeId == thrift.I64 { + if err = p.ReadField3(iprot); err != nil { + goto ReadFieldError + } + issetAmount = true + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + default: + if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + } + if err = iprot.ReadFieldEnd(); err != nil { + goto ReadFieldEndError + } + } + if err = iprot.ReadStructEnd(); err != nil { + goto ReadStructEndError + } + + if !issetCategory { + fieldId = 1 + goto RequiredFieldNotSetError + } + + if !issetResourceKey { + fieldId = 2 + goto RequiredFieldNotSetError + } + + if !issetAmount { + fieldId = 3 + goto RequiredFieldNotSetError + } + return nil +ReadStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T read struct begin error: ", p), err) +ReadFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedResourceConsumption[fieldId]), err) +SkipFieldError: + return thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) + +ReadFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T read field end error", p), err) +ReadStructEndError: + return thrift.PrependError(fmt.Sprintf("%T read struct end error: ", p), err) +RequiredFieldNotSetError: + return thrift.NewTProtocolExceptionWithType(thrift.INVALID_DATA, fmt.Errorf("required field %s is not set", fieldIDToName_ExpectedResourceConsumption[fieldId])) +} + +func (p *ExpectedResourceConsumption) ReadField1(iprot thrift.TProtocol) error { + + var _field string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = v + } + p.Category = _field + return nil +} +func (p *ExpectedResourceConsumption) ReadField2(iprot thrift.TProtocol) error { + + var _field string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = v + } + p.ResourceKey = _field + return nil +} +func (p *ExpectedResourceConsumption) ReadField3(iprot thrift.TProtocol) error { + + var _field int64 + if v, err := iprot.ReadI64(); err != nil { + return err + } else { + _field = v + } + p.Amount = _field + return nil +} + +func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) { + var fieldId int16 + if err = oprot.WriteStructBegin("ExpectedResourceConsumption"); err != nil { + goto WriteStructBeginError + } + if p != nil { + if err = p.writeField1(oprot); err != nil { + fieldId = 1 + goto WriteFieldError + } + if err = p.writeField2(oprot); err != nil { + fieldId = 2 + goto WriteFieldError + } + if err = p.writeField3(oprot); err != nil { + fieldId = 3 + goto WriteFieldError + } + } + if err = oprot.WriteFieldStop(); err != nil { + goto WriteFieldStopError + } + if err = oprot.WriteStructEnd(); err != nil { + goto WriteStructEndError + } + return nil +WriteStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T write struct begin error: ", p), err) +WriteFieldError: + return thrift.PrependError(fmt.Sprintf("%T write field %d error: ", p, fieldId), err) +WriteFieldStopError: + return thrift.PrependError(fmt.Sprintf("%T write field stop error: ", p), err) +WriteStructEndError: + return thrift.PrependError(fmt.Sprintf("%T write struct end error: ", p), err) +} + +func (p *ExpectedResourceConsumption) writeField1(oprot thrift.TProtocol) (err error) { + if err = oprot.WriteFieldBegin("category", thrift.STRING, 1); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(p.Category); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 end error: ", p), err) +} +func (p *ExpectedResourceConsumption) writeField2(oprot thrift.TProtocol) (err error) { + if err = oprot.WriteFieldBegin("resource_key", thrift.STRING, 2); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(p.ResourceKey); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 2 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 2 end error: ", p), err) +} +func (p *ExpectedResourceConsumption) writeField3(oprot thrift.TProtocol) (err error) { + if err = oprot.WriteFieldBegin("amount", thrift.I64, 3); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI64(p.Amount); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 3 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 3 end error: ", p), err) +} + +func (p *ExpectedResourceConsumption) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("ExpectedResourceConsumption(%+v)", *p) + +} + +func (p *ExpectedResourceConsumption) DeepEqual(ano *ExpectedResourceConsumption) bool { + if p == ano { + return true + } else if p == nil || ano == nil { + return false + } + if !p.Field1DeepEqual(ano.Category) { + return false + } + if !p.Field2DeepEqual(ano.ResourceKey) { + return false + } + if !p.Field3DeepEqual(ano.Amount) { + return false + } + return true +} + +func (p *ExpectedResourceConsumption) Field1DeepEqual(src string) bool { + + if strings.Compare(p.Category, src) != 0 { + return false + } + return true +} +func (p *ExpectedResourceConsumption) Field2DeepEqual(src string) bool { + + if strings.Compare(p.ResourceKey, src) != 0 { + return false + } + return true +} +func (p *ExpectedResourceConsumption) Field3DeepEqual(src int64) bool { + + if p.Amount != src { + return false + } + return true +} + +type ExpectedQuotaConsumption struct { + Resources []*ExpectedResourceConsumption `thrift:"resources,1,required" frugal:"1,required,list" form:"resources,required" json:"resources,required" query:"resources,required"` +} + +func NewExpectedQuotaConsumption() *ExpectedQuotaConsumption { + return &ExpectedQuotaConsumption{} +} + +func (p *ExpectedQuotaConsumption) InitDefault() { +} + +func (p *ExpectedQuotaConsumption) GetResources() (v []*ExpectedResourceConsumption) { + if p != nil { + return p.Resources + } + return +} +func (p *ExpectedQuotaConsumption) SetResources(val []*ExpectedResourceConsumption) { + p.Resources = val +} + +var fieldIDToName_ExpectedQuotaConsumption = map[int16]string{ + 1: "resources", +} + +func (p *ExpectedQuotaConsumption) Read(iprot thrift.TProtocol) (err error) { + var fieldTypeId thrift.TType + var fieldId int16 + var issetResources bool = false + + if _, err = iprot.ReadStructBegin(); err != nil { + goto ReadStructBeginError + } + + for { + _, fieldTypeId, fieldId, err = iprot.ReadFieldBegin() + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + + switch fieldId { + case 1: + if fieldTypeId == thrift.LIST { + if err = p.ReadField1(iprot); err != nil { + goto ReadFieldError + } + issetResources = true + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + default: + if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + } + if err = iprot.ReadFieldEnd(); err != nil { + goto ReadFieldEndError + } + } + if err = iprot.ReadStructEnd(); err != nil { + goto ReadStructEndError + } + + if !issetResources { + fieldId = 1 + goto RequiredFieldNotSetError + } + return nil +ReadStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T read struct begin error: ", p), err) +ReadFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedQuotaConsumption[fieldId]), err) +SkipFieldError: + return thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) + +ReadFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T read field end error", p), err) +ReadStructEndError: + return thrift.PrependError(fmt.Sprintf("%T read struct end error: ", p), err) +RequiredFieldNotSetError: + return thrift.NewTProtocolExceptionWithType(thrift.INVALID_DATA, fmt.Errorf("required field %s is not set", fieldIDToName_ExpectedQuotaConsumption[fieldId])) +} + +func (p *ExpectedQuotaConsumption) ReadField1(iprot thrift.TProtocol) error { + _, size, err := iprot.ReadListBegin() + if err != nil { + return err + } + _field := make([]*ExpectedResourceConsumption, 0, size) + values := make([]ExpectedResourceConsumption, size) + for i := 0; i < size; i++ { + _elem := &values[i] + _elem.InitDefault() + + if err := _elem.Read(iprot); err != nil { + return err + } + + _field = append(_field, _elem) + } + if err := iprot.ReadListEnd(); err != nil { + return err + } + p.Resources = _field + return nil +} + +func (p *ExpectedQuotaConsumption) Write(oprot thrift.TProtocol) (err error) { + var fieldId int16 + if err = oprot.WriteStructBegin("ExpectedQuotaConsumption"); err != nil { + goto WriteStructBeginError + } + if p != nil { + if err = p.writeField1(oprot); err != nil { + fieldId = 1 + goto WriteFieldError + } + } + if err = oprot.WriteFieldStop(); err != nil { + goto WriteFieldStopError + } + if err = oprot.WriteStructEnd(); err != nil { + goto WriteStructEndError + } + return nil +WriteStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T write struct begin error: ", p), err) +WriteFieldError: + return thrift.PrependError(fmt.Sprintf("%T write field %d error: ", p, fieldId), err) +WriteFieldStopError: + return thrift.PrependError(fmt.Sprintf("%T write field stop error: ", p), err) +WriteStructEndError: + return thrift.PrependError(fmt.Sprintf("%T write struct end error: ", p), err) +} + +func (p *ExpectedQuotaConsumption) writeField1(oprot thrift.TProtocol) (err error) { + if err = oprot.WriteFieldBegin("resources", thrift.LIST, 1); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteListBegin(thrift.STRUCT, len(p.Resources)); err != nil { + return err + } + for _, v := range p.Resources { + if err := v.Write(oprot); err != nil { + return err + } + } + if err := oprot.WriteListEnd(); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 end error: ", p), err) +} + +func (p *ExpectedQuotaConsumption) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("ExpectedQuotaConsumption(%+v)", *p) + +} + +func (p *ExpectedQuotaConsumption) DeepEqual(ano *ExpectedQuotaConsumption) bool { + if p == ano { + return true + } else if p == nil || ano == nil { + return false + } + if !p.Field1DeepEqual(ano.Resources) { + return false + } + return true +} + +func (p *ExpectedQuotaConsumption) Field1DeepEqual(src []*ExpectedResourceConsumption) bool { + + if len(p.Resources) != len(src) { + return false + } + for i, v := range p.Resources { + _src := src[i] + if !v.DeepEqual(_src) { + return false + } + } + return true +} + type Experiment struct { ID *int64 `thrift:"id,1,optional" frugal:"1,optional,i64" json:"id" form:"id" query:"id"` Name *string `thrift:"name,2,optional" frugal:"2,optional,string" form:"name" json:"name,omitempty" query:"name"` @@ -2271,6 +2787,11 @@ type Experiment struct { // 实验级多轮/SUA 跑法配置回显: 从 experiment.eval_conf.run_mode_config 反序列化, 与 Create/Submit 入参 run_mode_config 同构。 // 仅 SandboxAgent + MultiSetConfig 实验非空。SUA 模型的 api_key/base_url 是运行时从 TCC 解析注入 case-file, 绝不回显。 RunModeConfig *RunModeConfig `thrift:"run_mode_config,115,optional" frugal:"115,optional,RunModeConfig" form:"run_mode_config" json:"run_mode_config,omitempty" query:"run_mode_config"` + // ★ 新增段位 116~119: 中心化调度读视图 + // 调度优先级回显 (1-99, 越大越优先); 直读 experiment 表同名列, 历史数据为 1 + PriorityLevel *int32 `thrift:"priority_level,116,optional" frugal:"116,optional,i32" form:"priority_level" json:"priority_level,omitempty" query:"priority_level"` + // 执行模式回显: legacy(旧 per-experiment 链路) / enforce(中心调度); 直读 experiment 表同名列, 该列是唯一权威源 + SchedulerMode *string `thrift:"scheduler_mode,117,optional" frugal:"117,optional,string" form:"scheduler_mode" json:"scheduler_mode,omitempty" query:"scheduler_mode"` } func NewExperiment() *Experiment { @@ -2843,6 +3364,30 @@ func (p *Experiment) GetRunModeConfig() (v *RunModeConfig) { } return p.RunModeConfig } + +var Experiment_PriorityLevel_DEFAULT int32 + +func (p *Experiment) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return Experiment_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var Experiment_SchedulerMode_DEFAULT string + +func (p *Experiment) GetSchedulerMode() (v string) { + if p == nil { + return + } + if !p.IsSetSchedulerMode() { + return Experiment_SchedulerMode_DEFAULT + } + return *p.SchedulerMode +} func (p *Experiment) SetID(val *int64) { p.ID = val } @@ -2984,6 +3529,12 @@ func (p *Experiment) SetTotalItemCount(val *int64) { func (p *Experiment) SetRunModeConfig(val *RunModeConfig) { p.RunModeConfig = val } +func (p *Experiment) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *Experiment) SetSchedulerMode(val *string) { + p.SchedulerMode = val +} var fieldIDToName_Experiment = map[int16]string{ 1: "id", @@ -3033,6 +3584,8 @@ var fieldIDToName_Experiment = map[int16]string{ 113: "evaluators_concur_num", 114: "total_item_count", 115: "run_mode_config", + 116: "priority_level", + 117: "scheduler_mode", } func (p *Experiment) IsSetID() bool { @@ -3223,6 +3776,14 @@ func (p *Experiment) IsSetRunModeConfig() bool { return p.RunModeConfig != nil } +func (p *Experiment) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *Experiment) IsSetSchedulerMode() bool { + return p.SchedulerMode != nil +} + func (p *Experiment) Read(iprot thrift.TProtocol) (err error) { var fieldTypeId thrift.TType var fieldId int16 @@ -3617,6 +4178,22 @@ func (p *Experiment) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 116: + if fieldTypeId == thrift.I32 { + if err = p.ReadField116(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 117: + if fieldTypeId == thrift.STRING { + if err = p.ReadField117(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } default: if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError @@ -4225,6 +4802,28 @@ func (p *Experiment) ReadField115(iprot thrift.TProtocol) error { p.RunModeConfig = _field return nil } +func (p *Experiment) ReadField116(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *Experiment) ReadField117(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.SchedulerMode = _field + return nil +} func (p *Experiment) Write(oprot thrift.TProtocol) (err error) { var fieldId int16 @@ -4420,6 +5019,14 @@ func (p *Experiment) Write(oprot thrift.TProtocol) (err error) { fieldId = 115 goto WriteFieldError } + if err = p.writeField116(oprot); err != nil { + fieldId = 116 + goto WriteFieldError + } + if err = p.writeField117(oprot); err != nil { + fieldId = 117 + goto WriteFieldError + } } if err = oprot.WriteFieldStop(); err != nil { goto WriteFieldStopError @@ -5343,6 +5950,42 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 115 end error: ", p), err) } +func (p *Experiment) writeField116(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 116); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 116 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 116 end error: ", p), err) +} +func (p *Experiment) writeField117(oprot thrift.TProtocol) (err error) { + if p.IsSetSchedulerMode() { + if err = oprot.WriteFieldBegin("scheduler_mode", thrift.STRING, 117); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.SchedulerMode); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 117 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 117 end error: ", p), err) +} func (p *Experiment) String() string { if p == nil { @@ -5499,6 +6142,12 @@ func (p *Experiment) DeepEqual(ano *Experiment) bool { if !p.Field115DeepEqual(ano.RunModeConfig) { return false } + if !p.Field116DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field117DeepEqual(ano.SchedulerMode) { + return false + } return true } @@ -6018,6 +6667,30 @@ func (p *Experiment) Field115DeepEqual(src *RunModeConfig) bool { } return true } +func (p *Experiment) Field116DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *Experiment) Field117DeepEqual(src *string) bool { + + if p.SchedulerMode == src { + return true + } else if p.SchedulerMode == nil || src == nil { + return false + } + if strings.Compare(*p.SchedulerMode, *src) != 0 { + return false + } + return true +} // 实验模板基础信息 type ExptTemplateMeta struct { diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go index 19086254a4..63f24f12f0 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go @@ -24,6 +24,12 @@ var ( func (p *RunModeConfig) IsValid() error { return nil } +func (p *ExpectedResourceConsumption) IsValid() error { + return nil +} +func (p *ExpectedQuotaConsumption) IsValid() error { + return nil +} func (p *Experiment) IsValid() error { if p.EvalSet != nil { if err := p.EvalSet.IsValid(); err != nil { diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go index e53fe886d4..113507c246 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go @@ -659,6 +659,387 @@ func (p *RunModeConfig) DeepCopy(s interface{}) error { return nil } +func (p *ExpectedResourceConsumption) FastRead(buf []byte) (int, error) { + + var err error + var offset int + var l int + var fieldTypeId thrift.TType + var fieldId int16 + var issetCategory bool = false + var issetResourceKey bool = false + var issetAmount bool = false + for { + fieldTypeId, fieldId, l, err = thrift.Binary.ReadFieldBegin(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + switch fieldId { + case 1: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField1(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + issetCategory = true + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 2: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField2(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + issetResourceKey = true + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 3: + if fieldTypeId == thrift.I64 { + l, err = p.FastReadField3(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + issetAmount = true + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + default: + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + } + + if !issetCategory { + fieldId = 1 + goto RequiredFieldNotSetError + } + + if !issetResourceKey { + fieldId = 2 + goto RequiredFieldNotSetError + } + + if !issetAmount { + fieldId = 3 + goto RequiredFieldNotSetError + } + return offset, nil +ReadFieldBeginError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedResourceConsumption[fieldId]), err) +SkipFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) +RequiredFieldNotSetError: + return offset, thrift.NewProtocolException(thrift.INVALID_DATA, fmt.Sprintf("required field %s is not set", fieldIDToName_ExpectedResourceConsumption[fieldId])) +} + +func (p *ExpectedResourceConsumption) FastReadField1(buf []byte) (int, error) { + offset := 0 + + var _field string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = v + } + p.Category = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastReadField2(buf []byte) (int, error) { + offset := 0 + + var _field string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = v + } + p.ResourceKey = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastReadField3(buf []byte) (int, error) { + offset := 0 + + var _field int64 + if v, l, err := thrift.Binary.ReadI64(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = v + } + p.Amount = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastWrite(buf []byte) int { + return p.FastWriteNocopy(buf, nil) +} + +func (p *ExpectedResourceConsumption) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p != nil { + offset += p.fastWriteField3(buf[offset:], w) + offset += p.fastWriteField1(buf[offset:], w) + offset += p.fastWriteField2(buf[offset:], w) + } + offset += thrift.Binary.WriteFieldStop(buf[offset:]) + return offset +} + +func (p *ExpectedResourceConsumption) BLength() int { + l := 0 + if p != nil { + l += p.field1Length() + l += p.field2Length() + l += p.field3Length() + } + l += thrift.Binary.FieldStopLength() + return l +} + +func (p *ExpectedResourceConsumption) fastWriteField1(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 1) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, p.Category) + return offset +} + +func (p *ExpectedResourceConsumption) fastWriteField2(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 2) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, p.ResourceKey) + return offset +} + +func (p *ExpectedResourceConsumption) fastWriteField3(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I64, 3) + offset += thrift.Binary.WriteI64(buf[offset:], p.Amount) + return offset +} + +func (p *ExpectedResourceConsumption) field1Length() int { + l := 0 + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(p.Category) + return l +} + +func (p *ExpectedResourceConsumption) field2Length() int { + l := 0 + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(p.ResourceKey) + return l +} + +func (p *ExpectedResourceConsumption) field3Length() int { + l := 0 + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I64Length() + return l +} + +func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { + src, ok := s.(*ExpectedResourceConsumption) + if !ok { + return fmt.Errorf("%T's type not matched %T", s, p) + } + + if src.Category != "" { + p.Category = kutils.StringDeepCopy(src.Category) + } + + if src.ResourceKey != "" { + p.ResourceKey = kutils.StringDeepCopy(src.ResourceKey) + } + + p.Amount = src.Amount + + return nil +} + +func (p *ExpectedQuotaConsumption) FastRead(buf []byte) (int, error) { + + var err error + var offset int + var l int + var fieldTypeId thrift.TType + var fieldId int16 + var issetResources bool = false + for { + fieldTypeId, fieldId, l, err = thrift.Binary.ReadFieldBegin(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + switch fieldId { + case 1: + if fieldTypeId == thrift.LIST { + l, err = p.FastReadField1(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + issetResources = true + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + default: + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + } + + if !issetResources { + fieldId = 1 + goto RequiredFieldNotSetError + } + return offset, nil +ReadFieldBeginError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedQuotaConsumption[fieldId]), err) +SkipFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) +RequiredFieldNotSetError: + return offset, thrift.NewProtocolException(thrift.INVALID_DATA, fmt.Sprintf("required field %s is not set", fieldIDToName_ExpectedQuotaConsumption[fieldId])) +} + +func (p *ExpectedQuotaConsumption) FastReadField1(buf []byte) (int, error) { + offset := 0 + + _, size, l, err := thrift.Binary.ReadListBegin(buf[offset:]) + offset += l + if err != nil { + return offset, err + } + _field := make([]*ExpectedResourceConsumption, 0, size) + values := make([]ExpectedResourceConsumption, size) + for i := 0; i < size; i++ { + _elem := &values[i] + _elem.InitDefault() + if l, err := _elem.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + + _field = append(_field, _elem) + } + p.Resources = _field + return offset, nil +} + +func (p *ExpectedQuotaConsumption) FastWrite(buf []byte) int { + return p.FastWriteNocopy(buf, nil) +} + +func (p *ExpectedQuotaConsumption) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p != nil { + offset += p.fastWriteField1(buf[offset:], w) + } + offset += thrift.Binary.WriteFieldStop(buf[offset:]) + return offset +} + +func (p *ExpectedQuotaConsumption) BLength() int { + l := 0 + if p != nil { + l += p.field1Length() + } + l += thrift.Binary.FieldStopLength() + return l +} + +func (p *ExpectedQuotaConsumption) fastWriteField1(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.LIST, 1) + listBeginOffset := offset + offset += thrift.Binary.ListBeginLength() + var length int + for _, v := range p.Resources { + length++ + offset += v.FastWriteNocopy(buf[offset:], w) + } + thrift.Binary.WriteListBegin(buf[listBeginOffset:], thrift.STRUCT, length) + return offset +} + +func (p *ExpectedQuotaConsumption) field1Length() int { + l := 0 + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.ListBeginLength() + for _, v := range p.Resources { + _ = v + l += v.BLength() + } + return l +} + +func (p *ExpectedQuotaConsumption) DeepCopy(s interface{}) error { + src, ok := s.(*ExpectedQuotaConsumption) + if !ok { + return fmt.Errorf("%T's type not matched %T", s, p) + } + + if src.Resources != nil { + p.Resources = make([]*ExpectedResourceConsumption, 0, len(src.Resources)) + for _, elem := range src.Resources { + var _elem *ExpectedResourceConsumption + if elem != nil { + _elem = &ExpectedResourceConsumption{} + if err := _elem.DeepCopy(elem); err != nil { + return err + } + } + + p.Resources = append(p.Resources, _elem) + } + } + + return nil +} + func (p *Experiment) FastRead(buf []byte) (int, error) { var err error @@ -1334,6 +1715,34 @@ func (p *Experiment) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 116: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField116(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 117: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField117(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } default: l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) offset += l @@ -2081,6 +2490,34 @@ func (p *Experiment) FastReadField115(buf []byte) (int, error) { return offset, nil } +func (p *Experiment) FastReadField116(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *Experiment) FastReadField117(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.SchedulerMode = _field + return offset, nil +} + func (p *Experiment) FastWrite(buf []byte) int { return p.FastWriteNocopy(buf, nil) } @@ -2103,6 +2540,7 @@ func (p *Experiment) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { offset += p.fastWriteField64(buf[offset:], w) offset += p.fastWriteField113(buf[offset:], w) offset += p.fastWriteField114(buf[offset:], w) + offset += p.fastWriteField116(buf[offset:], w) offset += p.fastWriteField2(buf[offset:], w) offset += p.fastWriteField3(buf[offset:], w) offset += p.fastWriteField4(buf[offset:], w) @@ -2135,6 +2573,7 @@ func (p *Experiment) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { offset += p.fastWriteField111(buf[offset:], w) offset += p.fastWriteField112(buf[offset:], w) offset += p.fastWriteField115(buf[offset:], w) + offset += p.fastWriteField117(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) return offset @@ -2190,6 +2629,8 @@ func (p *Experiment) BLength() int { l += p.field113Length() l += p.field114Length() l += p.field115Length() + l += p.field116Length() + l += p.field117Length() } l += thrift.Binary.FieldStopLength() return l @@ -2668,6 +3109,24 @@ func (p *Experiment) fastWriteField115(buf []byte, w thrift.NocopyWriter) int { return offset } +func (p *Experiment) fastWriteField116(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 116) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *Experiment) fastWriteField117(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSchedulerMode() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 117) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.SchedulerMode) + } + return offset +} + func (p *Experiment) field1Length() int { l := 0 if p.IsSetID() { @@ -3119,6 +3578,24 @@ func (p *Experiment) field115Length() int { return l } +func (p *Experiment) field116Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *Experiment) field117Length() int { + l := 0 + if p.IsSetSchedulerMode() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.SchedulerMode) + } + return l +} + func (p *Experiment) DeepCopy(s interface{}) error { src, ok := s.(*Experiment) if !ok { @@ -3491,6 +3968,19 @@ func (p *Experiment) DeepCopy(s interface{}) error { } p.RunModeConfig = _runModeConfig + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + if src.SchedulerMode != nil { + var tmp string + if *src.SchedulerMode != "" { + tmp = kutils.StringDeepCopy(*src.SchedulerMode) + } + p.SchedulerMode = &tmp + } + return nil } diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go index cec0550e27..4c0218b96f 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go @@ -74,6 +74,15 @@ type CreateExperimentRequest struct { // 实验分组 key 默认为实验 id;填写 ref_group_experiment_id 时复用该引用实验的 group key(归入同一分组) // 引用分组实验 id:填写时校验其为当前空间内的实验 id RefGroupExperimentID *int64 `thrift:"ref_group_experiment_id,91,optional" frugal:"91,optional,i64" json:"ref_group_experiment_id" form:"ref_group_experiment_id" ` + // ★ 中心化调度入参 92~94 + // 调度优先级:1-99,数值越大越优先;缺省 1。仅在中心调度模式下参与排序,legacy 模式忽略 + PriorityLevel *int32 `thrift:"priority_level,92,optional" frugal:"92,optional,i32" form:"priority_level" json:"priority_level,omitempty"` + // 单 item 预期资源消耗向量:enforce 模式必填且非空,legacy 模式可选 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*",随后冻结进 eval_conf;Retry 继承不可覆盖 + ExpectedQuotaConsumption *expt.ExpectedQuotaConsumption `thrift:"expected_quota_consumption,93,optional" frugal:"93,optional,expt.ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty"` + // 执行模式:legacy / enforce。故意不加 api.body —— 公网调用方不得自行指定,仅允许 commercial wrapper 按灰度白名单填入 + // 与 trigger_type 同为服务端内部覆写字段;落库后 experiment.scheduler_mode 列是唯一权威源 + SchedulerMode *string `thrift:"scheduler_mode,94,optional" frugal:"94,optional,string" form:"scheduler_mode" json:"scheduler_mode,omitempty" query:"scheduler_mode"` // 通知配置 NotificationConf *expt.ExptNotificationConf `thrift:"notification_conf,110,optional" frugal:"110,optional,expt.ExptNotificationConf" form:"notification_conf" json:"notification_conf,omitempty"` Ext map[string]string `thrift:"ext,100,optional" frugal:"100,optional,map" form:"ext" json:"ext,omitempty"` @@ -491,6 +500,42 @@ func (p *CreateExperimentRequest) GetRefGroupExperimentID() (v int64) { return *p.RefGroupExperimentID } +var CreateExperimentRequest_PriorityLevel_DEFAULT int32 + +func (p *CreateExperimentRequest) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return CreateExperimentRequest_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var CreateExperimentRequest_ExpectedQuotaConsumption_DEFAULT *expt.ExpectedQuotaConsumption + +func (p *CreateExperimentRequest) GetExpectedQuotaConsumption() (v *expt.ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return CreateExperimentRequest_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + +var CreateExperimentRequest_SchedulerMode_DEFAULT string + +func (p *CreateExperimentRequest) GetSchedulerMode() (v string) { + if p == nil { + return + } + if !p.IsSetSchedulerMode() { + return CreateExperimentRequest_SchedulerMode_DEFAULT + } + return *p.SchedulerMode +} + var CreateExperimentRequest_NotificationConf_DEFAULT *expt.ExptNotificationConf func (p *CreateExperimentRequest) GetNotificationConf() (v *expt.ExptNotificationConf) { @@ -640,6 +685,15 @@ func (p *CreateExperimentRequest) SetTargetSharedOption(val *common.SharedResour func (p *CreateExperimentRequest) SetRefGroupExperimentID(val *int64) { p.RefGroupExperimentID = val } +func (p *CreateExperimentRequest) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *CreateExperimentRequest) SetExpectedQuotaConsumption(val *expt.ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} +func (p *CreateExperimentRequest) SetSchedulerMode(val *string) { + p.SchedulerMode = val +} func (p *CreateExperimentRequest) SetNotificationConf(val *expt.ExptNotificationConf) { p.NotificationConf = val } @@ -688,6 +742,9 @@ var fieldIDToName_CreateExperimentRequest = map[int16]string{ 80: "eval_set_shared_option", 81: "target_shared_option", 91: "ref_group_experiment_id", + 92: "priority_level", + 93: "expected_quota_consumption", + 94: "scheduler_mode", 110: "notification_conf", 100: "ext", 200: "session", @@ -826,6 +883,18 @@ func (p *CreateExperimentRequest) IsSetRefGroupExperimentID() bool { return p.RefGroupExperimentID != nil } +func (p *CreateExperimentRequest) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *CreateExperimentRequest) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + +func (p *CreateExperimentRequest) IsSetSchedulerMode() bool { + return p.SchedulerMode != nil +} + func (p *CreateExperimentRequest) IsSetNotificationConf() bool { return p.NotificationConf != nil } @@ -1134,6 +1203,30 @@ func (p *CreateExperimentRequest) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 92: + if fieldTypeId == thrift.I32 { + if err = p.ReadField92(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 93: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField93(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 94: + if fieldTypeId == thrift.STRING { + if err = p.ReadField94(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 110: if fieldTypeId == thrift.STRUCT { if err = p.ReadField110(iprot); err != nil { @@ -1626,6 +1719,36 @@ func (p *CreateExperimentRequest) ReadField91(iprot thrift.TProtocol) error { p.RefGroupExperimentID = _field return nil } +func (p *CreateExperimentRequest) ReadField92(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *CreateExperimentRequest) ReadField93(iprot thrift.TProtocol) error { + _field := expt.NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} +func (p *CreateExperimentRequest) ReadField94(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.SchedulerMode = _field + return nil +} func (p *CreateExperimentRequest) ReadField110(iprot thrift.TProtocol) error { _field := expt.NewExptNotificationConf() if err := _field.Read(iprot); err != nil { @@ -1822,6 +1945,18 @@ func (p *CreateExperimentRequest) Write(oprot thrift.TProtocol) (err error) { fieldId = 91 goto WriteFieldError } + if err = p.writeField92(oprot); err != nil { + fieldId = 92 + goto WriteFieldError + } + if err = p.writeField93(oprot); err != nil { + fieldId = 93 + goto WriteFieldError + } + if err = p.writeField94(oprot); err != nil { + fieldId = 94 + goto WriteFieldError + } if err = p.writeField110(oprot); err != nil { fieldId = 110 goto WriteFieldError @@ -2509,6 +2644,60 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 91 end error: ", p), err) } +func (p *CreateExperimentRequest) writeField92(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 92); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 end error: ", p), err) +} +func (p *CreateExperimentRequest) writeField93(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 93); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 end error: ", p), err) +} +func (p *CreateExperimentRequest) writeField94(oprot thrift.TProtocol) (err error) { + if p.IsSetSchedulerMode() { + if err = oprot.WriteFieldBegin("scheduler_mode", thrift.STRING, 94); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.SchedulerMode); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 94 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 94 end error: ", p), err) +} func (p *CreateExperimentRequest) writeField110(oprot thrift.TProtocol) (err error) { if p.IsSetNotificationConf() { if err = oprot.WriteFieldBegin("notification_conf", thrift.STRUCT, 110); err != nil { @@ -2709,6 +2898,15 @@ func (p *CreateExperimentRequest) DeepEqual(ano *CreateExperimentRequest) bool { if !p.Field91DeepEqual(ano.RefGroupExperimentID) { return false } + if !p.Field92DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field93DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } + if !p.Field94DeepEqual(ano.SchedulerMode) { + return false + } if !p.Field110DeepEqual(ano.NotificationConf) { return false } @@ -3102,6 +3300,37 @@ func (p *CreateExperimentRequest) Field91DeepEqual(src *int64) bool { } return true } +func (p *CreateExperimentRequest) Field92DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *CreateExperimentRequest) Field93DeepEqual(src *expt.ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} +func (p *CreateExperimentRequest) Field94DeepEqual(src *string) bool { + + if p.SchedulerMode == src { + return true + } else if p.SchedulerMode == nil || src == nil { + return false + } + if strings.Compare(*p.SchedulerMode, *src) != 0 { + return false + } + return true +} func (p *CreateExperimentRequest) Field110DeepEqual(src *expt.ExptNotificationConf) bool { if !p.NotificationConf.DeepEqual(src) { @@ -3431,6 +3660,15 @@ type SubmitExperimentRequest struct { // 实验分组 key 默认为实验 id;填写 ref_group_experiment_id 时复用该引用实验的 group key(归入同一分组) // 引用分组实验 id:填写时校验其为当前空间内的实验 id RefGroupExperimentID *int64 `thrift:"ref_group_experiment_id,91,optional" frugal:"91,optional,i64" json:"ref_group_experiment_id" form:"ref_group_experiment_id" ` + // ★ 中心化调度入参 92~94 + // 调度优先级:1-99,数值越大越优先;缺省 1。仅在中心调度模式下参与排序,legacy 模式忽略 + PriorityLevel *int32 `thrift:"priority_level,92,optional" frugal:"92,optional,i32" form:"priority_level" json:"priority_level,omitempty"` + // 单 item 预期资源消耗向量:enforce 模式必填且非空,legacy 模式可选 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*",随后冻结进 eval_conf;Retry 继承不可覆盖 + ExpectedQuotaConsumption *expt.ExpectedQuotaConsumption `thrift:"expected_quota_consumption,93,optional" frugal:"93,optional,expt.ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty"` + // 执行模式:legacy / enforce。故意不加 api.body —— 公网调用方不得自行指定,仅允许 commercial wrapper 按灰度白名单填入 + // 与 trigger_type 同为服务端内部覆写字段;落库后 experiment.scheduler_mode 列是唯一权威源 + SchedulerMode *string `thrift:"scheduler_mode,94,optional" frugal:"94,optional,string" form:"scheduler_mode" json:"scheduler_mode,omitempty" query:"scheduler_mode"` // 通知配置 NotificationConf *expt.ExptNotificationConf `thrift:"notification_conf,110,optional" frugal:"110,optional,expt.ExptNotificationConf" form:"notification_conf" json:"notification_conf,omitempty"` Session *common.Session `thrift:"session,200,optional" frugal:"200,optional,common.Session" form:"session" json:"session,omitempty" query:"session"` @@ -3883,6 +4121,42 @@ func (p *SubmitExperimentRequest) GetRefGroupExperimentID() (v int64) { return *p.RefGroupExperimentID } +var SubmitExperimentRequest_PriorityLevel_DEFAULT int32 + +func (p *SubmitExperimentRequest) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return SubmitExperimentRequest_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var SubmitExperimentRequest_ExpectedQuotaConsumption_DEFAULT *expt.ExpectedQuotaConsumption + +func (p *SubmitExperimentRequest) GetExpectedQuotaConsumption() (v *expt.ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return SubmitExperimentRequest_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + +var SubmitExperimentRequest_SchedulerMode_DEFAULT string + +func (p *SubmitExperimentRequest) GetSchedulerMode() (v string) { + if p == nil { + return + } + if !p.IsSetSchedulerMode() { + return SubmitExperimentRequest_SchedulerMode_DEFAULT + } + return *p.SchedulerMode +} + var SubmitExperimentRequest_NotificationConf_DEFAULT *expt.ExptNotificationConf func (p *SubmitExperimentRequest) GetNotificationConf() (v *expt.ExptNotificationConf) { @@ -4029,6 +4303,15 @@ func (p *SubmitExperimentRequest) SetExt(val map[string]string) { func (p *SubmitExperimentRequest) SetRefGroupExperimentID(val *int64) { p.RefGroupExperimentID = val } +func (p *SubmitExperimentRequest) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *SubmitExperimentRequest) SetExpectedQuotaConsumption(val *expt.ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} +func (p *SubmitExperimentRequest) SetSchedulerMode(val *string) { + p.SchedulerMode = val +} func (p *SubmitExperimentRequest) SetNotificationConf(val *expt.ExptNotificationConf) { p.NotificationConf = val } @@ -4077,6 +4360,9 @@ var fieldIDToName_SubmitExperimentRequest = map[int16]string{ 81: "target_shared_option", 100: "ext", 91: "ref_group_experiment_id", + 92: "priority_level", + 93: "expected_quota_consumption", + 94: "scheduler_mode", 110: "notification_conf", 200: "session", 255: "Base", @@ -4226,6 +4512,18 @@ func (p *SubmitExperimentRequest) IsSetRefGroupExperimentID() bool { return p.RefGroupExperimentID != nil } +func (p *SubmitExperimentRequest) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *SubmitExperimentRequest) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + +func (p *SubmitExperimentRequest) IsSetSchedulerMode() bool { + return p.SchedulerMode != nil +} + func (p *SubmitExperimentRequest) IsSetNotificationConf() bool { return p.NotificationConf != nil } @@ -4554,6 +4852,30 @@ func (p *SubmitExperimentRequest) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 92: + if fieldTypeId == thrift.I32 { + if err = p.ReadField92(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 93: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField93(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 94: + if fieldTypeId == thrift.STRING { + if err = p.ReadField94(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 110: if fieldTypeId == thrift.STRUCT { if err = p.ReadField110(iprot); err != nil { @@ -5080,6 +5402,36 @@ func (p *SubmitExperimentRequest) ReadField91(iprot thrift.TProtocol) error { p.RefGroupExperimentID = _field return nil } +func (p *SubmitExperimentRequest) ReadField92(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *SubmitExperimentRequest) ReadField93(iprot thrift.TProtocol) error { + _field := expt.NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} +func (p *SubmitExperimentRequest) ReadField94(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.SchedulerMode = _field + return nil +} func (p *SubmitExperimentRequest) ReadField110(iprot thrift.TProtocol) error { _field := expt.NewExptNotificationConf() if err := _field.Read(iprot); err != nil { @@ -5259,6 +5611,18 @@ func (p *SubmitExperimentRequest) Write(oprot thrift.TProtocol) (err error) { fieldId = 91 goto WriteFieldError } + if err = p.writeField92(oprot); err != nil { + fieldId = 92 + goto WriteFieldError + } + if err = p.writeField93(oprot); err != nil { + fieldId = 93 + goto WriteFieldError + } + if err = p.writeField94(oprot); err != nil { + fieldId = 94 + goto WriteFieldError + } if err = p.writeField110(oprot); err != nil { fieldId = 110 goto WriteFieldError @@ -6004,6 +6368,60 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 91 end error: ", p), err) } +func (p *SubmitExperimentRequest) writeField92(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 92); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 end error: ", p), err) +} +func (p *SubmitExperimentRequest) writeField93(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 93); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 end error: ", p), err) +} +func (p *SubmitExperimentRequest) writeField94(oprot thrift.TProtocol) (err error) { + if p.IsSetSchedulerMode() { + if err = oprot.WriteFieldBegin("scheduler_mode", thrift.STRING, 94); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.SchedulerMode); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 94 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 94 end error: ", p), err) +} func (p *SubmitExperimentRequest) writeField110(oprot thrift.TProtocol) (err error) { if p.IsSetNotificationConf() { if err = oprot.WriteFieldBegin("notification_conf", thrift.STRUCT, 110); err != nil { @@ -6184,6 +6602,15 @@ func (p *SubmitExperimentRequest) DeepEqual(ano *SubmitExperimentRequest) bool { if !p.Field91DeepEqual(ano.RefGroupExperimentID) { return false } + if !p.Field92DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field93DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } + if !p.Field94DeepEqual(ano.SchedulerMode) { + return false + } if !p.Field110DeepEqual(ano.NotificationConf) { return false } @@ -6606,6 +7033,37 @@ func (p *SubmitExperimentRequest) Field91DeepEqual(src *int64) bool { } return true } +func (p *SubmitExperimentRequest) Field92DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *SubmitExperimentRequest) Field93DeepEqual(src *expt.ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} +func (p *SubmitExperimentRequest) Field94DeepEqual(src *string) bool { + + if p.SchedulerMode == src { + return true + } else if p.SchedulerMode == nil || src == nil { + return false + } + if strings.Compare(*p.SchedulerMode, *src) != 0 { + return false + } + return true +} func (p *SubmitExperimentRequest) Field110DeepEqual(src *expt.ExptNotificationConf) bool { if !p.NotificationConf.DeepEqual(src) { diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go index 107f68f0bc..7ff3b05723 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go @@ -52,6 +52,11 @@ func (p *CreateExperimentRequest) IsValid() error { return fmt.Errorf("field TargetSharedOption not valid, %w", err) } } + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.NotificationConf != nil { if err := p.NotificationConf.IsValid(); err != nil { return fmt.Errorf("field NotificationConf not valid, %w", err) @@ -118,6 +123,11 @@ func (p *SubmitExperimentRequest) IsValid() error { return fmt.Errorf("field TargetSharedOption not valid, %w", err) } } + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.NotificationConf != nil { if err := p.NotificationConf.IsValid(); err != nil { return fmt.Errorf("field NotificationConf not valid, %w", err) diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go b/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go index 9bdd4f116c..70f8063770 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go @@ -534,6 +534,48 @@ func (p *CreateExperimentRequest) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 92: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField92(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 93: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField93(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 94: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField94(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 110: if fieldTypeId == thrift.STRUCT { l, err = p.FastReadField110(buf[offset:]) @@ -1145,6 +1187,46 @@ func (p *CreateExperimentRequest) FastReadField91(buf []byte) (int, error) { return offset, nil } +func (p *CreateExperimentRequest) FastReadField92(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *CreateExperimentRequest) FastReadField93(buf []byte) (int, error) { + offset := 0 + _field := expt.NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + +func (p *CreateExperimentRequest) FastReadField94(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.SchedulerMode = _field + return offset, nil +} + func (p *CreateExperimentRequest) FastReadField110(buf []byte) (int, error) { offset := 0 _field := expt.NewExptNotificationConf() @@ -1234,6 +1316,7 @@ func (p *CreateExperimentRequest) FastWriteNocopy(buf []byte, w thrift.NocopyWri offset += p.fastWriteField46(buf[offset:], w) offset += p.fastWriteField47(buf[offset:], w) offset += p.fastWriteField91(buf[offset:], w) + offset += p.fastWriteField92(buf[offset:], w) offset += p.fastWriteField4(buf[offset:], w) offset += p.fastWriteField5(buf[offset:], w) offset += p.fastWriteField6(buf[offset:], w) @@ -1254,6 +1337,8 @@ func (p *CreateExperimentRequest) FastWriteNocopy(buf []byte, w thrift.NocopyWri offset += p.fastWriteField71(buf[offset:], w) offset += p.fastWriteField80(buf[offset:], w) offset += p.fastWriteField81(buf[offset:], w) + offset += p.fastWriteField93(buf[offset:], w) + offset += p.fastWriteField94(buf[offset:], w) offset += p.fastWriteField110(buf[offset:], w) offset += p.fastWriteField100(buf[offset:], w) offset += p.fastWriteField200(buf[offset:], w) @@ -1300,6 +1385,9 @@ func (p *CreateExperimentRequest) BLength() int { l += p.field80Length() l += p.field81Length() l += p.field91Length() + l += p.field92Length() + l += p.field93Length() + l += p.field94Length() l += p.field110Length() l += p.field100Length() l += p.field200Length() @@ -1649,6 +1737,33 @@ func (p *CreateExperimentRequest) fastWriteField91(buf []byte, w thrift.NocopyWr return offset } +func (p *CreateExperimentRequest) fastWriteField92(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 92) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *CreateExperimentRequest) fastWriteField93(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 93) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + +func (p *CreateExperimentRequest) fastWriteField94(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSchedulerMode() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 94) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.SchedulerMode) + } + return offset +} + func (p *CreateExperimentRequest) fastWriteField110(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetNotificationConf() { @@ -2013,6 +2128,33 @@ func (p *CreateExperimentRequest) field91Length() int { return l } +func (p *CreateExperimentRequest) field92Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *CreateExperimentRequest) field93Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + +func (p *CreateExperimentRequest) field94Length() int { + l := 0 + if p.IsSetSchedulerMode() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.SchedulerMode) + } + return l +} + func (p *CreateExperimentRequest) field110Length() int { l := 0 if p.IsSetNotificationConf() { @@ -2306,6 +2448,28 @@ func (p *CreateExperimentRequest) DeepCopy(s interface{}) error { p.RefGroupExperimentID = &tmp } + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + var _expectedQuotaConsumption *expt.ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &expt.ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + + if src.SchedulerMode != nil { + var tmp string + if *src.SchedulerMode != "" { + tmp = kutils.StringDeepCopy(*src.SchedulerMode) + } + p.SchedulerMode = &tmp + } + var _notificationConf *expt.ExptNotificationConf if src.NotificationConf != nil { _notificationConf = &expt.ExptNotificationConf{} @@ -3060,6 +3224,48 @@ func (p *SubmitExperimentRequest) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 92: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField92(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 93: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField93(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 94: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField94(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 110: if fieldTypeId == thrift.STRUCT { l, err = p.FastReadField110(buf[offset:]) @@ -3707,6 +3913,46 @@ func (p *SubmitExperimentRequest) FastReadField91(buf []byte) (int, error) { return offset, nil } +func (p *SubmitExperimentRequest) FastReadField92(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *SubmitExperimentRequest) FastReadField93(buf []byte) (int, error) { + offset := 0 + _field := expt.NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + +func (p *SubmitExperimentRequest) FastReadField94(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.SchedulerMode = _field + return offset, nil +} + func (p *SubmitExperimentRequest) FastReadField110(buf []byte) (int, error) { offset := 0 _field := expt.NewExptNotificationConf() @@ -3764,6 +4010,7 @@ func (p *SubmitExperimentRequest) FastWriteNocopy(buf []byte, w thrift.NocopyWri offset += p.fastWriteField46(buf[offset:], w) offset += p.fastWriteField47(buf[offset:], w) offset += p.fastWriteField91(buf[offset:], w) + offset += p.fastWriteField92(buf[offset:], w) offset += p.fastWriteField4(buf[offset:], w) offset += p.fastWriteField5(buf[offset:], w) offset += p.fastWriteField6(buf[offset:], w) @@ -3787,6 +4034,8 @@ func (p *SubmitExperimentRequest) FastWriteNocopy(buf []byte, w thrift.NocopyWri offset += p.fastWriteField80(buf[offset:], w) offset += p.fastWriteField81(buf[offset:], w) offset += p.fastWriteField100(buf[offset:], w) + offset += p.fastWriteField93(buf[offset:], w) + offset += p.fastWriteField94(buf[offset:], w) offset += p.fastWriteField110(buf[offset:], w) offset += p.fastWriteField200(buf[offset:], w) offset += p.fastWriteField255(buf[offset:], w) @@ -3835,6 +4084,9 @@ func (p *SubmitExperimentRequest) BLength() int { l += p.field81Length() l += p.field100Length() l += p.field91Length() + l += p.field92Length() + l += p.field93Length() + l += p.field94Length() l += p.field110Length() l += p.field200Length() l += p.field255Length() @@ -4217,6 +4469,33 @@ func (p *SubmitExperimentRequest) fastWriteField91(buf []byte, w thrift.NocopyWr return offset } +func (p *SubmitExperimentRequest) fastWriteField92(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 92) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *SubmitExperimentRequest) fastWriteField93(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 93) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + +func (p *SubmitExperimentRequest) fastWriteField94(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSchedulerMode() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 94) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.SchedulerMode) + } + return offset +} + func (p *SubmitExperimentRequest) fastWriteField110(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetNotificationConf() { @@ -4597,6 +4876,33 @@ func (p *SubmitExperimentRequest) field91Length() int { return l } +func (p *SubmitExperimentRequest) field92Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *SubmitExperimentRequest) field93Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + +func (p *SubmitExperimentRequest) field94Length() int { + l := 0 + if p.IsSetSchedulerMode() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.SchedulerMode) + } + return l +} + func (p *SubmitExperimentRequest) field110Length() int { l := 0 if p.IsSetNotificationConf() { @@ -4905,6 +5211,28 @@ func (p *SubmitExperimentRequest) DeepCopy(s interface{}) error { p.RefGroupExperimentID = &tmp } + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + var _expectedQuotaConsumption *expt.ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &expt.ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + + if src.SchedulerMode != nil { + var tmp string + if *src.SchedulerMode != "" { + tmp = kutils.StringDeepCopy(*src.SchedulerMode) + } + p.SchedulerMode = &tmp + } + var _notificationConf *expt.ExptNotificationConf if src.NotificationConf != nil { _notificationConf = &expt.ExptNotificationConf{} From 7499874e721cb05dc85253a67aa17bc2ba28c022 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 12:45:01 +0800 Subject: [PATCH 06/58] =?UTF-8?q?feat(evaluation):=20run=20log=20=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E9=A2=84=E5=8D=A0=E6=8A=95=E5=BD=B1=20+=20=E7=AA=84?= =?UTF-8?q?=E6=B4=BE=E5=8F=91=20repo=EF=BC=88spec=201.15/1.16=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修一个我在实现中心调度 Adapter 时引入的真实缺陷。 原实现的 LoadRuntimeState 只把 status=Processing 算作活跃 item,还写了段注释自我说服: 「reservation 侧的活跃 item 由账本幂等保证,不必查 Redis 求并集,收益仅是 deficit 精确一点点」。 这个推理是错的:已在 Redis 预占但尚未被 consumer 消费的 item,在 MySQL 里仍是纯 Queueing, 于是它既不计入并发占用、又会再次进入待授予队列。后果不是「精确一点点」—— - deficit 被持续高估:并发 5 的实验即使 5 个 item 都已预占待消费,deficit 仍算成 5 - 每拍都为同一批 item 重复申请额度(ReserveBatch 幂等挡住了重复扣额,但没挡住重复申请) - 高优实验反复占用授予机会,挤掉本该拿到额度的低优实验 解法(按 spec 7ae163c):在 MySQL 侧显式表达「已预占」。 DDL(四处:docker init/patch + helm init/alter,两两 diff 一致) - quota_reservation_state TINYINT UNSIGNED NOT NULL DEFAULT 0 - idx_expt_run_dispatch(space_id, expt_id, expt_run_id, status, quota_reservation_state, id) 末位带 id 让 keyset 分页与稳定排序都走索引,避免 filesort entity - QuotaReservationState 只有 none/reserved 两值,**不是** Redis 状态机的复制品 (Redis 侧有 Reserved/Dispatched/DispatchUncertain/Running 四个非终态) Redis reservation 仍是账本真值;本列只是调度投影,不进 IDL/OpenAPI/Stats - 未知值按未预占处理:安全侧是让它进候选被重新预占,而非当成已占用而永不派发 IExptItemDispatchRepo(新建窄接口,不往 IExptItemResultRepo 加方法) - ClaimQuotaReserved:Queueing/none → Queueing/reserved,Redis 预占与 MQ 发布之间的必经关卡。 **逐个 CAS 而非批量 UPDATE**:批量只能拿到总 RowsAffected,无法知道哪些成功; 而调用方必须精确知道才能只发布成功项、并释放失败项的 Redis reservation - ResetQuotaReserved:MQ 明确失败时退回。条件带 status=Queueing —— 若已被 consumer 推进到 Processing 说明消息其实投递成功了,退回投影会让它被重复授予 - LoadDispatchRuntime:**一次查询**取回占用与候选。分两次查会在两次之间漏掉刚从 Queueing 变 Processing 的 item(第一次查它还是 Queueing、第二次只查 Queueing 已查不到), 导致占用少算、超发 - StartReservedItem:Queueing/reserved → Processing/none。清掉 reserved 标记, 因为 Processing 本身已代表占用,留着会让对账看到自相矛盾的状态 - MGetDispatchObservations:供分钟级对账识别四类漂移 分类逻辑抽成纯函数 classifyDispatchRuntime 以便直测 —— 它是本次正确性核心。 14 个用例覆盖:Processing 计占用、Queueing/reserved 计占用且不进候选、Queueing/none 唯一候选、 混合场景、limit 只约束候选不约束占用、Processing 带脏 reserved 不重复计数、nil 跳过、 chunk 分批(含 size<=0 防死循环)。 go build ./modules/evaluation/... 通过,新增测试全绿,gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/domain/entity/expt_result.go | 28 +++ .../domain/repo/expt_item_dispatch.go | 80 ++++++ .../expt_item_dispatch_repo_impl.go | 41 ++++ .../mysql/convert/expt_item_result_run_log.go | 44 ++-- .../experiment/mysql/expt_item_dispatch.go | 227 ++++++++++++++++++ .../mysql/expt_item_dispatch_test.go | 173 +++++++++++++ .../model/expt_item_result_run_log.gen.go | 27 ++- .../query/expt_item_result_run_log.gen.go | 34 +-- .../init-sql/expt_item_result_run_log.sql | 4 +- .../expt_item_result_run_log_alter.sql | 8 + .../init-sql/expt_item_result_run_log.sql | 4 +- .../expt_item_result_run_log_alter.sql | 8 + 12 files changed, 627 insertions(+), 51 deletions(-) create mode 100644 backend/modules/evaluation/domain/repo/expt_item_dispatch.go create mode 100644 backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go create mode 100644 backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go create mode 100644 backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch_test.go diff --git a/backend/modules/evaluation/domain/entity/expt_result.go b/backend/modules/evaluation/domain/entity/expt_result.go index 26488c0be8..686a97bf89 100644 --- a/backend/modules/evaluation/domain/entity/expt_result.go +++ b/backend/modules/evaluation/domain/entity/expt_result.go @@ -249,6 +249,34 @@ type ExptItemResultRunLog struct { LogID string ResultState int32 UpdatedAt *time.Time + // QuotaReservationState 中心调度的额度预占投影,与表字段 quota_reservation_state 一致。 + // + // 它**不是** Redis reservation 状态机的复制品:Redis 侧有 Reserved/Dispatched/ + // DispatchUncertain/Running 四个非终态,这里只保留 none/reserved 两值。 + // 存在的意义是让调度器不查 Redis 就能算准并发占用 —— + // 并发占用 = status=Processing 的 item + status=Queueing 且 reserved 的 item + // 可授予候选 = status=Queueing 且 none 的 item + // 缺了它,已预占未消费的 item 在 MySQL 里仍是纯 Queueing,会被反复选中导致 deficit 高估, + // 高优实验为同一批 item 反复抢额度、挤掉低优实验。 + // + // Redis reservation 仍是额度账本真值;本列只是调度投影,不进入 IDL/OpenAPI/Stats。 + QuotaReservationState QuotaReservationState +} + +// QuotaReservationState run log 上的额度预占投影,只有两值。 +type QuotaReservationState int32 + +const ( + // QuotaReservationStateNone 未预占。Queueing+none 才是可授予候选。 + QuotaReservationStateNone QuotaReservationState = 0 + // QuotaReservationStateReserved 已在 Redis 预占额度、尚未被 consumer 消费。 + // 计入并发占用且不得再次进入待授予队列。 + QuotaReservationStateReserved QuotaReservationState = 1 +) + +// IsQuotaReserved 报告该 run log 是否处于已预占状态。 +func (s QuotaReservationState) IsQuotaReserved() bool { + return s == QuotaReservationStateReserved } type ExptItemEvalResult struct { diff --git a/backend/modules/evaluation/domain/repo/expt_item_dispatch.go b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go new file mode 100644 index 0000000000..868c51d275 --- /dev/null +++ b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go @@ -0,0 +1,80 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package repo + +import ( + "context" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +//go:generate mockgen -destination=mocks/expt_item_dispatch.go -package=mocks . IExptItemDispatchRepo + +// IExptItemDispatchRepo 中心化调度的 item 派发投影读写。 +// +// 与既有 IExptItemResultRepo 的分工:后者面向 item 执行结果的完整生命周期;本接口只关心 +// 「哪些 item 可被授予、哪些已占用并发」这一个问题,因此方法都围绕 run log 的 +// (status, quota_reservation_state) 二元组,且全部走 idx_expt_run_dispatch。 +// +// 为什么单独立一个窄接口而不是往 IExptItemResultRepo 加方法:那个接口已有 15+ 方法、被十余处 +// 依赖,调度只需其中极小一部分;混进去会让所有实现方和 mock 都被迫感知调度概念。 +type IExptItemDispatchRepo interface { + // ClaimQuotaReserved 把一批 item 的 run log 从 Queueing/none CAS 成 Queueing/reserved。 + // + // 返回实际 claim 成功的 item ID。失败的 item 说明它已被别的路径改动(并发 tick、 + // consumer 抢先启动、或 item 已终态)—— 调用方必须只发布成功的那些,并释放失败项 + // 在 Redis 侧仍为 Reserved 的 reservation。 + // + // 这是 Redis 预占与 MQ 发布之间的必经关卡:没有它,同一 item 可能被两拍各自预占各自发布。 + ClaimQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) (claimed []int64, err error) + + // ResetQuotaReserved 把 Queueing/reserved 退回 Queueing/none,用于 MQ 明确发布失败的补偿。 + // + // 只处理仍为 Queueing/reserved 的记录:若 item 已被 consumer 推进到 Processing, + // 说明消息其实已投递成功,退回投影会让它被重复授予。 + ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) (reset []int64, err error) + + // LoadDispatchRuntime 一次查询取回该 run 的并发占用与可授予候选。 + // + // 并发占用 = status=Processing 的 item + status=Queueing 且 reserved 的 item; + // 可授予候选 = status=Queueing 且 none 的 item(按 id 升序,取前 limit 个)。 + // 两者由同一次查询产出,避免分两次查询之间状态漂移导致 deficit 算错。 + LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*ExptDispatchRuntime, error) + + // StartReservedItem 由 consumer 在取得执行权后调用,把 run log 从 Queueing/reserved + // 推进到 Processing/none,表示「预占已兑现为实际执行」。 + // + // 返回 false 表示 CAS 未命中:可能是重复投递(已 Processing)或 reservation 已被回收。 + // 调用方据此决定是否继续执行 item。 + StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (started bool, err error) + + // MGetDispatchObservations 供分钟级对账使用:批量读取指定 item 的 (status, 预占态)。 + // 对账据此与 Redis reservation 比对,识别四类漂移(Terminal 遗留、Processing 缺 reservation、 + // Queueing/reserved 超时未消费、Queueing/none 却有 reservation)。 + MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*ExptDispatchObservation, error) +} + +// ExptDispatchRuntime 一个 run 的派发运行态。 +type ExptDispatchRuntime struct { + // OccupiedItemIDs 占用并发的 item(Processing ∪ Queueing/reserved)。 + // 已在 DB 层去重 —— 同一 item 不可能同时是 Processing 和 Queueing。 + OccupiedItemIDs []int64 + // CandidateItemIDs 可授予候选(Queueing/none),按 id 升序保证翻页稳定。 + CandidateItemIDs []int64 +} + +// OccupiedCount 当前并发占用数。 +func (r *ExptDispatchRuntime) OccupiedCount() int { + if r == nil { + return 0 + } + return len(r.OccupiedItemIDs) +} + +// ExptDispatchObservation 单个 item 的派发投影观测,供对账使用。 +type ExptDispatchObservation struct { + ItemID int64 + Status int32 + QuotaReservationState entity.QuotaReservationState +} diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go new file mode 100644 index 0000000000..202a0970a8 --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go @@ -0,0 +1,41 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package experiment + +import ( + "context" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql" +) + +// ExptItemDispatchRepoImpl 派发投影的 repo 实现,纯透传到 DAO。 +// 保留这一层是为了让 domain 不依赖 gorm_gen model —— 与本包其它 repo 一致。 +type ExptItemDispatchRepoImpl struct { + dispatchDAO mysql.IExptItemDispatchDAO +} + +func NewExptItemDispatchRepo(dispatchDAO mysql.IExptItemDispatchDAO) repo.IExptItemDispatchRepo { + return &ExptItemDispatchRepoImpl{dispatchDAO: dispatchDAO} +} + +func (e *ExptItemDispatchRepoImpl) ClaimQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + return e.dispatchDAO.ClaimQuotaReserved(ctx, spaceID, exptID, exptRunID, itemIDs) +} + +func (e *ExptItemDispatchRepoImpl) ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + return e.dispatchDAO.ResetQuotaReserved(ctx, spaceID, exptID, exptRunID, itemIDs) +} + +func (e *ExptItemDispatchRepoImpl) LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*repo.ExptDispatchRuntime, error) { + return e.dispatchDAO.LoadDispatchRuntime(ctx, spaceID, exptID, exptRunID, candidateLimit) +} + +func (e *ExptItemDispatchRepoImpl) StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + return e.dispatchDAO.StartReservedItem(ctx, spaceID, exptID, exptRunID, itemID) +} + +func (e *ExptItemDispatchRepoImpl) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) { + return e.dispatchDAO.MGetDispatchObservations(ctx, spaceID, exptID, exptRunID, itemIDs) +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt_item_result_run_log.go b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt_item_result_run_log.go index 610e780d5a..2f867e6791 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt_item_result_run_log.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt_item_result_run_log.go @@ -21,17 +21,18 @@ func (c *ExptItemResultRunLogConverter) PO2DO(rl *model.ExptItemResultRunLog) *e return nil } do := &entity.ExptItemResultRunLog{ - ID: rl.ID, - SpaceID: rl.SpaceID, - ExptID: rl.ExptID, - ExptRunID: rl.ExptRunID, - ItemID: rl.ItemID, - ItemVersionID: rl.ItemVersionID, // ★ - Status: rl.Status, - ErrMsg: gptr.Indirect(rl.ErrMsg), - LogID: rl.LogID, - ResultState: gptr.Indirect(rl.ResultState), - UpdatedAt: gptr.Of(rl.UpdatedAt), + ID: rl.ID, + SpaceID: rl.SpaceID, + ExptID: rl.ExptID, + ExptRunID: rl.ExptRunID, + ItemID: rl.ItemID, + ItemVersionID: rl.ItemVersionID, // ★ + Status: rl.Status, + ErrMsg: gptr.Indirect(rl.ErrMsg), + LogID: rl.LogID, + ResultState: gptr.Indirect(rl.ResultState), + QuotaReservationState: entity.QuotaReservationState(rl.QuotaReservationState), + UpdatedAt: gptr.Of(rl.UpdatedAt), } return do @@ -42,16 +43,17 @@ func (c *ExptItemResultRunLogConverter) DO2PO(do *entity.ExptItemResultRunLog) * return nil } po := &model.ExptItemResultRunLog{ - ID: do.ID, - SpaceID: do.SpaceID, - ExptID: do.ExptID, - ExptRunID: do.ExptRunID, - ItemID: do.ItemID, - ItemVersionID: do.ItemVersionID, // ★ - Status: do.Status, - ErrMsg: gptr.Of(do.ErrMsg), - LogID: do.LogID, - ResultState: gptr.Of(do.ResultState), + ID: do.ID, + SpaceID: do.SpaceID, + ExptID: do.ExptID, + ExptRunID: do.ExptRunID, + ItemID: do.ItemID, + ItemVersionID: do.ItemVersionID, // ★ + Status: do.Status, + ErrMsg: gptr.Of(do.ErrMsg), + LogID: do.LogID, + ResultState: gptr.Of(do.ResultState), + QuotaReservationState: int32(do.QuotaReservationState), } if do.UpdatedAt != nil { diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go new file mode 100644 index 0000000000..a372e6b9b9 --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go @@ -0,0 +1,227 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package mysql + +import ( + "context" + + "github.com/coze-dev/coze-loop/backend/infra/db" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model" + "github.com/coze-dev/coze-loop/backend/pkg/errorx" +) + +//go:generate mockgen -destination=mocks/expt_item_dispatch.go -package=mocks . IExptItemDispatchDAO + +// IExptItemDispatchDAO run log 派发投影的数据访问。 +type IExptItemDispatchDAO interface { + ClaimQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) + ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) + LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*repo.ExptDispatchRuntime, error) + StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) + MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) +} + +func NewExptItemDispatchDAO(db db.Provider) IExptItemDispatchDAO { + return &exptItemDispatchDAOImpl{db: db} +} + +type exptItemDispatchDAOImpl struct { + db db.Provider +} + +// claimBatchSize 单次 CAS 的 item 上限。 +// 不做无界 IN 查询:单拍授予数本身有预算上限,超大 IN 会让执行计划退化。 +const claimBatchSize = 500 + +func (d *exptItemDispatchDAOImpl) ClaimQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + if len(itemIDs) == 0 { + return nil, nil + } + + claimed := make([]int64, 0, len(itemIDs)) + for _, batch := range chunkInt64(itemIDs, claimBatchSize) { + // 逐个 CAS 而非一条批量 UPDATE:批量 UPDATE 只能拿到总 RowsAffected, + // 无法知道**哪些** item 成功。而调用方必须精确知道,才能只发布成功项、 + // 并释放失败项的 Redis reservation —— 发布了未 claim 成功的 item 会造成双驱动。 + for _, itemID := range batch { + res := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ? AND item_id = ?", + spaceID, exptID, exptRunID, itemID). + Where("status = ?", int32(entity.ItemRunState_Queueing)). + Where("quota_reservation_state = ?", int32(entity.QuotaReservationStateNone)). + Update("quota_reservation_state", int32(entity.QuotaReservationStateReserved)) + if res.Error != nil { + return claimed, errorx.Wrapf(res.Error, "claim quota reserved fail, expt_run_id: %v, item_id: %v", exptRunID, itemID) + } + if res.RowsAffected > 0 { + claimed = append(claimed, itemID) + } + } + } + return claimed, nil +} + +func (d *exptItemDispatchDAOImpl) ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + if len(itemIDs) == 0 { + return nil, nil + } + + reset := make([]int64, 0, len(itemIDs)) + for _, batch := range chunkInt64(itemIDs, claimBatchSize) { + for _, itemID := range batch { + // 条件里带 status=Queueing:若 item 已被 consumer 推进到 Processing, + // 说明消息其实已投递成功,退回投影会让它被重复授予。 + res := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ? AND item_id = ?", + spaceID, exptID, exptRunID, itemID). + Where("status = ?", int32(entity.ItemRunState_Queueing)). + Where("quota_reservation_state = ?", int32(entity.QuotaReservationStateReserved)). + Update("quota_reservation_state", int32(entity.QuotaReservationStateNone)) + if res.Error != nil { + return reset, errorx.Wrapf(res.Error, "reset quota reserved fail, expt_run_id: %v, item_id: %v", exptRunID, itemID) + } + if res.RowsAffected > 0 { + reset = append(reset, itemID) + } + } + } + return reset, nil +} + +func (d *exptItemDispatchDAOImpl) LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*repo.ExptDispatchRuntime, error) { + if candidateLimit <= 0 { + candidateLimit = defaultLimit + } + + // 一次查询取回两类记录,而非分两次查 Processing 和 Queueing: + // 分两次的话,两次查询之间 item 可能从 Queueing 变成 Processing, + // 于是它在两个结果里都不出现(第一次查时还是 Queueing,第二次查时已是 Processing 但 + // 第二次只查 Queueing),导致并发占用少算、超发。 + type row struct { + ItemID int64 `gorm:"column:item_id"` + Status int32 `gorm:"column:status"` + QuotaReservationState int32 `gorm:"column:quota_reservation_state"` + } + var rows []row + + err := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Select("item_id, status, quota_reservation_state"). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ?", spaceID, exptID, exptRunID). + Where("status IN ?", []int32{ + int32(entity.ItemRunState_Queueing), + int32(entity.ItemRunState_Processing), + }). + Order("id ASC"). + Find(&rows).Error + if err != nil { + return nil, errorx.Wrapf(err, "load dispatch runtime fail, expt_run_id: %v", exptRunID) + } + + observations := make([]*repo.ExptDispatchObservation, 0, len(rows)) + for _, r := range rows { + observations = append(observations, &repo.ExptDispatchObservation{ + ItemID: r.ItemID, + Status: r.Status, + QuotaReservationState: entity.QuotaReservationState(r.QuotaReservationState), + }) + } + + return classifyDispatchRuntime(observations, candidateLimit), nil +} + +// classifyDispatchRuntime 把 run log 观测按 (status, 预占态) 分成「占用并发」与「可授予候选」。 +// +// 抽成纯函数是为了让这段分类规则可直接单测 —— 它是本次改动的正确性核心: +// 漏把 Queueing/reserved 计入占用会导致 deficit 高估、同一批 item 被反复授予; +// 误把它算进候选则会重复派发。 +func classifyDispatchRuntime(observations []*repo.ExptDispatchObservation, candidateLimit int) *repo.ExptDispatchRuntime { + runtime := &repo.ExptDispatchRuntime{} + for _, o := range observations { + if o == nil { + continue + } + switch { + case o.Status == int32(entity.ItemRunState_Processing): + runtime.OccupiedItemIDs = append(runtime.OccupiedItemIDs, o.ItemID) + case o.QuotaReservationState.IsQuotaReserved(): + // Queueing + reserved:已预占未消费,计入占用且不得再次授予 + runtime.OccupiedItemIDs = append(runtime.OccupiedItemIDs, o.ItemID) + default: + // Queueing + none:唯一的可授予候选 + if candidateLimit <= 0 || len(runtime.CandidateItemIDs) < candidateLimit { + runtime.CandidateItemIDs = append(runtime.CandidateItemIDs, o.ItemID) + } + } + } + return runtime +} + +func (d *exptItemDispatchDAOImpl) StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + // Queueing/reserved → Processing/none:预占兑现为实际执行。 + // 同时清掉预占标记,因为 Processing 本身已经代表占用,留着 reserved 会让对账看到 + // 一个自相矛盾的状态(Processing 且 reserved)。 + res := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ? AND item_id = ?", + spaceID, exptID, exptRunID, itemID). + Where("status = ?", int32(entity.ItemRunState_Queueing)). + Where("quota_reservation_state = ?", int32(entity.QuotaReservationStateReserved)). + Updates(map[string]any{ + "status": int32(entity.ItemRunState_Processing), + "quota_reservation_state": int32(entity.QuotaReservationStateNone), + }) + if res.Error != nil { + return false, errorx.Wrapf(res.Error, "start reserved item fail, expt_run_id: %v, item_id: %v", exptRunID, itemID) + } + return res.RowsAffected > 0, nil +} + +func (d *exptItemDispatchDAOImpl) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) { + if len(itemIDs) == 0 { + return nil, nil + } + + type row struct { + ItemID int64 `gorm:"column:item_id"` + Status int32 `gorm:"column:status"` + QuotaReservationState int32 `gorm:"column:quota_reservation_state"` + } + + out := make([]*repo.ExptDispatchObservation, 0, len(itemIDs)) + for _, batch := range chunkInt64(itemIDs, claimBatchSize) { + var rows []row + err := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Select("item_id, status, quota_reservation_state"). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ?", spaceID, exptID, exptRunID). + Where("item_id IN ?", batch). + Find(&rows).Error + if err != nil { + return nil, errorx.Wrapf(err, "mget dispatch observations fail, expt_run_id: %v", exptRunID) + } + for _, r := range rows { + out = append(out, &repo.ExptDispatchObservation{ + ItemID: r.ItemID, + Status: r.Status, + QuotaReservationState: entity.QuotaReservationState(r.QuotaReservationState), + }) + } + } + return out, nil +} + +func chunkInt64(in []int64, size int) [][]int64 { + if size <= 0 || len(in) == 0 { + return nil + } + out := make([][]int64, 0, (len(in)+size-1)/size) + for i := 0; i < len(in); i += size { + end := i + size + if end > len(in) { + end = len(in) + } + out = append(out, in[i:end]) + } + return out +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch_test.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch_test.go new file mode 100644 index 0000000000..26a648b744 --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package mysql + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" +) + +func obs(itemID int64, status entity.ItemRunState, quota entity.QuotaReservationState) *repo.ExptDispatchObservation { + return &repo.ExptDispatchObservation{ + ItemID: itemID, + Status: int32(status), + QuotaReservationState: quota, + } +} + +func TestClassifyDispatchRuntime(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + observations []*repo.ExptDispatchObservation + candidateLimit int + wantOccupied []int64 + wantCandidates []int64 + }{ + { + name: "Processing 计入占用", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + obs(2, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + }, + candidateLimit: 10, + wantOccupied: []int64{1, 2}, + wantCandidates: nil, + }, + { + name: "Queueing/reserved 计入占用且不进候选(本次改动的核心)", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Queueing, entity.QuotaReservationStateReserved), + }, + candidateLimit: 10, + wantOccupied: []int64{1}, + wantCandidates: nil, + }, + { + name: "Queueing/none 是唯一候选", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(2, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + }, + candidateLimit: 10, + wantOccupied: nil, + wantCandidates: []int64{1, 2}, + }, + { + name: "混合场景:占用 3 候选 2", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + obs(2, entity.ItemRunState_Queueing, entity.QuotaReservationStateReserved), + obs(3, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(4, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + obs(5, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + }, + candidateLimit: 10, + wantOccupied: []int64{1, 2, 4}, + wantCandidates: []int64{3, 5}, + }, + { + name: "候选数受 limit 约束,占用不受限", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(2, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(3, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(4, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + obs(5, entity.ItemRunState_Processing, entity.QuotaReservationStateNone), + }, + candidateLimit: 2, + wantOccupied: []int64{4, 5}, + wantCandidates: []int64{1, 2}, + }, + { + name: "Processing 即使带 reserved 也只计一次占用(不重复)", + observations: []*repo.ExptDispatchObservation{ + // 理论上 StartReservedItem 会清掉 reserved,此处防御脏数据 + obs(1, entity.ItemRunState_Processing, entity.QuotaReservationStateReserved), + }, + candidateLimit: 10, + wantOccupied: []int64{1}, + wantCandidates: nil, + }, + { + name: "空观测", + observations: nil, + candidateLimit: 10, + wantOccupied: nil, + wantCandidates: nil, + }, + { + name: "nil 元素跳过", + observations: []*repo.ExptDispatchObservation{ + nil, + obs(1, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + }, + candidateLimit: 10, + wantOccupied: nil, + wantCandidates: []int64{1}, + }, + { + name: "limit<=0 不截断候选", + observations: []*repo.ExptDispatchObservation{ + obs(1, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + obs(2, entity.ItemRunState_Queueing, entity.QuotaReservationStateNone), + }, + candidateLimit: 0, + wantOccupied: nil, + wantCandidates: []int64{1, 2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := classifyDispatchRuntime(tt.observations, tt.candidateLimit) + assert.Equal(t, tt.wantOccupied, got.OccupiedItemIDs) + assert.Equal(t, tt.wantCandidates, got.CandidateItemIDs) + assert.Equal(t, len(tt.wantOccupied), got.OccupiedCount()) + }) + } +} + +func TestExptDispatchRuntime_OccupiedCount_Nil(t *testing.T) { + t.Parallel() + var r *repo.ExptDispatchRuntime + assert.Equal(t, 0, r.OccupiedCount()) +} + +func TestChunkInt64(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in []int64 + size int + want [][]int64 + }{ + {name: "整除", in: []int64{1, 2, 3, 4}, size: 2, want: [][]int64{{1, 2}, {3, 4}}}, + {name: "有余数", in: []int64{1, 2, 3}, size: 2, want: [][]int64{{1, 2}, {3}}}, + {name: "size 大于长度", in: []int64{1}, size: 10, want: [][]int64{{1}}}, + {name: "空输入", in: nil, size: 10, want: nil}, + {name: "size<=0 返回 nil 防死循环", in: []int64{1, 2}, size: 0, want: nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, chunkInt64(tt.in, tt.size)) + }) + } +} + +func TestQuotaReservationState_IsQuotaReserved(t *testing.T) { + t.Parallel() + assert.True(t, entity.QuotaReservationStateReserved.IsQuotaReserved()) + assert.False(t, entity.QuotaReservationStateNone.IsQuotaReserved()) + // 未知值按未预占处理:安全侧是让它进候选被重新预占,而非当成已占用而永不派发 + assert.False(t, entity.QuotaReservationState(99).IsQuotaReserved()) +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go index ba89b81f4d..064252dc74 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go @@ -14,19 +14,20 @@ const TableNameExptItemResultRunLog = "expt_item_result_run_log" // ExptItemResultRunLog expt_item_result_run_log type ExptItemResultRunLog struct { - ID int64 `gorm:"column:id;type:bigint(20) unsigned;primaryKey;comment:id" json:"id"` // id - SpaceID int64 `gorm:"column:space_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:1;index:idx_expt_item_turn,priority:1;index:idx_expt_run_result_state,priority:1;comment:空间 id" json:"space_id"` // 空间 id - ExptID int64 `gorm:"column:expt_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:2;index:idx_expt_item_turn,priority:2;index:idx_expt_run_result_state,priority:2;comment:实验 id" json:"expt_id"` // 实验 id - ExptRunID int64 `gorm:"column:expt_run_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:3;index:idx_expt_run_result_state,priority:3;comment:实验运行 id" json:"expt_run_id"` // 实验运行 id - ItemID int64 `gorm:"column:item_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:4;index:idx_expt_item_turn,priority:3;comment:item_id" json:"item_id"` // item_id - ItemVersionID int64 `gorm:"column:item_version_id;type:bigint(20) unsigned;not null;comment:item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref" json:"item_version_id"` // item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref - Status int32 `gorm:"column:status;type:int(11) unsigned;not null;comment:状态" json:"status"` // 状态 - ErrMsg *[]byte `gorm:"column:err_msg;type:blob binary;comment:错误信息" json:"err_msg"` // 错误信息 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp;comment:删除时间" json:"deleted_at"` // 删除时间 - LogID string `gorm:"column:log_id;type:varchar(128);not null;comment:日志 id" json:"log_id"` // 日志 id - ResultState *int32 `gorm:"column:result_state;type:int(11);index:idx_expt_run_result_state,priority:4;comment:回写结果表状态" json:"result_state"` // 回写结果表状态 + ID int64 `gorm:"column:id;type:bigint(20) unsigned;primaryKey;comment:id" json:"id"` // id + SpaceID int64 `gorm:"column:space_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:1;index:idx_expt_item_turn,priority:1;index:idx_expt_run_result_state,priority:1;comment:空间 id" json:"space_id"` // 空间 id + ExptID int64 `gorm:"column:expt_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:2;index:idx_expt_item_turn,priority:2;index:idx_expt_run_result_state,priority:2;comment:实验 id" json:"expt_id"` // 实验 id + ExptRunID int64 `gorm:"column:expt_run_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:3;index:idx_expt_run_result_state,priority:3;comment:实验运行 id" json:"expt_run_id"` // 实验运行 id + ItemID int64 `gorm:"column:item_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_run_item_turn,priority:4;index:idx_expt_item_turn,priority:3;comment:item_id" json:"item_id"` // item_id + ItemVersionID int64 `gorm:"column:item_version_id;type:bigint(20) unsigned;not null;comment:item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref" json:"item_version_id"` // item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref + Status int32 `gorm:"column:status;type:int(11) unsigned;not null;comment:状态" json:"status"` // 状态 + ErrMsg *[]byte `gorm:"column:err_msg;type:blob binary;comment:错误信息" json:"err_msg"` // 错误信息 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp;comment:删除时间" json:"deleted_at"` // 删除时间 + LogID string `gorm:"column:log_id;type:varchar(128);not null;comment:日志 id" json:"log_id"` // 日志 id + ResultState *int32 `gorm:"column:result_state;type:int(11);index:idx_expt_run_result_state,priority:4;comment:回写结果表状态" json:"result_state"` // 回写结果表状态 + QuotaReservationState int32 `gorm:"column:quota_reservation_state;type:tinyint(3) unsigned;not null;index:idx_expt_run_dispatch,priority:5;comment:中心调度额度预占投影: 0=none, 1=reserved" json:"quota_reservation_state"` // 中心调度额度预占投影 } // TableName ExptItemResultRunLog's table name diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/expt_item_result_run_log.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/expt_item_result_run_log.gen.go index fd6e5dfc10..e6f69f25b8 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/expt_item_result_run_log.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/query/expt_item_result_run_log.gen.go @@ -40,6 +40,7 @@ func newExptItemResultRunLog(db *gorm.DB, opts ...gen.DOOption) exptItemResultRu _exptItemResultRunLog.DeletedAt = field.NewField(tableName, "deleted_at") _exptItemResultRunLog.LogID = field.NewString(tableName, "log_id") _exptItemResultRunLog.ResultState = field.NewInt32(tableName, "result_state") + _exptItemResultRunLog.QuotaReservationState = field.NewInt32(tableName, "quota_reservation_state") _exptItemResultRunLog.fillFieldMap() @@ -50,20 +51,21 @@ func newExptItemResultRunLog(db *gorm.DB, opts ...gen.DOOption) exptItemResultRu type exptItemResultRunLog struct { exptItemResultRunLogDo exptItemResultRunLogDo - ALL field.Asterisk - ID field.Int64 // id - SpaceID field.Int64 // 空间 id - ExptID field.Int64 // 实验 id - ExptRunID field.Int64 // 实验运行 id - ItemID field.Int64 // item_id - ItemVersionID field.Int64 // item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref - Status field.Int32 // 状态 - ErrMsg field.Bytes // 错误信息 - CreatedAt field.Time // 创建时间 - UpdatedAt field.Time // 更新时间 - DeletedAt field.Field // 删除时间 - LogID field.String // 日志 id - ResultState field.Int32 // 回写结果表状态 + ALL field.Asterisk + ID field.Int64 // id + SpaceID field.Int64 // 空间 id + ExptID field.Int64 // 实验 id + ExptRunID field.Int64 // 实验运行 id + ItemID field.Int64 // item_id + ItemVersionID field.Int64 // item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref + Status field.Int32 // 状态 + ErrMsg field.Bytes // 错误信息 + CreatedAt field.Time // 创建时间 + UpdatedAt field.Time // 更新时间 + DeletedAt field.Field // 删除时间 + LogID field.String // 日志 id + ResultState field.Int32 // 回写结果表状态 + QuotaReservationState field.Int32 // 中心调度额度预占投影: 0=none, 1=reserved fieldMap map[string]field.Expr } @@ -93,6 +95,7 @@ func (e *exptItemResultRunLog) updateTableName(table string) *exptItemResultRunL e.DeletedAt = field.NewField(table, "deleted_at") e.LogID = field.NewString(table, "log_id") e.ResultState = field.NewInt32(table, "result_state") + e.QuotaReservationState = field.NewInt32(table, "quota_reservation_state") e.fillFieldMap() @@ -121,7 +124,7 @@ func (e *exptItemResultRunLog) GetFieldByName(fieldName string) (field.OrderExpr } func (e *exptItemResultRunLog) fillFieldMap() { - e.fieldMap = make(map[string]field.Expr, 13) + e.fieldMap = make(map[string]field.Expr, 14) e.fieldMap["id"] = e.ID e.fieldMap["space_id"] = e.SpaceID e.fieldMap["expt_id"] = e.ExptID @@ -135,6 +138,7 @@ func (e *exptItemResultRunLog) fillFieldMap() { e.fieldMap["deleted_at"] = e.DeletedAt e.fieldMap["log_id"] = e.LogID e.fieldMap["result_state"] = e.ResultState + e.fieldMap["quota_reservation_state"] = e.QuotaReservationState } func (e exptItemResultRunLog) clone(db *gorm.DB) exptItemResultRunLog { diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql index 66770637cc..6a25566083 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql @@ -13,10 +13,12 @@ CREATE TABLE IF NOT EXISTS `expt_item_result_run_log` `deleted_at` timestamp NULL DEFAULT NULL COMMENT '删除时间', `log_id` varchar(128) NOT NULL DEFAULT '' COMMENT '日志 id', `result_state` int DEFAULT NULL COMMENT '回写结果表状态', + `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_run_item_turn` (`space_id`, `expt_id`, `expt_run_id`, `item_id`), KEY `idx_expt_item_turn` (`space_id`, `expt_id`, `item_id`), - KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`) + KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`), + KEY `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='expt_item_result_run_log'; diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql index c5cf279532..d44299f32c 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql @@ -1,2 +1,10 @@ ALTER TABLE `expt_item_result_run_log` ADD COLUMN `item_version_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref' AFTER `item_id`; + +ALTER TABLE `expt_item_result_run_log` + ADD COLUMN `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用' AFTER `result_state`; + +-- 中心调度派发查询索引:按 (run, status, 预占态) 定位「可授予候选」与「已预占占用」两类 item。 +-- 末位带 id 是为了让 keyset 分页与稳定排序都能走索引,避免 filesort。 +ALTER TABLE `expt_item_result_run_log` + ADD INDEX `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`); diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql index 66770637cc..6a25566083 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql @@ -13,10 +13,12 @@ CREATE TABLE IF NOT EXISTS `expt_item_result_run_log` `deleted_at` timestamp NULL DEFAULT NULL COMMENT '删除时间', `log_id` varchar(128) NOT NULL DEFAULT '' COMMENT '日志 id', `result_state` int DEFAULT NULL COMMENT '回写结果表状态', + `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_run_item_turn` (`space_id`, `expt_id`, `expt_run_id`, `item_id`), KEY `idx_expt_item_turn` (`space_id`, `expt_id`, `item_id`), - KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`) + KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`), + KEY `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='expt_item_result_run_log'; diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql index c5cf279532..d44299f32c 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql @@ -1,2 +1,10 @@ ALTER TABLE `expt_item_result_run_log` ADD COLUMN `item_version_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'item 自身版本号; 0=旧数据/无版本概念; 真值源 expt_item_ref' AFTER `item_id`; + +ALTER TABLE `expt_item_result_run_log` + ADD COLUMN `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用' AFTER `result_state`; + +-- 中心调度派发查询索引:按 (run, status, 预占态) 定位「可授予候选」与「已预占占用」两类 item。 +-- 末位带 id 是为了让 keyset 分页与稳定排序都能走索引,避免 filesort。 +ALTER TABLE `expt_item_result_run_log` + ADD INDEX `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`); From f827a92b21f5c2534ee29d42f6f94feb26b6ec67 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 13:03:11 +0800 Subject: [PATCH 07/58] =?UTF-8?q?feat(evaluation):=20consumer=20=E5=85=91?= =?UTF-8?q?=E7=8E=B0=20run=20log=20=E6=8A=95=E5=BD=B1=20Queueing/reserved?= =?UTF-8?q?=20=E2=86=92=20Processing/none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard 此前只做了 Redis 侧 ConfirmRunning,但 spec 要求 consumer 还要把 run log 投影推进到 Processing/none。缺这一步,item 已开始执行而投影仍停在 Queueing/reserved:下一拍把它当 「已预占未消费」继续计入占用(这本身没错),但一旦 reservation 因超时被清理,它就变成 「既不 Processing、也无 reservation」的孤儿,对账要多绕一圈才能修。就地兑现让两侧同步收敛。 几处刻意的错误处理: - 投影写失败返回 error 让 MQ 重试:额度已预占且 reservation 已转 Running, 丢弃消息会让这份额度占着直到超时清理 - CAS 未命中(started=false)不阻断执行:可能是重复投递(已 Processing)或投影已被 repair 修正;此时 reservation 校验已通过说明额度是真的,继续执行安全 - dispatchRepo 为 nil 时跳过:保持 legacy 路径与开源部署不受影响 repo wire set 注册 NewExptItemDispatchRepo + NewExptItemDispatchDAO,wire 自动接入 consumer。 mock 手写而非跑 mockgen:本地 mockgen 版本较新,全量重生成会带来大量无关 diff (参数名重命名 + isgomock 字段),淹没真实改动 —— 与前几次同样处理。 go build ./modules/evaluation/... 通过,consumer 构造测试通过,gofmt 干净。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/application/wire_gen.go | 8 +- .../domain/repo/mocks/expt_item_dispatch.go | 111 ++++++++++++++++++ .../service/expt_run_item_event_impl.go | 26 ++++ .../service/expt_run_item_event_impl_test.go | 1 + .../evaluation/infra/repo/experiment/wire.go | 3 + 5 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index e4a54905a9..82eb9facaa 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -182,7 +182,9 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d iItemCompletePublisher := service.ProvideNilItemCompletePublisher() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) iCentralReservationGuard := component.NewNoopCentralReservationGuard() - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, v4...) + iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) + iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) @@ -454,7 +456,9 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL iItemCompletePublisher := service.ProvideNilItemCompletePublisher() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) iCentralReservationGuard := component.NewNoopCentralReservationGuard() - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, v4...) + iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) + iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) diff --git a/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go b/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go new file mode 100644 index 0000000000..ae6a79bb75 --- /dev/null +++ b/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo (interfaces: IExptItemDispatchRepo) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + repo "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + gomock "go.uber.org/mock/gomock" +) + +// MockIExptItemDispatchRepo is a mock of IExptItemDispatchRepo interface. +type MockIExptItemDispatchRepo struct { + ctrl *gomock.Controller + recorder *MockIExptItemDispatchRepoMockRecorder +} + +// MockIExptItemDispatchRepoMockRecorder is the mock recorder for MockIExptItemDispatchRepo. +type MockIExptItemDispatchRepoMockRecorder struct { + mock *MockIExptItemDispatchRepo +} + +// NewMockIExptItemDispatchRepo creates a new mock instance. +func NewMockIExptItemDispatchRepo(ctrl *gomock.Controller) *MockIExptItemDispatchRepo { + mock := &MockIExptItemDispatchRepo{ctrl: ctrl} + mock.recorder = &MockIExptItemDispatchRepoMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIExptItemDispatchRepo) EXPECT() *MockIExptItemDispatchRepoMockRecorder { + return m.recorder +} + +// ClaimQuotaReserved mocks base method. +func (m *MockIExptItemDispatchRepo) ClaimQuotaReserved(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int64) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClaimQuotaReserved", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClaimQuotaReserved indicates an expected call of ClaimQuotaReserved. +func (mr *MockIExptItemDispatchRepoMockRecorder) ClaimQuotaReserved(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClaimQuotaReserved", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).ClaimQuotaReserved), arg0, arg1, arg2, arg3, arg4) +} + +// LoadDispatchRuntime mocks base method. +func (m *MockIExptItemDispatchRepo) LoadDispatchRuntime(arg0 context.Context, arg1, arg2, arg3 int64, arg4 int) (*repo.ExptDispatchRuntime, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDispatchRuntime", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(*repo.ExptDispatchRuntime) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDispatchRuntime indicates an expected call of LoadDispatchRuntime. +func (mr *MockIExptItemDispatchRepoMockRecorder) LoadDispatchRuntime(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDispatchRuntime", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).LoadDispatchRuntime), arg0, arg1, arg2, arg3, arg4) +} + +// MGetDispatchObservations mocks base method. +func (m *MockIExptItemDispatchRepo) MGetDispatchObservations(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int64) ([]*repo.ExptDispatchObservation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MGetDispatchObservations", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]*repo.ExptDispatchObservation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MGetDispatchObservations indicates an expected call of MGetDispatchObservations. +func (mr *MockIExptItemDispatchRepoMockRecorder) MGetDispatchObservations(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetDispatchObservations", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).MGetDispatchObservations), arg0, arg1, arg2, arg3, arg4) +} + +// ResetQuotaReserved mocks base method. +func (m *MockIExptItemDispatchRepo) ResetQuotaReserved(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int64) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetQuotaReserved", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResetQuotaReserved indicates an expected call of ResetQuotaReserved. +func (mr *MockIExptItemDispatchRepoMockRecorder) ResetQuotaReserved(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetQuotaReserved", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).ResetQuotaReserved), arg0, arg1, arg2, arg3, arg4) +} + +// StartReservedItem mocks base method. +func (m *MockIExptItemDispatchRepo) StartReservedItem(arg0 context.Context, arg1, arg2, arg3, arg4 int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartReservedItem", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StartReservedItem indicates an expected call of StartReservedItem. +func (mr *MockIExptItemDispatchRepoMockRecorder) StartReservedItem(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartReservedItem", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).StartReservedItem), arg0, arg1, arg2, arg3, arg4) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 47efaf2797..f8c5c21ead 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -59,6 +59,8 @@ type ExptItemEventEvalServiceImpl struct { // centralGuard 中心化调度的额度预占校验闸。开源部署注入 noop(enforce 消息 fail-closed), // 商业版由 Wire 注入真实账本适配器。legacy 实验不经过它,行为与引入前一致。 centralGuard component.ICentralReservationGuard + // dispatchRepo run log 派发投影读写,用于把 Queueing/reserved 兑现为 Processing/none。 + dispatchRepo repo.IExptItemDispatchRepo } func NewExptRecordEvalService( @@ -86,6 +88,7 @@ func NewExptRecordEvalService( itemCompletePublisher component.IItemCompletePublisher, sandboxAgentMetrics metrics.SandboxAgentMetrics, // 沙箱 agent 端到端 turn 打点; 可空 (走 noop) centralGuard component.ICentralReservationGuard, + dispatchRepo repo.IExptItemDispatchRepo, sandboxAgentNotifier ...ISandboxAgentNotifier, // variadic 兼容 wire_gen 未接入通知器 ) ExptItemEvalEvent { i := &ExptItemEventEvalServiceImpl{ @@ -113,6 +116,7 @@ func NewExptRecordEvalService( itemCompletePublisher: itemCompletePublisher, sandboxAgentMetrics: sandboxAgentMetrics, centralGuard: centralGuard, + dispatchRepo: dispatchRepo, } if len(sandboxAgentNotifier) > 0 { i.sandboxAgentNotifier = sandboxAgentNotifier[0] @@ -209,6 +213,28 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE return nil } + // 把 run log 投影从 Queueing/reserved 兑现为 Processing/none。 + // + // 为什么必须在这里而不是留给下游 handleEventExec:投影是调度器算并发占用的依据, + // 若 item 已开始执行而投影仍停在 Queueing/reserved,下一拍会把它当"已预占未消费" + // 继续计入占用(这没错),但一旦 reservation 因超时被清理,它就变成"既不 Processing + // 也无 reservation"的孤儿,对账要多绕一圈才能修。就地兑现让两侧同步收敛。 + // + // CAS 未命中(started=false)不阻断执行:可能是重复投递(已 Processing)或 + // 投影已被 repair 修正。此时 reservation 校验已通过,说明额度是真的,继续执行是安全的。 + if e.dispatchRepo != nil { + started, err := e.dispatchRepo.StartReservedItem(ctx, event.SpaceID, event.ExptID, event.ExptRunID, event.EvalSetItemID) + if err != nil { + // 投影写失败:返回错误让 MQ 重试。额度已预占且 reservation 已转 Running, + // 丢弃消息会让这份额度占着直到超时清理。 + return err + } + if !started { + logs.CtxInfo(ctx, "[CentralReservation] run log projection not claimed (duplicate delivery or repaired), continue, expt_run_id: %v, item_id: %v", + event.ExptRunID, event.EvalSetItemID) + } + } + return next(ctx, event) } } diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go index eee7866f7d..5819273a27 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go @@ -53,6 +53,7 @@ func TestNewExptRecordEvalService(t *testing.T) { nil, // itemCompletePublisher nil, // sandboxAgentMetrics component.NewNoopCentralReservationGuard(), + repoMocks.NewMockIExptItemDispatchRepo(ctrl), ) assert.NotNil(t, service) } diff --git a/backend/modules/evaluation/infra/repo/experiment/wire.go b/backend/modules/evaluation/infra/repo/experiment/wire.go index e9c0b1503f..215f8068e0 100644 --- a/backend/modules/evaluation/infra/repo/experiment/wire.go +++ b/backend/modules/evaluation/infra/repo/experiment/wire.go @@ -18,6 +18,9 @@ var ExperimentRepoSet = wire.NewSet( NewExptStatsRepo, NewExptAggrResultRepo, NewExptItemResultRepo, + // 中心调度的 run log 派发投影(Queueing/reserved 与 Queueing/none 的读写) + NewExptItemDispatchRepo, + exptmysql.NewExptItemDispatchDAO, NewExptTurnResultRepo, NewExptItemRefRepo, NewExptRunLogRepo, From 4ee479d5090452b359fb6e31eb6b05dc3b92e14b Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 19:58:13 +0800 Subject: [PATCH 08/58] =?UTF-8?q?feat(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=8A=A0=20scheduler=5Fscope=20=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上与所有 PPE 泳道共用同一个 MySQL 库,而中心调度是"跨空间扫全局 → 抢租约 → 扣额度"的后台任务。缺少所有权边界时,泳道实例会扫出线上的 enforce 实验、为其 预占额度并把 item 发进泳道 topic,由泳道 consumer 执行、结果写回共享库 —— 线上侧对此毫无感知。这是数据污染,不是资源浪费。 引入 scheduler_scope 作为调度所有权与 Priority 排序边界: - entity.Experiment.SchedulerScope:创建时冻结的不透明稳定 ID,Retry 继承, legacy 为空串;业务代码不得解析该字符串(泳道/空间/App/Region 只是生成规则的输入) - SchedulerQueueScanParam.SchedulerScope 必填,DAO 下推为 WHERE 等值条件。 空值直接报错而非退化成扫全表 —— 这是挡住越界的物理闸门,宁可可见地报错, 不可静默地污染 - 四份部署 SQL 同步加列,索引改为 (scheduler_mode, scheduler_scope, status, deleted_at, priority_level DESC, created_at, id)。 scope 插在第 2 位而非追加末尾:它是等值条件,必须排在 range 条件 status IN(...) 之前,否则 MySQL 无法用索引满足 ORDER BY - GORM model 同步加字段,priority_level 的索引 priority 从 4 改为 5 (scope 占用第 2 位,不改会与 status 撞位、生成的列序与 SQL 不一致) Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/expt.go | 12 ++++++++++ .../domain/entity/expt_scheduler_queue.go | 6 +++++ .../repo/experiment/mysql/convert/expt.go | 6 +++-- .../infra/repo/experiment/mysql/expt.go | 24 +++++++++++++++---- .../mysql/gorm_gen/model/experiment.gen.go | 3 ++- .../mysql-init/init-sql/experiment.sql | 3 ++- .../mysql-init/patch-sql/experiment_alter.sql | 9 +++++-- .../init/mysql/init-sql/experiment.sql | 3 ++- .../init/mysql/init-sql/experiment_alter.sql | 9 +++++-- 9 files changed, 61 insertions(+), 14 deletions(-) diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index 73e4317553..2d173b8e78 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -225,6 +225,18 @@ type Experiment struct { // 配置热变更不得让存量实验在中心调度与旧 daemon 之间切换。 // 命名注意:本字段与 entity.ExptSchedulerMode (实验跑法调度器 interface) 是完全不同的概念,勿混用。 ExptDispatchMode string + // SchedulerScope 中心调度所有权与 Priority 排序边界,与表字段 scheduler_scope 一致。 + // + // 服务端生成并在创建时冻结的**不透明稳定 ID**,回答"哪个中心调度器拥有这个实验、 + // 它与哪些实验一起比较 Priority"。legacy 实验为空串。 + // + // 为什么需要它:线上与各 PPE 泳道共用同一个库,而中心调度是跨空间扫全局的后台任务。 + // 无此边界时泳道调度器会扫出线上实验并派发 item(结果写回共享库、线上侧无感知)。 + // + // 业务代码**不得解析**该字符串:泳道 / 空间 / App / Region 都只是生成规则的输入, + // 未来改按空间拆分时只换生成规则,字段语义与查询结构不变。 + // Retry 继承原值,不按当前运行环境重算。 + SchedulerScope string } func (e *Experiment) ToEvaluatorRefDO() []*ExptEvaluatorRef { diff --git a/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go b/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go index fb33b7927e..70008eee88 100644 --- a/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go +++ b/backend/modules/evaluation/domain/entity/expt_scheduler_queue.go @@ -12,6 +12,12 @@ package entity type SchedulerQueueScanParam struct { // DispatchMode 目标调度模式,通常为 enforce。 DispatchMode string + // SchedulerScope 调度所有权边界,**必填**。只扫属于该 Scope 的实验。 + // + // 为什么必填而非可选:线上与所有 PPE 泳道共用同一个库,缺了这个条件,泳道的调度器 + // 会扫出线上实验并为其派发 item(结果写回共享库、线上侧无感知)。空值由 DAO 拒绝, + // 不退化成"扫所有 Scope"。 + SchedulerScope string // Statuses 候选状态,通常为 Pending + Processing。用单条 status IN (...) 查询而非按状态分别扫描, // 避免双流归并游标与未消费 lookahead。 Statuses []int32 diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go index e968e3d2cc..eba87559c0 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/convert/expt.go @@ -59,8 +59,9 @@ func (ExptConverter) DO2PO(experiment *entity.Experiment) (*model.Experiment, er TargetSpaceID: experiment.TargetSpaceID, // ★ 跨空间共享: 评测对象来源空间 EvalSetAccessLevel: experiment.EvalSetAccessLevel, // ★ 跨空间共享: 冻结访问级别 // ★ 中心化调度: 两列都是 NOT NULL, 写入前收敛为合法值 —— 上游若漏传, 落 legacy/1 而非空串/0 - PriorityLevel: entity.NormalizeExptPriorityLevel(experiment.PriorityLevel), - SchedulerMode: entity.NormalizeExptDispatchMode(experiment.ExptDispatchMode), + PriorityLevel: entity.NormalizeExptPriorityLevel(experiment.PriorityLevel), + SchedulerMode: entity.NormalizeExptDispatchMode(experiment.ExptDispatchMode), + SchedulerScope: experiment.SchedulerScope, } if experiment.MaxAliveTime != 0 { @@ -149,6 +150,7 @@ func (ExptConverter) PO2DO(expt *model.Experiment, refs []*model.ExptEvaluatorRe // ★ 中心化调度: 历史行虽有 DB 默认值兜底, 仍过一遍 Normalize —— 脏数据按 legacy/1 处理而非中断读路径 PriorityLevel: entity.NormalizeExptPriorityLevel(expt.PriorityLevel), ExptDispatchMode: entity.NormalizeExptDispatchMode(expt.SchedulerMode), + SchedulerScope: expt.SchedulerScope, } // 反序列化 notification_conf diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go index f0ea5205c6..949c566e64 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/bytedance/gg/gptr" "gorm.io/gorm" @@ -48,10 +49,11 @@ type IExptDAO interface { ExistGroupKey(ctx context.Context, groupKey string, spaceID int64) (bool, error) - // ScanSchedulerQueue 跨空间扫描中心调度候选实验。 + // ScanSchedulerQueue 在指定 scheduler_scope 内跨空间扫描中心调度候选实验。 // - // 与 List 的关键差别:不带 space_id 条件 —— 中心调度按全局优先级排序,若按空间分别扫描, - // 低优空间的实验会先于高优空间被处理,全局优先级语义即失效。 + // 与 List 的关键差别:不带 space_id 条件 —— 中心调度在 Scope 内按全局优先级排序,若按空间 + // 分别扫描,低优空间的实验会先于高优空间被处理,全局优先级语义即失效。 + // 但 scheduler_scope 必须带:它是调度所有权边界(线上/各 PPE 泳道共库)。 // 走 idx_scheduler_queue,keyset 分页保证翻页不重不漏。 ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*model.Experiment, error) } @@ -470,7 +472,7 @@ func (d *exptDAOImpl) ExistGroupKey(ctx context.Context, groupKey string, spaceI return cnt > 0, nil } -// ScanSchedulerQueue 跨空间扫描中心调度候选实验,走 idx_scheduler_queue。 +// ScanSchedulerQueue 在指定 scheduler_scope 内跨空间扫描中心调度候选实验,走 idx_scheduler_queue。 // // 用裸 gorm 而非 gen DSL:keyset 的三元组比较是一段带括号 OR 的复合条件, // gen 的链式 API 表达它需要嵌套多层 Or(...),可读性远差于一条 SQL 片段。 @@ -487,8 +489,19 @@ func (d *exptDAOImpl) ScanSchedulerQueue(ctx context.Context, param *entity.Sche limit = defaultLimit } + // Scope 为空时拒绝查询而非退化成扫全表。 + // + // 这是"泳道不得调度线上实验"的物理闸门:一旦这里放行空 Scope,PPE 实例就会扫出 + // 线上的 enforce 实验、为它们预占额度并派发 item(item 走泳道 topic、结果写回共享库), + // 而线上侧完全无感知。宁可这一拍报错(可见),也不能静默越界(不可见)。 + if strings.TrimSpace(param.SchedulerScope) == "" { + return nil, errorx.NewByCode(errno.CommonInvalidParamCode, + errorx.WithExtraMsg("empty scheduler_scope for scheduler queue scan; refusing to scan across all scopes")) + } + tx := d.db.NewSession(ctx).Model(&model.Experiment{}). Where("scheduler_mode = ?", param.DispatchMode). + Where("scheduler_scope = ?", param.SchedulerScope). Where("deleted_at IS NULL"). // latest_run_id > 0 排除"只 Create 尚未 Run"的实验:它们没有 run 可供派发 item, // 扫进来只会让每拍白跑一遍。 @@ -513,7 +526,8 @@ func (d *exptDAOImpl) ScanSchedulerQueue(ctx context.Context, param *entity.Sche Order("priority_level DESC, created_at ASC, id ASC"). Limit(limit). Find(&pos).Error; err != nil { - return nil, errorx.Wrapf(err, "mysql scan scheduler queue fail, mode: %v, statuses: %v", param.DispatchMode, param.Statuses) + return nil, errorx.Wrapf(err, "mysql scan scheduler queue fail, mode: %v, scope: %v, statuses: %v", + param.DispatchMode, param.SchedulerScope, param.Statuses) } return pos, nil } diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go index 1332245264..050acc98ae 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go @@ -50,8 +50,9 @@ type Experiment struct { EvalSetSpaceID int64 `gorm:"column:eval_set_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测集来源空间(跨空间共享,0=同空间)" json:"eval_set_space_id"` // 评测集来源空间(跨空间共享,0=同空间) TargetSpaceID int64 `gorm:"column:target_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测对象来源空间(跨空间共享,0=同空间)" json:"target_space_id"` // 评测对象来源空间(跨空间共享,0=同空间) EvalSetAccessLevel string `gorm:"column:eval_set_access_level;type:varchar(32) character set utf8mb4;not null;default:'';comment:发起冻结的评测集访问级别(execute/readable/空)" json:"eval_set_access_level"` // 发起冻结的评测集访问级别 - PriorityLevel int32 `gorm:"column:priority_level;type:int(11) unsigned;not null;index:idx_scheduler_queue,priority:4;default:1;comment:实验调度优先级,1-99,数值越大越优先" json:"priority_level"` // 实验调度优先级,1-99,数值越大越优先 + PriorityLevel int32 `gorm:"column:priority_level;type:int(11) unsigned;not null;index:idx_scheduler_queue,priority:5;default:1;comment:实验调度优先级,1-99,数值越大越优先" json:"priority_level"` // 实验调度优先级,1-99,数值越大越优先 SchedulerMode string `gorm:"column:scheduler_mode;type:varchar(16) character set utf8mb4;not null;index:idx_scheduler_queue,priority:1;default:legacy;comment:调度模式:legacy(旧per-experiment链路)/enforce(中心调度)" json:"scheduler_mode"` // 调度模式:legacy(旧per-experiment链路)/enforce(中心调度) + SchedulerScope string `gorm:"column:scheduler_scope;type:varchar(128) character set utf8mb4;not null;index:idx_scheduler_queue,priority:2;comment:中心调度所有权与Priority排序边界; legacy为空" json:"scheduler_scope"` // 中心调度所有权与Priority排序边界; legacy为空 } // TableName Experiment's table name diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql index f106fb1680..58d9a8a6f4 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS `experiment` `notification_conf` blob COMMENT '通知配置,json格式存储webhook/飞书通知配置', `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先', `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)', + `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_item_idx` (`space_id`, `name`, `deleted_at`), KEY `idx_space_deleted_created_by` (`space_id`, `created_by`, `deleted_at`), @@ -49,7 +50,7 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), - KEY `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) + KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql index 3f04ffc05b..57c5c0a983 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql @@ -44,7 +44,12 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)' AFTER `priority_level`; --- 中心调度主扫描索引:scheduler_mode + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 +-- 中心调度所有权与 Priority 排序边界。线上与各 PPE 泳道共用同一个库,缺此列则泳道调度器会扫出 +-- 线上实验并为其派发 item(结果写回共享库、线上侧无感知)。legacy 历史行保持空串。 +ALTER TABLE `experiment` + ADD COLUMN `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空' AFTER `scheduler_mode`; + +-- 中心调度主扫描索引:scheduler_mode + scheduler_scope + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 -- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 ALTER TABLE `experiment` - ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql index f106fb1680..58d9a8a6f4 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS `experiment` `notification_conf` blob COMMENT '通知配置,json格式存储webhook/飞书通知配置', `priority_level` int unsigned NOT NULL DEFAULT '1' COMMENT '实验调度优先级,1-99,数值越大越优先', `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)', + `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空', PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_item_idx` (`space_id`, `name`, `deleted_at`), KEY `idx_space_deleted_created_by` (`space_id`, `created_by`, `deleted_at`), @@ -49,7 +50,7 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), - KEY `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) + KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql index 3f04ffc05b..57c5c0a983 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql @@ -44,7 +44,12 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `scheduler_mode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'legacy' COMMENT '调度模式:legacy(旧per-experiment链路)/enforce(中心调度)' AFTER `priority_level`; --- 中心调度主扫描索引:scheduler_mode + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 +-- 中心调度所有权与 Priority 排序边界。线上与各 PPE 泳道共用同一个库,缺此列则泳道调度器会扫出 +-- 线上实验并为其派发 item(结果写回共享库、线上侧无感知)。legacy 历史行保持空串。 +ALTER TABLE `experiment` + ADD COLUMN `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空' AFTER `scheduler_mode`; + +-- 中心调度主扫描索引:scheduler_mode + scheduler_scope + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 -- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 ALTER TABLE `experiment` - ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); From c24d9ea4c0e101d331c843662dcc3d5659eb4851 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 20:37:25 +0800 Subject: [PATCH 09/58] =?UTF-8?q?perf(evaluation):=20=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E6=94=B6=E6=95=9B=E4=B8=BA=204=20=E5=88=97?= =?UTF-8?q?=EF=BC=8C=E5=8E=BB=E6=8E=89=E6=97=A0=E6=95=88=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BOE 真机 EXPLAIN(MySQL 8.0.27-18-ndb)证明原索引后 3 列不产生任何收益: status IN (2,3) → type=range, Extra="Using index condition; Using where; Using filesort" status=3 单值 → type=ref, Extra 无 filesort 真实查询用 status IN (Pending,Processing),这是 range 条件。MySQL 用索引满足 ORDER BY 的前提是排序列不位于 range 列之后,所以 priority_level/created_at/id 放进索引消不掉 filesort,只让每个索引条目更宽、写放大更高。单值那组证明索引本身 没问题 —— filesort 来自查询里的 IN,改索引解决不了。 索引收敛为 (scheduler_mode, scheduler_scope, status, deleted_at): - deleted_at 保留:查询含 deleted_at IS NULL,索引内判掉可省回表 - id 不写:InnoDB 二级索引条目天然带主键,显式写是冗余 排序开销可接受:候选集是当前活跃的 enforce 实验,量级几十到几百而非全表。 彻底消除 filesort 需把 IN 拆成两条等值查询再归并,但那要引入双流归并游标, spec §1.4 已否决该方案。 同步修正 GORM model 的索引 tag(此前 status/deleted_at 未登记进 idx_scheduler_queue,与 SQL 不一致)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../mysql/gorm_gen/model/experiment.gen.go | 78 +++++++++---------- .../mysql-init/init-sql/experiment.sql | 2 +- .../mysql-init/patch-sql/experiment_alter.sql | 18 ++++- .../init/mysql/init-sql/experiment.sql | 2 +- .../init/mysql/init-sql/experiment_alter.sql | 18 ++++- 5 files changed, 71 insertions(+), 47 deletions(-) diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go index 050acc98ae..8020666430 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/experiment.gen.go @@ -14,45 +14,45 @@ const TableNameExperiment = "experiment" // Experiment experiment type Experiment struct { - ID int64 `gorm:"column:id;type:bigint(20) unsigned;primaryKey;comment:id" json:"id"` // id - SpaceID int64 `gorm:"column:space_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_item_idx,priority:1;index:idx_space_deleted_created_by,priority:1;index:idx_space_deleted_status,priority:1;index:idx_deleted_dataset,priority:1;index:idx_deleted_target_type,priority:1;index:idx_target_id_delete_at,priority:1;index:idx_eval_set_id_delete_at,priority:1;index:idx_space_start_at,priority:1;index:idx_space_end_at,priority:1;index:idx_space_expt_template_id_delete_at,priority:1;index:idx_space_trigger_type_delete_at,priority:1;comment:空间 id" json:"space_id"` // 空间 id - CreatedBy string `gorm:"column:created_by;type:varchar(128) character set utf8mb4;not null;index:idx_space_deleted_created_by,priority:2;comment:创建者 id" json:"created_by"` // 创建者 id - Name string `gorm:"column:name;type:varchar(255) character set utf8mb4;not null;uniqueIndex:uk_expt_item_idx,priority:2;comment:实验名称" json:"name"` // 实验名称 - ExperimentGroupKey string `gorm:"column:experiment_group_key;type:varchar(255) character set utf8mb4;not null;index:idx_experiment_group_key_deleted_at,priority:1;comment:实验分组key,默认实验ID" json:"experiment_group_key"` // 实验分组key,默认实验ID - Description string `gorm:"column:description;type:varchar(1024) character set utf8mb4;not null;comment:实验描述" json:"description"` // 实验描述 - EvalSetVersionID int64 `gorm:"column:eval_set_version_id;type:bigint(20) unsigned;not null;index:idx_deleted_dataset,priority:2;comment:评测集版本 id" json:"eval_set_version_id"` // 评测集版本 id - TargetType int64 `gorm:"column:target_type;type:bigint(20) unsigned;not null;index:idx_deleted_target_type,priority:2;comment:评估对象类型" json:"target_type"` // 评估对象类型 - TargetVersionID int64 `gorm:"column:target_version_id;type:bigint(20) unsigned;not null;comment:评估对象版本 id" json:"target_version_id"` // 评估对象版本 id - EvalConf *[]byte `gorm:"column:eval_conf;type:blob binary;comment:实验评估流程配置" json:"eval_conf"` // 实验评估流程配置 - Status int32 `gorm:"column:status;type:int(11) unsigned;not null;index:idx_space_deleted_status,priority:2;comment:状态" json:"status"` // 状态 - StatusMessage *[]byte `gorm:"column:status_message;type:blob binary;comment:状态提示信息" json:"status_message"` // 状态提示信息 - StartAt *time.Time `gorm:"column:start_at;type:timestamp;index:idx_space_start_at,priority:2;comment:开始执行时间" json:"start_at"` // 开始执行时间 - EndAt *time.Time `gorm:"column:end_at;type:timestamp;index:idx_space_end_at,priority:2;comment:结束执行时间" json:"end_at"` // 结束执行时间 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp;uniqueIndex:uk_expt_item_idx,priority:3;index:idx_space_deleted_created_by,priority:3;index:idx_space_deleted_status,priority:3;index:idx_deleted_dataset,priority:3;index:idx_deleted_target_type,priority:3;index:idx_target_id_delete_at,priority:3;index:idx_eval_set_id_delete_at,priority:3;index:idx_space_expt_template_id_delete_at,priority:3;index:idx_space_trigger_type_delete_at,priority:3;index:idx_experiment_group_key_deleted_at,priority:2;comment:删除时间" json:"deleted_at"` // 删除时间 - LatestRunID int64 `gorm:"column:latest_run_id;type:bigint(20) unsigned;not null;comment:最后运行id" json:"latest_run_id"` // 最后运行id - TargetID int64 `gorm:"column:target_id;type:bigint(20) unsigned;not null;index:idx_target_id_delete_at,priority:2;comment:评估对象 id" json:"target_id"` // 评估对象 id - EvalSetID int64 `gorm:"column:eval_set_id;type:bigint(20) unsigned;not null;index:idx_eval_set_id_delete_at,priority:2;comment:评测集 id" json:"eval_set_id"` // 评测集 id - EvalSetSourceType int32 `gorm:"column:eval_set_source_type;type:int(11) unsigned;not null;default:1;comment:评测集来源模式: 1=SingleSet(老,单评测集) / 2=MultiSetConfig(新,多评测集+配置,权威源 eval_conf)" json:"eval_set_source_type"` // 评测集来源模式: 1=SingleSet(老,单评测集) / 2=MultiSetConfig(新,多评测集+配置,权威源 eval_conf) - ExptTemplateID int64 `gorm:"column:expt_template_id;type:bigint(20) unsigned;not null;index:idx_space_expt_template_id_delete_at,priority:2;comment:实验模板 id" json:"expt_template_id"` // 实验模板 id - CreditCost int32 `gorm:"column:credit_cost;type:int(11);not null;comment:权益消耗模式" json:"credit_cost"` // 权益消耗模式 - SourceType int32 `gorm:"column:source_type;type:int(11) unsigned;not null;index:idx_source_type_source_id,priority:1;default:1;comment:实验来源类型,评测:1,自动化任务:2..." json:"source_type"` // 实验来源类型,评测:1,自动化任务:2... - SourceID string `gorm:"column:source_id;type:varchar(128) character set utf8mb4;not null;index:idx_source_type_source_id,priority:2;default:0;comment:实验来源id" json:"source_id"` // 实验来源id - ExptType int32 `gorm:"column:expt_type;type:int(11) unsigned;not null;default:1;comment:实验类型,offline:1,online:2..." json:"expt_type"` // 实验类型,offline:1,online:2... - MaxAliveTime *int64 `gorm:"column:max_alive_time;type:bigint(20) unsigned;comment:最大存活时间" json:"max_alive_time"` // 最大存活时间 - TriggerType string `gorm:"column:trigger_type;type:varchar(32) character set utf8mb4;not null;index:idx_space_trigger_type_delete_at,priority:2;default:manual;comment:实验触发方式:manual/openapi/schedule" json:"trigger_type"` // 实验触发方式:manual/openapi/schedule - Visibility int32 `gorm:"column:visibility;type:int(11) unsigned;not null;comment:可见性,默认0-可见,1-隐藏" json:"visibility"` // 可见性,默认0-可见,1-隐藏 - ThreadID *string `gorm:"column:thread_id;type:varchar(255);comment:智能生成会话ID" json:"thread_id"` // 智能生成会话ID - TrialRunItemCount *int64 `gorm:"column:trial_run_item_count;type:bigint(20) unsigned;comment:试运行行数" json:"trial_run_item_count"` // 试运行行数 - OfflineExptAnalysisStatus int32 `gorm:"column:offline_expt_analysis_status;type:int(11) unsigned;not null;comment:离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded)" json:"offline_expt_analysis_status"` // 离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded) - NotificationConf *[]byte `gorm:"column:notification_conf;type:blob binary;comment:通知配置,json格式存储webhook/飞书通知配置" json:"notification_conf"` // 通知配置,json格式存储webhook/飞书通知配置 - EvalSetSpaceID int64 `gorm:"column:eval_set_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测集来源空间(跨空间共享,0=同空间)" json:"eval_set_space_id"` // 评测集来源空间(跨空间共享,0=同空间) - TargetSpaceID int64 `gorm:"column:target_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测对象来源空间(跨空间共享,0=同空间)" json:"target_space_id"` // 评测对象来源空间(跨空间共享,0=同空间) - EvalSetAccessLevel string `gorm:"column:eval_set_access_level;type:varchar(32) character set utf8mb4;not null;default:'';comment:发起冻结的评测集访问级别(execute/readable/空)" json:"eval_set_access_level"` // 发起冻结的评测集访问级别 - PriorityLevel int32 `gorm:"column:priority_level;type:int(11) unsigned;not null;index:idx_scheduler_queue,priority:5;default:1;comment:实验调度优先级,1-99,数值越大越优先" json:"priority_level"` // 实验调度优先级,1-99,数值越大越优先 - SchedulerMode string `gorm:"column:scheduler_mode;type:varchar(16) character set utf8mb4;not null;index:idx_scheduler_queue,priority:1;default:legacy;comment:调度模式:legacy(旧per-experiment链路)/enforce(中心调度)" json:"scheduler_mode"` // 调度模式:legacy(旧per-experiment链路)/enforce(中心调度) - SchedulerScope string `gorm:"column:scheduler_scope;type:varchar(128) character set utf8mb4;not null;index:idx_scheduler_queue,priority:2;comment:中心调度所有权与Priority排序边界; legacy为空" json:"scheduler_scope"` // 中心调度所有权与Priority排序边界; legacy为空 + ID int64 `gorm:"column:id;type:bigint(20) unsigned;primaryKey;comment:id" json:"id"` // id + SpaceID int64 `gorm:"column:space_id;type:bigint(20) unsigned;not null;uniqueIndex:uk_expt_item_idx,priority:1;index:idx_space_deleted_created_by,priority:1;index:idx_space_deleted_status,priority:1;index:idx_deleted_dataset,priority:1;index:idx_deleted_target_type,priority:1;index:idx_target_id_delete_at,priority:1;index:idx_eval_set_id_delete_at,priority:1;index:idx_space_start_at,priority:1;index:idx_space_end_at,priority:1;index:idx_space_expt_template_id_delete_at,priority:1;index:idx_space_trigger_type_delete_at,priority:1;comment:空间 id" json:"space_id"` // 空间 id + CreatedBy string `gorm:"column:created_by;type:varchar(128) character set utf8mb4;not null;index:idx_space_deleted_created_by,priority:2;comment:创建者 id" json:"created_by"` // 创建者 id + Name string `gorm:"column:name;type:varchar(255) character set utf8mb4;not null;uniqueIndex:uk_expt_item_idx,priority:2;comment:实验名称" json:"name"` // 实验名称 + ExperimentGroupKey string `gorm:"column:experiment_group_key;type:varchar(255) character set utf8mb4;not null;index:idx_experiment_group_key_deleted_at,priority:1;comment:实验分组key,默认实验ID" json:"experiment_group_key"` // 实验分组key,默认实验ID + Description string `gorm:"column:description;type:varchar(1024) character set utf8mb4;not null;comment:实验描述" json:"description"` // 实验描述 + EvalSetVersionID int64 `gorm:"column:eval_set_version_id;type:bigint(20) unsigned;not null;index:idx_deleted_dataset,priority:2;comment:评测集版本 id" json:"eval_set_version_id"` // 评测集版本 id + TargetType int64 `gorm:"column:target_type;type:bigint(20) unsigned;not null;index:idx_deleted_target_type,priority:2;comment:评估对象类型" json:"target_type"` // 评估对象类型 + TargetVersionID int64 `gorm:"column:target_version_id;type:bigint(20) unsigned;not null;comment:评估对象版本 id" json:"target_version_id"` // 评估对象版本 id + EvalConf *[]byte `gorm:"column:eval_conf;type:blob binary;comment:实验评估流程配置" json:"eval_conf"` // 实验评估流程配置 + Status int32 `gorm:"column:status;type:int(11) unsigned;not null;index:idx_scheduler_queue,priority:3;index:idx_space_deleted_status,priority:2;comment:状态" json:"status"` // 状态 + StatusMessage *[]byte `gorm:"column:status_message;type:blob binary;comment:状态提示信息" json:"status_message"` // 状态提示信息 + StartAt *time.Time `gorm:"column:start_at;type:timestamp;index:idx_space_start_at,priority:2;comment:开始执行时间" json:"start_at"` // 开始执行时间 + EndAt *time.Time `gorm:"column:end_at;type:timestamp;index:idx_space_end_at,priority:2;comment:结束执行时间" json:"end_at"` // 结束执行时间 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp;not null;default:CURRENT_TIMESTAMP;comment:更新时间" json:"updated_at"` // 更新时间 + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp;uniqueIndex:uk_expt_item_idx,priority:3;index:idx_space_deleted_created_by,priority:3;index:idx_scheduler_queue,priority:4;index:idx_space_deleted_status,priority:3;index:idx_deleted_dataset,priority:3;index:idx_deleted_target_type,priority:3;index:idx_target_id_delete_at,priority:3;index:idx_eval_set_id_delete_at,priority:3;index:idx_space_expt_template_id_delete_at,priority:3;index:idx_space_trigger_type_delete_at,priority:3;index:idx_experiment_group_key_deleted_at,priority:2;comment:删除时间" json:"deleted_at"` // 删除时间 + LatestRunID int64 `gorm:"column:latest_run_id;type:bigint(20) unsigned;not null;comment:最后运行id" json:"latest_run_id"` // 最后运行id + TargetID int64 `gorm:"column:target_id;type:bigint(20) unsigned;not null;index:idx_target_id_delete_at,priority:2;comment:评估对象 id" json:"target_id"` // 评估对象 id + EvalSetID int64 `gorm:"column:eval_set_id;type:bigint(20) unsigned;not null;index:idx_eval_set_id_delete_at,priority:2;comment:评测集 id" json:"eval_set_id"` // 评测集 id + EvalSetSourceType int32 `gorm:"column:eval_set_source_type;type:int(11) unsigned;not null;default:1;comment:评测集来源模式: 1=SingleSet(老,单评测集) / 2=MultiSetConfig(新,多评测集+配置,权威源 eval_conf)" json:"eval_set_source_type"` // 评测集来源模式: 1=SingleSet(老,单评测集) / 2=MultiSetConfig(新,多评测集+配置,权威源 eval_conf) + ExptTemplateID int64 `gorm:"column:expt_template_id;type:bigint(20) unsigned;not null;index:idx_space_expt_template_id_delete_at,priority:2;comment:实验模板 id" json:"expt_template_id"` // 实验模板 id + CreditCost int32 `gorm:"column:credit_cost;type:int(11);not null;comment:权益消耗模式" json:"credit_cost"` // 权益消耗模式 + SourceType int32 `gorm:"column:source_type;type:int(11) unsigned;not null;index:idx_source_type_source_id,priority:1;default:1;comment:实验来源类型,评测:1,自动化任务:2..." json:"source_type"` // 实验来源类型,评测:1,自动化任务:2... + SourceID string `gorm:"column:source_id;type:varchar(128) character set utf8mb4;not null;index:idx_source_type_source_id,priority:2;default:0;comment:实验来源id" json:"source_id"` // 实验来源id + ExptType int32 `gorm:"column:expt_type;type:int(11) unsigned;not null;default:1;comment:实验类型,offline:1,online:2..." json:"expt_type"` // 实验类型,offline:1,online:2... + MaxAliveTime *int64 `gorm:"column:max_alive_time;type:bigint(20) unsigned;comment:最大存活时间" json:"max_alive_time"` // 最大存活时间 + TriggerType string `gorm:"column:trigger_type;type:varchar(32) character set utf8mb4;not null;index:idx_space_trigger_type_delete_at,priority:2;default:manual;comment:实验触发方式:manual/openapi/schedule" json:"trigger_type"` // 实验触发方式:manual/openapi/schedule + Visibility int32 `gorm:"column:visibility;type:int(11) unsigned;not null;comment:可见性,默认0-可见,1-隐藏" json:"visibility"` // 可见性,默认0-可见,1-隐藏 + ThreadID *string `gorm:"column:thread_id;type:varchar(255);comment:智能生成会话ID" json:"thread_id"` // 智能生成会话ID + TrialRunItemCount *int64 `gorm:"column:trial_run_item_count;type:bigint(20) unsigned;comment:试运行行数" json:"trial_run_item_count"` // 试运行行数 + OfflineExptAnalysisStatus int32 `gorm:"column:offline_expt_analysis_status;type:int(11) unsigned;not null;comment:离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded)" json:"offline_expt_analysis_status"` // 离线实验分析状态:0-未开始,1-进行中,2-成功,3-失败,4-已被取代(superseded) + NotificationConf *[]byte `gorm:"column:notification_conf;type:blob binary;comment:通知配置,json格式存储webhook/飞书通知配置" json:"notification_conf"` // 通知配置,json格式存储webhook/飞书通知配置 + EvalSetSpaceID int64 `gorm:"column:eval_set_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测集来源空间(跨空间共享,0=同空间)" json:"eval_set_space_id"` // 评测集来源空间(跨空间共享,0=同空间) + TargetSpaceID int64 `gorm:"column:target_space_id;type:bigint(20) unsigned;not null;default:0;comment:评测对象来源空间(跨空间共享,0=同空间)" json:"target_space_id"` // 评测对象来源空间(跨空间共享,0=同空间) + EvalSetAccessLevel string `gorm:"column:eval_set_access_level;type:varchar(32) character set utf8mb4;not null;default:'';comment:发起冻结的评测集访问级别(execute/readable/空)" json:"eval_set_access_level"` // 发起冻结的评测集访问级别 + PriorityLevel int32 `gorm:"column:priority_level;type:int(11) unsigned;not null;default:1;comment:实验调度优先级,1-99,数值越大越优先" json:"priority_level"` // 实验调度优先级,1-99,数值越大越优先 + SchedulerMode string `gorm:"column:scheduler_mode;type:varchar(16) character set utf8mb4;not null;index:idx_scheduler_queue,priority:1;default:legacy;comment:调度模式:legacy(旧per-experiment链路)/enforce(中心调度)" json:"scheduler_mode"` // 调度模式:legacy(旧per-experiment链路)/enforce(中心调度) + SchedulerScope string `gorm:"column:scheduler_scope;type:varchar(128) character set utf8mb4;not null;index:idx_scheduler_queue,priority:2;comment:中心调度所有权与Priority排序边界; legacy为空" json:"scheduler_scope"` // 中心调度所有权与Priority排序边界; legacy为空 } // TableName Experiment's table name diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql index 58d9a8a6f4..929ffe662c 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/experiment.sql @@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), - KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) + KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql index 57c5c0a983..927a25ffcb 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/experiment_alter.sql @@ -49,7 +49,19 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空' AFTER `scheduler_mode`; --- 中心调度主扫描索引:scheduler_mode + scheduler_scope + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 --- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 +-- 中心调度主扫描索引:四列只做过滤,**刻意不含排序列**。 +-- +-- 真实查询是 status IN (Pending,Processing),这是 range 条件;MySQL 用索引满足 ORDER BY 的前提是 +-- 排序列不在 range 列之后,因此把 priority_level/created_at/id 放进本索引**不产生任何排序收益**, +-- 只会让每个索引条目变宽、写放大更高。BOE 实测(MySQL 8.0.27): +-- status IN (2,3) → type=range, Extra="Using index condition; Using where; Using filesort" +-- status=3 单值 → type=ref, Extra 无 filesort +-- 即索引本身没问题,filesort 来自查询里的 IN,改索引无法消除。 +-- +-- 排序开销可接受:候选集是"当前活跃的 enforce 实验",量级在几十到几百,不是全表。 +-- 想彻底消掉 filesort 需把 IN 拆成两条等值查询再归并,但那要引入双流归并游标(spec §1.4 已否决)。 +-- +-- deleted_at 保留:查询含 deleted_at IS NULL,在索引内判掉可省回表。 +-- id 不写:InnoDB 二级索引条目天然带主键,显式写是冗余。 ALTER TABLE `experiment` - ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`); diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql index 58d9a8a6f4..929ffe662c 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment.sql @@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS `experiment` KEY `idx_space_expt_template_id_delete_at` (`space_id`, `expt_template_id`, `deleted_at`), KEY `idx_space_trigger_type_delete_at` (`space_id`, `trigger_type`, `deleted_at`), KEY `idx_experiment_group_key_deleted_at` (`experiment_group_key`, `deleted_at`), - KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`) + KEY `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='experiment'; diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql index 57c5c0a983..927a25ffcb 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/experiment_alter.sql @@ -49,7 +49,19 @@ ALTER TABLE `experiment` ALTER TABLE `experiment` ADD COLUMN `scheduler_scope` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '中心调度所有权与Priority排序边界; legacy为空' AFTER `scheduler_mode`; --- 中心调度主扫描索引:scheduler_mode + scheduler_scope + status 定位候选,priority_level DESC/created_at/id 提供稳定排序 --- 注意:降序索引需 MySQL 8.0+;低版本会静默忽略 DESC 退化为升序,上线前须确认实例版本并 EXPLAIN 验证 +-- 中心调度主扫描索引:四列只做过滤,**刻意不含排序列**。 +-- +-- 真实查询是 status IN (Pending,Processing),这是 range 条件;MySQL 用索引满足 ORDER BY 的前提是 +-- 排序列不在 range 列之后,因此把 priority_level/created_at/id 放进本索引**不产生任何排序收益**, +-- 只会让每个索引条目变宽、写放大更高。BOE 实测(MySQL 8.0.27): +-- status IN (2,3) → type=range, Extra="Using index condition; Using where; Using filesort" +-- status=3 单值 → type=ref, Extra 无 filesort +-- 即索引本身没问题,filesort 来自查询里的 IN,改索引无法消除。 +-- +-- 排序开销可接受:候选集是"当前活跃的 enforce 实验",量级在几十到几百,不是全表。 +-- 想彻底消掉 filesort 需把 IN 拆成两条等值查询再归并,但那要引入双流归并游标(spec §1.4 已否决)。 +-- +-- deleted_at 保留:查询含 deleted_at IS NULL,在索引内判掉可省回表。 +-- id 不写:InnoDB 二级索引条目天然带主键,显式写是冗余。 ALTER TABLE `experiment` - ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`, `priority_level` DESC, `created_at`, `id`); + ADD INDEX `idx_scheduler_queue` (`scheduler_mode`, `scheduler_scope`, `status`, `deleted_at`); From a57850dc29615c9732ee41a38a370ddaba60c1d5 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 20:59:58 +0800 Subject: [PATCH 10/58] =?UTF-8?q?feat(evaluation):=20consumer=20=E4=BE=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=20item=20=E7=9A=84=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=9F=9F=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调度侧的 WHERE scheduler_scope 只防住"泳道去调度线上实验",防不住反方向: item MQ 的泳道路由依赖 producer 的 x_tt_env tag,而该 tag 会因环境变量缺失、 消息重投、broker 配置差异而失效。届时一条 PPE 的 item 消息可能被线上 consumer 取到(或反之),而两个环境共用同一个库 —— 不校验归属就会用一个环境的进程去跑 另一个环境的 item,结果直接写进对方的数据。 新增窄 port ICentralSchedulerScopeOwner 回答"这个 item 该不该由我来跑": - 与 ICentralReservationGuard 分开:Guard 回答"有没有额度",本 port 回答归属。 即使额度充足也可能必须拒绝(消息投错环境) - noop 实现取**放行**(与 Guard 的 fail-closed 相反):单环境部署不存在"别的环境", 拒绝执行只会让所有 enforce item 永久卡住,那是自造故障而非防护 - 解析失败返回 error 而非 false:false 表示"确定不属于我,丢弃",error 表示 "无法判定,请重试"。混淆二者会让一次环境探测抖动静默丢弃本该执行的 item, 而它已预占额度、要等 reservation 超时才回收 HandleCentralReservation 增加两道闸: - enforce 实验 scheduler_scope 为空 → fail-closed 丢弃(没有 Scope 就无法确定 去哪本账查 reservation,猜一本账等于用别人的额度跑这个 item) - Scope 不属于本进程 → 丢弃而非报错重试(Scope 不匹配是路由问题,重试只会在同一个 错误进程上再失败;正确的进程会从自己队列拿到消息,或由下一拍重新派发) ConfirmRunning/Release 增加 schedulerScope 参数,由调用方从 DB 读出后传入, 不由实现方按当前运行环境推断 —— 推断会让"实验属于哪本账"取决于谁在处理消息, 而它本该只取决于数据本身。 新增参数放在 variadic 之前而非 setter 注入:setter 会让 wire 构造出实例但无人 调用 setter、字段恒为 nil,而 nil 在本文件里被解释为"跳过校验",等于静默关闭防护 (centralGuard 此前已踩过一次)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/application/wire_gen.go | 6 ++- .../component/central_reservation_guard.go | 47 +++++++++++++++++-- .../central_reservation_guard_test.go | 24 ++++++++-- .../service/expt_run_item_event_impl.go | 43 ++++++++++++++++- .../service/expt_run_item_event_impl_test.go | 1 + .../modules/evaluation/domain/service/wire.go | 1 + 6 files changed, 112 insertions(+), 10 deletions(-) diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 82eb9facaa..51702c4872 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -184,7 +184,8 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d iCentralReservationGuard := component.NewNoopCentralReservationGuard() iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, v4...) + iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, componentIConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, serviceEvaluatorService, idgen2, benefitSvc, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, iCentralSchedulerScopeOwner, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) @@ -458,7 +459,8 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL iCentralReservationGuard := component.NewNoopCentralReservationGuard() iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) - exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, v4...) + iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() + exptItemEvalEvent := service.NewExptRecordEvalService(iExptManager, iConfiger, exptEventPublisher, iExptItemResultRepo, iExptTurnResultRepo, iExptStatsRepo, iExperimentRepo, iExptItemRefRepo, quotaRepo, iLocker, idempotentService, auditClient, exptMetric, exptResultService, iEvalTargetService, evaluationSetItemService, evaluatorRecordService, evaluatorService, idgen2, benefitService, iEvalAsyncRepo, iItemCompletePublisher, sandboxAgentMetrics, iCentralReservationGuard, iExptItemDispatchRepo, iCentralSchedulerScopeOwner, v4...) iExptAnnotateService := service.NewExptAnnotateService(db2, iExptAnnotateRepo, iExptTurnResultRepo, exptEventPublisher, evaluationSetItemService, iExperimentRepo, exptResultService, iExptTurnResultFilterRepo, iExptAggrResultRepo) exptResultExportRecordDAO := mysql.NewExptResultExportRecordDAO(db2) iExptResultExportRecordRepo := experiment.NewExptResultExportRecordRepo(exptResultExportRecordDAO, idgen2) diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index 2dea311493..ea07a3d916 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -22,11 +22,15 @@ type ICentralReservationGuard interface { // 返回 false 表示 reservation 不存在 —— 调用方**必须放弃执行并丢弃消息**,不得继续跑 item。 // 已是 Running 的重复投递返回 true:同一 item 的合法原地重试要继续持有原额度, // 不重新预占也不重复扣减。 - ConfirmRunning(ctx context.Context, exptRunID, itemID int64) (bool, error) + // + // schedulerScope 是 Experiment 冻结的调度域,决定去哪本额度账本查这条 reservation。 + // 由调用方从 DB 读出后传入,不由实现方按当前运行环境推断 —— 推断会让"实验属于哪本账" + // 取决于谁在处理消息,而它本该只取决于数据本身。 + ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) // Release 在 item 进入终态(成功/失败/终止/僵尸清理)时幂等释放额度。 // 重复调用为 no-op。 - Release(ctx context.Context, exptRunID, itemID int64, reason string) error + Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error } // NewNoopCentralReservationGuard 返回开源部署使用的 noop 实现。 @@ -41,12 +45,47 @@ type noopCentralReservationGuard struct{} // 选择 fail-closed 而非放行:本方法只会被判定为 enforce 的实验触达,而 enforce 意味着 // "该实验的额度由中心账本管控"。没有账本却放行,等于让实验在无任何额度约束下跑, // 这比让它停下来等待更危险 —— 后者可见(实验不动会被发现),前者静默(资源被打爆才发现)。 -func (noopCentralReservationGuard) ConfirmRunning(ctx context.Context, exptRunID, itemID int64) (bool, error) { +func (noopCentralReservationGuard) ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) { return false, nil } // Release 无账本可释放,直接成功返回。 // 不返回错误:终态收口路径不应因为额度模块缺席而失败。 -func (noopCentralReservationGuard) Release(ctx context.Context, exptRunID, itemID int64, reason string) error { +func (noopCentralReservationGuard) Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error { return nil } + +//go:generate mockgen -destination=mocks/central_scope_owner.go -package=mocks . ICentralSchedulerScopeOwner + +// ICentralSchedulerScopeOwner 判定本进程是否拥有某个调度域。 +// +// 与 ICentralReservationGuard 分开是因为职责不同:Guard 回答"这个 item 有没有额度", +// 本 port 回答"这个 item 该不该由我来跑"。后者是路由/归属问题,即使额度充足也可能 +// 必须拒绝执行(消息投错了环境)。 +// +// 为什么消息可能投错环境:item MQ 的泳道路由依赖 producer 侧的 x_tt_env tag, +// 而该 tag 会因环境变量缺失、消息重投、broker 配置差异而失效。届时一条 PPE 的 item +// 消息可能被线上 consumer 取到(或反之),若不校验归属就会用一个环境的进程去跑另一个 +// 环境的 item,而两个环境共用同一个库 —— 结果直接写进对方的数据。 +// +// 开源部署注入 noop(恒定拥有):单环境部署不存在跨环境投递,无需此校验。 +type ICentralSchedulerScopeOwner interface { + // OwnsSchedulerScope 报告 schedulerScope 是否属于当前进程。 + // 返回 error 表示无法判定(如运行环境探测失败)—— 调用方应重试而非放行。 + OwnsSchedulerScope(ctx context.Context, schedulerScope string) (bool, error) +} + +// NewNoopCentralSchedulerScopeOwner 返回开源部署使用的 noop 实现:恒定拥有。 +// +// 与 Guard 的 noop 取 fail-closed 相反,本实现取放行。原因是二者防的风险不同: +// 无账本时放行会绕过额度(危险),而单环境部署下不存在"别的环境",拒绝执行只会让 +// 所有 enforce item 永久卡住 —— 那是自造故障,不是防护。 +func NewNoopCentralSchedulerScopeOwner() ICentralSchedulerScopeOwner { + return noopCentralSchedulerScopeOwner{} +} + +type noopCentralSchedulerScopeOwner struct{} + +func (noopCentralSchedulerScopeOwner) OwnsSchedulerScope(ctx context.Context, schedulerScope string) (bool, error) { + return true, nil +} diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go index 7a4f0cf90a..cd6acc0367 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go @@ -18,7 +18,7 @@ func TestNoopCentralReservationGuard_ConfirmRunningFailsClosed(t *testing.T) { // 关键语义:无账本环境下一律拒绝执行。 // 放行会让 enforce 实验在零额度约束下跑(静默、事后才发现);拒绝则可见(实验不动会被察觉)。 - ok, err := guard.ConfirmRunning(context.Background(), 1, 2) + ok, err := guard.ConfirmRunning(context.Background(), "fornax_cn_prod", 1, 2) require.NoError(t, err) assert.False(t, ok, "noop guard 必须 fail-closed,不得放行 enforce item") } @@ -29,6 +29,24 @@ func TestNoopCentralReservationGuard_ReleaseNeverErrors(t *testing.T) { guard := NewNoopCentralReservationGuard() // 终态收口路径不应因为额度模块缺席而失败 - require.NoError(t, guard.Release(context.Background(), 1, 2, "item success")) - require.NoError(t, guard.Release(context.Background(), 1, 2, "duplicate terminal event")) + require.NoError(t, guard.Release(context.Background(), "fornax_cn_prod", 1, 2, "item success")) + require.NoError(t, guard.Release(context.Background(), "fornax_cn_prod", 1, 2, "duplicate terminal event")) +} + +func TestNoopCentralSchedulerScopeOwner_AlwaysOwns(t *testing.T) { + t.Parallel() + + owner := NewNoopCentralSchedulerScopeOwner() + + // 与 Guard 的 noop 取 fail-closed 相反,本实现刻意放行: + // 单环境部署不存在"别的环境",拒绝执行只会让所有 enforce item 永久卡住 —— 那是自造故障。 + owned, err := owner.OwnsSchedulerScope(context.Background(), "fornax_cn_prod") + require.NoError(t, err) + assert.True(t, owned) + + // 空 Scope 也放行:本 port 只回答归属,"Scope 是否合法"由上游 fail-closed 判定, + // 两处都拒绝会让职责重叠、排查时分不清是哪一层挡的。 + owned, err = owner.OwnsSchedulerScope(context.Background(), "") + require.NoError(t, err) + assert.True(t, owned) } diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index f8c5c21ead..2c861ae1c7 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -61,6 +61,9 @@ type ExptItemEventEvalServiceImpl struct { centralGuard component.ICentralReservationGuard // dispatchRepo run log 派发投影读写,用于把 Queueing/reserved 兑现为 Processing/none。 dispatchRepo repo.IExptItemDispatchRepo + // centralScopeOwner 判定本进程是否拥有某实验的调度域。开源部署注入 noop(恒定拥有)。 + // 防的是 item 消息跨环境投递后被错误的进程执行(详见该 port 的注释)。 + centralScopeOwner component.ICentralSchedulerScopeOwner } func NewExptRecordEvalService( @@ -89,6 +92,10 @@ func NewExptRecordEvalService( sandboxAgentMetrics metrics.SandboxAgentMetrics, // 沙箱 agent 端到端 turn 打点; 可空 (走 noop) centralGuard component.ICentralReservationGuard, dispatchRepo repo.IExptItemDispatchRepo, + // centralScopeOwner 放在 variadic 之前:Go 只要求 variadic 是最后一个参数。 + // 不做 setter 注入 —— setter 会让 wire 构造出实例但无人调用 setter,字段恒为 nil, + // 而 nil 在本文件里被解释为"跳过校验",等于静默关闭防护(此前 centralGuard 已踩过一次)。 + centralScopeOwner component.ICentralSchedulerScopeOwner, sandboxAgentNotifier ...ISandboxAgentNotifier, // variadic 兼容 wire_gen 未接入通知器 ) ExptItemEvalEvent { i := &ExptItemEventEvalServiceImpl{ @@ -116,6 +123,7 @@ func NewExptRecordEvalService( itemCompletePublisher: itemCompletePublisher, sandboxAgentMetrics: sandboxAgentMetrics, centralGuard: centralGuard, + centralScopeOwner: centralScopeOwner, dispatchRepo: dispatchRepo, } if len(sandboxAgentNotifier) > 0 { @@ -191,6 +199,39 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE return next(ctx, event) } + // enforce 实验必须有冻结的 Scope。空 Scope 说明数据异常(DDL 未执行、或绕过 + // Create 路径写入),fail-closed 丢弃:没有 Scope 就无法确定去哪本账查 reservation, + // 猜一本账等于用别人的额度跑这个 item。 + if expt.SchedulerScope == "" { + logs.CtxError(ctx, "[CentralReservation] enforce experiment with empty scheduler_scope, drop event, expt_id: %v, item_id: %v", + event.ExptID, event.EvalSetItemID) + return nil + } + + // 本进程必须拥有该实验的 Scope 才可执行。 + // + // 这是"泳道不得执行线上 item"的最后一道闸。前面的调度侧闸门(ScanCandidates 带 + // WHERE scheduler_scope)防的是"泳道去调度线上实验";本闸防的是消息侧 —— + // item MQ 的泳道路由靠 producer 的 x_tt_env tag,而 tag 可能因环境变量缺失、 + // 消息重投或 broker 配置而失效,导致一条 PPE 的 item 消息被线上 consumer 拿到 + // (或反之)。届时若不校验归属,就会用一个环境的进程去跑另一个环境的 item, + // 结果写回共享库。 + // + // 丢弃而非报错重试:Scope 不匹配是路由问题,重试只会在同一个错误进程上再失败一次; + // 正确的那个进程会从自己的队列里拿到这条消息(或由中心调度下一拍重新派发)。 + if e.centralScopeOwner != nil { + owned, err := e.centralScopeOwner.OwnsSchedulerScope(ctx, expt.SchedulerScope) + if err != nil { + // 无法判定归属:返回错误重试,而不是赌一把放行。 + return err + } + if !owned { + logs.CtxWarn(ctx, "[CentralReservation] scheduler scope not owned by this instance, discard event, expt_scope: %v, expt_id: %v, item_id: %v", + expt.SchedulerScope, event.ExptID, event.EvalSetItemID) + return nil + } + } + guard := e.centralGuard if guard == nil { // 判定为 enforce 却没有注入闸门:fail-closed。 @@ -200,7 +241,7 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE return nil } - ok, err := guard.ConfirmRunning(ctx, event.ExptRunID, event.EvalSetItemID) + ok, err := guard.ConfirmRunning(ctx, expt.SchedulerScope, event.ExptRunID, event.EvalSetItemID) if err != nil { // 账本暂时不可用:返回错误让 MQ 重试,而不是丢弃 —— item 已被预占, // 丢弃会让它停在 Queueing 直到 reservation 超时清理,白等一轮。 diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go index 5819273a27..046408dcf2 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl_test.go @@ -54,6 +54,7 @@ func TestNewExptRecordEvalService(t *testing.T) { nil, // sandboxAgentMetrics component.NewNoopCentralReservationGuard(), repoMocks.NewMockIExptItemDispatchRepo(ctrl), + component.NewNoopCentralSchedulerScopeOwner(), ) assert.NotNil(t, service) } diff --git a/backend/modules/evaluation/domain/service/wire.go b/backend/modules/evaluation/domain/service/wire.go index f049b73d6b..f54ab4c704 100644 --- a/backend/modules/evaluation/domain/service/wire.go +++ b/backend/modules/evaluation/domain/service/wire.go @@ -50,6 +50,7 @@ var ExperimentDomainServiceSet = wire.NewSet( // 中心化调度额度闸:开源侧提供 noop(enforce 消息 fail-closed,legacy 不受影响)。 // 商业版在自己的 wire set 里注入真实账本适配器覆盖它。 component.NewNoopCentralReservationGuard, + component.NewNoopCentralSchedulerScopeOwner, // Infrastructure Sets taskrpc.TaskRPCSet, pipeline.PipelineRPCSet, From 0bda8627751e67ef10cdb79366576d56072f91dd Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 23:08:11 +0800 Subject: [PATCH 11/58] =?UTF-8?q?fix(evaluation):=20=E6=8E=A5=E9=80=9A?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=86=BB=E7=BB=93=E9=93=BE=20+=20=E4=BF=AE?= =?UTF-8?q?=20item=20=E7=BB=88=E6=80=81=E4=B8=8D=E9=87=8A=E6=94=BE?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个 P0,任一不修中心调度都无法在泳道跑通。 全仓零个 Release 生产调用点:CompleteItemRun / completeItemRunOnUnretriableErr 只写 MySQL,zombie 与沙箱提前终态所在的 ExptSchedulerImpl 压根没有 guard 字段。结果 Redis used 单调递增,第一批额度跑满后全线卡死。症状还很像"额度配小了",第一反应会去调大 TCC, 调大后再次跑满,很难联想到根因。 释放接在两处,不逐个终态分支加: - consumer 侧收在 HandleCentralReservation 出口一处。终态路径有四条(success / fail / 不可重试前置失败 / indebt 终止),逐条手动调释放意味着以后任何人新增分支都可能漏, 而漏掉是静默的额度泄漏。 **不能无条件释放**:MQ 重试也从这里返回,此刻释放会让重投消息在 ConfirmRunning 处 因 reservation 不存在被丢弃、item 永久卡 Processing。因此回查 run log 投影, 只在 IsItemRunFinished 时释放 —— 不靠 execErr 判断(execErr==nil 未必终态, asyncAbort 下 item 仍在跑;execErr!=nil 未必非终态,已被兜底落 Fail)。 - zombie / 沙箱提前终态由 daemon 直接判终态,对应 consumer 消息可能永不返回, 必须单独接(releaseCentralQuotaForItems)。 原 Lua 对 state 不存在的账本置 ready,注释写"首次启用没有 active item"。该前提只在 真·首次启用成立;Redis 重启 / key 淘汰 / 误删同样走这个分支,而此时 MySQL 里一批 Processing item 仍占着资源 → 按 used=0 再授予一遍即超卖。 改为置 rebuilding + 拒绝授予(LedgerNotReady)。代码分不清"首次启用"与"账本丢失", 就取安全侧:首次启用要等一次 rebuild 才能调度(可见可等),超卖是静默的。 GetState 的同款默认值一并修正 —— 它是运维查"账本健不健康"的入口,报 ready 会让人 以为一切正常、也会让对账跳过重建。 IDL 有 priority_level / expected_quota_consumption / scheduler_mode,但业务转换全程 不使用,正常入口落成 legacy / 1 / "" / nil —— 中心调度没有任何候选。 按"trigger=evalx 即可信"接通: - entity.ShouldEnforceByTrigger:只有 EvalX 进 enforce。不按空间白名单 —— 白名单会把 同空间里人手点的实验也拽进 enforce,而那些不申报消耗向量,调度器只能跳过, 表现为"实验建好了但一个 item 都不跑"。按来源区分天然对齐"谁申报、谁被管控"。 - CreateExptParam 加 priority / scope / 消耗向量;ConvertCreateReq 映射 priority 与 向量,**刻意忽略请求里的 scheduler_mode/scheduler_scope** —— 否则任何内部 RPC 调用方 都能自行声明 enforce 并伪造 scope,绕过额度、甚至去动别的环境的账本。 - CreateExpt 冻结:mode 由 trigger 派生,scope 经新增窄 port ICentralSchedulerScopeProvider 解析(解析失败/空值报错,不降级成 legacy —— 降级会让 EvalX 以为受管控、实际走旧链路绕过额度),消耗向量校验后冻结进 eval_conf (调度侧 frozenConsumptionOf 正是从 EvalConf 读)。 - Submit → Create 透传 priority 与向量。 Retry 无需改动:它走 AllowExptRun + 直接发 schedule event,不经 Create 转换, 已冻结的 mode/scope/向量天然被继承。 新增 port 的参数一律放在 variadic 之前而非 setter 注入:setter 会让 wire 构造出实例 但无人调用、字段恒 nil,而 nil 在这些位置被解释为"跳过校验/跳过释放",等于静默关闭防护 (centralGuard 已踩过一次)。 测试:trigger admission 11 例(含大小写/空白容忍、前缀后缀不得误命中)+ IDL 常量一致性; 账本 fail-closed 两例(拒绝授予且 used 保持 0、状态落 rebuilding)。 evaluation 全量 55 个包测试通过。 Co-Authored-By: Claude Opus 5 (1M context) --- .../application/convertor/experiment/expt.go | 36 +++++++++ .../evaluation/application/experiment_app.go | 6 ++ .../evaluation/application/wire_gen.go | 14 ++-- .../component/central_reservation_guard.go | 28 +++++++ .../domain/entity/expt_dispatch_mode.go | 24 ++++++ .../entity/expt_dispatch_mode_trigger_test.go | 51 ++++++++++++ .../modules/evaluation/domain/entity/param.go | 11 +++ .../domain/service/expt_manage_impl.go | 79 +++++++++++++++++++ .../domain/service/expt_manage_impl_test.go | 3 + .../service/expt_run_item_event_impl.go | 73 ++++++++++++++++- .../service/expt_run_scheduler_event_impl.go | 54 +++++++++++++ .../expt_run_scheduler_event_impl_test.go | 3 + .../sandbox_agent_hourly_middleware_test.go | 6 +- .../modules/evaluation/domain/service/wire.go | 1 + 14 files changed, 380 insertions(+), 9 deletions(-) create mode 100644 backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go diff --git a/backend/modules/evaluation/application/convertor/experiment/expt.go b/backend/modules/evaluation/application/convertor/experiment/expt.go index 4f766b9079..7b8ae31da4 100644 --- a/backend/modules/evaluation/application/convertor/experiment/expt.go +++ b/backend/modules/evaluation/application/convertor/experiment/expt.go @@ -901,6 +901,16 @@ func ConvertCreateReq(cer *expt.CreateExperimentRequest, evaluatorVersionRunConf if cer.IsSetTriggerType() { param.TriggerType = strings.TrimSpace(cer.GetTriggerType()) } + + // ★ 中心化调度字段。 + // + // priority 与 expected_quota_consumption 由调用方申报(EvalX 会带),透传即可。 + // **scheduler_mode / scheduler_scope 刻意不从请求读取**:mode 由 trigger 派生、 + // scope 由服务端按运行环境解析,两者都不接受调用方指定 —— 否则任何内部 RPC 调用方 + // 都能自己声明 enforce 并伪造一个 scope,绕过额度管控、甚至去动别的环境的账本。 + // IDL 里这两个字段不带 api.body 已挡住公网,但内部调用仍需这一层。 + param.PriorityLevel = entity.NormalizeExptPriorityLevel(cer.GetPriorityLevel()) + param.ExpectedQuotaConsumption = expectedQuotaConsumptionDTO2DO(cer.GetExpectedQuotaConsumption()) if cer.NotificationConf != nil { notifConf, err := NotificationConfDTO2DO(cer.NotificationConf) if err != nil { @@ -1525,3 +1535,29 @@ func suaModeDO2DTO(m entity.SuaMode) domain_expt.SuaMode { return domain_expt.SuaMode_HumanLoop } } + +// expectedQuotaConsumptionDTO2DO 把申报的资源消耗向量转成领域对象。 +// +// 不在此处做合法性校验(非空 / amount>0 / 键唯一 / 禁通配):校验属于领域规则, +// 收口在 entity.ExpectedQuotaConsumption.Validate(),由 CreateExpt 在冻结前调用。 +// 转换层只负责搬运,否则同一套规则会散落在转换与领域两处、各自演化。 +func expectedQuotaConsumptionDTO2DO(dto *domain_expt.ExpectedQuotaConsumption) *entity.ExpectedQuotaConsumption { + if dto == nil || len(dto.GetResources()) == 0 { + return nil + } + resources := make([]*entity.ExpectedResourceConsumption, 0, len(dto.GetResources())) + for _, r := range dto.GetResources() { + if r == nil { + continue + } + resources = append(resources, &entity.ExpectedResourceConsumption{ + Category: r.GetCategory(), + ResourceKey: r.GetResourceKey(), + Amount: r.GetAmount(), + }) + } + if len(resources) == 0 { + return nil + } + return &entity.ExpectedQuotaConsumption{Resources: resources} +} diff --git a/backend/modules/evaluation/application/experiment_app.go b/backend/modules/evaluation/application/experiment_app.go index fc393c8093..4346e7eccf 100644 --- a/backend/modules/evaluation/application/experiment_app.go +++ b/backend/modules/evaluation/application/experiment_app.go @@ -613,6 +613,12 @@ func (e *experimentApplication) SubmitExperiment(ctx context.Context, req *expt. // 否则 SubmitExperiment 路径丢失来源空间,发起鉴权/加载会退化成消费方空间。 EvalSetSharedOption: req.EvalSetSharedOption, TargetSharedOption: req.TargetSharedOption, + // ★ 中心化调度: priority 与消耗向量透传到 Create(EvalX 走 Submit 入口)。 + // 不透传 scheduler_mode/scheduler_scope —— 两者由服务端按 trigger 派生与解析, + // 见 ConvertCreateReq 与 CreateExpt。缺这两个字段的透传会让 Submit 建出的实验 + // 永远是 legacy,中心调度没有候选。 + PriorityLevel: req.PriorityLevel, + ExpectedQuotaConsumption: req.ExpectedQuotaConsumption, } if req.IsSetExptTemplateID() { createReq.ExptTemplateID = gptr.Of(req.GetExptTemplateID()) diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 51702c4872..8e0546906e 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -176,12 +176,13 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d return nil, err } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics) - v4 := service.ProvideNoSandboxAgentNotifiers() + iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider) + v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, componentIConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) iCentralReservationGuard := component.NewNoopCentralReservationGuard() + exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() @@ -451,12 +452,13 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL return nil, err } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics) - v4 := service.ProvideNoSandboxAgentNotifiers() + iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider) + v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, iConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, v4...) iCentralReservationGuard := component.NewNoopCentralReservationGuard() + exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index ea07a3d916..d904acdeb8 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -89,3 +89,31 @@ type noopCentralSchedulerScopeOwner struct{} func (noopCentralSchedulerScopeOwner) OwnsSchedulerScope(ctx context.Context, schedulerScope string) (bool, error) { return true, nil } + +//go:generate mockgen -destination=mocks/central_scope_provider.go -package=mocks . ICentralSchedulerScopeProvider + +// ICentralSchedulerScopeProvider 为新建实验解析要冻结的 scheduler_scope。 +// +// 与 ICentralSchedulerScopeOwner 的分工:Provider 在**创建期**回答"这个实验该归哪个 +// Scope",Owner 在**执行期**回答"这个 Scope 是不是我的"。分开是因为两者的失败语义相反: +// Provider 解析不出来必须拒绝创建(否则实验冻结了空 Scope,永远不会被任何调度器扫到), +// Owner 判不出来应当重试(可能只是环境探测抖动)。 +// +// 开源部署注入 noop:返回空 Scope + nil error,配合 admission 只在 EvalX trigger 下 +// 才走 enforce,开源侧不会产生 enforce 实验,因此拿不到 Scope 也无影响。 +type ICentralSchedulerScopeProvider interface { + // ResolveSchedulerScope 返回该空间新建实验应冻结的 Scope。 + // 返回空串且 err 为 nil 表示"本部署不启用中心调度"。 + ResolveSchedulerScope(ctx context.Context, spaceID int64) (string, error) +} + +// NewNoopCentralSchedulerScopeProvider 返回开源部署使用的 noop 实现。 +func NewNoopCentralSchedulerScopeProvider() ICentralSchedulerScopeProvider { + return noopCentralSchedulerScopeProvider{} +} + +type noopCentralSchedulerScopeProvider struct{} + +func (noopCentralSchedulerScopeProvider) ResolveSchedulerScope(ctx context.Context, spaceID int64) (string, error) { + return "", nil +} diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go index c42cdde6e6..7cae81c599 100644 --- a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go @@ -3,6 +3,8 @@ package entity +import "strings" + // ExptDispatchMode 实验的 item 派发模式,对应 experiment.scheduler_mode 列。 // // 该列是唯一权威源:创建实验时按灰度白名单一次性写入并冻结,Run / Retry / MQ consumer 一律回查此列裁决, @@ -70,3 +72,25 @@ func NormalizeExptPriorityLevel(priority int32) int32 { return priority } } + +// ExptTriggerTypeEvalx 是 EvalX 平台发起实验时携带的 trigger_type,与 IDL 常量 +// `const ExptTriggerType Evalx = "evalx"` 一致。 +// +// 单独在 entity 层再声明一次而不 import kitex_gen:domain 层不依赖生成代码是本仓库的 +// 分层约束;两处值必须一致,由 TestExptTriggerTypeEvalxMatchesIDL 守住。 +const ExptTriggerTypeEvalx = "evalx" + +// ShouldEnforceByTrigger 判断某 trigger 来源的新实验是否应写 enforce。 +// +// 本期规则:只有 EvalX 发起的实验进入中心调度。EvalX 是内部平台,会按约定携带 +// priority 与 expected_quota_consumption;其它入口(控制台手动、OpenAPI、定时任务) +// 一律 legacy,行为与引入中心调度前完全一致。 +// +// 为什么按 trigger 而不是按空间白名单:空间白名单会把同一空间里"人手点的实验"也拽进 +// enforce,而那些实验不会申报资源消耗向量 —— 没有向量就无法预占,调度器只能跳过, +// 表现为"实验建好了但一个 item 都不跑"。按来源区分则天然对齐"谁申报、谁被管控"。 +// +// 大小写与空白容忍:trigger_type 是跨系统传递的字符串字段,上游拼装方式不受本仓库控制。 +func ShouldEnforceByTrigger(triggerType string) bool { + return strings.EqualFold(strings.TrimSpace(triggerType), ExptTriggerTypeEvalx) +} diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go new file mode 100644 index 0000000000..ed062b832c --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldEnforceByTrigger(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + trigger string + want bool + }{ + // 只有 EvalX 进 enforce:它会申报 priority 与消耗向量。 + "evalx": {trigger: "evalx", want: true}, + "evalx with spaces": {trigger: " evalx ", want: true}, + "evalx mixed case": {trigger: "EvalX", want: true}, + "evalx upper": {trigger: "EVALX", want: true}, + // 其它入口保持 legacy,行为与引入中心调度前一致。 + "manual": {trigger: "manual", want: false}, + "empty": {trigger: "", want: false}, + "blank": {trigger: " ", want: false}, + "schedule": {trigger: "schedule", want: false}, + "openapi": {trigger: "openapi", want: false}, + // 前缀/子串不得误命中 —— 否则一个叫 "evalx_v2" 的新来源会被静默拽进 enforce, + // 而它可能并不申报消耗向量,实验会建好却一个 item 都不跑。 + "prefix only": {trigger: "evalx_v2", want: false}, + "suffix only": {trigger: "my_evalx", want: false}, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, c.want, ShouldEnforceByTrigger(c.trigger)) + }) + } +} + +func TestExptTriggerTypeEvalxValue(t *testing.T) { + t.Parallel() + + // 该常量必须与 IDL 的 `const ExptTriggerType Evalx = "evalx"` 逐字一致。 + // domain 层不 import kitex_gen(分层约束),因此靠本断言守住两处不漂移 —— + // 一旦漂移,EvalX 发起的实验会被判成 legacy,中心调度静默失去全部候选。 + assert.Equal(t, "evalx", ExptTriggerTypeEvalx) +} diff --git a/backend/modules/evaluation/domain/entity/param.go b/backend/modules/evaluation/domain/entity/param.go index 8e730d8cf7..4afa4e58d2 100644 --- a/backend/modules/evaluation/domain/entity/param.go +++ b/backend/modules/evaluation/domain/entity/param.go @@ -423,6 +423,17 @@ type CreateExptParam struct { // 多评测集 MultiSetConfig 走 EvalSetConfigs 内每个元素的 shared_option。 EvalSetSharedOption *SharedResourceOption `json:"eval_set_shared_option,omitempty"` TargetSharedOption *SharedResourceOption `json:"target_shared_option,omitempty"` + + // ★ 中心化调度 (仅 EvalX 发起的实验使用):三个字段在创建时一次性冻结到 experiment 表。 + // + // PriorityLevel 调度优先级 1-99,越大越优先;未申报按 1。 + PriorityLevel int32 `json:"priority_level,omitempty"` + // SchedulerScope 调度所有权与排序边界,由服务端解析后写入,**不接受调用方指定** + // (见 ConvertCreateReq:请求里的同名字段被忽略)。legacy 实验为空串。 + SchedulerScope string `json:"scheduler_scope,omitempty"` + // ExpectedQuotaConsumption 单 item 预期资源消耗向量,由 EvalX 申报。 + // enforce 实验缺这个向量就无法预占额度,调度器只能跳过 —— 因此创建时即校验非空。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption `json:"expected_quota_consumption,omitempty"` } type ExptRunCheckOption struct { diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index f215f06bb2..035afea925 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -66,6 +66,10 @@ func NewExptManager( pipelineListAdapter rpc.IPipelineListAdapter, resourceAccessAuthorizer ResourceAccessAuthorizer, sandboxAgentMetrics metrics.SandboxAgentMetrics, + // centralScopeProvider 创建 enforce 实验时解析要冻结的 scheduler_scope。 + // 开源部署注入 noop(返回空 Scope),配合"仅 EvalX trigger 才 enforce"的 admission, + // 开源侧不会产生 enforce 实验,因此拿不到 Scope 无影响。 + centralScopeProvider component.ICentralSchedulerScopeProvider, ) IExptManager { return &ExptMangerImpl{ // tupleSvc: tupleSvc, @@ -98,6 +102,7 @@ func NewExptManager( pipelineListAdapter: pipelineListAdapter, resourceAccessAuthorizer: resourceAccessAuthorizer, sandboxAgentMetrics: sandboxAgentMetrics, + centralScopeProvider: centralScopeProvider, } } @@ -133,6 +138,8 @@ type ExptMangerImpl struct { resourceAccessAuthorizer ResourceAccessAuthorizer // 沙箱 agent 稳定性打点,CompleteExpt 里上报 experiment_finished / experiment_duration sandboxAgentMetrics metrics.SandboxAgentMetrics + // centralScopeProvider 解析新建 enforce 实验要冻结的 scheduler_scope。 + centralScopeProvider component.ICentralSchedulerScopeProvider } func (e *ExptMangerImpl) MGetDetail(ctx context.Context, exptIDs []int64, spaceID int64, session *entity.Session) ([]*entity.Experiment, error) { @@ -1292,6 +1299,40 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP } experimentGroupKey = refExpt.ExperimentGroupKey } + // ★ 中心化调度模式在创建时一次性冻结。 + // + // 只有 EvalX 发起的实验进入 enforce:它是内部平台,会按约定申报 priority 与 + // expected_quota_consumption。其它入口(控制台手动、OpenAPI、定时)保持 legacy, + // 行为与引入中心调度前完全一致。 + // + // 模式由 trigger 派生而非取请求字段:请求里的 scheduler_mode 不可信(任何内部调用方 + // 都能声明 enforce),而 trigger_type 是上游身份的既有表达,已被其它逻辑依赖。 + dispatchMode := entity.ExptDispatchModeLegacy + schedulerScope := "" + expectedQuota := req.ExpectedQuotaConsumption + if entity.ShouldEnforceByTrigger(triggerType) { + // enforce 实验必须有合法的资源消耗向量:没有向量就无法预占额度, + // 调度器只能永远跳过它 —— 表现为"实验建好了但一个 item 都不跑",且那条分支静默。 + // 因此在创建时就拒绝,把问题暴露在调用方能看到的地方。 + if expectedQuota == nil { + return nil, errorx.NewByCode(errno.CommonInvalidParamCode, + errorx.WithExtraMsg("expected_quota_consumption is required for evalx-triggered experiment")) + } + expectedQuota = expectedQuota.Normalize() + if err := expectedQuota.Validate(); err != nil { + return nil, err + } + + scope, err := e.resolveSchedulerScope(ctx, req.WorkspaceID) + if err != nil { + // Scope 解析失败不静默降级成 legacy:降级会让 EvalX 以为实验受中心调度管控, + // 实际却走旧链路自主派发、绕过全局额度。报错让调用方立刻知道。 + return nil, err + } + dispatchMode = entity.ExptDispatchModeEnforce + schedulerScope = scope + } + do := &entity.Experiment{ ID: ids[0], SpaceID: req.WorkspaceID, @@ -1312,6 +1353,13 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP TrialRunItemCount: req.TrialRunItemCount, TriggerType: triggerType, + // 中心化调度冻结值。legacy 时 mode=legacy / scope="" / priority 仍落 1(DB 默认值语义)。 + // expected_quota_consumption 不在此处 —— 它属于 EvalConf(序列化进 eval_conf 列), + // 见下方赋值;调度侧 frozenConsumptionOf 正是从 EvalConf 读取。 + PriorityLevel: entity.NormalizeExptPriorityLevel(req.PriorityLevel), + ExptDispatchMode: dispatchMode, + SchedulerScope: schedulerScope, + Target: tuple.Target, Evaluators: tuple.Evaluators, EvalSet: tuple.EvalSet, @@ -1321,6 +1369,16 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP TargetSpaceID: targetSpaceID, EvalSetAccessLevel: evalSetAccessLevel, } + // ★ 把消耗向量冻结进 eval_conf。 + // + // 放在 EvalConf 而非 Experiment 顶层字段:调度侧 frozenConsumptionOf 读的是 + // EvalConf.ExpectedQuotaConsumption,而 eval_conf 是既有的 JSON 列,加字段无需 DDL。 + // 冻结后运行期只读这份快照,不回查外部 —— 否则同一实验在不同时刻可能按不同规格扣额度, + // 释放时也就对不上账(释放按 reservation 快照走,与此处一致)。 + if expectedQuota != nil && do.EvalConf != nil { + do.EvalConf.ExpectedQuotaConsumption = expectedQuota + } + // 同空间语义归零: 落库 0/"" 表示同消费方空间 (向后兼容, 执行期 fallback 消费方空间) if do.EvalSetSpaceID == req.WorkspaceID { do.EvalSetSpaceID = 0 @@ -1702,3 +1760,24 @@ func (e *ExptMangerImpl) Clone(ctx context.Context, exptID, spaceID int64, sessi return expt, e.Create(ctx, expt, session) } + +// resolveSchedulerScope 解析新建 enforce 实验应冻结的 scheduler_scope。 +// +// 空 Scope 一律报错而非放行:enforce + 空 Scope 的实验不属于任何调度域,任何调度器的 +// 候选查询(WHERE scheduler_scope = ?)都扫不到它 —— 实验会永久停在 Pending, +// 而现象是"提交成功但完全不动",比创建时报错难查得多。 +func (e *ExptMangerImpl) resolveSchedulerScope(ctx context.Context, spaceID int64) (string, error) { + if e.centralScopeProvider == nil { + return "", errorx.NewByCode(errno.CommonInternalErrorCode, + errorx.WithExtraMsg("central scheduler scope provider not wired; refusing to create enforce experiment")) + } + scope, err := e.centralScopeProvider.ResolveSchedulerScope(ctx, spaceID) + if err != nil { + return "", err + } + if scope == "" { + return "", errorx.NewByCode(errno.CommonInternalErrorCode, + errorx.WithExtraMsg("resolved empty scheduler_scope; central scheduling is not available in this deployment")) + } + return scope, nil +} diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go index 83a5d6abc2..7de362002b 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/rpc" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/rpc/mocks" "go.uber.org/mock/gomock" @@ -1784,6 +1786,7 @@ func TestNewExptManager(t *testing.T) { nil, nil, nil, + component.NewNoopCentralSchedulerScopeProvider(), ) impl, ok := mgr.(*ExptMangerImpl) diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 2c861ae1c7..c8c1d62255 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -276,8 +276,79 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE } } - return next(ctx, event) + // 执行链返回后释放额度:这是 consumer 侧唯一的释放点。 + // + // 为什么放在这一层而不是 CompleteItemRun 等各个终态写库处:终态路径有四条 + // (success / fail / 不可重试前置失败 / indebt 终止),每条都手动调一次释放 + // 意味着以后任何人新增一条终态分支都可能漏掉,而漏掉的后果是额度永久泄漏 + // —— 静默、且要等额度跑满才暴露。收在中间件出口只有一处,且天然覆盖 + // panic recover 后的返回(HandleEventErr 在更外层,已把 panic 转成 err)。 + // + // 为什么不能无条件释放:MQ 重试路径也会从这里返回。重试消息稍后会被重新投递并 + // 再次执行同一 item,若此刻释放了额度,重投的消息在 ConfirmRunning 处会因 + // reservation 不存在而被丢弃 —— item 永久停在 Processing。因此必须只在 + // item 真的进入终态时释放,靠回查 run log 投影判定,不靠猜。 + execErr := next(ctx, event) + e.releaseQuotaIfItemTerminal(ctx, event, expt.SchedulerScope, guard, execErr) + return execErr + } +} + +// releaseQuotaIfItemTerminal 在 item 确已进入终态时释放其额度预占。 +// +// 判定依据是 run log 的实际状态,而不是 execErr 是否为 nil: +// - execErr == nil 未必终态(asyncAbort 场景下 item 仍在异步执行中); +// - execErr != nil 未必非终态(不可重试的前置失败已被兜底落成 Fail/Logged)。 +// +// 用状态判定才能两个方向都不出错 —— 少释放会泄漏额度,多释放会让重投消息被丢弃、 +// item 卡死 Processing。 +// +// 全程 best-effort:释放失败只告警。额度对账(spec §3.11)是最终防线, +// 而让终态收口因为额度模块失败而报错会把"额度泄漏"升级成"实验不收敛"。 +func (e *ExptItemEventEvalServiceImpl) releaseQuotaIfItemTerminal( + ctx context.Context, + event *entity.ExptItemEvalEvent, + schedulerScope string, + guard component.ICentralReservationGuard, + execErr error, +) { + if guard == nil || e.dispatchRepo == nil { + return + } + + // 用独立超时的 ctx:主 ctx 可能已因执行失败被取消,而释放必须尽力完成。 + relCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), exptRunLogPersistTimeout) + defer cancel() + + obs, err := e.dispatchRepo.MGetDispatchObservations(relCtx, event.SpaceID, event.ExptID, event.ExptRunID, + []int64{event.EvalSetItemID}) + if err != nil { + logs.CtxWarn(relCtx, "[CentralReservation] load dispatch observation for release fail, expt_run_id: %v, item_id: %v, err: %v", + event.ExptRunID, event.EvalSetItemID, err) + return + } + if len(obs) == 0 { + // run log 查不到:item 记录已被清理(重跑清表等)。此时 reservation 也该走, + // 交由对账处理 —— 这里贸然释放可能释放的是新一轮 run 的额度。 + return + } + + if !entity.IsItemRunFinished(entity.ItemRunState(obs[0].Status)) { + // 仍在执行 / 等待重投:保留 reservation。 + return + } + + reason := "item success" + if execErr != nil { + reason = "item failed: " + execErr.Error() + } + if err := guard.Release(relCtx, schedulerScope, event.ExptRunID, event.EvalSetItemID, reason); err != nil { + logs.CtxWarn(relCtx, "[CentralReservation] release quota fail, expt_run_id: %v, item_id: %v, err: %v", + event.ExptRunID, event.EvalSetItemID, err) + return } + logs.CtxInfo(relCtx, "[CentralReservation] quota released on item terminal, scope: %v, expt_run_id: %v, item_id: %v, reason: %v", + schedulerScope, event.ExptRunID, event.EvalSetItemID, reason) } func (e *ExptItemEventEvalServiceImpl) HandleEventErr(next RecordEvalEndPoint) RecordEvalEndPoint { diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go index d3d3790162..5f01db9a3b 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go @@ -69,6 +69,13 @@ type ExptSchedulerImpl struct { sandboxAgentMetrics metrics.SandboxAgentMetrics // sandboxAgentNotifier 每 1h 进度快照飞书通知; 允许为 nil (未接入通知)。 sandboxAgentNotifier ISandboxAgentNotifier + // centralGuard 中心调度额度闸,用于 zombie / 沙箱提前终态这两条**不经 consumer** 的 + // 终态路径释放额度。consumer 侧的释放在 HandleCentralReservation 出口统一处理。 + // + // 为什么这两条必须单独接:它们由 daemon 直接把 item 判为 Fail 落库,consumer 那条 + // 消息可能已经卡死或永不返回 —— 不在这里释放,这些 item 的额度会永久泄漏。 + // 允许为 nil(开源部署注入 noop)。 + centralGuard component.ICentralReservationGuard } func NewExptSchedulerSvc( @@ -94,6 +101,10 @@ func NewExptSchedulerSvc( itemCompletePublisher component.IItemCompletePublisher, exptItemRefRepo repo.IExptItemRefRepo, sandboxAgentMetrics metrics.SandboxAgentMetrics, + // centralGuard 放在 variadic 之前(Go 只要求 variadic 最后)。不做 setter 注入: + // setter 会让 wire 构造出实例但无人调用、字段恒 nil,而 nil 在此被解释为"跳过释放", + // 等于静默恢复额度泄漏。 + centralGuard component.ICentralReservationGuard, sandboxAgentNotifier ...ISandboxAgentNotifier, // variadic 兼容旧单测 ) ExptSchedulerEvent { i := &ExptSchedulerImpl{ @@ -115,6 +126,7 @@ func NewExptSchedulerSvc( IDGen: idGen, evaluationSetItemService: evaluationSetItemService, schedulerModeFactory: schedulerModeFactory, + centralGuard: centralGuard, evalTargetService: evalTargetService, itemCompletePublisher: itemCompletePublisher, exptItemRefRepo: exptItemRefRepo, @@ -760,6 +772,10 @@ func (e *ExptSchedulerImpl) handleZombies(ctx context.Context, event *entity.Exp return nil, nil, err } + // item 已落终态 → 释放其额度预占。放在状态写库之后:先确保终态可见, + // 再释放额度,避免"额度已放但 item 仍显示 Processing"这一瞬间被下一拍读到而重复授予。 + e.releaseCentralQuotaForItems(ctx, expt, event.ExptRunID, zombieItemIDs, "item zombie timeout") + // 不清 run_log 的 target_result_id / evaluator_result_ids: // zombie 场景是「终态失败」,需要保留已入库的 record id, // 让 /results/batch_get 能返回 eval_target_record.id、evaluator_record.id 供用户查详情。 @@ -980,6 +996,10 @@ func (e *ExptSchedulerImpl) sweepTerminatedSandboxItems(ctx context.Context, eve return nil, nil, err } + // item 已落终态 → 释放额度预占。沙箱已提前终止,对应 consumer 消息不会再回来, + // 不在此释放则这些 item 的额度永久泄漏。 + e.releaseCentralQuotaForItems(ctx, expt, event.ExptRunID, terminatedItemIDs, "sandbox terminated before report") + // 打点:复用沙箱 agent 评测对象的 EmitInvokeFinished,让"沙箱终态未回调 → 兜底失败" // 与正常回调路径的 invoke_finished 在同一 dashboard 上可比。err_code 用 // SandboxTerminatedBeforeReportCode,classifier 归入 non_engineering。 @@ -1169,3 +1189,37 @@ func sandboxAgentTargetTagsFromExpt(expt *entity.Experiment) (agentName string, } return } + +// releaseCentralQuotaForItems 为一批已被判为终态的 item 释放中心调度额度预占。 +// +// 用于 zombie 清理与沙箱提前终态两条 daemon 路径:它们直接把 item 落成 Fail, +// 对应的 consumer 消息可能已经卡死或永不返回,因此必须在这里释放,否则额度永久泄漏 +// (现象是"额度跑满后再也调度不动",且看起来像上限配小了,极易误判)。 +// +// legacy 实验直接返回:它们从不预占额度,调用释放只是无谓的 Redis 往返。 +// 全程 best-effort:单个 item 释放失败只告警,不影响 zombie 清理本身 —— +// 清理失败会让实验永不收敛,比额度泄漏更严重,而额度有对账兜底。 +func (e *ExptSchedulerImpl) releaseCentralQuotaForItems(ctx context.Context, expt *entity.Experiment, exptRunID int64, itemIDs []int64, reason string) { + if e.centralGuard == nil || expt == nil || len(itemIDs) == 0 { + return + } + if !entity.IsCentralDispatch(expt.ExptDispatchMode) { + return + } + if expt.SchedulerScope == "" { + // enforce 却无 Scope:数据异常,无法确定去哪本账释放。告警交由对账处理, + // 不猜一本账 —— 猜错会归还别人的额度,比不归还更糟。 + logs.CtxError(ctx, "[CentralReservation] enforce experiment without scheduler_scope, skip release, expt_id: %v, item_ids: %v", + expt.ID, itemIDs) + return + } + + for _, itemID := range itemIDs { + if err := e.centralGuard.Release(ctx, expt.SchedulerScope, exptRunID, itemID, reason); err != nil { + logs.CtxWarn(ctx, "[CentralReservation] release quota fail, scope: %v, expt_run_id: %v, item_id: %v, reason: %v, err: %v", + expt.SchedulerScope, exptRunID, itemID, reason, err) + } + } + logs.CtxInfo(ctx, "[CentralReservation] quota released for daemon-terminated items, scope: %v, expt_run_id: %v, count: %v, reason: %v", + expt.SchedulerScope, exptRunID, len(itemIDs), reason) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go index 70147fadeb..69c0d9730e 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" + "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -503,6 +505,7 @@ func TestNewExptSchedulerSvc(t *testing.T) { nil, // itemCompletePublisher: 开源侧 nil, scheduler 循环内以非空守卫跳过发送 exptItemRefRepo, metricsmocks.NewMockSandboxAgentMetrics(ctrl), + component.NewNoopCentralReservationGuard(), ) assert.NotNil(t, svc) assert.Implements(t, (*ExptSchedulerEvent)(nil), svc) diff --git a/backend/modules/evaluation/domain/service/sandbox_agent_hourly_middleware_test.go b/backend/modules/evaluation/domain/service/sandbox_agent_hourly_middleware_test.go index 07aabd6cb7..267166551d 100644 --- a/backend/modules/evaluation/domain/service/sandbox_agent_hourly_middleware_test.go +++ b/backend/modules/evaluation/domain/service/sandbox_agent_hourly_middleware_test.go @@ -8,6 +8,8 @@ import ( "errors" "testing" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" + "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -108,12 +110,12 @@ func TestNewExptSchedulerSvc_VariadicNotifier(t *testing.T) { defer ctrl.Finish() notifier := svcmocks.NewMockISandboxAgentNotifier(ctrl) - svc := NewExptSchedulerSvc(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, notifier) + svc := NewExptSchedulerSvc(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, component.NewNoopCentralReservationGuard(), notifier) impl, ok := svc.(*ExptSchedulerImpl) assert.True(t, ok) assert.Same(t, notifier, impl.sandboxAgentNotifier) - svc2 := NewExptSchedulerSvc(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + svc2 := NewExptSchedulerSvc(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, component.NewNoopCentralReservationGuard()) impl2 := svc2.(*ExptSchedulerImpl) assert.Nil(t, impl2.sandboxAgentNotifier) } diff --git a/backend/modules/evaluation/domain/service/wire.go b/backend/modules/evaluation/domain/service/wire.go index f54ab4c704..4904a3e511 100644 --- a/backend/modules/evaluation/domain/service/wire.go +++ b/backend/modules/evaluation/domain/service/wire.go @@ -51,6 +51,7 @@ var ExperimentDomainServiceSet = wire.NewSet( // 商业版在自己的 wire set 里注入真实账本适配器覆盖它。 component.NewNoopCentralReservationGuard, component.NewNoopCentralSchedulerScopeOwner, + component.NewNoopCentralSchedulerScopeProvider, // Infrastructure Sets taskrpc.TaskRPCSet, pipeline.PipelineRPCSet, From 1957f8f357b353c61c66bfc14006bb732c1be683 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 23:15:02 +0800 Subject: [PATCH 12/58] =?UTF-8?q?refactor(evaluation):=20=E6=8B=86?= =?UTF-8?q?=E5=87=BA=E7=AA=84=E6=8E=A5=E5=8F=A3=20IExperimentSchedulerQueu?= =?UTF-8?q?eRepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScanSchedulerQueue 此前并入宽接口 IExperimentRepo(15+ 方法、十余处依赖), 导致多处手写 fake 因缺少新方法而编译失败 —— 而那个编译失败又掩盖了同包内既有的 测试失败(22 个 _BitsUTGen / region 路由用例),排查时先看到的是编译错误, 真实失败要等编译修好才浮现。 拆成单方法窄接口后,"新增调度能力"不再波及无关调用方:本次一并删掉了上一个 commit 里为 5 个手写 fake 补的桩方法 —— 它们现在完全不需要存在,这正是拆分的收益。 实现方仍是同一个 exptRepoImpl(同表、同 DAO),拆的只是消费侧契约; 中心调度 adapter 只依赖窄接口。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/repo/expt.go | 20 +++++++++++++++++-- .../infra/repo/experiment/expt_repo_impl.go | 9 +++++++++ .../evaluation/infra/repo/experiment/wire.go | 1 + 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/modules/evaluation/domain/repo/expt.go b/backend/modules/evaluation/domain/repo/expt.go index 371d0155d8..e3cef5a23d 100644 --- a/backend/modules/evaluation/domain/repo/expt.go +++ b/backend/modules/evaluation/domain/repo/expt.go @@ -27,9 +27,25 @@ type IExperimentRepo interface { // ExistGroupKey 判断 group key 是否已被“其它空间”占用(跨空间隔离), 用于创建实验时校验。 ExistGroupKey(ctx context.Context, groupKey string, spaceID int64) (bool, error) GetEvaluatorRefByExptIDs(ctx context.Context, exptID []int64, spaceID int64) ([]*entity.ExptEvaluatorRef, error) +} - // ScanSchedulerQueue 跨空间扫描中心调度候选实验(按 priority DESC, created_at ASC, id ASC)。 - // 不接受 spaceID —— 中心调度按全局优先级排序,按空间分别扫描会使全局优先级失效。 +//go:generate mockgen -destination=mocks/expt_scheduler_queue.go -package=mocks . IExperimentSchedulerQueueRepo + +// IExperimentSchedulerQueueRepo 中心调度的候选实验扫描,单方法窄接口。 +// +// 为什么不放进 IExperimentRepo:那个接口已有 15+ 方法、被十余处依赖(含大量手写 fake)。 +// 中心调度只需要"扫候选"这一件事,塞进去会让所有实现方和 fake 都被迫感知调度概念 —— +// 实际后果已经发生过一次:ScanSchedulerQueue 曾并入宽接口,导致多处手写 fake 编译失败, +// 而那个编译失败又掩盖了同包内既有的测试失败。窄接口让"加调度能力"不再波及无关调用方。 +// +// 实现方仍是同一个 exptRepoImpl(同一张表、同一个 DAO),拆的只是消费侧契约。 +type IExperimentSchedulerQueueRepo interface { + // ScanSchedulerQueue 在指定 scheduler_scope 内跨空间扫描候选实验 + // (按 priority DESC, created_at ASC, id ASC)。 + // + // 不接受 spaceID —— 中心调度在 Scope 内按全局优先级排序,按空间分别扫描会使全局优先级失效。 + // 但 param.SchedulerScope 必填:它是调度所有权边界(线上与各 PPE 泳道共库), + // 空值由实现方拒绝而非退化成扫全表。 ScanSchedulerQueue(ctx context.Context, param *entity.SchedulerQueueScanParam) ([]*entity.Experiment, error) } diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go index a7954c3cf3..14913e3e7b 100644 --- a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go +++ b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl.go @@ -25,6 +25,15 @@ func NewExptRepo(exptDAO mysql.IExptDAO, exptEvaluatorRefDAO mysql.IExptEvaluato return &exptRepoImpl{exptDAO: exptDAO, exptEvaluatorRefDAO: exptEvaluatorRefDAO, idgen: idgen} } +// NewExptSchedulerQueueRepo 返回中心调度候选扫描的窄接口实现。 +// +// 与 NewExptRepo 共用同一个 exptRepoImpl(同表、同 DAO),拆的只是消费侧契约: +// 中心调度只依赖"扫候选"这一个方法,不必因此感知 Create/Update/List 等十余个无关方法, +// 反过来那十余处调用方(含大量手写 fake)也不必因为新增调度能力而被迫改动。 +func NewExptSchedulerQueueRepo(exptDAO mysql.IExptDAO, exptEvaluatorRefDAO mysql.IExptEvaluatorRefDAO, idgen idgen.IIDGenerator) repo.IExperimentSchedulerQueueRepo { + return &exptRepoImpl{exptDAO: exptDAO, exptEvaluatorRefDAO: exptEvaluatorRefDAO, idgen: idgen} +} + type exptRepoImpl struct { idgen idgen.IIDGenerator exptDAO mysql.IExptDAO diff --git a/backend/modules/evaluation/infra/repo/experiment/wire.go b/backend/modules/evaluation/infra/repo/experiment/wire.go index 215f8068e0..0634300190 100644 --- a/backend/modules/evaluation/infra/repo/experiment/wire.go +++ b/backend/modules/evaluation/infra/repo/experiment/wire.go @@ -15,6 +15,7 @@ import ( var ExperimentRepoSet = wire.NewSet( NewExptRepo, + NewExptSchedulerQueueRepo, NewExptStatsRepo, NewExptAggrResultRepo, NewExptItemResultRepo, From e1c03b0300353f0b6e8b2b55bc87cd59f7a92d1a Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 19 Aug 2026 23:55:15 +0800 Subject: [PATCH 13/58] =?UTF-8?q?perf(evaluation):=20=E5=8F=96=E6=B6=88=20?= =?UTF-8?q?expt=5Fitem=5Fresult=5Frun=5Flog=20=E7=9A=84=20idx=5Fexpt=5Frun?= =?UTF-8?q?=5Fdispatch=20=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内场实测后判定该索引收益接近零、代价明确,故取消;experiment 表的 idx_scheduler_queue 保留不动(那张表确实需要跨实验按优先级扫描)。 三条依据: 1. 所有 dispatch 查询的 WHERE 恒以 (space_id, expt_id, expt_run_id) 打头 —— ClaimQuotaReserved / ResetQuotaReserved / StartReservedItem 三个再加 item_id, LoadDispatchRuntime / MGetDispatchObservations 走三列前缀。没有任何查询需要 status 或 quota_reservation_state 作索引前缀:中心调度只扫「当前 run 的 run log」, 跨实验扫描发生在 experiment 表、不在本表。 2. 该前缀已被既有索引完全覆盖:uk_expt_run_item_turn(space_id,expt_id,expt_run_id,item_id) UNIQUE 让带 item_id 的精确 CAS 直接定位单行;idx_expt_run_result_state (space_id,expt_id,expt_run_id,result_state) 覆盖不带 item_id 的那两个。 3. 单个 run 的 run log 实测仅 ~900 行(内场最大 914),三列前缀定位后按 status/quota_reservation_state 过滤是内存操作。 代价对比:本表内场 7800 万行(experiment 才 23 万)。为几百行的内存过滤给 7800 万行表 建 6 列复合索引,换来的是在线 DDL 与长期写放大。只加列不加索引后,带默认值的 tinyint 在 MySQL 8.0 走 instant add column,秒级完成、无需 gh-ost(内场库 8.0.27-18-ndb)。 改动 6 处:4 份部署 SQL(docker/helm 双路径已 diff 验证一致,CI mysql-schema-check 会校验)、GORM model 的 index tag、IExptItemDispatchRepo 的接口注释 —— 注释里补上「新增方法请保持该前缀形状」与跨 run 对账的 caveat,避免以后有人按旧假设 写出需要索引的查询却发现索引已不存在。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/repo/expt_item_dispatch.go | 9 +++++++- .../model/expt_item_result_run_log.gen.go | 2 +- .../init-sql/expt_item_result_run_log.sql | 3 +-- .../expt_item_result_run_log_alter.sql | 22 +++++++++++++++---- .../init-sql/expt_item_result_run_log.sql | 3 +-- .../expt_item_result_run_log_alter.sql | 22 +++++++++++++++---- 6 files changed, 47 insertions(+), 14 deletions(-) diff --git a/backend/modules/evaluation/domain/repo/expt_item_dispatch.go b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go index 868c51d275..7b25574f31 100644 --- a/backend/modules/evaluation/domain/repo/expt_item_dispatch.go +++ b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go @@ -15,7 +15,14 @@ import ( // // 与既有 IExptItemResultRepo 的分工:后者面向 item 执行结果的完整生命周期;本接口只关心 // 「哪些 item 可被授予、哪些已占用并发」这一个问题,因此方法都围绕 run log 的 -// (status, quota_reservation_state) 二元组,且全部走 idx_expt_run_dispatch。 +// (status, quota_reservation_state) 二元组。 +// +// 索引:本接口刻意**不依赖新增索引**。所有方法的 WHERE 恒以 (space_id, expt_id, expt_run_id) +// 打头,该前缀已被既有 uk_expt_run_item_turn / idx_expt_run_result_state 覆盖; +// 单个 run 的 run log 仅数百行(内场实测最大 914),status 与预占态的过滤是内存操作。 +// 本表内场 7800 万行,为此加复合索引收益接近零而写放大长期存在。 +// 新增方法时请保持这个前缀形状;若确实需要跨 run 扫描(如未来的超时 reservation 对账), +// 按那个查询的真实形状单独评估索引,不要沿用本接口的假设。 // // 为什么单独立一个窄接口而不是往 IExptItemResultRepo 加方法:那个接口已有 15+ 方法、被十余处 // 依赖,调度只需其中极小一部分;混进去会让所有实现方和 mock 都被迫感知调度概念。 diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go index 064252dc74..218282bd60 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model/expt_item_result_run_log.gen.go @@ -27,7 +27,7 @@ type ExptItemResultRunLog struct { DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;type:timestamp;comment:删除时间" json:"deleted_at"` // 删除时间 LogID string `gorm:"column:log_id;type:varchar(128);not null;comment:日志 id" json:"log_id"` // 日志 id ResultState *int32 `gorm:"column:result_state;type:int(11);index:idx_expt_run_result_state,priority:4;comment:回写结果表状态" json:"result_state"` // 回写结果表状态 - QuotaReservationState int32 `gorm:"column:quota_reservation_state;type:tinyint(3) unsigned;not null;index:idx_expt_run_dispatch,priority:5;comment:中心调度额度预占投影: 0=none, 1=reserved" json:"quota_reservation_state"` // 中心调度额度预占投影 + QuotaReservationState int32 `gorm:"column:quota_reservation_state;type:tinyint(3) unsigned;not null;comment:中心调度额度预占投影: 0=none, 1=reserved" json:"quota_reservation_state"` // 中心调度额度预占投影 } // TableName ExptItemResultRunLog's table name diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql index 6a25566083..980aa7df05 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/init-sql/expt_item_result_run_log.sql @@ -17,8 +17,7 @@ CREATE TABLE IF NOT EXISTS `expt_item_result_run_log` PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_run_item_turn` (`space_id`, `expt_id`, `expt_run_id`, `item_id`), KEY `idx_expt_item_turn` (`space_id`, `expt_id`, `item_id`), - KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`), - KEY `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`) + KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='expt_item_result_run_log'; diff --git a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql index d44299f32c..8ef13317f4 100644 --- a/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql +++ b/release/deployment/docker-compose/bootstrap/mysql-init/patch-sql/expt_item_result_run_log_alter.sql @@ -4,7 +4,21 @@ ALTER TABLE `expt_item_result_run_log` ALTER TABLE `expt_item_result_run_log` ADD COLUMN `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用' AFTER `result_state`; --- 中心调度派发查询索引:按 (run, status, 预占态) 定位「可授予候选」与「已预占占用」两类 item。 --- 末位带 id 是为了让 keyset 分页与稳定排序都能走索引,避免 filesort。 -ALTER TABLE `expt_item_result_run_log` - ADD INDEX `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`); +-- 刻意不为本表新增索引。 +-- +-- 所有 dispatch 查询(ClaimQuotaReserved / ResetQuotaReserved / StartReservedItem / +-- LoadDispatchRuntime / MGetDispatchObservations)的 WHERE 恒以 +-- (space_id, expt_id, expt_run_id) 打头 —— 中心调度只扫「当前 run 的 run log」, +-- 跨实验扫描发生在 experiment 表、不在本表。该前缀已被既有索引完全覆盖: +-- uk_expt_run_item_turn(space_id,expt_id,expt_run_id,item_id) UNIQUE —— 带 item_id 的 +-- 精确 CAS 直接命中唯一索引定位单行,status/quota_reservation_state 只是回表判断; +-- idx_expt_run_result_state(space_id,expt_id,expt_run_id,result_state) —— 不带 item_id 的 +-- LoadDispatchRuntime 走前三列前缀。 +-- +-- 单个 run 的 run log 实测仅 ~900 行(内场最大 914),三列前缀定位后按 +-- status/quota_reservation_state 过滤是内存操作。而本表内场 7800 万行 +-- (experiment 才 23 万),为几百行的内存过滤给大表加 6 列复合索引, +-- 收益接近零、代价是在线 DDL + 长期写放大。 +-- +-- caveat:若未来 reconcile 实现成「跨 run 扫超时 reservation」(不带 expt_run_id), +-- 届时按那个查询的实际形状再评估,不要现在预建。 diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql index 6a25566083..980aa7df05 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log.sql @@ -17,8 +17,7 @@ CREATE TABLE IF NOT EXISTS `expt_item_result_run_log` PRIMARY KEY (`id`), UNIQUE KEY `uk_expt_run_item_turn` (`space_id`, `expt_id`, `expt_run_id`, `item_id`), KEY `idx_expt_item_turn` (`space_id`, `expt_id`, `item_id`), - KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`), - KEY `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`) + KEY `idx_expt_run_result_state` (`space_id`, `expt_id`, `expt_run_id`, `result_state`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT ='expt_item_result_run_log'; diff --git a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql index d44299f32c..8ef13317f4 100644 --- a/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql +++ b/release/deployment/helm-chart/charts/app/bootstrap/init/mysql/init-sql/expt_item_result_run_log_alter.sql @@ -4,7 +4,21 @@ ALTER TABLE `expt_item_result_run_log` ALTER TABLE `expt_item_result_run_log` ADD COLUMN `quota_reservation_state` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '中心调度额度预占投影: 0=none, 1=reserved; Redis reservation 是账本真值, 本列仅供调度算准并发占用' AFTER `result_state`; --- 中心调度派发查询索引:按 (run, status, 预占态) 定位「可授予候选」与「已预占占用」两类 item。 --- 末位带 id 是为了让 keyset 分页与稳定排序都能走索引,避免 filesort。 -ALTER TABLE `expt_item_result_run_log` - ADD INDEX `idx_expt_run_dispatch` (`space_id`, `expt_id`, `expt_run_id`, `status`, `quota_reservation_state`, `id`); +-- 刻意不为本表新增索引。 +-- +-- 所有 dispatch 查询(ClaimQuotaReserved / ResetQuotaReserved / StartReservedItem / +-- LoadDispatchRuntime / MGetDispatchObservations)的 WHERE 恒以 +-- (space_id, expt_id, expt_run_id) 打头 —— 中心调度只扫「当前 run 的 run log」, +-- 跨实验扫描发生在 experiment 表、不在本表。该前缀已被既有索引完全覆盖: +-- uk_expt_run_item_turn(space_id,expt_id,expt_run_id,item_id) UNIQUE —— 带 item_id 的 +-- 精确 CAS 直接命中唯一索引定位单行,status/quota_reservation_state 只是回表判断; +-- idx_expt_run_result_state(space_id,expt_id,expt_run_id,result_state) —— 不带 item_id 的 +-- LoadDispatchRuntime 走前三列前缀。 +-- +-- 单个 run 的 run log 实测仅 ~900 行(内场最大 914),三列前缀定位后按 +-- status/quota_reservation_state 过滤是内存操作。而本表内场 7800 万行 +-- (experiment 才 23 万),为几百行的内存过滤给大表加 6 列复合索引, +-- 收益接近零、代价是在线 DDL + 长期写放大。 +-- +-- caveat:若未来 reconcile 实现成「跨 run 扫超时 reservation」(不带 expt_run_id), +-- 届时按那个查询的实际形状再评估,不要现在预建。 From ac5ca90acf2873bba77e90cde8729671b6d4fbfc Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 01:02:09 +0800 Subject: [PATCH 14/58] =?UTF-8?q?fix(evaluation):=20=E9=98=BB=E6=AD=A2?= =?UTF-8?q?=E9=83=A8=E5=88=86=E6=9B=B4=E6=96=B0=E6=94=B9=E5=86=99=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E6=9C=9F=E5=86=BB=E7=BB=93=E7=9A=84=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题(实测确认,不是假想) exptDAO.Update 用 struct 做 Updates,GORM 只跳过**零值**字段。而 DO2PO 会把未设置的 调度字段 Normalize 成非零值(mode ""→"legacy"、priority 0→1,见 convert/expt.go:61-63)。 实测 DO2PO(&Experiment{ID:123, LatestRunID:456}) 产出 SchedulerMode="legacy" / PriorityLevel=1 —— 两者非零,必然被写进 UPDATE。 全仓有 8 处「只带 ID + 一两个业务字段」的部分更新(LogRun 写 latest_run_id、 ScheduleStart 改 status 等),每一处都会顺手把 enforce 实验改回 legacy、 把 EvalX 申报的优先级重置为 1。 后果静默且严重,三条链路同时失效: - 中心调度扫不到它了(扫描条件 scheduler_mode='enforce'); - 旧 daemon 的抑制判断 IsCentralDispatch 读到 legacy → **恢复自主派发**, 同一 run 出现两个派发驱动、绕过全局额度账本,正是设计明令禁止的情形; - consumer 侧 guard 同样读到 legacy,item 执行时不再校验 reservation。 且 scheduler_scope 是零值会被跳过,最终留下 mode=legacy + scope 非空 的不可能组合。 注意代码注释一直声称这些值"创建时冻结",但写路径并未兑现 —— struct Updates 不构成冻结。 ## 修法 DAO 层显式 Omit(schedulingFrozenColumns...):这三列的唯一合法写入点是 Create。 选 Omit 而非"让 DO2PO 不 Normalize":后者依赖零值跳过,而零值跳过正是本次踩的坑, 再靠它一次仍然脆弱(且 Create 会因此需要额外保证)。 ## 测试 expt_update_frozen_columns_test.go 用 dry-run gorm 渲染真实 UPDATE 语句做断言 —— 断言 SQL 文本而非转换结果,因为本 bug 的成因正是"转换结果看起来合理、 但 GORM 会把它写进 UPDATE",只测转换层测不出来。 已验证:临时移除 Omit 后该测试失败并点名 priority_level 与 scheduler_mode, 即它确实守住了不变量而非恒真。 另加一条测试把冻结列名单钉死,将来新增冻结列漏加会失败。 (harness 需 SkipDefaultTransaction:GORM 的 Updates 默认包事务,sqlmock 会拒绝 Begin。) ## 数据影响 无需修数据:线上 fornax_evaluation 目前尚无这三列(DDL 未执行), 本修复先于任何 enforce 实验落地,不存在已损坏的行。 evaluation 全量 55 个包测试通过。 Co-Authored-By: Claude Opus 5 (1M context) --- .../infra/repo/experiment/mysql/expt.go | 20 ++++- .../mysql/expt_update_frozen_columns_test.go | 87 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go index 949c566e64..55d5ab709e 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go @@ -90,8 +90,26 @@ func (d *exptDAOImpl) Create(ctx context.Context, expt *model.Experiment) error return nil } +// schedulingFrozenColumns 是创建时一次性冻结、此后任何 Update 都不得改写的调度列。 +// +// 为什么必须显式 Omit:本方法用 struct 做 Updates,GORM 只跳过**零值**字段。 +// 而 DO2PO 会把未设置的调度字段 Normalize 成非零值(mode ""→"legacy"、priority 0→1, +// 见 convert/expt.go),于是任何"只带 ID + 一两个业务字段"的部分更新(全仓 8 处,如 +// LogRun 写 latest_run_id、ScheduleStart 改 status)都会顺手把 enforce 实验改回 legacy、 +// 把申报的优先级重置为 1。 +// +// 后果是静默且严重的:mode 变回 legacy 后中心调度再也扫不到它(扫描条件是 +// scheduler_mode='enforce'),而旧 daemon 的抑制判断读到 legacy 会**恢复自主派发** —— +// 同一个 run 出现两个派发驱动、绕过全局额度账本,正是设计上明令禁止的情形。 +// 且 scope 是零值会被跳过,最终留下 mode=legacy + scope 非空 的不可能组合。 +// +// 这三列的唯一合法写入点是 Create(见 expt_manage_impl.go 的冻结逻辑)。 +var schedulingFrozenColumns = []string{"priority_level", "scheduler_mode", "scheduler_scope"} + func (d *exptDAOImpl) Update(ctx context.Context, expt *model.Experiment) error { - if err := d.db.NewSession(ctx).Model(&model.Experiment{}).Where("id = ?", expt.ID).Updates(expt).Error; err != nil { + if err := d.db.NewSession(ctx).Model(&model.Experiment{}).Where("id = ?", expt.ID). + Omit(schedulingFrozenColumns...). + Updates(expt).Error; err != nil { return errorx.Wrapf(err, "update expt fail, expt_id: %v, updated: %v", expt.ID, json.Jsonify(expt)) } return nil diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go new file mode 100644 index 0000000000..501d8db8d1 --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package mysql + +import ( + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/mysql" + "gorm.io/gorm" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql/gorm_gen/model" +) + +// renderExptUpdate 用 dry-run gorm 渲染 Update 的最终 SQL,直接断言列是否出现在 SET 里。 +// +// 断言 SQL 文本而非断言转换结果:本 bug 的成因正是"转换结果看起来合理、但 GORM 会把它写进 +// UPDATE",只测转换层测不出来。渲染 SQL 才能覆盖 Omit 是否真的生效。 +func renderExptUpdate(t *testing.T, expt *model.Experiment) string { + t.Helper() + sqlDB, _, err := sqlmock.New() + require.NoError(t, err) + defer func() { _ = sqlDB.Close() }() + + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), + &gorm.Config{DryRun: true, SkipDefaultTransaction: true}) + require.NoError(t, err) + + tx := gormDB.Model(&model.Experiment{}).Where("id = ?", expt.ID). + Omit(schedulingFrozenColumns...). + Updates(expt) + // DryRun 下 Statement.SQL 可能为空,用 Explain 把 SQL + 参数一起还原成可断言的文本。 + return gormDB.Dialector.Explain(tx.Statement.SQL.String(), tx.Statement.Vars...) +} + +// TestExptDAO_Update_NeverClobbersFrozenSchedulingColumns 守住「调度列创建时冻结」这条不变量。 +// +// 背景(真实 bug,非假想):Update 用 struct 做 Updates,GORM 只跳过**零值**字段; +// 而 DO2PO 会把未设置的调度字段 Normalize 成非零值(mode ""→"legacy"、priority 0→1)。 +// 于是全仓 8 处「只带 ID + 一两个业务字段」的部分更新(LogRun 写 latest_run_id、 +// ScheduleStart 改 status 等)都会顺手把 enforce 实验改回 legacy、把申报优先级重置为 1。 +// +// 后果静默且严重:mode 变回 legacy 后中心调度扫不到它(扫描条件 scheduler_mode='enforce'), +// 而旧 daemon 的抑制判断读到 legacy 会恢复自主派发 —— 同一 run 两个派发驱动、绕过全局额度。 +func TestExptDAO_Update_NeverClobbersFrozenSchedulingColumns(t *testing.T) { + t.Parallel() + + // 模拟 DO2PO 的真实产出:调度列全是 Normalize 后的非零值(这正是危险所在)。 + // 若不 Omit,这三列都会进 SET 子句。 + po := &model.Experiment{ + ID: 123, + LatestRunID: 456, // 调用方真正想改的字段 + PriorityLevel: 1, + SchedulerMode: "legacy", + SchedulerScope: "", + } + + sql := strings.ToLower(renderExptUpdate(t, po)) + + // 调用方想改的字段必须仍在 + assert.Contains(t, sql, "latest_run_id", "业务字段必须正常更新,Omit 不能误伤") + + // 三个冻结列一个都不许出现在 UPDATE 里 + for _, col := range schedulingFrozenColumns { + assert.NotContains(t, sql, col, + "冻结列 %s 不得出现在 UPDATE 语句中;它只能由 Create 写入,"+ + "否则部分更新会把 enforce 实验静默改回 legacy、绕过额度账本", col) + } +} + +// TestSchedulingFrozenColumns_CoversAllSchedulingColumns 防止将来新增调度列时漏进 Omit 名单。 +// +// 新增一个"创建时冻结"的列却忘了加进 schedulingFrozenColumns,就会重新引入上面那个 bug, +// 且同样静默。这里把名单钉死成显式期望值,新增列时测试会失败、迫使人做出选择。 +func TestSchedulingFrozenColumns_CoversAllSchedulingColumns(t *testing.T) { + t.Parallel() + + assert.ElementsMatch(t, + []string{"priority_level", "scheduler_mode", "scheduler_scope"}, + schedulingFrozenColumns, + "新增创建期冻结的调度列时,必须同步加入 schedulingFrozenColumns,"+ + "否则部分更新会静默改写它") +} From 5994a1caf1aa328611d8cd20970984a35fdb9503 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 13:27:49 +0800 Subject: [PATCH 15/58] =?UTF-8?q?feat(evaluation):=20=E5=A2=9E=E5=8A=A0=20?= =?UTF-8?q?ICentralAdmissionPolicy=20=E7=AA=84=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigger 判据(trigger_type=evalx)之上的第二道闸,语义是 AND 而非 OR: 只有 trigger 已判定为 EvalX(因而调用方一定申报了 expected_quota_consumption)时 才咨询本 policy,由它决定"这个空间/这类评测对象是否纳入本轮灰度"。 为什么必须是收窄而不能扩大:enforce 实验强制要求资源消耗向量(缺向量在创建期即报错), 而非 EvalX 入口(控制台手动 / OpenAPI / 定时)目前没有传向量的字段。若 policy 能把这些 入口的实验也拽进 enforce,结果要么创建报错、要么被调度器永远跳过 —— 后者表现为 "实验建好了但一个 item 都不跑",且那条分支是静默 return。等 OpenAPI 具备申报能力后 才可考虑放宽成 OR。 CentralAdmissionSubject 只带"这个实验是什么"(space/target 类型与 ID),不带 "该不该 enforce"—— 后者是 policy 的职责。字段用基础类型而非 entity.EvalTarget: policy 实现在 commercial,让它依赖 OSS 领域实体会把整个 target 模型拖进配置层。 开源部署注入 noop(恒定放行):开源侧不产生 EvalX trigger,本 policy 不会被咨询到。 与 Guard 的 noop 取 fail-closed 相反 —— 那里放行会绕过额度,这里拒绝只会让 单环境部署的所有 enforce 实验退回 legacy,是自造故障。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/central_reservation_guard.go | 51 +++++++++++++++++++ .../central_reservation_guard_test.go | 22 ++++++++ 2 files changed, 73 insertions(+) diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index d904acdeb8..894ec28832 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -117,3 +117,54 @@ type noopCentralSchedulerScopeProvider struct{} func (noopCentralSchedulerScopeProvider) ResolveSchedulerScope(ctx context.Context, spaceID int64) (string, error) { return "", nil } + +//go:generate mockgen -destination=mocks/central_admission_policy.go -package=mocks . ICentralAdmissionPolicy + +// CentralAdmissionSubject 是 admission 判定的输入。 +// +// 只带"这个实验是什么",不带"该不该 enforce" —— 后者是 policy 的职责。 +// 字段刻意用基础类型而非 entity.EvalTarget:policy 实现在 commercial, +// 让它依赖 OSS 的领域实体会把整个 target 模型拖进配置层。 +type CentralAdmissionSubject struct { + SpaceID int64 + // TargetType 评测对象类型的配置名(entity.EvalTargetType.ConfigName(),如 "sandbox_agent")。 + // 空串表示该实验无评测对象(skip-target)或类型未登记配置名。 + TargetType string + // TargetID 评测对象 ID。0 表示无评测对象。 + TargetID int64 +} + +// ICentralAdmissionPolicy 在创建期收窄中心调度的准入范围。 +// +// 它是 trigger 判据之上的**第二道闸**,语义是 AND 而非 OR: +// 只有 trigger 已判定为 EvalX(因而调用方一定申报了 expected_quota_consumption)时 +// 才会咨询本 policy,由它决定"这个空间/这类评测对象是否纳入本轮灰度"。 +// +// 为什么必须是收窄而不能扩大:enforce 实验强制要求资源消耗向量(缺向量在创建期即报错), +// 而非 EvalX 入口(控制台手动、OpenAPI、定时)目前没有传向量的字段。若 policy 能把 +// 这些入口的实验也拽进 enforce,结果是要么创建报错、要么被调度器永远跳过 —— +// 后者表现为"实验建好了但一个 item 都不跑",且分支静默。等 OpenAPI 具备申报能力后, +// 才可以考虑放宽成 OR。 +// +// 开源部署注入 noop(恒定放行):开源侧不产生 EvalX trigger,本 policy 不会被咨询到。 +type ICentralAdmissionPolicy interface { + // AllowCentralScheduling 报告该实验是否纳入中心调度。 + // + // 返回 error 表示配置不可判定。调用方应拒绝创建 enforce 实验而非放行 —— + // 放行会让一个本该受额度管控的实验绕过管控,且无从发现。 + AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (bool, error) +} + +// NewNoopCentralAdmissionPolicy 返回开源部署使用的 noop 实现:恒定放行。 +// +// 取放行而非拒绝:本 policy 的职责是"在已申报向量的实验里再筛一遍", +// 缺省不筛等于保持 trigger 判据的原有行为,这是引入本 port 前的语义。 +func NewNoopCentralAdmissionPolicy() ICentralAdmissionPolicy { + return noopCentralAdmissionPolicy{} +} + +type noopCentralAdmissionPolicy struct{} + +func (noopCentralAdmissionPolicy) AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (bool, error) { + return true, nil +} diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go index cd6acc0367..9008f118f2 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go @@ -50,3 +50,25 @@ func TestNoopCentralSchedulerScopeOwner_AlwaysOwns(t *testing.T) { require.NoError(t, err) assert.True(t, owned) } + +func TestNoopCentralAdmissionPolicy_AlwaysAllows(t *testing.T) { + t.Parallel() + + policy := NewNoopCentralAdmissionPolicy() + + // noop 取放行而非拒绝:本 policy 的职责是"在已申报向量的实验里再筛一遍", + // 缺省不筛等于保持引入本闸之前的语义(trigger 判据单独生效)。 + // 若取拒绝,开源部署一旦出现 EvalX trigger 就再也建不出 enforce 实验。 + allowed, err := policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{ + SpaceID: 123, + TargetType: "sandbox_agent", + TargetID: 456, + }) + require.NoError(t, err) + assert.True(t, allowed) + + // 零值 subject(skip-target 实验:无评测对象类型与 ID)同样放行。 + allowed, err = policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{}) + require.NoError(t, err) + assert.True(t, allowed) +} From 918f330b715cf9db17893bf3d01c52c84340ee9c Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 13:37:37 +0800 Subject: [PATCH 16/58] =?UTF-8?q?feat(evaluation):=20=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=9C=9F=E6=8C=89=20admission=20policy=20=E6=94=B6=E7=AA=84=20?= =?UTF-8?q?enforce=20=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigger_type=evalx 之上的第二道闸:只有 trigger 已判定为 EvalX 时才咨询 policy, 由它按空间 / 评测对象类型 / 评测对象 ID 决定"这个实验是否纳入本轮灰度"。 语义是 AND(收窄)而非 OR —— 理由见 ICentralAdmissionPolicy 的 port 注释。 CreateExpt 的判定链变成: trigger==evalx → policy 允许 → 校验并冻结消耗向量 → 解析并冻结 scope → enforce 任一环不成立即落 legacy(policy 不匹配是灰度常态,记 Info 不记 Warn)。 policy 返回 error 时**不降级放行**:那意味着配置读取本身坏了,放行会让实验在无灰度 控制的情况下进 enforce。与"不匹配"(正常结果,落 legacy)严格区分。 新增 EvalTargetType.ConfigName() 提供 snake_case 稳定名,供人工维护的 TCC 白名单使用。 与 String() 分开是受众不同:String() 是驼峰名给日志/错误用;ConfigName() 与 OpenAPI 公开枚举("sandbox_agent")和 CLI --target-type 取值一致,配置里写名字比写枚举数字 可读且不会因枚举调整而失配。未登记类型返回空串 —— 调用方应视为"不匹配任何配置项", 不得回落默认类型,否则新增类型会意外命中既有灰度规则。仅记录型(*Online)不给配置名: 它们不执行评测对象、不会进调度。 evaluation 全量 55 个包测试通过。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/application/wire_gen.go | 6 +- .../evaluation/domain/entity/target.go | 39 +++++++++ .../evaluation/domain/entity/target_test.go | 31 +++++++ .../domain/service/expt_manage_impl.go | 84 ++++++++++++++----- .../domain/service/expt_manage_impl_test.go | 1 + .../modules/evaluation/domain/service/wire.go | 1 + 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 8e0546906e..1b0d04ea74 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -177,7 +177,8 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider) + icentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, icentralAdmissionPolicy) v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, componentIConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() @@ -453,7 +454,8 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider) + icentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, icentralAdmissionPolicy) v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, iConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() diff --git a/backend/modules/evaluation/domain/entity/target.go b/backend/modules/evaluation/domain/entity/target.go index 30986afa78..c94db8aade 100644 --- a/backend/modules/evaluation/domain/entity/target.go +++ b/backend/modules/evaluation/domain/entity/target.go @@ -196,6 +196,45 @@ func (p EvalTargetType) SupptTrajectory() bool { } } +// ConfigName 返回该类型在人工维护的配置(TCC 灰度白名单等)中使用的稳定名称。 +// +// 与 String() 分开是因为两者的受众不同:String() 是驼峰名,用于日志和错误信息; +// 本方法返回 snake_case,与 OpenAPI 公开枚举(domain_openapi/eval_target.thrift 的 +// `EvalTargetType_SandboxAgent = "sandbox_agent"`)以及 CLI 的 --target-type 取值一致。 +// 配置里写 "sandbox_agent" 比写 17 可读得多,也不会因枚举值调整而失配。 +// +// 返回空串表示该类型未定义配置名 —— 调用方应视为"不匹配任何配置项", +// 而不是回落到某个默认类型(回落会让未登记的新类型意外命中灰度规则)。 +// 仅记录型(*Online)刻意不给配置名:它们不执行评测对象,不会进入调度。 +func (p EvalTargetType) ConfigName() string { + switch p { + case EvalTargetTypeCozeBot: + return "coze_bot" + case EvalTargetTypeLoopPrompt: + return "coze_loop_prompt" + case EvalTargetTypeLoopTrace: + return "trace" + case EvalTargetTypeCozeWorkflow: + return "coze_workflow" + case EvalTargetTypeVolcengineAgent: + return "volcengine_agent" + case EvalTargetTypeCustomRPCServer: + return "custom_rpc_server" + case EvalTargetTypeVolcengineAgentAgentkit: + return "volcengine_agent_agentkit" + case EvalTargetTypeWebAgent: + return "web_agent" + case EvalTargetTypeA2AAgent: + return "a2a_agent" + case EvalTargetTypeCustomAgent: + return "custom_agent" + case EvalTargetTypeSandboxAgent: + return "sandbox_agent" + default: + return "" + } +} + func EvalTargetTypePtr(v EvalTargetType) *EvalTargetType { return &v } func (p *EvalTargetType) Scan(value interface{}) (err error) { diff --git a/backend/modules/evaluation/domain/entity/target_test.go b/backend/modules/evaluation/domain/entity/target_test.go index 005a9af3f6..07f5400aa3 100644 --- a/backend/modules/evaluation/domain/entity/target_test.go +++ b/backend/modules/evaluation/domain/entity/target_test.go @@ -29,6 +29,37 @@ func TestEvalTargetType_String(t *testing.T) { assert.Equal(t, "", unknown.String()) } +func TestEvalTargetType_ConfigName(t *testing.T) { + t.Parallel() + + // 配置名必须与 OpenAPI 公开枚举(domain_openapi/eval_target.thrift)及 + // CLI --target-type 的取值逐字一致 —— TCC 里写的就是这些字符串, + // 拼错不会报错、只会静默不命中灰度规则。 + assert.Equal(t, "coze_bot", EvalTargetTypeCozeBot.ConfigName()) + assert.Equal(t, "coze_loop_prompt", EvalTargetTypeLoopPrompt.ConfigName()) + assert.Equal(t, "trace", EvalTargetTypeLoopTrace.ConfigName()) + assert.Equal(t, "coze_workflow", EvalTargetTypeCozeWorkflow.ConfigName()) + assert.Equal(t, "volcengine_agent", EvalTargetTypeVolcengineAgent.ConfigName()) + assert.Equal(t, "custom_rpc_server", EvalTargetTypeCustomRPCServer.ConfigName()) + assert.Equal(t, "volcengine_agent_agentkit", EvalTargetTypeVolcengineAgentAgentkit.ConfigName()) + assert.Equal(t, "web_agent", EvalTargetTypeWebAgent.ConfigName()) + assert.Equal(t, "a2a_agent", EvalTargetTypeA2AAgent.ConfigName()) + assert.Equal(t, "custom_agent", EvalTargetTypeCustomAgent.ConfigName()) + assert.Equal(t, "sandbox_agent", EvalTargetTypeSandboxAgent.ConfigName()) + + // 仅记录型不给配置名:它们不执行评测对象,不会进入调度。 + assert.Empty(t, EvalTargetTypeCozeBotOnline.ConfigName()) + assert.Empty(t, EvalTargetTypeCozeLoopPromptOnline.ConfigName()) + assert.Empty(t, EvalTargetTypeCozeWorkflowOnline.ConfigName()) + assert.Empty(t, EvalTargetTypeVolcengineAgentOnline.ConfigName()) + assert.Empty(t, EvalTargetTypeCustomRPCServerOnline.ConfigName()) + assert.Empty(t, EvalTargetTypeVolcengineAgentAgentkitOnline.ConfigName()) + + // 未登记类型返回空串而非回落到某个默认值:回落会让新类型意外命中灰度规则。 + var unregistered EvalTargetType = 99 + assert.Empty(t, unregistered.ConfigName()) +} + func TestEvalTargetType_SupptTrajectory(t *testing.T) { tests := []struct { name string diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index 035afea925..3923417185 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -70,6 +70,9 @@ func NewExptManager( // 开源部署注入 noop(返回空 Scope),配合"仅 EvalX trigger 才 enforce"的 admission, // 开源侧不会产生 enforce 实验,因此拿不到 Scope 无影响。 centralScopeProvider component.ICentralSchedulerScopeProvider, + // centralAdmissionPolicy 在 trigger 判据之上收窄 enforce 范围(按空间/评测对象类型/ID 灰度)。 + // 开源部署注入 noop(恒定放行)。 + centralAdmissionPolicy component.ICentralAdmissionPolicy, ) IExptManager { return &ExptMangerImpl{ // tupleSvc: tupleSvc, @@ -103,6 +106,7 @@ func NewExptManager( resourceAccessAuthorizer: resourceAccessAuthorizer, sandboxAgentMetrics: sandboxAgentMetrics, centralScopeProvider: centralScopeProvider, + centralAdmissionPolicy: centralAdmissionPolicy, } } @@ -140,6 +144,8 @@ type ExptMangerImpl struct { sandboxAgentMetrics metrics.SandboxAgentMetrics // centralScopeProvider 解析新建 enforce 实验要冻结的 scheduler_scope。 centralScopeProvider component.ICentralSchedulerScopeProvider + // centralAdmissionPolicy 在 trigger 判据之上收窄 enforce 范围。 + centralAdmissionPolicy component.ICentralAdmissionPolicy } func (e *ExptMangerImpl) MGetDetail(ctx context.Context, exptIDs []int64, spaceID int64, session *entity.Session) ([]*entity.Experiment, error) { @@ -1301,9 +1307,15 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP } // ★ 中心化调度模式在创建时一次性冻结。 // - // 只有 EvalX 发起的实验进入 enforce:它是内部平台,会按约定申报 priority 与 - // expected_quota_consumption。其它入口(控制台手动、OpenAPI、定时)保持 legacy, - // 行为与引入中心调度前完全一致。 + // 准入是两道闸的 AND: + // ① trigger 判据 —— 只有 EvalX 发起的实验有资格,因为它是内部平台,会按约定申报 + // priority 与 expected_quota_consumption。其它入口(控制台手动、OpenAPI、定时) + // 保持 legacy,行为与引入中心调度前完全一致。 + // ② admission policy —— 在①的基础上按空间 / 评测对象类型 / 评测对象 ID 收窄灰度范围。 + // + // 两道闸是 AND 而非 OR:policy 只能收窄、不能扩大。若 policy 能把非 EvalX 入口的实验 + // 也拽进 enforce,那些实验没有申报向量的字段,结果要么在下面的向量校验处报错、 + // 要么(若放宽校验)被调度器永远跳过 —— 后者表现为"实验建好了但一个 item 都不跑"。 // // 模式由 trigger 派生而非取请求字段:请求里的 scheduler_mode 不可信(任何内部调用方 // 都能声明 enforce),而 trigger_type 是上游身份的既有表达,已被其它逻辑依赖。 @@ -1311,26 +1323,36 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP schedulerScope := "" expectedQuota := req.ExpectedQuotaConsumption if entity.ShouldEnforceByTrigger(triggerType) { - // enforce 实验必须有合法的资源消耗向量:没有向量就无法预占额度, - // 调度器只能永远跳过它 —— 表现为"实验建好了但一个 item 都不跑",且那条分支静默。 - // 因此在创建时就拒绝,把问题暴露在调用方能看到的地方。 - if expectedQuota == nil { - return nil, errorx.NewByCode(errno.CommonInvalidParamCode, - errorx.WithExtraMsg("expected_quota_consumption is required for evalx-triggered experiment")) - } - expectedQuota = expectedQuota.Normalize() - if err := expectedQuota.Validate(); err != nil { + // 灰度收窄闸。先判 policy 再校验向量:policy 不放行时该实验走 legacy, + // 此时缺向量是正常的(EvalX 对未纳管空间也可能不传),不该报错。 + admitted, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple) + if err != nil { + // 配置不可判定时拒绝创建 enforce 实验,而不是放行或降级 legacy: + // 放行会让本该受额度管控的实验绕过管控;静默降级会让 EvalX 以为受管控。 return nil, err } + if admitted { + // enforce 实验必须有合法的资源消耗向量:没有向量就无法预占额度, + // 调度器只能永远跳过它 —— 表现为"实验建好了但一个 item 都不跑",且那条分支静默。 + // 因此在创建时就拒绝,把问题暴露在调用方能看到的地方。 + if expectedQuota == nil { + return nil, errorx.NewByCode(errno.CommonInvalidParamCode, + errorx.WithExtraMsg("expected_quota_consumption is required for evalx-triggered experiment")) + } + expectedQuota = expectedQuota.Normalize() + if err := expectedQuota.Validate(); err != nil { + return nil, err + } - scope, err := e.resolveSchedulerScope(ctx, req.WorkspaceID) - if err != nil { - // Scope 解析失败不静默降级成 legacy:降级会让 EvalX 以为实验受中心调度管控, - // 实际却走旧链路自主派发、绕过全局额度。报错让调用方立刻知道。 - return nil, err + scope, err := e.resolveSchedulerScope(ctx, req.WorkspaceID) + if err != nil { + // Scope 解析失败不静默降级成 legacy:降级会让 EvalX 以为实验受中心调度管控, + // 实际却走旧链路自主派发、绕过全局额度。报错让调用方立刻知道。 + return nil, err + } + dispatchMode = entity.ExptDispatchModeEnforce + schedulerScope = scope } - dispatchMode = entity.ExptDispatchModeEnforce - schedulerScope = scope } do := &entity.Experiment{ @@ -1761,6 +1783,30 @@ func (e *ExptMangerImpl) Clone(ctx context.Context, exptID, spaceID int64, sessi return expt, e.Create(ctx, expt, session) } +// allowCentralScheduling 判定该实验是否落在中心调度的灰度范围内。 +// +// 只在 trigger 已判定为 EvalX 后调用,因此它的语义是"收窄"而非"准入": +// 返回 false 表示该实验本轮不纳管,走 legacy —— 这是正常结果,不是错误。 +// +// policy 未注入时放行:保持引入本闸之前的行为(trigger 判据单独生效)。 +// 开源部署注入 noop 也是恒定放行,二者一致。 +func (e *ExptMangerImpl) allowCentralScheduling(ctx context.Context, spaceID int64, tuple *entity.ExptTuple) (bool, error) { + if e.centralAdmissionPolicy == nil { + return true, nil + } + + subject := component.CentralAdmissionSubject{SpaceID: spaceID} + // tuple.Target 为 nil 是合法场景:--skip-target 允许创建无评测对象的实验。 + // 此时 TargetType/TargetID 保持零值,由 policy 决定这类实验算不算命中 + // (通常不该命中按 target 维度配置的灰度规则)。 + if tuple != nil && tuple.Target != nil { + subject.TargetType = tuple.Target.EvalTargetType.ConfigName() + subject.TargetID = tuple.Target.ID + } + + return e.centralAdmissionPolicy.AllowCentralScheduling(ctx, subject) +} + // resolveSchedulerScope 解析新建 enforce 实验应冻结的 scheduler_scope。 // // 空 Scope 一律报错而非放行:enforce + 空 Scope 的实验不属于任何调度域,任何调度器的 diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go index 7de362002b..c6d3152357 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go @@ -1787,6 +1787,7 @@ func TestNewExptManager(t *testing.T) { nil, nil, component.NewNoopCentralSchedulerScopeProvider(), + component.NewNoopCentralAdmissionPolicy(), ) impl, ok := mgr.(*ExptMangerImpl) diff --git a/backend/modules/evaluation/domain/service/wire.go b/backend/modules/evaluation/domain/service/wire.go index 4904a3e511..a129c4ad0e 100644 --- a/backend/modules/evaluation/domain/service/wire.go +++ b/backend/modules/evaluation/domain/service/wire.go @@ -52,6 +52,7 @@ var ExperimentDomainServiceSet = wire.NewSet( component.NewNoopCentralReservationGuard, component.NewNoopCentralSchedulerScopeOwner, component.NewNoopCentralSchedulerScopeProvider, + component.NewNoopCentralAdmissionPolicy, // Infrastructure Sets taskrpc.TaskRPCSet, pipeline.PipelineRPCSet, From ccb11b2a7385a00ccf3fedd5ca607769fc092658 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 16:52:43 +0800 Subject: [PATCH 17/58] =?UTF-8?q?feat(evaluation):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=8C=96=E8=B0=83=E5=BA=A6=E8=AF=BB=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=EF=BC=88priority/mode/quota=EF=BC=89=EF=BC=8Cschedule?= =?UTF-8?q?r=5Fscope=20=E4=B8=8D=E5=A4=96=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IDL 早就预留了 domain/expt.thrift 的 116/117 并注明"中心化调度读视图", 但 ToExptDTO 从未赋值 —— 字段定义好了、管子没接,调用方只能查库才知道自己的实验 有没有被中心调度纳管、申报了多少额度。OpenAPI 面连字段都没有。 本次接通两套读模型: - domain/expt.thrift 补 118 expected_quota_consumption - domain_openapi/experiment.thrift 补 116~118 + 对等结构 ExpectedQuotaConsumption (另立一份而非 include domain,与 RunModeConfig/ExptEvalSetSourceType 同套模式,避免符号冲突) - ToExptDTO / DomainExperimentDTO2OpenAPI / entity 直转路径三处赋值 两个刻意的取舍: 1. legacy 实验也回显 priority_level / scheduler_mode,不做"仅 enforce 才给"的裁剪。 放量期最高频的疑问是"我这个实验为什么没进中心调度",那时最需要看的恰恰是一个 legacy 实验的 scheduler_mode —— 回显 legacy 就一眼确认没纳管,不必去猜是 trigger 没带 evalx、灰度范围没命中还是代码没生效。若只有 enforce 才给,调用方还无法分辨 "这实验是 legacy"与"这接口版本不支持该字段"。二者都有 DB 默认值,天然非空。 2. scheduler_scope 一律不进读模型。它是不透明调度域 ID(形如 fornax_cn_prod), 对调用方没有可用语义却泄露部署拓扑;业务代码本就不允许解析 Scope 字符串, 回显只会诱使调用方依赖这个不稳定契约。内部运维需要时直接查表。 TestToExptDTO_SchedulerScopeNeverExposed 用整个 DTO 的字符串形式做断言, 而不是只查某个已知字段 —— 将来新增字段若误带 scope 也会被挡住。 expected_quota_consumption 取"有则回显、无则省略":legacy 确实没申报, 省略比返回空结构更如实;调用方据此区分"没申报"与"申报了空向量"(数据异常)。 entity→openapi 走 entity→domain→openapi 两跳而非直转,复用 Normalize* 的 历史/异常取值收敛(0→1、越界夹取、非法模式→legacy),避免规则复制两份后漂移。 --- .../coze/loop/evaluation/domain/expt/expt.go | 71 ++ .../evaluation/domain/expt/expt_validator.go | 5 + .../loop/evaluation/domain/expt/k-expt.go | 55 ++ .../domain_openapi/experiment/experiment.go | 778 +++++++++++++++++- .../experiment/experiment_validator.go | 11 + .../domain_openapi/experiment/k-experiment.go | 542 ++++++++++++ .../application/convertor/experiment/expt.go | 48 ++ .../expt_scheduling_readview_test.go | 180 ++++ .../convertor/experiment/openapi.go | 45 + .../experiment/openapi_new_fields_test.go | 72 ++ .../coze/loop/evaluation/domain/expt.thrift | 7 + .../domain_openapi/experiment.thrift | 29 + 12 files changed, 1841 insertions(+), 2 deletions(-) create mode 100644 backend/modules/evaluation/application/convertor/experiment/expt_scheduling_readview_test.go diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go index 9257a18b9c..e882d5828e 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go @@ -2792,6 +2792,9 @@ type Experiment struct { PriorityLevel *int32 `thrift:"priority_level,116,optional" frugal:"116,optional,i32" form:"priority_level" json:"priority_level,omitempty" query:"priority_level"` // 执行模式回显: legacy(旧 per-experiment 链路) / enforce(中心调度); 直读 experiment 表同名列, 该列是唯一权威源 SchedulerMode *string `thrift:"scheduler_mode,117,optional" frugal:"117,optional,string" form:"scheduler_mode" json:"scheduler_mode,omitempty" query:"scheduler_mode"` + // 单 item 预期资源消耗向量回显: 从 experiment.eval_conf 反序列化, 与 Create/Submit 入参同构。 + // "有则回显、无则省略": legacy 实验确实没申报向量, 省略比返回空结构更如实。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption `thrift:"expected_quota_consumption,118,optional" frugal:"118,optional,ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty" query:"expected_quota_consumption"` } func NewExperiment() *Experiment { @@ -3388,6 +3391,18 @@ func (p *Experiment) GetSchedulerMode() (v string) { } return *p.SchedulerMode } + +var Experiment_ExpectedQuotaConsumption_DEFAULT *ExpectedQuotaConsumption + +func (p *Experiment) GetExpectedQuotaConsumption() (v *ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return Experiment_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} func (p *Experiment) SetID(val *int64) { p.ID = val } @@ -3535,6 +3550,9 @@ func (p *Experiment) SetPriorityLevel(val *int32) { func (p *Experiment) SetSchedulerMode(val *string) { p.SchedulerMode = val } +func (p *Experiment) SetExpectedQuotaConsumption(val *ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} var fieldIDToName_Experiment = map[int16]string{ 1: "id", @@ -3586,6 +3604,7 @@ var fieldIDToName_Experiment = map[int16]string{ 115: "run_mode_config", 116: "priority_level", 117: "scheduler_mode", + 118: "expected_quota_consumption", } func (p *Experiment) IsSetID() bool { @@ -3784,6 +3803,10 @@ func (p *Experiment) IsSetSchedulerMode() bool { return p.SchedulerMode != nil } +func (p *Experiment) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + func (p *Experiment) Read(iprot thrift.TProtocol) (err error) { var fieldTypeId thrift.TType var fieldId int16 @@ -4194,6 +4217,14 @@ func (p *Experiment) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 118: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField118(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } default: if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError @@ -4824,6 +4855,14 @@ func (p *Experiment) ReadField117(iprot thrift.TProtocol) error { p.SchedulerMode = _field return nil } +func (p *Experiment) ReadField118(iprot thrift.TProtocol) error { + _field := NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} func (p *Experiment) Write(oprot thrift.TProtocol) (err error) { var fieldId int16 @@ -5027,6 +5066,10 @@ func (p *Experiment) Write(oprot thrift.TProtocol) (err error) { fieldId = 117 goto WriteFieldError } + if err = p.writeField118(oprot); err != nil { + fieldId = 118 + goto WriteFieldError + } } if err = oprot.WriteFieldStop(); err != nil { goto WriteFieldStopError @@ -5986,6 +6029,24 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 117 end error: ", p), err) } +func (p *Experiment) writeField118(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 118); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 118 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 118 end error: ", p), err) +} func (p *Experiment) String() string { if p == nil { @@ -6148,6 +6209,9 @@ func (p *Experiment) DeepEqual(ano *Experiment) bool { if !p.Field117DeepEqual(ano.SchedulerMode) { return false } + if !p.Field118DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } return true } @@ -6691,6 +6755,13 @@ func (p *Experiment) Field117DeepEqual(src *string) bool { } return true } +func (p *Experiment) Field118DeepEqual(src *ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} // 实验模板基础信息 type ExptTemplateMeta struct { diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go index 63f24f12f0..1450685c1e 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt_validator.go @@ -86,6 +86,11 @@ func (p *Experiment) IsValid() error { return fmt.Errorf("field RunModeConfig not valid, %w", err) } } + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } return nil } func (p *ExptTemplateMeta) IsValid() error { diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go index 113507c246..6043e809e3 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go @@ -1743,6 +1743,20 @@ func (p *Experiment) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 118: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField118(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } default: l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) offset += l @@ -2518,6 +2532,18 @@ func (p *Experiment) FastReadField117(buf []byte) (int, error) { return offset, nil } +func (p *Experiment) FastReadField118(buf []byte) (int, error) { + offset := 0 + _field := NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + func (p *Experiment) FastWrite(buf []byte) int { return p.FastWriteNocopy(buf, nil) } @@ -2574,6 +2600,7 @@ func (p *Experiment) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { offset += p.fastWriteField112(buf[offset:], w) offset += p.fastWriteField115(buf[offset:], w) offset += p.fastWriteField117(buf[offset:], w) + offset += p.fastWriteField118(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) return offset @@ -2631,6 +2658,7 @@ func (p *Experiment) BLength() int { l += p.field115Length() l += p.field116Length() l += p.field117Length() + l += p.field118Length() } l += thrift.Binary.FieldStopLength() return l @@ -3127,6 +3155,15 @@ func (p *Experiment) fastWriteField117(buf []byte, w thrift.NocopyWriter) int { return offset } +func (p *Experiment) fastWriteField118(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 118) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + func (p *Experiment) field1Length() int { l := 0 if p.IsSetID() { @@ -3596,6 +3633,15 @@ func (p *Experiment) field117Length() int { return l } +func (p *Experiment) field118Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + func (p *Experiment) DeepCopy(s interface{}) error { src, ok := s.(*Experiment) if !ok { @@ -3981,6 +4027,15 @@ func (p *Experiment) DeepCopy(s interface{}) error { p.SchedulerMode = &tmp } + var _expectedQuotaConsumption *ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + return nil } diff --git a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go index ce0a68cf20..cda517c2a5 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go @@ -7647,8 +7647,19 @@ type Experiment struct { // 跑法配置回显。字段号 115 与 domain/expt.thrift 的 struct Experiment 刻意对齐, 便于两套 // 读模型对照 —— 它们是同一个概念的两种表示 (本文件用字符串枚举, domain 用整数枚举)。 // 用本文件已有的 RunModeConfig (字符串枚举), 不要 include domain/expt.thrift —— 会符号冲突。 - RunModeConfig *RunModeConfig `thrift:"run_mode_config,115,optional" frugal:"115,optional,RunModeConfig" form:"run_mode_config" json:"run_mode_config,omitempty" query:"run_mode_config"` - BaseInfo *common.BaseInfo `thrift:"base_info,100,optional" frugal:"100,optional,common.BaseInfo" form:"base_info" json:"base_info,omitempty" query:"base_info"` + RunModeConfig *RunModeConfig `thrift:"run_mode_config,115,optional" frugal:"115,optional,RunModeConfig" form:"run_mode_config" json:"run_mode_config,omitempty" query:"run_mode_config"` + // ★ 中心化调度读视图。字段号 116~118 同样与 domain/expt.thrift 对齐。 + // 调度优先级 (1-99, 越大越优先); 历史数据为 1。 + // 注: 它只影响 scheduler_mode=enforce 的实验; legacy 实验此值虽有 (默认 1) 但不参与调度排序。 + PriorityLevel *int32 `thrift:"priority_level,116,optional" frugal:"116,optional,i32" form:"priority_level" json:"priority_level,omitempty" query:"priority_level"` + // 执行模式: legacy(旧 per-experiment 链路) / enforce(中心调度)。 + // **legacy 实验也回显**: "为什么我的实验没进中心调度"是最高频疑问, 回显 legacy 可一眼确认。 + SchedulerMode *string `thrift:"scheduler_mode,117,optional" frugal:"117,optional,string" form:"scheduler_mode" json:"scheduler_mode,omitempty" query:"scheduler_mode"` + // 单 item 预期资源消耗向量; "有则回显、无则省略"(legacy 实验确实没申报)。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption `thrift:"expected_quota_consumption,118,optional" frugal:"118,optional,ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty" query:"expected_quota_consumption"` + // 注: scheduler_scope **不进读模型**。它是不透明调度域 ID, 对调用方无可用语义却泄露部署拓扑; + // 内部运维需要时直接查 experiment.scheduler_scope 列。 + BaseInfo *common.BaseInfo `thrift:"base_info,100,optional" frugal:"100,optional,common.BaseInfo" form:"base_info" json:"base_info,omitempty" query:"base_info"` } func NewExperiment() *Experiment { @@ -7970,6 +7981,42 @@ func (p *Experiment) GetRunModeConfig() (v *RunModeConfig) { return p.RunModeConfig } +var Experiment_PriorityLevel_DEFAULT int32 + +func (p *Experiment) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return Experiment_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var Experiment_SchedulerMode_DEFAULT string + +func (p *Experiment) GetSchedulerMode() (v string) { + if p == nil { + return + } + if !p.IsSetSchedulerMode() { + return Experiment_SchedulerMode_DEFAULT + } + return *p.SchedulerMode +} + +var Experiment_ExpectedQuotaConsumption_DEFAULT *ExpectedQuotaConsumption + +func (p *Experiment) GetExpectedQuotaConsumption() (v *ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return Experiment_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + var Experiment_BaseInfo_DEFAULT *common.BaseInfo func (p *Experiment) GetBaseInfo() (v *common.BaseInfo) { @@ -8059,6 +8106,15 @@ func (p *Experiment) SetTotalItemCount(val *int64) { func (p *Experiment) SetRunModeConfig(val *RunModeConfig) { p.RunModeConfig = val } +func (p *Experiment) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *Experiment) SetSchedulerMode(val *string) { + p.SchedulerMode = val +} +func (p *Experiment) SetExpectedQuotaConsumption(val *ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} func (p *Experiment) SetBaseInfo(val *common.BaseInfo) { p.BaseInfo = val } @@ -8090,6 +8146,9 @@ var fieldIDToName_Experiment = map[int16]string{ 113: "evaluators_concur_num", 114: "total_item_count", 115: "run_mode_config", + 116: "priority_level", + 117: "scheduler_mode", + 118: "expected_quota_consumption", 100: "base_info", } @@ -8197,6 +8256,18 @@ func (p *Experiment) IsSetRunModeConfig() bool { return p.RunModeConfig != nil } +func (p *Experiment) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *Experiment) IsSetSchedulerMode() bool { + return p.SchedulerMode != nil +} + +func (p *Experiment) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + func (p *Experiment) IsSetBaseInfo() bool { return p.BaseInfo != nil } @@ -8427,6 +8498,30 @@ func (p *Experiment) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 116: + if fieldTypeId == thrift.I32 { + if err = p.ReadField116(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 117: + if fieldTypeId == thrift.STRING { + if err = p.ReadField117(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 118: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField118(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 100: if fieldTypeId == thrift.STRUCT { if err = p.ReadField100(iprot); err != nil { @@ -8774,6 +8869,36 @@ func (p *Experiment) ReadField115(iprot thrift.TProtocol) error { p.RunModeConfig = _field return nil } +func (p *Experiment) ReadField116(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *Experiment) ReadField117(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.SchedulerMode = _field + return nil +} +func (p *Experiment) ReadField118(iprot thrift.TProtocol) error { + _field := NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} func (p *Experiment) ReadField100(iprot thrift.TProtocol) error { _field := common.NewBaseInfo() if err := _field.Read(iprot); err != nil { @@ -8893,6 +9018,18 @@ func (p *Experiment) Write(oprot thrift.TProtocol) (err error) { fieldId = 115 goto WriteFieldError } + if err = p.writeField116(oprot); err != nil { + fieldId = 116 + goto WriteFieldError + } + if err = p.writeField117(oprot); err != nil { + fieldId = 117 + goto WriteFieldError + } + if err = p.writeField118(oprot); err != nil { + fieldId = 118 + goto WriteFieldError + } if err = p.writeField100(oprot); err != nil { fieldId = 100 goto WriteFieldError @@ -9415,6 +9552,60 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 115 end error: ", p), err) } +func (p *Experiment) writeField116(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 116); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 116 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 116 end error: ", p), err) +} +func (p *Experiment) writeField117(oprot thrift.TProtocol) (err error) { + if p.IsSetSchedulerMode() { + if err = oprot.WriteFieldBegin("scheduler_mode", thrift.STRING, 117); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.SchedulerMode); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 117 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 117 end error: ", p), err) +} +func (p *Experiment) writeField118(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 118); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 118 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 118 end error: ", p), err) +} func (p *Experiment) writeField100(oprot thrift.TProtocol) (err error) { if p.IsSetBaseInfo() { if err = oprot.WriteFieldBegin("base_info", thrift.STRUCT, 100); err != nil { @@ -9526,6 +9717,15 @@ func (p *Experiment) DeepEqual(ano *Experiment) bool { if !p.Field115DeepEqual(ano.RunModeConfig) { return false } + if !p.Field116DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field117DeepEqual(ano.SchedulerMode) { + return false + } + if !p.Field118DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } if !p.Field100DeepEqual(ano.BaseInfo) { return false } @@ -9808,6 +10008,37 @@ func (p *Experiment) Field115DeepEqual(src *RunModeConfig) bool { } return true } +func (p *Experiment) Field116DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *Experiment) Field117DeepEqual(src *string) bool { + + if p.SchedulerMode == src { + return true + } else if p.SchedulerMode == nil || src == nil { + return false + } + if strings.Compare(*p.SchedulerMode, *src) != 0 { + return false + } + return true +} +func (p *Experiment) Field118DeepEqual(src *ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} func (p *Experiment) Field100DeepEqual(src *common.BaseInfo) bool { if !p.BaseInfo.DeepEqual(src) { @@ -9816,6 +10047,549 @@ func (p *Experiment) Field100DeepEqual(src *common.BaseInfo) bool { return true } +// 单资源预期消耗。与 domain/expt.thrift 的 ExpectedResourceConsumption 同构 —— +// 本文件另立一份而非 include, 与 RunModeConfig / ExptEvalSetSourceType 同套模式, 避免符号冲突。 +type ExpectedResourceConsumption struct { + // 资源类别: sandbox / agent_account / model / evaluator + Category *string `thrift:"category,1,optional" frugal:"1,optional,string" form:"category" json:"category,omitempty" query:"category"` + // 资源标识: default / doubao_pro / gpt5.5 等 + ResourceKey *string `thrift:"resource_key,2,optional" frugal:"2,optional,string" form:"resource_key" json:"resource_key,omitempty" query:"resource_key"` + // 单 item 预期占用量; 单位由服务端 TCC 资源配置定义, 不由调用方指定 + Amount *int64 `thrift:"amount,3,optional" frugal:"3,optional,i64" json:"amount" form:"amount" query:"amount"` +} + +func NewExpectedResourceConsumption() *ExpectedResourceConsumption { + return &ExpectedResourceConsumption{} +} + +func (p *ExpectedResourceConsumption) InitDefault() { +} + +var ExpectedResourceConsumption_Category_DEFAULT string + +func (p *ExpectedResourceConsumption) GetCategory() (v string) { + if p == nil { + return + } + if !p.IsSetCategory() { + return ExpectedResourceConsumption_Category_DEFAULT + } + return *p.Category +} + +var ExpectedResourceConsumption_ResourceKey_DEFAULT string + +func (p *ExpectedResourceConsumption) GetResourceKey() (v string) { + if p == nil { + return + } + if !p.IsSetResourceKey() { + return ExpectedResourceConsumption_ResourceKey_DEFAULT + } + return *p.ResourceKey +} + +var ExpectedResourceConsumption_Amount_DEFAULT int64 + +func (p *ExpectedResourceConsumption) GetAmount() (v int64) { + if p == nil { + return + } + if !p.IsSetAmount() { + return ExpectedResourceConsumption_Amount_DEFAULT + } + return *p.Amount +} +func (p *ExpectedResourceConsumption) SetCategory(val *string) { + p.Category = val +} +func (p *ExpectedResourceConsumption) SetResourceKey(val *string) { + p.ResourceKey = val +} +func (p *ExpectedResourceConsumption) SetAmount(val *int64) { + p.Amount = val +} + +var fieldIDToName_ExpectedResourceConsumption = map[int16]string{ + 1: "category", + 2: "resource_key", + 3: "amount", +} + +func (p *ExpectedResourceConsumption) IsSetCategory() bool { + return p.Category != nil +} + +func (p *ExpectedResourceConsumption) IsSetResourceKey() bool { + return p.ResourceKey != nil +} + +func (p *ExpectedResourceConsumption) IsSetAmount() bool { + return p.Amount != nil +} + +func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { + var fieldTypeId thrift.TType + var fieldId int16 + + if _, err = iprot.ReadStructBegin(); err != nil { + goto ReadStructBeginError + } + + for { + _, fieldTypeId, fieldId, err = iprot.ReadFieldBegin() + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + + switch fieldId { + case 1: + if fieldTypeId == thrift.STRING { + if err = p.ReadField1(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 2: + if fieldTypeId == thrift.STRING { + if err = p.ReadField2(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 3: + if fieldTypeId == thrift.I64 { + if err = p.ReadField3(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + default: + if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + } + if err = iprot.ReadFieldEnd(); err != nil { + goto ReadFieldEndError + } + } + if err = iprot.ReadStructEnd(); err != nil { + goto ReadStructEndError + } + + return nil +ReadStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T read struct begin error: ", p), err) +ReadFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedResourceConsumption[fieldId]), err) +SkipFieldError: + return thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) + +ReadFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T read field end error", p), err) +ReadStructEndError: + return thrift.PrependError(fmt.Sprintf("%T read struct end error: ", p), err) +} + +func (p *ExpectedResourceConsumption) ReadField1(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.Category = _field + return nil +} +func (p *ExpectedResourceConsumption) ReadField2(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.ResourceKey = _field + return nil +} +func (p *ExpectedResourceConsumption) ReadField3(iprot thrift.TProtocol) error { + + var _field *int64 + if v, err := iprot.ReadI64(); err != nil { + return err + } else { + _field = &v + } + p.Amount = _field + return nil +} + +func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) { + var fieldId int16 + if err = oprot.WriteStructBegin("ExpectedResourceConsumption"); err != nil { + goto WriteStructBeginError + } + if p != nil { + if err = p.writeField1(oprot); err != nil { + fieldId = 1 + goto WriteFieldError + } + if err = p.writeField2(oprot); err != nil { + fieldId = 2 + goto WriteFieldError + } + if err = p.writeField3(oprot); err != nil { + fieldId = 3 + goto WriteFieldError + } + } + if err = oprot.WriteFieldStop(); err != nil { + goto WriteFieldStopError + } + if err = oprot.WriteStructEnd(); err != nil { + goto WriteStructEndError + } + return nil +WriteStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T write struct begin error: ", p), err) +WriteFieldError: + return thrift.PrependError(fmt.Sprintf("%T write field %d error: ", p, fieldId), err) +WriteFieldStopError: + return thrift.PrependError(fmt.Sprintf("%T write field stop error: ", p), err) +WriteStructEndError: + return thrift.PrependError(fmt.Sprintf("%T write struct end error: ", p), err) +} + +func (p *ExpectedResourceConsumption) writeField1(oprot thrift.TProtocol) (err error) { + if p.IsSetCategory() { + if err = oprot.WriteFieldBegin("category", thrift.STRING, 1); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.Category); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 end error: ", p), err) +} +func (p *ExpectedResourceConsumption) writeField2(oprot thrift.TProtocol) (err error) { + if p.IsSetResourceKey() { + if err = oprot.WriteFieldBegin("resource_key", thrift.STRING, 2); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.ResourceKey); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 2 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 2 end error: ", p), err) +} +func (p *ExpectedResourceConsumption) writeField3(oprot thrift.TProtocol) (err error) { + if p.IsSetAmount() { + if err = oprot.WriteFieldBegin("amount", thrift.I64, 3); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI64(*p.Amount); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 3 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 3 end error: ", p), err) +} + +func (p *ExpectedResourceConsumption) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("ExpectedResourceConsumption(%+v)", *p) + +} + +func (p *ExpectedResourceConsumption) DeepEqual(ano *ExpectedResourceConsumption) bool { + if p == ano { + return true + } else if p == nil || ano == nil { + return false + } + if !p.Field1DeepEqual(ano.Category) { + return false + } + if !p.Field2DeepEqual(ano.ResourceKey) { + return false + } + if !p.Field3DeepEqual(ano.Amount) { + return false + } + return true +} + +func (p *ExpectedResourceConsumption) Field1DeepEqual(src *string) bool { + + if p.Category == src { + return true + } else if p.Category == nil || src == nil { + return false + } + if strings.Compare(*p.Category, *src) != 0 { + return false + } + return true +} +func (p *ExpectedResourceConsumption) Field2DeepEqual(src *string) bool { + + if p.ResourceKey == src { + return true + } else if p.ResourceKey == nil || src == nil { + return false + } + if strings.Compare(*p.ResourceKey, *src) != 0 { + return false + } + return true +} +func (p *ExpectedResourceConsumption) Field3DeepEqual(src *int64) bool { + + if p.Amount == src { + return true + } else if p.Amount == nil || src == nil { + return false + } + if *p.Amount != *src { + return false + } + return true +} + +// 单 item 的多资源预期消耗向量。 +type ExpectedQuotaConsumption struct { + Resources []*ExpectedResourceConsumption `thrift:"resources,1,optional" frugal:"1,optional,list" form:"resources" json:"resources,omitempty" query:"resources"` +} + +func NewExpectedQuotaConsumption() *ExpectedQuotaConsumption { + return &ExpectedQuotaConsumption{} +} + +func (p *ExpectedQuotaConsumption) InitDefault() { +} + +var ExpectedQuotaConsumption_Resources_DEFAULT []*ExpectedResourceConsumption + +func (p *ExpectedQuotaConsumption) GetResources() (v []*ExpectedResourceConsumption) { + if p == nil { + return + } + if !p.IsSetResources() { + return ExpectedQuotaConsumption_Resources_DEFAULT + } + return p.Resources +} +func (p *ExpectedQuotaConsumption) SetResources(val []*ExpectedResourceConsumption) { + p.Resources = val +} + +var fieldIDToName_ExpectedQuotaConsumption = map[int16]string{ + 1: "resources", +} + +func (p *ExpectedQuotaConsumption) IsSetResources() bool { + return p.Resources != nil +} + +func (p *ExpectedQuotaConsumption) Read(iprot thrift.TProtocol) (err error) { + var fieldTypeId thrift.TType + var fieldId int16 + + if _, err = iprot.ReadStructBegin(); err != nil { + goto ReadStructBeginError + } + + for { + _, fieldTypeId, fieldId, err = iprot.ReadFieldBegin() + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + + switch fieldId { + case 1: + if fieldTypeId == thrift.LIST { + if err = p.ReadField1(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + default: + if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + } + if err = iprot.ReadFieldEnd(); err != nil { + goto ReadFieldEndError + } + } + if err = iprot.ReadStructEnd(); err != nil { + goto ReadStructEndError + } + + return nil +ReadStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T read struct begin error: ", p), err) +ReadFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedQuotaConsumption[fieldId]), err) +SkipFieldError: + return thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) + +ReadFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T read field end error", p), err) +ReadStructEndError: + return thrift.PrependError(fmt.Sprintf("%T read struct end error: ", p), err) +} + +func (p *ExpectedQuotaConsumption) ReadField1(iprot thrift.TProtocol) error { + _, size, err := iprot.ReadListBegin() + if err != nil { + return err + } + _field := make([]*ExpectedResourceConsumption, 0, size) + values := make([]ExpectedResourceConsumption, size) + for i := 0; i < size; i++ { + _elem := &values[i] + _elem.InitDefault() + + if err := _elem.Read(iprot); err != nil { + return err + } + + _field = append(_field, _elem) + } + if err := iprot.ReadListEnd(); err != nil { + return err + } + p.Resources = _field + return nil +} + +func (p *ExpectedQuotaConsumption) Write(oprot thrift.TProtocol) (err error) { + var fieldId int16 + if err = oprot.WriteStructBegin("ExpectedQuotaConsumption"); err != nil { + goto WriteStructBeginError + } + if p != nil { + if err = p.writeField1(oprot); err != nil { + fieldId = 1 + goto WriteFieldError + } + } + if err = oprot.WriteFieldStop(); err != nil { + goto WriteFieldStopError + } + if err = oprot.WriteStructEnd(); err != nil { + goto WriteStructEndError + } + return nil +WriteStructBeginError: + return thrift.PrependError(fmt.Sprintf("%T write struct begin error: ", p), err) +WriteFieldError: + return thrift.PrependError(fmt.Sprintf("%T write field %d error: ", p, fieldId), err) +WriteFieldStopError: + return thrift.PrependError(fmt.Sprintf("%T write field stop error: ", p), err) +WriteStructEndError: + return thrift.PrependError(fmt.Sprintf("%T write struct end error: ", p), err) +} + +func (p *ExpectedQuotaConsumption) writeField1(oprot thrift.TProtocol) (err error) { + if p.IsSetResources() { + if err = oprot.WriteFieldBegin("resources", thrift.LIST, 1); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteListBegin(thrift.STRUCT, len(p.Resources)); err != nil { + return err + } + for _, v := range p.Resources { + if err := v.Write(oprot); err != nil { + return err + } + } + if err := oprot.WriteListEnd(); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 1 end error: ", p), err) +} + +func (p *ExpectedQuotaConsumption) String() string { + if p == nil { + return "" + } + return fmt.Sprintf("ExpectedQuotaConsumption(%+v)", *p) + +} + +func (p *ExpectedQuotaConsumption) DeepEqual(ano *ExpectedQuotaConsumption) bool { + if p == ano { + return true + } else if p == nil || ano == nil { + return false + } + if !p.Field1DeepEqual(ano.Resources) { + return false + } + return true +} + +func (p *ExpectedQuotaConsumption) Field1DeepEqual(src []*ExpectedResourceConsumption) bool { + + if len(p.Resources) != len(src) { + return false + } + for i, v := range p.Resources { + _src := src[i] + if !v.DeepEqual(_src) { + return false + } + } + return true +} + // 列定义 - 评测集字段 type ColumnEvalSetField struct { Key *string `thrift:"key,1,optional" frugal:"1,optional,string" form:"key" json:"key,omitempty" query:"key"` diff --git a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment_validator.go b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment_validator.go index f3251d807e..d5a6d87de1 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment_validator.go @@ -160,6 +160,11 @@ func (p *Experiment) IsValid() error { return fmt.Errorf("field RunModeConfig not valid, %w", err) } } + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.BaseInfo != nil { if err := p.BaseInfo.IsValid(); err != nil { return fmt.Errorf("field BaseInfo not valid, %w", err) @@ -167,6 +172,12 @@ func (p *Experiment) IsValid() error { } return nil } +func (p *ExpectedResourceConsumption) IsValid() error { + return nil +} +func (p *ExpectedQuotaConsumption) IsValid() error { + return nil +} func (p *ColumnEvalSetField) IsValid() error { return nil } diff --git a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go index bcbf54fc83..82d6d74ca2 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go @@ -5643,6 +5643,48 @@ func (p *Experiment) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 116: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField116(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 117: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField117(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 118: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField118(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 100: if fieldTypeId == thrift.STRUCT { l, err = p.FastReadField100(buf[offset:]) @@ -6067,6 +6109,46 @@ func (p *Experiment) FastReadField115(buf []byte) (int, error) { return offset, nil } +func (p *Experiment) FastReadField116(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *Experiment) FastReadField117(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.SchedulerMode = _field + return offset, nil +} + +func (p *Experiment) FastReadField118(buf []byte) (int, error) { + offset := 0 + _field := NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + func (p *Experiment) FastReadField100(buf []byte) (int, error) { offset := 0 _field := common.NewBaseInfo() @@ -6094,6 +6176,7 @@ func (p *Experiment) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { offset += p.fastWriteField60(buf[offset:], w) offset += p.fastWriteField113(buf[offset:], w) offset += p.fastWriteField114(buf[offset:], w) + offset += p.fastWriteField116(buf[offset:], w) offset += p.fastWriteField2(buf[offset:], w) offset += p.fastWriteField3(buf[offset:], w) offset += p.fastWriteField4(buf[offset:], w) @@ -6112,6 +6195,8 @@ func (p *Experiment) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { offset += p.fastWriteField111(buf[offset:], w) offset += p.fastWriteField112(buf[offset:], w) offset += p.fastWriteField115(buf[offset:], w) + offset += p.fastWriteField117(buf[offset:], w) + offset += p.fastWriteField118(buf[offset:], w) offset += p.fastWriteField100(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) @@ -6147,6 +6232,9 @@ func (p *Experiment) BLength() int { l += p.field113Length() l += p.field114Length() l += p.field115Length() + l += p.field116Length() + l += p.field117Length() + l += p.field118Length() l += p.field100Length() } l += thrift.Binary.FieldStopLength() @@ -6415,6 +6503,33 @@ func (p *Experiment) fastWriteField115(buf []byte, w thrift.NocopyWriter) int { return offset } +func (p *Experiment) fastWriteField116(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 116) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *Experiment) fastWriteField117(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSchedulerMode() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 117) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.SchedulerMode) + } + return offset +} + +func (p *Experiment) fastWriteField118(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 118) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + func (p *Experiment) fastWriteField100(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetBaseInfo() { @@ -6674,6 +6789,33 @@ func (p *Experiment) field115Length() int { return l } +func (p *Experiment) field116Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *Experiment) field117Length() int { + l := 0 + if p.IsSetSchedulerMode() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.SchedulerMode) + } + return l +} + +func (p *Experiment) field118Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + func (p *Experiment) field100Length() int { l := 0 if p.IsSetBaseInfo() { @@ -6900,6 +7042,28 @@ func (p *Experiment) DeepCopy(s interface{}) error { } p.RunModeConfig = _runModeConfig + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + if src.SchedulerMode != nil { + var tmp string + if *src.SchedulerMode != "" { + tmp = kutils.StringDeepCopy(*src.SchedulerMode) + } + p.SchedulerMode = &tmp + } + + var _expectedQuotaConsumption *ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + var _baseInfo *common.BaseInfo if src.BaseInfo != nil { _baseInfo = &common.BaseInfo{} @@ -6912,6 +7076,384 @@ func (p *Experiment) DeepCopy(s interface{}) error { return nil } +func (p *ExpectedResourceConsumption) FastRead(buf []byte) (int, error) { + + var err error + var offset int + var l int + var fieldTypeId thrift.TType + var fieldId int16 + for { + fieldTypeId, fieldId, l, err = thrift.Binary.ReadFieldBegin(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + switch fieldId { + case 1: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField1(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 2: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField2(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 3: + if fieldTypeId == thrift.I64 { + l, err = p.FastReadField3(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + default: + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + } + + return offset, nil +ReadFieldBeginError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedResourceConsumption[fieldId]), err) +SkipFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) +} + +func (p *ExpectedResourceConsumption) FastReadField1(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.Category = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastReadField2(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.ResourceKey = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastReadField3(buf []byte) (int, error) { + offset := 0 + + var _field *int64 + if v, l, err := thrift.Binary.ReadI64(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.Amount = _field + return offset, nil +} + +func (p *ExpectedResourceConsumption) FastWrite(buf []byte) int { + return p.FastWriteNocopy(buf, nil) +} + +func (p *ExpectedResourceConsumption) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p != nil { + offset += p.fastWriteField3(buf[offset:], w) + offset += p.fastWriteField1(buf[offset:], w) + offset += p.fastWriteField2(buf[offset:], w) + } + offset += thrift.Binary.WriteFieldStop(buf[offset:]) + return offset +} + +func (p *ExpectedResourceConsumption) BLength() int { + l := 0 + if p != nil { + l += p.field1Length() + l += p.field2Length() + l += p.field3Length() + } + l += thrift.Binary.FieldStopLength() + return l +} + +func (p *ExpectedResourceConsumption) fastWriteField1(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetCategory() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 1) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.Category) + } + return offset +} + +func (p *ExpectedResourceConsumption) fastWriteField2(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetResourceKey() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 2) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.ResourceKey) + } + return offset +} + +func (p *ExpectedResourceConsumption) fastWriteField3(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetAmount() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I64, 3) + offset += thrift.Binary.WriteI64(buf[offset:], *p.Amount) + } + return offset +} + +func (p *ExpectedResourceConsumption) field1Length() int { + l := 0 + if p.IsSetCategory() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.Category) + } + return l +} + +func (p *ExpectedResourceConsumption) field2Length() int { + l := 0 + if p.IsSetResourceKey() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.ResourceKey) + } + return l +} + +func (p *ExpectedResourceConsumption) field3Length() int { + l := 0 + if p.IsSetAmount() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I64Length() + } + return l +} + +func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { + src, ok := s.(*ExpectedResourceConsumption) + if !ok { + return fmt.Errorf("%T's type not matched %T", s, p) + } + + if src.Category != nil { + var tmp string + if *src.Category != "" { + tmp = kutils.StringDeepCopy(*src.Category) + } + p.Category = &tmp + } + + if src.ResourceKey != nil { + var tmp string + if *src.ResourceKey != "" { + tmp = kutils.StringDeepCopy(*src.ResourceKey) + } + p.ResourceKey = &tmp + } + + if src.Amount != nil { + tmp := *src.Amount + p.Amount = &tmp + } + + return nil +} + +func (p *ExpectedQuotaConsumption) FastRead(buf []byte) (int, error) { + + var err error + var offset int + var l int + var fieldTypeId thrift.TType + var fieldId int16 + for { + fieldTypeId, fieldId, l, err = thrift.Binary.ReadFieldBegin(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldBeginError + } + if fieldTypeId == thrift.STOP { + break + } + switch fieldId { + case 1: + if fieldTypeId == thrift.LIST { + l, err = p.FastReadField1(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + default: + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + } + + return offset, nil +ReadFieldBeginError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d begin error: ", p, fieldId), err) +ReadFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T read field %d '%s' error: ", p, fieldId, fieldIDToName_ExpectedQuotaConsumption[fieldId]), err) +SkipFieldError: + return offset, thrift.PrependError(fmt.Sprintf("%T field %d skip type %d error: ", p, fieldId, fieldTypeId), err) +} + +func (p *ExpectedQuotaConsumption) FastReadField1(buf []byte) (int, error) { + offset := 0 + + _, size, l, err := thrift.Binary.ReadListBegin(buf[offset:]) + offset += l + if err != nil { + return offset, err + } + _field := make([]*ExpectedResourceConsumption, 0, size) + values := make([]ExpectedResourceConsumption, size) + for i := 0; i < size; i++ { + _elem := &values[i] + _elem.InitDefault() + if l, err := _elem.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + + _field = append(_field, _elem) + } + p.Resources = _field + return offset, nil +} + +func (p *ExpectedQuotaConsumption) FastWrite(buf []byte) int { + return p.FastWriteNocopy(buf, nil) +} + +func (p *ExpectedQuotaConsumption) FastWriteNocopy(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p != nil { + offset += p.fastWriteField1(buf[offset:], w) + } + offset += thrift.Binary.WriteFieldStop(buf[offset:]) + return offset +} + +func (p *ExpectedQuotaConsumption) BLength() int { + l := 0 + if p != nil { + l += p.field1Length() + } + l += thrift.Binary.FieldStopLength() + return l +} + +func (p *ExpectedQuotaConsumption) fastWriteField1(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetResources() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.LIST, 1) + listBeginOffset := offset + offset += thrift.Binary.ListBeginLength() + var length int + for _, v := range p.Resources { + length++ + offset += v.FastWriteNocopy(buf[offset:], w) + } + thrift.Binary.WriteListBegin(buf[listBeginOffset:], thrift.STRUCT, length) + } + return offset +} + +func (p *ExpectedQuotaConsumption) field1Length() int { + l := 0 + if p.IsSetResources() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.ListBeginLength() + for _, v := range p.Resources { + _ = v + l += v.BLength() + } + } + return l +} + +func (p *ExpectedQuotaConsumption) DeepCopy(s interface{}) error { + src, ok := s.(*ExpectedQuotaConsumption) + if !ok { + return fmt.Errorf("%T's type not matched %T", s, p) + } + + if src.Resources != nil { + p.Resources = make([]*ExpectedResourceConsumption, 0, len(src.Resources)) + for _, elem := range src.Resources { + var _elem *ExpectedResourceConsumption + if elem != nil { + _elem = &ExpectedResourceConsumption{} + if err := _elem.DeepCopy(elem); err != nil { + return err + } + } + + p.Resources = append(p.Resources, _elem) + } + } + + return nil +} + func (p *ColumnEvalSetField) FastRead(buf []byte) (int, error) { var err error diff --git a/backend/modules/evaluation/application/convertor/experiment/expt.go b/backend/modules/evaluation/application/convertor/experiment/expt.go index 7b8ae31da4..3fc0ce8b1d 100644 --- a/backend/modules/evaluation/application/convertor/experiment/expt.go +++ b/backend/modules/evaluation/application/convertor/experiment/expt.go @@ -480,6 +480,26 @@ func ToExptDTO(experiment *entity.Experiment) *domain_expt.Experiment { res.RunModeConfig = runModeConfigDO2DTO(experiment.EvalConf.RunModeConfig) } + // ★ 中心化调度读视图 (IDL 116~118)。 + // + // priority_level / scheduler_mode **对 legacy 实验也回显**, 不做"仅 enforce 才给"的裁剪: + // 放量期最高频的疑问是"我这个实验为什么没进中心调度", 那时最需要看的恰恰是一个 legacy + // 实验的 scheduler_mode —— 回显 legacy 就一眼确认没纳管, 不必去猜是 trigger 没带 evalx、 + // 灰度范围没命中还是代码没生效。若只有 enforce 才给, 调用方还无法分辨"这实验是 legacy" + // 与"这接口版本不支持该字段"。二者都有 DB 默认值 (legacy / 1), 天然非空。 + // + // ⚠️ scheduler_scope **一律不回显** (内部运维查库即可): 它是不透明调度域 ID + // (形如 fornax_boe_boe), 对调用方没有可用语义, 却泄露部署拓扑与环境划分。 + // 业务代码本就不允许解析 Scope 字符串, 回显它只会诱使调用方依赖这个不稳定契约。 + res.PriorityLevel = gptr.Of(entity.NormalizeExptPriorityLevel(experiment.PriorityLevel)) + res.SchedulerMode = gptr.Of(entity.NormalizeExptDispatchMode(experiment.ExptDispatchMode)) + + // expected_quota_consumption 采用"有则回显、无则省略"的 optional 语义: + // legacy 实验确实没申报向量 (nil), 省略比返回空结构更如实。 + if experiment.EvalConf != nil && experiment.EvalConf.ExpectedQuotaConsumption != nil { + res.ExpectedQuotaConsumption = expectedQuotaConsumptionDO2DTO(experiment.EvalConf.ExpectedQuotaConsumption) + } + // ★ §2 老字段降级投影 (新实验 MultiSetConfig): 老字段 = 主集封面/去重投影, 仅当 flat 来源为空才兜底 projectLegacyFieldsFromPrimarySet(experiment, res) @@ -1561,3 +1581,31 @@ func expectedQuotaConsumptionDTO2DO(dto *domain_expt.ExpectedQuotaConsumption) * } return &entity.ExpectedQuotaConsumption{Resources: resources} } + +// expectedQuotaConsumptionDO2DTO 把已冻结的资源消耗向量回显给调用方。 +// +// 与 DTO2DO 同样只搬运不校验:回显的是创建时已通过校验并冻结的值, +// 此处再校验一遍只会把"历史数据不合规"变成"读接口报错",而读接口应当如实反映库里的状态。 +// +// 全空时返回 nil 而非空结构:让 optional 字段在序列化时直接省略, +// 调用方据此区分"没申报"(legacy 实验)与"申报了空向量"(不应存在的数据异常)。 +func expectedQuotaConsumptionDO2DTO(do *entity.ExpectedQuotaConsumption) *domain_expt.ExpectedQuotaConsumption { + if do == nil || len(do.Resources) == 0 { + return nil + } + resources := make([]*domain_expt.ExpectedResourceConsumption, 0, len(do.Resources)) + for _, r := range do.Resources { + if r == nil { + continue + } + resources = append(resources, &domain_expt.ExpectedResourceConsumption{ + Category: r.Category, + ResourceKey: r.ResourceKey, + Amount: r.Amount, + }) + } + if len(resources) == 0 { + return nil + } + return &domain_expt.ExpectedQuotaConsumption{Resources: resources} +} diff --git a/backend/modules/evaluation/application/convertor/experiment/expt_scheduling_readview_test.go b/backend/modules/evaluation/application/convertor/experiment/expt_scheduling_readview_test.go new file mode 100644 index 0000000000..2a1abc6d64 --- /dev/null +++ b/backend/modules/evaluation/application/convertor/experiment/expt_scheduling_readview_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package experiment + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// legacy 实验也必须回显 priority_level / scheduler_mode。 +// +// 为什么不做"仅 enforce 才给"的裁剪:放量期最高频的疑问是"我这个实验为什么没进中心调度", +// 那时最需要看的恰恰是一个 legacy 实验的 scheduler_mode —— 回显 legacy 就一眼确认没纳管, +// 不必去猜是 trigger 没带 evalx、灰度范围没命中还是代码没生效。 +// +// 另一个理由是可分辨性:若只有 enforce 才给,调用方无法区分"这实验是 legacy" +// 与"这接口版本还不支持该字段"。 +func TestToExptDTO_SchedulingReadView_Legacy(t *testing.T) { + t.Parallel() + + dto := ToExptDTO(&entity.Experiment{ + ID: 1001, + PriorityLevel: 1, + ExptDispatchMode: entity.ExptDispatchModeLegacy, + SchedulerScope: "", // legacy 的 scope 恒为空 + }) + + require.NotNil(t, dto) + assert.Equal(t, int32(1), dto.GetPriorityLevel()) + assert.Equal(t, entity.ExptDispatchModeLegacy, dto.GetSchedulerMode()) + // legacy 没申报向量 → 省略而非返回空结构 + assert.Nil(t, dto.ExpectedQuotaConsumption) +} + +func TestToExptDTO_SchedulingReadView_Enforce(t *testing.T) { + t.Parallel() + + dto := ToExptDTO(&entity.Experiment{ + ID: 1002, + PriorityLevel: 9, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + SchedulerScope: "fornax_cn_prod", + EvalConf: &entity.EvaluationConfiguration{ + ExpectedQuotaConsumption: &entity.ExpectedQuotaConsumption{ + Resources: []*entity.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + {Category: "model", ResourceKey: "gpt5.5", Amount: 1000}, + }, + }, + }, + }) + + require.NotNil(t, dto) + assert.Equal(t, int32(9), dto.GetPriorityLevel()) + assert.Equal(t, entity.ExptDispatchModeEnforce, dto.GetSchedulerMode()) + + require.NotNil(t, dto.ExpectedQuotaConsumption) + res := dto.ExpectedQuotaConsumption.GetResources() + require.Len(t, res, 2) + assert.Equal(t, "sandbox", res[0].GetCategory()) + assert.Equal(t, "default", res[0].GetResourceKey()) + assert.Equal(t, int64(1), res[0].GetAmount()) + assert.Equal(t, "model", res[1].GetCategory()) + assert.Equal(t, "gpt5.5", res[1].GetResourceKey()) + assert.Equal(t, int64(1000), res[1].GetAmount()) +} + +// ★ scheduler_scope 一律不回显 —— 这是刻意的产品决定,不是遗漏。 +// +// 它是不透明调度域 ID(形如 fornax_cn_prod),对调用方没有可用语义却泄露部署拓扑与 +// 环境划分;业务代码本就不允许解析 Scope 字符串,回显只会诱使调用方依赖这个不稳定契约。 +// 内部运维需要时直接查 experiment.scheduler_scope 列。 +// +// 本测试用反射扫整个 DTO,确保**任何字段**都没把 scope 值带出去 —— +// 而不是只断言某个已知字段为空(那样将来有人新增字段仍会漏)。 +func TestToExptDTO_SchedulerScopeNeverExposed(t *testing.T) { + t.Parallel() + + const secretScope = "fornax_cn_ppe_fornax_evalx" + dto := ToExptDTO(&entity.Experiment{ + ID: 1003, + PriorityLevel: 5, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + SchedulerScope: secretScope, + }) + require.NotNil(t, dto) + + assert.NotContains(t, dto.String(), secretScope, + "scheduler_scope 不得出现在任何回显字段里;它只存在于 DB 列供内部运维查询") +} + +// 历史/异常取值必须被收敛,读接口不该把脏数据原样吐出去。 +func TestToExptDTO_SchedulingReadView_NormalizesDirtyValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + priority int32 + mode string + wantPriority int32 + wantMode string + }{ + {"零值历史数据", 0, "", 1, entity.ExptDispatchModeLegacy}, + {"优先级越界偏低", -5, entity.ExptDispatchModeEnforce, 1, entity.ExptDispatchModeEnforce}, + {"优先级越界偏高", 100, entity.ExptDispatchModeEnforce, 99, entity.ExptDispatchModeEnforce}, + {"非法模式收敛为 legacy", 3, "bogus_mode", 3, entity.ExptDispatchModeLegacy}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + dto := ToExptDTO(&entity.Experiment{ + ID: 2001, + PriorityLevel: tt.priority, + ExptDispatchMode: tt.mode, + }) + require.NotNil(t, dto) + assert.Equal(t, tt.wantPriority, dto.GetPriorityLevel()) + assert.Equal(t, tt.wantMode, dto.GetSchedulerMode()) + }) + } +} + +// 冻结向量为空结构时省略而非返回空 list: +// 让调用方能区分"没申报"与"申报了空向量"(后者是不应存在的数据异常)。 +func TestToExptDTO_EmptyQuotaVectorOmitted(t *testing.T) { + t.Parallel() + + dto := ToExptDTO(&entity.Experiment{ + ID: 3001, + EvalConf: &entity.EvaluationConfiguration{ + ExpectedQuotaConsumption: &entity.ExpectedQuotaConsumption{Resources: nil}, + }, + }) + require.NotNil(t, dto) + assert.Nil(t, dto.ExpectedQuotaConsumption) +} + +// 向量里混入 nil 元素时跳过,不 panic 也不回显空壳资源。 +func TestToExptDTO_QuotaVectorSkipsNilResource(t *testing.T) { + t.Parallel() + + dto := ToExptDTO(&entity.Experiment{ + ID: 3002, + EvalConf: &entity.EvaluationConfiguration{ + ExpectedQuotaConsumption: &entity.ExpectedQuotaConsumption{ + Resources: []*entity.ExpectedResourceConsumption{ + nil, + {Category: "sandbox", ResourceKey: "default", Amount: 2}, + }, + }, + }, + }) + require.NotNil(t, dto) + require.NotNil(t, dto.ExpectedQuotaConsumption) + require.Len(t, dto.ExpectedQuotaConsumption.GetResources(), 1) + assert.Equal(t, int64(2), dto.ExpectedQuotaConsumption.GetResources()[0].GetAmount()) +} + +// 全 nil 元素等价于没申报。 +func TestToExptDTO_QuotaVectorAllNilOmitted(t *testing.T) { + t.Parallel() + + dto := ToExptDTO(&entity.Experiment{ + ID: 3003, + EvalConf: &entity.EvaluationConfiguration{ + ExpectedQuotaConsumption: &entity.ExpectedQuotaConsumption{ + Resources: []*entity.ExpectedResourceConsumption{nil, nil}, + }, + }, + }) + require.NotNil(t, dto) + assert.Nil(t, dto.ExpectedQuotaConsumption) +} diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi.go b/backend/modules/evaluation/application/convertor/experiment/openapi.go index 0f69f73f54..80daa85ec1 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi.go @@ -417,6 +417,12 @@ func DomainExperimentDTO2OpenAPI(dto *domainExpt.Experiment) *openapiExperiment. // 跑法配置回显 (115): 写侧 SubmitExperimentRequest 能配, 读侧此前无字段, OpenAPI 调用方 // 查不到自己配了什么跑法 —— 内部接口一直能回显, 属两套读模型的不对称, 2026-08 补齐。 result.RunModeConfig = RunModeConfigDomain2OpenAPI(dto.RunModeConfig) + // 中心化调度读视图 (116~118)。与 run_mode_config 同理: 内部读模型能回显, OpenAPI 侧此前没有, + // 调用方查不到自己的实验有没有被中心调度纳管、申报了多少额度。 + // scheduler_scope 刻意不透出, 见 domain_openapi/experiment.thrift 的字段注释。 + result.PriorityLevel = dto.PriorityLevel + result.SchedulerMode = dto.SchedulerMode + result.ExpectedQuotaConsumption = ExpectedQuotaConsumptionDomain2OpenAPI(dto.ExpectedQuotaConsumption) return result } @@ -791,6 +797,16 @@ func OpenAPIExptDO2DTO(experiment *entity.Experiment) *openapiExperiment.Experim result.RunModeConfig = RunModeConfigDomain2OpenAPI(runModeConfigDO2DTO(experiment.EvalConf.RunModeConfig)) } + // 中心化调度读视图 (116~118)。同样走两跳复用 entity 侧的归一化 —— + // NormalizeExptPriorityLevel / NormalizeExptDispatchMode 负责把历史与异常取值收敛 + // (0→1, 越界夹取, 非法模式→legacy), 直转会把这套规则复制一份。 + result.PriorityLevel = gptr.Of(entity.NormalizeExptPriorityLevel(experiment.PriorityLevel)) + result.SchedulerMode = gptr.Of(entity.NormalizeExptDispatchMode(experiment.ExptDispatchMode)) + if experiment.EvalConf != nil { + result.ExpectedQuotaConsumption = ExpectedQuotaConsumptionDomain2OpenAPI( + expectedQuotaConsumptionDO2DTO(experiment.EvalConf.ExpectedQuotaConsumption)) + } + return result } @@ -3246,6 +3262,35 @@ func RunModeConfigDomain2OpenAPI(c *domainExpt.RunModeConfig) *openapiExperiment return out } +// ExpectedQuotaConsumptionDomain2OpenAPI 把已冻结的资源消耗向量转成 OpenAPI 读模型。 +// +// 与 RunModeConfigDomain2OpenAPI 同样另立一份结构而非 include domain/expt.thrift —— +// OpenAPI 面与内部面刻意隔离(见 domain_openapi/experiment.thrift 的字段注释), +// 直接 include 会撞符号。两套结构字段一一对应但类型不同,不能强转。 +// +// nil 或空向量返回 nil,让 optional 字段在序列化时省略:调用方据此区分 +// "没申报"(legacy 实验)与"申报了空向量"(不应存在的数据异常)。 +func ExpectedQuotaConsumptionDomain2OpenAPI(c *domainExpt.ExpectedQuotaConsumption) *openapiExperiment.ExpectedQuotaConsumption { + if c == nil || len(c.GetResources()) == 0 { + return nil + } + resources := make([]*openapiExperiment.ExpectedResourceConsumption, 0, len(c.GetResources())) + for _, r := range c.GetResources() { + if r == nil { + continue + } + resources = append(resources, &openapiExperiment.ExpectedResourceConsumption{ + Category: gptr.Of(r.GetCategory()), + ResourceKey: gptr.Of(r.GetResourceKey()), + Amount: gptr.Of(r.GetAmount()), + }) + } + if len(resources) == 0 { + return nil + } + return &openapiExperiment.ExpectedQuotaConsumption{Resources: resources} +} + // domainRunModeToOpenAPI 是 openAPIRunModeToDomain 的反向映射。 // // 两套枚举在**这一层**恰好一一对应 (都只有四个对外形态), 所以看起来像可以直接强转 —— diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go index 44eb286d8b..7d9faddd2b 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go @@ -723,3 +723,75 @@ func TestOpenAPIEvalSetConfigsDTO2Domain_EvaluatorFilter(t *testing.T) { assert.False(t, ec2.IsSetFilterMode()) assert.Equal(t, int32(0), ec2.GetFilterMode()) } + +// 中心化调度读视图在 OpenAPI 面的回显(IDL 116~118)。 +// +// 补这三个字段的动因与 run_mode_config 相同:写侧能配、读侧没有,OpenAPI 调用方 +// 查不到自己的实验有没有被中心调度纳管、申报了多少额度 —— 两套读模型的不对称。 +func TestDomainExperimentDTO2OpenAPI_SchedulingReadView(t *testing.T) { + t.Parallel() + + out := DomainExperimentDTO2OpenAPI(&domainExpt.Experiment{ + ID: gptr.Of(int64(1)), + PriorityLevel: gptr.Of(int32(9)), + SchedulerMode: gptr.Of("enforce"), + ExpectedQuotaConsumption: &domainExpt.ExpectedQuotaConsumption{ + Resources: []*domainExpt.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + {Category: "model", ResourceKey: "gpt5.5", Amount: 1000}, + }, + }, + }) + + assert.NotNil(t, out) + assert.Equal(t, int32(9), out.GetPriorityLevel()) + assert.Equal(t, "enforce", out.GetSchedulerMode()) + + res := out.GetExpectedQuotaConsumption().GetResources() + assert.Len(t, res, 2) + assert.Equal(t, "sandbox", res[0].GetCategory()) + assert.Equal(t, "default", res[0].GetResourceKey()) + assert.Equal(t, int64(1), res[0].GetAmount()) + assert.Equal(t, "model", res[1].GetCategory()) + assert.Equal(t, int64(1000), res[1].GetAmount()) +} + +// legacy 实验也回显 mode/priority,向量省略。 +func TestDomainExperimentDTO2OpenAPI_SchedulingReadView_Legacy(t *testing.T) { + t.Parallel() + + out := DomainExperimentDTO2OpenAPI(&domainExpt.Experiment{ + ID: gptr.Of(int64(2)), + PriorityLevel: gptr.Of(int32(1)), + SchedulerMode: gptr.Of("legacy"), + }) + + assert.NotNil(t, out) + assert.Equal(t, "legacy", out.GetSchedulerMode(), + "legacy 也要回显——'为什么我的实验没进中心调度'是最高频疑问,回显 legacy 可一眼确认") + assert.Equal(t, int32(1), out.GetPriorityLevel()) + assert.Nil(t, out.ExpectedQuotaConsumption, "legacy 确实没申报向量,省略比返回空结构更如实") +} + +func TestExpectedQuotaConsumptionDomain2OpenAPI(t *testing.T) { + t.Parallel() + + // nil / 空 / 全 nil 元素都返回 nil,让 optional 字段省略。 + assert.Nil(t, ExpectedQuotaConsumptionDomain2OpenAPI(nil)) + assert.Nil(t, ExpectedQuotaConsumptionDomain2OpenAPI(&domainExpt.ExpectedQuotaConsumption{})) + assert.Nil(t, ExpectedQuotaConsumptionDomain2OpenAPI(&domainExpt.ExpectedQuotaConsumption{ + Resources: []*domainExpt.ExpectedResourceConsumption{nil, nil}, + })) + + // 混入 nil 元素时跳过,不 panic 也不产出空壳资源。 + got := ExpectedQuotaConsumptionDomain2OpenAPI(&domainExpt.ExpectedQuotaConsumption{ + Resources: []*domainExpt.ExpectedResourceConsumption{ + nil, + {Category: "evaluator", ResourceKey: "*", Amount: 3}, + }, + }) + assert.NotNil(t, got) + assert.Len(t, got.GetResources(), 1) + assert.Equal(t, "evaluator", got.GetResources()[0].GetCategory()) + assert.Equal(t, int64(3), got.GetResources()[0].GetAmount()) +} diff --git a/idl/thrift/coze/loop/evaluation/domain/expt.thrift b/idl/thrift/coze/loop/evaluation/domain/expt.thrift index c704950b75..22c2e817a8 100644 --- a/idl/thrift/coze/loop/evaluation/domain/expt.thrift +++ b/idl/thrift/coze/loop/evaluation/domain/expt.thrift @@ -222,6 +222,13 @@ struct Experiment { 116: optional i32 priority_level // 执行模式回显: legacy(旧 per-experiment 链路) / enforce(中心调度); 直读 experiment 表同名列, 该列是唯一权威源 117: optional string scheduler_mode + // 单 item 预期资源消耗向量回显: 从 experiment.eval_conf 反序列化, 与 Create/Submit 入参同构。 + // "有则回显、无则省略": legacy 实验确实没申报向量, 省略比返回空结构更如实。 + 118: optional ExpectedQuotaConsumption expected_quota_consumption + + // 注: scheduler_scope **刻意不进读视图**。它是不透明调度域 ID (形如 fornax_cn_prod), + // 对调用方没有可用语义却泄露部署拓扑; 业务代码本就不允许解析该字符串, 回显只会诱使 + // 调用方依赖这个不稳定契约。内部运维需要时直接查 experiment.scheduler_scope 列。 } // 实验模板基础信息 diff --git a/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift b/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift index ef63ba39f6..fc647e0a53 100644 --- a/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift +++ b/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift @@ -305,10 +305,39 @@ struct Experiment { // 用本文件已有的 RunModeConfig (字符串枚举), 不要 include domain/expt.thrift —— 会符号冲突。 115: optional RunModeConfig run_mode_config + // ★ 中心化调度读视图。字段号 116~118 同样与 domain/expt.thrift 对齐。 + // 调度优先级 (1-99, 越大越优先); 历史数据为 1。 + // 注: 它只影响 scheduler_mode=enforce 的实验; legacy 实验此值虽有 (默认 1) 但不参与调度排序。 + 116: optional i32 priority_level + // 执行模式: legacy(旧 per-experiment 链路) / enforce(中心调度)。 + // **legacy 实验也回显**: "为什么我的实验没进中心调度"是最高频疑问, 回显 legacy 可一眼确认。 + 117: optional string scheduler_mode + // 单 item 预期资源消耗向量; "有则回显、无则省略"(legacy 实验确实没申报)。 + 118: optional ExpectedQuotaConsumption expected_quota_consumption + + // 注: scheduler_scope **不进读模型**。它是不透明调度域 ID, 对调用方无可用语义却泄露部署拓扑; + // 内部运维需要时直接查 experiment.scheduler_scope 列。 + 100: optional common.BaseInfo base_info } +// 单资源预期消耗。与 domain/expt.thrift 的 ExpectedResourceConsumption 同构 —— +// 本文件另立一份而非 include, 与 RunModeConfig / ExptEvalSetSourceType 同套模式, 避免符号冲突。 +struct ExpectedResourceConsumption { + // 资源类别: sandbox / agent_account / model / evaluator + 1: optional string category + // 资源标识: default / doubao_pro / gpt5.5 等 + 2: optional string resource_key + // 单 item 预期占用量; 单位由服务端 TCC 资源配置定义, 不由调用方指定 + 3: optional i64 amount (api.js_conv = 'true', go.tag = 'json:"amount"') +} + +// 单 item 的多资源预期消耗向量。 +struct ExpectedQuotaConsumption { + 1: optional list resources +} + // 列定义 - 评测集字段 struct ColumnEvalSetField { 1: optional string key From 6694510078939af82979d03ad3f1cb8a930e39cb Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 18:25:45 +0800 Subject: [PATCH 18/58] =?UTF-8?q?fix(evaluation):=20=E8=A1=A5=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E7=BB=88=E6=80=81=E8=B7=AF=E5=BE=84=E7=9A=84=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E5=BD=92=E8=BF=98=EF=BC=8C=E5=B9=B6=E6=8A=8A=E5=8F=96?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=9D=83=E7=A7=BB=E8=BF=9B=20item=20?= =?UTF-8?q?=E9=94=81=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、额度泄漏(必然发生,非罕见组合) 额度闸(HandleCentralReservation)在中间件链内层按 run log 状态决定是否归还额度, 而有两条终态路径是在它**外层**写的,届时它已经返回: ① 不可重试的前置失败:BuildExptRecordEvalCtx 等阶段失败时,item 由 HandleEventErr 里的 completeItemRunOnUnretriableErr 兜底落 Fail。 额度闸判定时 item 还是 Processing,正确地保留了 reservation,之后无人释放 ② 欠费终止:把整个实验落 Terminated 却完全不动 item run log, 因此额度闸按状态判定**永远**不会释放 既无 reservation TTL 清理也还没有对账,所以这两条路径上的额度永久泄漏。现象是 "额度慢慢跑满后整个 Scope 再也调度不动",看起来像上限配小了,极难反推。 修法:额度闸取得执行权后往 ctx 挂预占凭据,HandleEventErr 在**落 Fail 之后**据凭据补一次 释放。顺序不能反——先释放会出现"额度已还但 item 仍算 Processing"的窗口,下一拍按虚高的 占用少派 item。与额度闸内的释放重复调用是安全的(Redis HDEL + used 有下限保护)。 不把 Scope/guard 泄进 HandleEventErr 签名,是因为那一层不该知道中心调度的存在。 二、取执行权移进 item 锁内 原顺序是 ConfirmRunning → StartReservedItem → 抢 item 锁 → 执行。正确性此前由幂等兜住 (Lua 对已 running 返回 1、StartReservedItem 是 CAS、Release 是 HDEL),不会重复扣减, 但两条并发消息会各自发一次无谓的 Redis 写和一次注定失败的 CAS,且"谁在执行"这个事实被 拆到了锁的两边。 拆成两层而不是简单调换顺序: - HandleCentralAdmission(锁外):纯读,"这条消息该不该由我处理"——模式/Scope 非空/ Scope 归属/guard 已注入。不该处理的消息不必去抢锁 - HandleCentralReservation(锁内):写,取执行权 + 兑现投影 为什么不把 Lock 直接挪到最外层:那会让归属校验也进锁,一个路由错误的进程要先抢到 item 锁才发现不归自己。两层之间靠 ctx 传已查到的实验,省掉锁内第二次 GetByID。 测试 14 个。RunsInsideLock 断言的是调用序列本身 (lock → confirm → start_reserved → exec → release → unlock), 以后有人把顺序改回去会失败而不是静默通过;另有反向用例钉住"重试路径不得释放" ——那个方向的错误更隐蔽:会让重投消息因 reservation 不存在被丢弃,item 永久卡 Processing。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/event.go | 53 ++ .../expt_central_quota_release_test.go | 485 ++++++++++++++++++ .../service/expt_run_item_event_impl.go | 126 ++++- 3 files changed, 655 insertions(+), 9 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_central_quota_release_test.go diff --git a/backend/modules/evaluation/domain/entity/event.go b/backend/modules/evaluation/domain/entity/event.go index dde766d34a..45a4435128 100644 --- a/backend/modules/evaluation/domain/entity/event.go +++ b/backend/modules/evaluation/domain/entity/event.go @@ -29,6 +29,12 @@ type ctxTargetCalledCacheKey struct{} type ctxForceNoRetryKey struct{} +// ctxCentralQuotaHeldKey 见 WithCtxCentralQuotaHeld。 +type ctxCentralQuotaHeldKey struct{} + +// ctxCentralAdmittedExptKey 见 WithCtxCentralAdmittedExpt。 +type ctxCentralAdmittedExptKey struct{} + type ExptItemEvalEvent struct { SpaceID int64 ExptID int64 @@ -59,6 +65,53 @@ func (e *ExptItemEvalEvent) IgnoreExistedTargetResult() bool { return e.ignoreExistedResult() } +// WithCtxCentralAdmittedExpt 记录"该实验已通过中心调度准入检查、由本进程纳管"。 +// +// 存在意义:准入判断(模式 / Scope 非空 / Scope 归属 / guard 已注入)在 item 锁**外**做 —— +// 不归本进程的消息不该先去抢锁;而取额度执行权必须在锁**内**做。两者被 item 锁隔成上下两层, +// 靠 ctx 把已查到的实验递下去,省掉锁内重复一次 GetByID(那是热路径上的一次多余 DB 往返, +// 且两次读之间实验可能已被改写,反而要额外推演一致性)。 +// +// 只在 enforce 且通过**全部**准入检查后写入。缺失即表示"不走额度闸",与 legacy 直通同义 —— +// 因此下游拿不到时必须直通而不是 fail-closed,否则 legacy 实验会被误挡。 +func (e *ExptItemEvalEvent) WithCtxCentralAdmittedExpt(ctx context.Context, expt *Experiment) { + ctxcache.Store(ctx, ctxCentralAdmittedExptKey{}, expt) +} + +// CtxCentralAdmittedExpt 取回已准入的实验。ok=false 表示本条消息不经额度闸 +// (legacy 实验,或已在准入层被丢弃)。 +func (e *ExptItemEvalEvent) CtxCentralAdmittedExpt(ctx context.Context) (*Experiment, bool) { + expt, ok := ctxcache.Get[*Experiment](ctx, ctxCentralAdmittedExptKey{}) + if !ok || expt == nil { + return nil, false + } + return expt, true +} + +// WithCtxCentralQuotaHeld 记录"本次处理已为该 item 持有一份中心调度额度预占",值为账本 Scope。 +// +// 存在意义:额度闸(HandleCentralReservation)在中间件链的内层,而"不可重试前置失败"的兜底 +// 落 Fail 在更外层(HandleEventErr)。内层按 run log 状态判定时 item 还是 Processing、 +// 因此正确地保留了 reservation;等外层把它改成 Fail,内层已经返回,再没有释放的机会。 +// 靠 ctx 传递凭据,外层就能在落 Fail 之后补一次释放,而不必让 HandleEventErr 知道 +// Scope / guard 这些中心调度概念。 +// +// 只在真的取得执行权(ConfirmRunning 返回 true)之后调用 —— 否则外层会为一份并不存在的 +// reservation 发起释放,虽然 Release 幂等不至于出错,但会掩盖"额度到底有没有预占"的真相。 +func (e *ExptItemEvalEvent) WithCtxCentralQuotaHeld(ctx context.Context, schedulerScope string) { + ctxcache.Store(ctx, ctxCentralQuotaHeldKey{}, schedulerScope) +} + +// CtxCentralQuotaHeldScope 取回额度预占凭据。ok=false 表示本次处理没有持有预占 +// (legacy 实验、被丢弃的消息、或额度闸压根没走到取执行权那一步)。 +func (e *ExptItemEvalEvent) CtxCentralQuotaHeldScope(ctx context.Context) (string, bool) { + scope, ok := ctxcache.Get[string](ctx, ctxCentralQuotaHeldKey{}) + if !ok || scope == "" { + return "", false + } + return scope, true +} + func (e *ExptItemEvalEvent) WithCtxTargetCalled(ctx context.Context) { ctxcache.Store(ctx, ctxTargetCalledCacheKey{}, struct{}{}) } diff --git a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go new file mode 100644 index 0000000000..3eeafcfdf7 --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go @@ -0,0 +1,485 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 +package service + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + lockmocks "github.com/coze-dev/coze-loop/backend/infra/lock/mocks" + metricsmocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/metrics/mocks" + componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + eventmocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/events/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + svcmocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service/mocks" + "github.com/coze-dev/coze-loop/backend/pkg/ctxcache" +) + +// releaseCall 记录一次 Release 调用,供断言"释放了几次、释放的是谁"。 +type releaseCall struct { + Scope string + RunID int64 + ItemID int64 + Reason string +} + +// fakeGuard 手写的额度闸替身。 +// +// 不用 mockgen:这些测试关心的是「调用序列」与「调用次数」,手写替身能直接把序列存下来断言, +// 而 gomock 表达"恰好一次、参数是这个"需要更多样板,且 ICentralReservationGuard 目前 +// 没有生成 mock。 +type fakeGuard struct { + mu sync.Mutex + + confirmResult bool + confirmErr error + confirmCalls int + + releaseErr error + releaseCalls []releaseCall +} + +func (f *fakeGuard) ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.confirmCalls++ + return f.confirmResult, f.confirmErr +} + +func (f *fakeGuard) Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.releaseCalls = append(f.releaseCalls, releaseCall{Scope: schedulerScope, RunID: exptRunID, ItemID: itemID, Reason: reason}) + return f.releaseErr +} + +func (f *fakeGuard) releases() []releaseCall { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]releaseCall, len(f.releaseCalls)) + copy(out, f.releaseCalls) + return out +} + +const testScope = "fornax_cn_ppe_fornax_evalx" + +// TestReleaseCentralQuotaOutsideGate_ReleasesWhenQuotaHeld 覆盖本次修复的核心场景: +// 前置阶段(BuildExptRecordEvalCtx 等)失败且不可重试时,额度必须被归还。 +// +// 回归的是一个**必然发生**的泄漏:额度闸在中间件链内层,按 run log 状态判定时 item 还是 +// Processing 因而正确地保留了 reservation;把 item 落成 Fail 的兜底在更外层,那时额度闸 +// 已经返回。修复前这条路径上的额度永久留在账本里(无 TTL 清理、也还没有对账)。 +func TestReleaseCentralQuotaOutsideGate_ReleasesWhenQuotaHeld(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + svc.releaseCentralQuotaOutsideGate(ctx, event, errors.New("build eval ctx fail"), "item unretriable pre-exec failure") + + calls := guard.releases() + assert.Len(t, calls, 1, "持有预占且已落终态时必须归还额度,否则永久泄漏") + assert.Equal(t, testScope, calls[0].Scope) + assert.Equal(t, int64(2), calls[0].RunID) + assert.Equal(t, int64(4), calls[0].ItemID) + assert.Contains(t, calls[0].Reason, "item unretriable pre-exec failure") + assert.Contains(t, calls[0].Reason, "build eval ctx fail", "reason 要带上原始错误,否则排查时看不出为什么释放") +} + +// TestReleaseCentralQuotaOutsideGate_SkipsWithoutCredential legacy 实验与"取得执行权之前 +// 就失败的消息"都不该触发释放 —— 前者从不预占,后者预占不属于本次处理。 +// 多释放的后果不是浪费一次 Redis 往返,而是可能归还别人的额度。 +func TestReleaseCentralQuotaOutsideGate_SkipsWithoutCredential(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + // ctx 里没有凭据(legacy 实验走的就是这条) + ctx := ctxcache.Init(context.Background()) + svc.releaseCentralQuotaOutsideGate(ctx, event, errors.New("boom"), "reason") + assert.Empty(t, guard.releases(), "无凭据不得释放") + + // ctx 压根没 Init(防御:ctxcache.Get 在无缓存 ctx 上返回 false) + svc.releaseCentralQuotaOutsideGate(context.Background(), event, errors.New("boom"), "reason") + assert.Empty(t, guard.releases(), "ctx 无缓存时不得释放") +} + +// TestReleaseCentralQuotaOutsideGate_NoopOnNilInputs 无错误 / 无 guard / 无 event 时静默返回。 +// 成功路径由额度闸内的 releaseQuotaIfItemTerminal 负责,本函数只补终态失败那两条。 +func TestReleaseCentralQuotaOutsideGate_NoopOnNilInputs(t *testing.T) { + guard := &fakeGuard{} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + svcWithGuard := &ExptItemEventEvalServiceImpl{centralGuard: guard} + svcWithGuard.releaseCentralQuotaOutsideGate(ctx, event, nil, "reason") + assert.Empty(t, guard.releases(), "evalErr 为 nil 说明没失败,释放该由额度闸内的终态判定负责") + + svcWithGuard.releaseCentralQuotaOutsideGate(ctx, nil, errors.New("boom"), "reason") + assert.Empty(t, guard.releases()) + + svcNoGuard := &ExptItemEventEvalServiceImpl{} + svcNoGuard.releaseCentralQuotaOutsideGate(ctx, event, errors.New("boom"), "reason") // 不 panic 即通过 +} + +// TestReleaseCentralQuotaOutsideGate_SwallowsReleaseError 释放失败只告警。 +// 让"额度归还失败"阻断终态收口,会把额度泄漏升级成实验永不收敛 —— 后者严重得多。 +func TestReleaseCentralQuotaOutsideGate_SwallowsReleaseError(t *testing.T) { + guard := &fakeGuard{releaseErr: errors.New("redis down")} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + svc.releaseCentralQuotaOutsideGate(ctx, event, errors.New("boom"), "reason") // 不 panic、不冒泡 + assert.Len(t, guard.releases(), 1) +} + +// TestHandleEventErr_ReleasesQuotaOnUnretriableErr 端到端验证 HandleEventErr 这一层: +// 不可重试失败时,落 Fail 与归还额度**两件事都要做**。 +// +// 这是修复前真正漏掉的那条链路,因此断言的是 HandleEventErr 而不只是辅助函数。 +func TestHandleEventErr_ReleasesQuotaOnUnretriableErr(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockMetric := metricsmocks.NewMockExptMetric(ctrl) + mockItemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + mockTurnResultRepo := repoMocks.NewMockIExptTurnResultRepo(ctrl) + + // RetryTimes 已达上限 → 不重试 → 走兜底落 Fail + 归还额度 + mockConfiger.EXPECT().GetErrRetryConf(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.RetryConf{RetryTimes: 3, RetryIntervalSecond: 60, IsInDebt: false}) + mockMetric.EXPECT().EmitItemExecResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + mockItemResultRepo.EXPECT().UpdateItemRunLog(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + mockTurnResultRepo.EXPECT().CreateOrUpdateItemsTurnRunLogStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{ + configer: mockConfiger, + metric: mockMetric, + exptItemResultRepo: mockItemResultRepo, + exptTurnResultRepo: mockTurnResultRepo, + centralGuard: guard, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4, RetryTimes: 3} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + handler := svc.HandleEventErr(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return errors.New("build eval ctx fail") + }) + assert.NoError(t, handler(ctx, event), "兜底路径吞掉错误,不让 MQ 无限重投") + + assert.Len(t, guard.releases(), 1, "不可重试失败落 Fail 后必须归还额度") +} + +// TestHandleEventErr_ReleasesQuotaOnIndebtTermination 欠费终止分支同样要归还额度。 +// +// 该分支把整个实验落 Terminated 却不动 item run log,所以额度闸内按 run log 状态判定时 +// 一定不会释放 —— 不在这里补,预占就跟着终止的实验一起永久留在账本里。 +func TestHandleEventErr_ReleasesQuotaOnIndebtTermination(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockMetric := metricsmocks.NewMockExptMetric(ctrl) + mockManager := svcmocks.NewMockIExptManager(ctrl) + + mockConfiger.EXPECT().GetErrRetryConf(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.RetryConf{RetryTimes: 3, RetryIntervalSecond: 60, IsInDebt: true}) + mockMetric.EXPECT().EmitItemExecResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + mockManager.EXPECT().CompleteRun(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + mockManager.EXPECT().CompleteExpt(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{ + configer: mockConfiger, + metric: mockMetric, + manager: mockManager, + centralGuard: guard, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + handler := svc.HandleEventErr(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return errors.New("insufficient benefit") + }) + assert.NoError(t, handler(ctx, event)) + + calls := guard.releases() + assert.Len(t, calls, 1, "欠费终止后必须归还额度") + assert.Contains(t, calls[0].Reason, "indebt") +} + +// TestHandleEventErr_NoReleaseWhenRetrying 重试路径**绝不能**释放。 +// +// 这是反方向的错误、且后果更隐蔽:若在重试前释放了额度,重投的消息会在 ConfirmRunning +// 处因 reservation 不存在被丢弃,item 永久停在 Processing、实验永不收敛。 +func TestHandleEventErr_NoReleaseWhenRetrying(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockMetric := metricsmocks.NewMockExptMetric(ctrl) + mockPublisher := eventmocks.NewMockExptEventPublisher(ctrl) + + mockConfiger.EXPECT().GetErrRetryConf(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.RetryConf{RetryTimes: 3, RetryIntervalSecond: 60, IsInDebt: false}) + mockMetric.EXPECT().EmitItemExecResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()) + mockPublisher.EXPECT().PublishExptRecordEvalEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{ + configer: mockConfiger, + metric: mockMetric, + publisher: mockPublisher, + centralGuard: guard, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4, RetryTimes: 1} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralQuotaHeld(ctx, testScope) + + handler := svc.HandleEventErr(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return errors.New("transient fail") + }) + assert.NoError(t, handler(ctx, event)) + + assert.Empty(t, guard.releases(), + "重试路径释放额度会让重投消息被丢弃,item 永久卡 Processing") +} + +// TestHandleCentralAdmission_LegacyPassesThroughWithoutCredential legacy 实验必须原样直通, +// 且不在 ctx 留下准入凭据 —— 否则锁内那层会为它去查额度账本。 +func TestHandleCentralAdmission_LegacyPassesThroughWithoutCredential(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockExptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + mockExptRepo.EXPECT().GetByID(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeLegacy}, nil) + + svc := &ExptItemEventEvalServiceImpl{experimentRepo: mockExptRepo} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + + nextCalled := false + handler := svc.HandleCentralAdmission(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + _, admitted := event.CtxCentralAdmittedExpt(ctx) + assert.False(t, admitted, "legacy 实验不得留下准入凭据") + return nil + }) + + assert.NoError(t, handler(ctx, event)) + assert.True(t, nextCalled, "legacy 实验必须直通执行") +} + +// TestHandleCentralAdmission_EnforceStoresCredential enforce 实验通过全部准入检查后, +// 必须把实验挂到 ctx —— 锁内那层据此判断"要走额度闸",并复用这个实验免去二次 GetByID。 +func TestHandleCentralAdmission_EnforceStoresCredential(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockExptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + expt := &entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope} + mockExptRepo.EXPECT().GetByID(gomock.Any(), gomock.Any(), gomock.Any()).Return(expt, nil) + + svc := &ExptItemEventEvalServiceImpl{experimentRepo: mockExptRepo, centralGuard: &fakeGuard{}} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + + nextCalled := false + handler := svc.HandleCentralAdmission(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + got, admitted := event.CtxCentralAdmittedExpt(ctx) + assert.True(t, admitted, "enforce 实验通过准入后必须留下凭据") + assert.Equal(t, testScope, got.SchedulerScope) + return nil + }) + + assert.NoError(t, handler(ctx, event)) + assert.True(t, nextCalled) +} + +// TestHandleCentralAdmission_DropsEnforceWithEmptyScope 空 Scope 的 enforce 实验必须丢弃 +// 且不进入下游:没有 Scope 就无法确定去哪本账查 reservation,猜一本账等于用别人的额度跑 item。 +func TestHandleCentralAdmission_DropsEnforceWithEmptyScope(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockExptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + mockExptRepo.EXPECT().GetByID(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: ""}, nil) + + svc := &ExptItemEventEvalServiceImpl{experimentRepo: mockExptRepo, centralGuard: &fakeGuard{}} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + nextCalled := false + handler := svc.HandleCentralAdmission(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + return nil + }) + + assert.NoError(t, handler(ctxcache.Init(context.Background()), event), "丢弃而非报错,避免 MQ 无限重投") + assert.False(t, nextCalled, "空 Scope 的 enforce 实验不得执行") +} + +// TestHandleCentralAdmission_DropsWhenGuardMissing 判定为 enforce 却没注入闸门时 fail-closed。 +// 放行等于让实验在无额度约束下跑(静默);停下来则可见。 +func TestHandleCentralAdmission_DropsWhenGuardMissing(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockExptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + mockExptRepo.EXPECT().GetByID(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope}, nil) + + svc := &ExptItemEventEvalServiceImpl{experimentRepo: mockExptRepo} // centralGuard 为 nil + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + nextCalled := false + handler := svc.HandleCentralAdmission(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + return nil + }) + + assert.NoError(t, handler(ctxcache.Init(context.Background()), event)) + assert.False(t, nextCalled, "无闸门时 enforce 实验必须 fail-closed") +} + +// TestHandleCentralReservation_PassesThroughWhenNotAdmitted 锁内那层拿不到准入凭据时必须直通。 +// +// 方向很关键:这里的"缺失"含义是"legacy 或已被上层丢弃",取 fail-closed 会把全部 legacy +// 实验误挡住 —— 那是自造的全量故障。 +func TestHandleCentralReservation_PassesThroughWhenNotAdmitted(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + nextCalled := false + handler := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + return nil + }) + + assert.NoError(t, handler(ctxcache.Init(context.Background()), event)) + assert.True(t, nextCalled, "无凭据必须直通(legacy 语义)") + assert.Zero(t, guard.confirmCalls, "legacy 不该查额度账本") +} + +// TestHandleCentralReservation_DiscardsWhenReservationAbsent ConfirmRunning 返回 false +// (迟到消息 / 账本已重建 / 已被释放)时必须丢弃,不得执行。 +func TestHandleCentralReservation_DiscardsWhenReservationAbsent(t *testing.T) { + guard := &fakeGuard{confirmResult: false} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + + nextCalled := false + handler := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + nextCalled = true + return nil + }) + + assert.NoError(t, handler(ctx, event)) + assert.False(t, nextCalled, "reservation 不存在时不得执行 item") + assert.Equal(t, 1, guard.confirmCalls) +} + +// TestHandleCentralReservation_RunsInsideLock 验证本次调整的目标: +// ConfirmRunning 必须在 item 锁**内**发生。 +// +// 断言方式是把 Lock 与 Reservation 按生产链的顺序串起来,记录事件序列 —— +// 直接断言"顺序"而不是断言"调用过",这样以后有人把两层顺序改回去,测试会失败而不是静默通过。 +func TestHandleCentralReservation_RunsInsideLock(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + var seq []string + + mockMutex := lockmocks.NewMockILocker(ctrl) + mockMutex.EXPECT().LockWithRenew(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, key string, _, _ interface{}) (bool, context.Context, func(), error) { + seq = append(seq, "lock") + return true, ctx, func() {}, nil + }) + mockMutex.EXPECT().Unlock(gomock.Any()).DoAndReturn(func(key string) (bool, error) { + seq = append(seq, "unlock") + return true, nil + }) + + guard := &recordingGuard{seq: &seq, confirmResult: true} + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _, _, _ int64) (bool, error) { + seq = append(seq, "start_reserved") + return true, nil + }) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Success)}, + }, nil) + + svc := &ExptItemEventEvalServiceImpl{ + mutex: mockMutex, + centralGuard: guard, + dispatchRepo: dispatchRepo, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + + // 按生产链顺序组装:Lock 在外,Reservation 在内。 + inner := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + seq = append(seq, "exec") + return nil + }) + handler := svc.HandleEventLock(inner) + + assert.NoError(t, handler(ctx, event)) + + // lock 必须先于 confirm,unlock 必须后于 exec。 + assert.Equal(t, []string{"lock", "confirm", "start_reserved", "exec", "release", "unlock"}, seq, + "ConfirmRunning / StartReservedItem / 执行 必须都在 item 锁的临界区内") +} + +// recordingGuard 在 fakeGuard 之外单独一个类型:这里只需要把调用顺序追加到共享切片, +// 不需要计数与并发保护(RunsInsideLock 是单 goroutine 的顺序断言)。 +type recordingGuard struct { + seq *[]string + confirmResult bool +} + +func (r *recordingGuard) ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) { + *r.seq = append(*r.seq, "confirm") + return r.confirmResult, nil +} + +func (r *recordingGuard) Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error { + *r.seq = append(*r.seq, "release") + return nil +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index c8c1d62255..235fdde8be 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -133,10 +133,12 @@ func NewExptRecordEvalService( i.endpoints = RecordEvalChain( i.HandleEventErr, i.HandleEventCheck, - // 额度闸放在 Check 之后、Lock 之前:Check 已排除掉终态 run(那些消息不需要额度校验), - // 而放在 Lock 之前可避免为一条注定要丢弃的消息去抢 item 锁。 - i.HandleCentralReservation, + // 额度准入放在 Check 之后、Lock 之前:Check 已排除掉终态 run(那些消息不需要额度校验), + // 而放在 Lock 之前可避免为一条注定要丢弃的消息(不归本进程 / 无 Scope)去抢 item 锁。 + i.HandleCentralAdmission, i.HandleEventLock, + // ★ 取执行权与兑现投影必须在锁内:见 HandleCentralReservation 注释。 + i.HandleCentralReservation, i.HandleEventExec, )(func(_ context.Context, _ *entity.ExptItemEvalEvent) error { return nil }) @@ -183,12 +185,22 @@ func (e *ExptItemEventEvalServiceImpl) HandleEventCheck(next RecordEvalEndPoint) } } -// HandleCentralReservation 对中心调度纳管的实验校验额度预占。 +// HandleCentralAdmission 判定该 item 消息是否该由本进程执行,并把实验挂到 ctx 供下游复用。 +// +// 与 HandleCentralReservation 拆开、夹在 item 锁两侧,是为了让两类判断各就各位: +// +// 本层(锁外):纯读,"这条消息该不该由我处理" —— 不该处理的消息不必去抢锁 +// 下层(锁内):写,"取额度执行权 + 兑现投影" —— 必须与执行本身在同一临界区 +// +// 反过来把归属校验也塞进锁内,会让一个路由错误的进程先抢到 item 锁再发现不归自己, +// 白占一段临界区;而把 ConfirmRunning 留在锁外,则两条并发消息可以各自取到执行权 +// (Lua 对已 running 幂等返回 1),虽然靠幂等兜住了正确性,但会产生无谓的 Redis 写与 +// 一次注定失败的 CAS。 // // 模式判定**回查 experiment.scheduler_mode DB 列**,不看 event 上的任何标记: // 若模式随 event 传递,字段丢失或取默认零值时,一条实际为 central 的消息会被当作 legacy 处理, -// 从而跳过本校验、静默绕过额度执行 —— 这个方向的失败是无声的,比多查一次 DB 危险得多。 -func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalEndPoint) RecordEvalEndPoint { +// 从而跳过额度校验、静默绕过额度执行 —— 这个方向的失败是无声的,比多查一次 DB 危险得多。 +func (e *ExptItemEventEvalServiceImpl) HandleCentralAdmission(next RecordEvalEndPoint) RecordEvalEndPoint { return func(ctx context.Context, event *entity.ExptItemEvalEvent) error { expt, err := e.experimentRepo.GetByID(ctx, event.ExptID, event.SpaceID) if err != nil { @@ -232,8 +244,7 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE } } - guard := e.centralGuard - if guard == nil { + if e.centralGuard == nil { // 判定为 enforce 却没有注入闸门:fail-closed。 // 放行等于让实验在无额度约束下跑,静默且难以发现;停下来则可见。 logs.CtxWarn(ctx, "[CentralReservation] enforce experiment without guard, drop event, expt_id: %v, item_id: %v", @@ -241,6 +252,38 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE return nil } + // 把已判定为"本进程纳管"的实验交给锁内那一层,省掉一次重复的 GetByID。 + // 只在 enforce 且通过全部准入检查后写入 —— 下游据此判断"要不要走额度闸", + // 缺失即视为不需要,与 legacy 直通的语义一致。 + event.WithCtxCentralAdmittedExpt(ctx, expt) + + return next(ctx, event) + } +} + +// HandleCentralReservation 取得该 item 的额度执行权并兑现 run log 投影。 +// +// ★ 必须在 item 锁**内**:ConfirmRunning(取执行权)+ StartReservedItem(CAS 投影)+ 执行本身 +// 是一个整体,锁外做前两步意味着两条并发消息可以各自"取到执行权",之后靠 CAS 与 Lua 幂等 +// 兜住正确性 —— 兜得住,但多余的 Redis 写与注定失败的 CAS 都是白做的,且把"谁在执行" +// 这个事实拆到了锁的两边,后续任何依赖它的改动都要重新推演一遍并发。 +func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalEndPoint) RecordEvalEndPoint { + return func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + expt, admitted := event.CtxCentralAdmittedExpt(ctx) + if !admitted { + // legacy 实验,或已在 admission 层被丢弃。不经额度闸。 + return next(ctx, event) + } + + guard := e.centralGuard + if guard == nil { + // admission 已挡过一次,走到这里说明 guard 在两层之间被置空 —— 不可能发生, + // 但保持 fail-closed 而不是让 nil 解引用把 consumer 打崩。 + logs.CtxWarn(ctx, "[CentralReservation] guard absent inside lock, drop event, expt_id: %v, item_id: %v", + event.ExptID, event.EvalSetItemID) + return nil + } + ok, err := guard.ConfirmRunning(ctx, expt.SchedulerScope, event.ExptRunID, event.EvalSetItemID) if err != nil { // 账本暂时不可用:返回错误让 MQ 重试,而不是丢弃 —— item 已被预占, @@ -263,6 +306,8 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE // // CAS 未命中(started=false)不阻断执行:可能是重复投递(已 Processing)或 // 投影已被 repair 修正。此时 reservation 校验已通过,说明额度是真的,继续执行是安全的。 + // 现在本层在 item 锁内,同一 item 不会有并发的第二个执行者,未命中只剩"已 Processing + // 的重复投递"与"被 repair 修正"两种,两者继续执行都由 item 锁 + 幂等写兜住。 if e.dispatchRepo != nil { started, err := e.dispatchRepo.StartReservedItem(ctx, event.SpaceID, event.ExptID, event.ExptRunID, event.EvalSetItemID) if err != nil { @@ -276,7 +321,7 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE } } - // 执行链返回后释放额度:这是 consumer 侧唯一的释放点。 + // 执行链返回后释放额度:这是 consumer 侧的主释放点。 // // 为什么放在这一层而不是 CompleteItemRun 等各个终态写库处:终态路径有四条 // (success / fail / 不可重试前置失败 / indebt 终止),每条都手动调一次释放 @@ -288,6 +333,14 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE // 再次执行同一 item,若此刻释放了额度,重投的消息在 ConfirmRunning 处会因 // reservation 不存在而被丢弃 —— item 永久停在 Processing。因此必须只在 // item 真的进入终态时释放,靠回查 run log 投影判定,不靠猜。 + // + // ★ 把释放凭据挂到 ctx:本层判定"未终态、保留 reservation"之后,err 会继续冒到更外层的 + // HandleEventErr,那里的 completeItemRunOnUnretriableErr 兜底把 item 落成 Fail + // —— 那一步发生在本层**之外**,本层再没有机会释放,reservation 就永久泄漏了 + // (无 TTL 清理、无对账,见 §B1)。凭据让外层能在落 Fail 之后补一次释放, + // 而不必把 Scope / guard 这些中心调度概念泄进 HandleEventErr 的签名。 + event.WithCtxCentralQuotaHeld(ctx, expt.SchedulerScope) + execErr := next(ctx, event) e.releaseQuotaIfItemTerminal(ctx, event, expt.SchedulerScope, guard, execErr) return execErr @@ -391,6 +444,12 @@ func (e *ExptItemEventEvalServiceImpl) HandleEventErr(next RecordEvalEndPoint) R return errorx.Wrapf(err, "complete expt fail, expt_id: %v, expt_run_id: %v", event.ExptID, event.ExptRunID) } + // ★ 欠费终止同样要归还额度:整个实验已 Terminated,这条 item 的 run log 却停在 + // Processing(本分支不落 item 终态),内层额度闸按状态判定时又已经返回 —— 不补这一次 + // 释放,该 item 的预占就跟着终止的实验一起永久留在账本里。 + // 每条 item 消息都会各自走到这里(IsInDebt 是空间级配置),所以按 item 粒度释放即可覆盖全 run。 + e.releaseCentralQuotaOutsideGate(ctx, event, nextErr, "expt terminated on indebt") + return nil } @@ -414,10 +473,59 @@ func (e *ExptItemEventEvalServiceImpl) HandleEventErr(next RecordEvalEndPoint) R // 此处按 CompleteItemRun 同样的字段兜底(status=Fail + err_msg + result_state=Logged), 幂等可重复写。 e.completeItemRunOnUnretriableErr(ctx, event, nextErr) + // ★ 顺序不能反:必须先落 Fail 再释放。 + // 落 Fail 让 item 不再计入并发占用,此时归还额度才是守恒的;反过来先释放, + // 会出现"额度已归还但 item 仍算 Processing"的窗口,下一拍调度器按虚高的占用少派 item。 + e.releaseCentralQuotaOutsideGate(ctx, event, nextErr, "item unretriable pre-exec failure") + return nil } } +// releaseCentralQuotaOutsideGate 在额度闸之外的终态路径上释放中心调度额度预占。 +// +// 补的是 HandleCentralReservation 够不着的两条路径 —— 它们都在额度闸的**外层**把 +// item / 实验判成终态,而额度闸按 run log 状态判定时那些状态还没写下去: +// +// ① 不可重试的前置失败:BuildExptRecordEvalCtx 等阶段失败,item 由 +// completeItemRunOnUnretriableErr 兜底落 Fail +// ② 欠费终止:整个实验落 Terminated,item run log 不动 +// +// 没有这一步,这两条路径上的额度**永久泄漏**:既无 reservation TTL 清理,也还没有对账(§B1), +// 现象是"额度慢慢跑满后整个 Scope 再也调度不动",且看起来像上限配小了。 +// +// 靠 ctx 凭据而非回查 DB 判定是否需要释放:凭据由额度闸在取得执行权后写入, +// 天然排除掉 legacy 实验与在取得执行权之前就被丢弃的消息,也免去一次 experiment 查询。 +// +// 与额度闸内的 releaseQuotaIfItemTerminal 重复释放是安全的:Release 落到 Redis 是 +// HDEL + used 递减,同一 field 二次删除不生效,不会把 used 扣成负数。 +// +// best-effort:释放失败只告警。让"额度归还失败"阻断终态收口会把额度泄漏升级成实验不收敛。 +func (e *ExptItemEventEvalServiceImpl) releaseCentralQuotaOutsideGate(ctx context.Context, event *entity.ExptItemEvalEvent, evalErr error, reasonPrefix string) { + if event == nil || evalErr == nil || e.centralGuard == nil { + return + } + + schedulerScope, held := event.CtxCentralQuotaHeldScope(ctx) + if !held { + // 没持有预占:legacy 实验,或消息在取得执行权之前就被丢弃/失败了。 + return + } + + // 独立超时的 ctx:主 ctx 可能已因执行失败被取消,而释放必须尽力完成。 + relCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), exptRunLogPersistTimeout) + defer cancel() + + reason := reasonPrefix + ": " + evalErr.Error() + if err := e.centralGuard.Release(relCtx, schedulerScope, event.ExptRunID, event.EvalSetItemID, reason); err != nil { + logs.CtxWarn(relCtx, "[CentralReservation] release quota outside gate fail, scope: %v, expt_run_id: %v, item_id: %v, err: %v", + schedulerScope, event.ExptRunID, event.EvalSetItemID, err) + return + } + logs.CtxInfo(relCtx, "[CentralReservation] quota released outside gate, scope: %v, expt_run_id: %v, item_id: %v, reason: %v", + schedulerScope, event.ExptRunID, event.EvalSetItemID, reason) +} + // completeItemRunOnUnretriableErr 将 item 落为 Fail 并写入错误信息。 // 仅在"失败且不可重试"时调用; 写库失败只告警不影响主流程(僵尸清理仍是最后防线)。 // From 72e27b14341d9aa7e76963273ec1ce44fcc21469 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 19:45:11 +0800 Subject: [PATCH 19/58] =?UTF-8?q?feat(evaluation):=20priority=20=E9=9C=80?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=89=8D=E8=83=BD=E6=8C=87=E5=AE=9A=EF=BC=8C?= =?UTF-8?q?trigger=20=E5=8F=AF=E4=BF=A1=E6=9D=A5=E6=BA=90=E6=94=B9?= =?UTF-8?q?=E6=8C=89=20caller=20PSM=20=E5=88=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品要求「只有 Fornax 管理员才能在发起实验时选择 priority,其他人一律走 default」。 此前**完全没有门控**:priority_level 在 IDL 里带 json/form 绑定标签(field 92,Submit 与 Create 两个入口都有),任何有 createLoopEvaluationExperiment 权限的调用方都能填 99 插队, 且不违反任何校验、不报错。中心调度按严格优先级排序,这意味着一个人把自己所有实验设成 99 就能让别人的实验饿死。 两道闸,都在 application 层: expt_priority_white_list 谁能指定 priority(user_emails / space_ids / caller_psms,OR) expt_trigger_trust_conf 谁能自称 evalx 从而进 enforce(按 caller PSM) 为什么不放 Authorization 层:商业版 allowlist_decorator 对特定 caller+method 直接 跳过整个 Authorization,门控放那里会被整条绕过。CreateExperiment 是四个入口 (EvalX / 控制台 / OpenAPI / 定时)的唯一汇聚点,Submit 也转成本请求后调进来。 ★ 两个缺省方向刻意相反,因为失败代价不对称: priority 配不到 → 拒绝。大家退回缺省优先级,可见且无损 trigger 配不到 → 放行。若一律拒绝会让全部 EvalX 实验静默退回 legacy, 中心调度突然没有任何候选,现象是"实验都在跑但一个都不受额度管控" trigger 闸另有独立 enabled 开关,默认关闭:先在灰度确认 PSM 名单无误再打开, 不打断当前靠自报 evalx 的测试路径。上线前必须打开(已记入 spec 待办)。 ★ 用 user_email 而非 user_id:名单靠人维护、靠人 review,zhangsan@bytedance.com 一眼知道是谁而 7123456789012345678 要另查一次 —— 加错人是"给了插队权", 最不该靠肉眼比对 19 位数字来防。邮箱取自已验证的 ByteTIM ticket claim (商业版 CtxUser 中间件),不是请求体字段,调用方无法伪造,故可作授权键。 同理 trigger 判据用 kitex caller 而非请求体里的 trigger_type —— 后者谁都能自称 evalx。 ★ space_ids 的正确用法是「只有管理员在的私有空间」,此时它是受控人员名单的代理。 类型注释里写明了红线:绝不填普通业务空间(谁都能建实验、成员随时增减)。 ★ 新增 ConfigIDList:19 位雪花 ID 的配置读写两侧要求恰好相反 —— 写入侧 bytedcli tcc 把 JSON number 当 double 会静默截断,读取侧 encoding/json 解到 []int64 时字符串形态直接报错、整份配置回落缺省值。两种失败都不报错给运维, 现象都是"配了却不生效"。故两种写法都接受(ParseInt 不经 float64)。 附带修复:加接口方法后三个包的**手写** fake configer(非 mockgen 生成)编译失败 —— infra/storage、infra/repo/target、infra/repo/evaluator,已补齐新方法。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/application/experiment_app.go | 90 +++++++ .../experiment_priority_whitelist_test.go | 230 +++++++++++++++++ .../evaluation/domain/component/conf.go | 6 + .../domain/component/mocks/expt_configer.go | 28 ++ .../domain/entity/expt_priority_conf.go | 241 ++++++++++++++++++ .../domain/entity/expt_priority_conf_test.go | 227 +++++++++++++++++ .../evaluator/evaluator_record_impl_test.go | 8 + .../repo/target/eval_target_repo_impl_test.go | 8 + .../infra/storage/record_data_test.go | 8 + .../modules/evaluation/pkg/conf/configer.go | 19 ++ 10 files changed, 865 insertions(+) create mode 100644 backend/modules/evaluation/application/experiment_priority_whitelist_test.go create mode 100644 backend/modules/evaluation/domain/entity/expt_priority_conf.go create mode 100644 backend/modules/evaluation/domain/entity/expt_priority_conf_test.go diff --git a/backend/modules/evaluation/application/experiment_app.go b/backend/modules/evaluation/application/experiment_app.go index 4346e7eccf..5130c03c0a 100644 --- a/backend/modules/evaluation/application/experiment_app.go +++ b/backend/modules/evaluation/application/experiment_app.go @@ -12,9 +12,11 @@ import ( "time" "github.com/bytedance/gg/gptr" + "github.com/cloudwego/kitex/pkg/utils/kitexutil" "github.com/coze-dev/coze-loop/backend/infra/backoff" "github.com/coze-dev/coze-loop/backend/infra/idgen" + "github.com/coze-dev/coze-loop/backend/infra/middleware/session" "github.com/coze-dev/coze-loop/backend/kitex_gen/base" "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation" "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/domain/common" @@ -206,6 +208,18 @@ func (e *experimentApplication) CreateExperiment(ctx context.Context, req *expt. errorx.WithExtraMsg(fmt.Sprintf("space %d is not allowed to create multi-set experiments", req.GetWorkspaceID()))) } + // ★ 调度优先级白名单闸:未获授权的调用方申报的 priority 一律丢弃、强制走缺省值。 + // + // 放在这里(而不是 Authorization 层)有硬性原因:商业版的 auth allowlist decorator + // 对特定 caller+method 会直接跳过整个 Authorization,门控做在那一层会被整条绕过。 + // CreateExperiment 是四个入口(EvalX / 控制台 / OpenAPI / 定时)的唯一汇聚点, + // SubmitExperiment 也是转成本请求后调进来的,卡在此处即全覆盖。 + e.enforcePriorityWhiteList(ctx, req) + + // ★ trigger 可信来源闸:不可信调用方自称 evalx 时,把 trigger 降级掉。 + // 与上面同理放在此处 —— "谁被中心调度纳管"必须由我们的名单决定,不能靠调用方自报。 + e.enforceTriggerTrust(ctx, req) + // 收集 evaluator_version_id(包含顺序解析 EvaluatorIDVersionList)、runconfig 和 score weight evalVersionIDs, evaluatorVersionRunConfigs, evaluatorScoreWeights, err := e.resolveEvaluatorVersionIDsFromCreateReq(ctx, req) if err != nil { @@ -247,6 +261,82 @@ func (e *experimentApplication) CreateExperiment(ctx context.Context, req *expt. }, nil } +// enforcePriorityWhiteList 未获授权的调用方申报的 priority 一律清空,改由下游按缺省值处理。 +// +// 为什么需要这道闸:priority 在中心调度下参与严格优先级排序,高优实验持续抢占额度。 +// 该字段在 IDL 里带 json/form 绑定标签,任何有建实验权限的调用方都能填 —— 不设闸的话 +// 一个人把自己所有实验设成 99 就能让别人的实验饿死,且不违反任何校验、不报错。 +// +// **静默降级而不报错**,这是刻意的取舍: +// - 报错的好处是调用方立刻知道无权限,但会破坏兼容 —— priority 字段已在 IDL 里存在一段时间, +// 可能已有内部调用方在传(它们原本只是"传了个不生效的字段"),上线后突然报错会把它们打挂 +// - 因此改为清空 + Warn 日志(带 user/space/caller/被丢弃的值)。灰度期从日志即可看出 +// "谁在尝试指定 priority",据此决定要不要加进白名单,而不是等对方来报障 +// +// 清空而非改写成 DefaultExptPriorityLevel:缺省值的权威定义在下游 +// (entity.NormalizeExptPriorityLevel 会把 0 收敛为缺省),在此写死会多出一处需要同步的常量。 +func (e *experimentApplication) enforcePriorityWhiteList(ctx context.Context, req *expt.CreateExperimentRequest) { + // 没申报就无需判定:省掉一次配置读取,也避免给未使用该字段的调用方刷无关日志。 + if req == nil || !req.IsSetPriorityLevel() { + return + } + + // caller PSM 取自 RPC 框架填充的 caller 字段,调用方无法在业务参数里伪造。 + // 这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段,任何人都能自称 "evalx", + // 因此绝不能拿 trigger_type 当授权判据。 + callerPSM, _ := kitexutil.GetCaller(ctx) + + // 邮箱取自已验证的 ByteTIM ticket claim(商业版 CtxUser 中间件写入),不是请求体字段, + // 因此可以当授权键用。用邮箱而非 user_id 是为了让这份名单人可读、便于 review。 + userEmail := "" + if u, ok := session.UserInCtx(ctx); ok && u != nil { + userEmail = u.Email + } + + subject := entity.ExptPrioritySubject{ + UserEmail: userEmail, + SpaceID: req.GetWorkspaceID(), + CallerPSM: callerPSM, + } + if e.configer.GetExptPriorityWhiteList(ctx).AllowSpecifyPriority(subject) { + return + } + + logs.CtxWarn(ctx, "[ExptPriority] caller not allowed to specify priority_level, falling back to default; "+ + "requested: %d, user_email: %v, space_id: %v, caller_psm: %v", + req.GetPriorityLevel(), userEmail, subject.SpaceID, callerPSM) + req.PriorityLevel = nil +} + +// enforceTriggerTrust 不可信调用方自称 evalx 时,把 trigger_type 降级为 manual。 +// +// 为什么需要:enforce 的第一道闸只比对请求体里的 trigger_type 字符串,而那是调用方 +// **自己填的普通字段** —— 任何人都能自称 "evalx" 从而让实验进入中心调度。也就是说 +// "谁被中心调度纳管"部分取决于调用方自报,而它本该完全由我们的名单决定。 +// 本闸把判据换成 RPC 框架填充的 caller,那个调用方无法在业务参数里伪造。 +// +// 降级为 manual 而不是报错:与 priority 闸同样的兼容考虑 —— 报错会打挂已经在自报 evalx 的 +// 调用方(它们此前是被接受的)。降级后实验照常创建、只是走 legacy 链路,功能不受影响, +// 而 Warn 日志让我们能在灰度期看清"谁在自称 evalx",据此补名单。 +// +// 只处理 evalx:其它 trigger(manual / openapi / schedule)不触发 enforce, +// 伪造它们不产生特权,没必要为此增加一道可能误伤的校验。 +func (e *experimentApplication) enforceTriggerTrust(ctx context.Context, req *expt.CreateExperimentRequest) { + if req == nil || !entity.ShouldEnforceByTrigger(req.GetTriggerType()) { + return + } + + callerPSM, _ := kitexutil.GetCaller(ctx) + if e.configer.GetExptTriggerTrustConf(ctx).TrustEvalxTrigger(callerPSM) { + return + } + + logs.CtxWarn(ctx, "[ExptTrigger] caller not trusted to declare evalx trigger, downgrading to manual; "+ + "declared: %v, space_id: %v, caller_psm: %v", + req.GetTriggerType(), req.GetWorkspaceID(), callerPSM) + req.TriggerType = gptr.Of(domain_expt.Manual) +} + func (e *experimentApplication) CreateExperimentTemplate(ctx context.Context, req *expt.CreateExperimentTemplateRequest) (r *expt.CreateExperimentTemplateResponse, err error) { session := entity.NewSession(ctx) if req.Session != nil && req.Session.UserID != nil { diff --git a/backend/modules/evaluation/application/experiment_priority_whitelist_test.go b/backend/modules/evaluation/application/experiment_priority_whitelist_test.go new file mode 100644 index 0000000000..bf5ff9fa21 --- /dev/null +++ b/backend/modules/evaluation/application/experiment_priority_whitelist_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package application + +import ( + "context" + "testing" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + domain_expt "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/domain/expt" + "github.com/coze-dev/coze-loop/backend/infra/middleware/session" + "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/expt" + componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// ctxWithEmail 造一个带已验证邮箱的 ctx。生产链路里这份邮箱由商业版 CtxUser 中间件 +// 从 ByteTIM ticket claim 写入(不是请求体字段),所以它可以当授权键用。 +func ctxWithEmail(email string) context.Context { + return session.WithCtxUser(context.Background(), &session.User{ID: "123", Email: email}) +} + +// enforcePriorityWhiteList 的行为是**静默清空**而不是报错,所以这些用例断言的是 +// "req.PriorityLevel 有没有被清掉",而不是返回值 —— 该函数无返回值正是这个设计的体现。 + +func TestEnforcePriorityWhiteList_ClearsWhenNotAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + // 空白名单 = 谁都不许指定 + mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()).Return(&entity.ExptPriorityWhiteList{}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(99)), + } + + app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel, + "未获授权的申报值必须被清空,否则任何人都能设 99 插队") +} + +func TestEnforcePriorityWhiteList_KeepsWhenUserAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). + Return(&entity.ExptPriorityWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(99)), + } + + app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) + + assert.NotNil(t, req.PriorityLevel, "白名单用户的申报值必须保留") + assert.Equal(t, int32(99), req.GetPriorityLevel()) +} + +func TestEnforcePriorityWhiteList_KeepsWhenSpaceAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). + Return(&entity.ExptPriorityWhiteList{SpaceIDs: []int64{456}}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(50)), + } + + // user 不在名单里,靠 space 维度放行(验证 OR 语义在真实调用链上也成立) + app.enforcePriorityWhiteList(ctxWithEmail("nobody@bytedance.com"), req) + + assert.Equal(t, int32(50), req.GetPriorityLevel()) +} + +// TestEnforcePriorityWhiteList_SkipsConfigReadWhenUnset 没申报 priority 时不读配置。 +// +// 断言方式是"不给 mock 设 EXPECT" —— gomock 在发生未预期调用时会失败, +// 因此这个用例同时钉住了"不发无谓的配置读取"和"不给未使用该字段的调用方刷日志"。 +func TestEnforcePriorityWhiteList_SkipsConfigReadWhenUnset(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + app := &experimentApplication{configer: mockConfiger} + + req := &expt.CreateExperimentRequest{WorkspaceID: 456} // 未设 PriorityLevel + app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel) +} + +// TestEnforcePriorityWhiteList_NilSafe req/session 为 nil 时不得 panic。 +// 这道闸在热路径上,一次 panic 会打挂整个创建实验接口。 +func TestEnforcePriorityWhiteList_NilSafe(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + app := &experimentApplication{configer: mockConfiger} + + // req 为 nil:直接返回,不读配置 + app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), nil) + + // ctx 里没有用户信息(拿不到邮箱)但有申报值:需要读配置,邮箱按空串处理 + mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). + Return(&entity.ExptPriorityWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(99)), + } + app.enforcePriorityWhiteList(context.Background(), req) + assert.Nil(t, req.PriorityLevel, "拿不到用户身份时不得放行") +} + +// TestEnforcePriorityWhiteList_ConfigReadFailureDeniesByDefault 配置读取失败(返回 nil)时拒绝。 +// +// configer 的实现在读取失败时回落到 DefaultExptPriorityWhiteList(),但这里直接喂 nil +// 做纵深防御:万一将来某个实现真的返回了 nil,行为也必须是拒绝而不是 panic 或放行。 +func TestEnforcePriorityWhiteList_ConfigReadFailureDeniesByDefault(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()).Return(nil) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(99)), + } + + app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel, + "配置不可判定时必须拒绝 —— priority 是插队能力,读不到配置宁可不给") +} + + +// ---- enforceTriggerTrust ---- + +// TestEnforceTriggerTrust_DowngradesUntrustedEvalx 不可信调用方自称 evalx 时降级为 manual。 +// +// 这道闸的意义:enforce 的第一道判据只看请求体里的 trigger_type 字符串,那是调用方自己填的 +// —— 不设闸的话"谁被中心调度纳管"部分取决于调用方自报,而它本该完全由我们的名单决定。 +func TestEnforceTriggerTrust_DowngradesUntrustedEvalx(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + // 启用校验且名单里没有本次 caller(测试环境下 caller 为空) + mockConfiger.EXPECT().GetExptTriggerTrustConf(gomock.Any()). + Return(&entity.ExptTriggerTrustConf{Enabled: true, EvalxCallerPSMs: []string{"stone.cozeloop.evalx"}}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + TriggerType: gptr.Of(domain_expt.Evalx), + } + + app.enforceTriggerTrust(context.Background(), req) + + assert.Equal(t, domain_expt.Manual, req.GetTriggerType(), + "不可信调用方声明的 evalx 必须被降级,否则任何人都能让实验进 enforce") +} + +// TestEnforceTriggerTrust_KeepsWhenDisabled 未启用校验时保持原样(缺省行为,不额外收紧)。 +func TestEnforceTriggerTrust_KeepsWhenDisabled(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptTriggerTrustConf(gomock.Any()). + Return(&entity.ExptTriggerTrustConf{Enabled: false}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + TriggerType: gptr.Of(domain_expt.Evalx), + } + + app.enforceTriggerTrust(context.Background(), req) + + assert.Equal(t, domain_expt.Evalx, req.GetTriggerType(), "未启用校验时不得改动 trigger") +} + +// TestEnforceTriggerTrust_IgnoresNonEvalxTriggers 只处理 evalx。 +// +// 其它 trigger 不触发 enforce,伪造它们不产生特权 —— 为此增加校验只会带来误伤风险。 +// 断言方式是"不给 mock 设 EXPECT":一旦实现去读了配置,gomock 会因未预期调用而失败。 +func TestEnforceTriggerTrust_IgnoresNonEvalxTriggers(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + app := &experimentApplication{configer: mockConfiger} + + for _, trigger := range []string{domain_expt.Manual, domain_expt.OpenAPI, domain_expt.Schedule} { + req := &expt.CreateExperimentRequest{WorkspaceID: 456, TriggerType: gptr.Of(trigger)} + app.enforceTriggerTrust(context.Background(), req) + assert.Equal(t, trigger, req.GetTriggerType(), "非 evalx trigger 不得被改动") + } + + // 未设 trigger 同样不该读配置 + reqUnset := &expt.CreateExperimentRequest{WorkspaceID: 456} + app.enforceTriggerTrust(context.Background(), reqUnset) +} + +// TestEnforceTriggerTrust_NilSafe req 为 nil 时不得 panic(这道闸在创建实验热路径上)。 +func TestEnforceTriggerTrust_NilSafe(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + app := &experimentApplication{configer: componentMocks.NewMockIConfiger(ctrl)} + app.enforceTriggerTrust(context.Background(), nil) +} diff --git a/backend/modules/evaluation/domain/component/conf.go b/backend/modules/evaluation/domain/component/conf.go index f9d85c7ac3..0d4a412f10 100644 --- a/backend/modules/evaluation/domain/component/conf.go +++ b/backend/modules/evaluation/domain/component/conf.go @@ -35,6 +35,12 @@ type IConfiger interface { GetExptTurnResultFilterBmqProducerCfg(ctx context.Context) *entity.BmqProducerCfg GetCKDBName(ctx context.Context) *entity.CKDBConfig GetExptExportWhiteList(ctx context.Context) *entity.ExptExportWhiteList + // GetExptPriorityWhiteList 谁可以在发起实验时指定调度优先级(user / space / caller PSM 三维 OR)。 + // 未命中者的申报值被忽略、强制走缺省优先级。读取失败时按"谁都不许"处理。 + GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList + // GetExptTriggerTrustConf 谁可以自称 EvalX 从而让实验进入 enforce(按 caller PSM 判定)。 + // 缺省不启用校验 —— 一律拒绝会让全部 EvalX 实验静默退回 legacy,比"配了没生效"隐蔽。 + GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf GetMaintainerUserIDs(ctx context.Context) map[string]bool GetSchedulerAbortCtrl(ctx context.Context) *entity.SchedulerAbortCtrl GetTargetTrajectoryConf(ctx context.Context) *entity.TargetTrajectoryConf diff --git a/backend/modules/evaluation/domain/component/mocks/expt_configer.go b/backend/modules/evaluation/domain/component/mocks/expt_configer.go index 8d80d1f94b..0fd5aa59a5 100644 --- a/backend/modules/evaluation/domain/component/mocks/expt_configer.go +++ b/backend/modules/evaluation/domain/component/mocks/expt_configer.go @@ -197,6 +197,20 @@ func (mr *MockIConfigerMockRecorder) GetExptMultiSetWhiteList(ctx any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptMultiSetWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptMultiSetWhiteList), ctx) } +// GetExptPriorityWhiteList mocks base method. +func (m *MockIConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExptPriorityWhiteList", ctx) + ret0, _ := ret[0].(*entity.ExptPriorityWhiteList) + return ret0 +} + +// GetExptPriorityWhiteList indicates an expected call of GetExptPriorityWhiteList. +func (mr *MockIConfigerMockRecorder) GetExptPriorityWhiteList(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptPriorityWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptPriorityWhiteList), ctx) +} + // GetExptTemplateUpdateEvalSetWhiteList mocks base method. func (m *MockIConfiger) GetExptTemplateUpdateEvalSetWhiteList(ctx context.Context) *entity.ExptTemplateUpdateEvalSetWhiteList { m.ctrl.T.Helper() @@ -211,6 +225,20 @@ func (mr *MockIConfigerMockRecorder) GetExptTemplateUpdateEvalSetWhiteList(ctx a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTemplateUpdateEvalSetWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptTemplateUpdateEvalSetWhiteList), ctx) } +// GetExptTriggerTrustConf mocks base method. +func (m *MockIConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetExptTriggerTrustConf", ctx) + ret0, _ := ret[0].(*entity.ExptTriggerTrustConf) + return ret0 +} + +// GetExptTriggerTrustConf indicates an expected call of GetExptTriggerTrustConf. +func (mr *MockIConfigerMockRecorder) GetExptTriggerTrustConf(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTriggerTrustConf", reflect.TypeOf((*MockIConfiger)(nil).GetExptTriggerTrustConf), ctx) +} + // GetExptTurnResultFilterBmqProducerCfg mocks base method. func (m *MockIConfiger) GetExptTurnResultFilterBmqProducerCfg(ctx context.Context) *entity.BmqProducerCfg { m.ctrl.T.Helper() diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf.go b/backend/modules/evaluation/domain/entity/expt_priority_conf.go new file mode 100644 index 0000000000..d9a3b235f9 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_priority_conf.go @@ -0,0 +1,241 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" +) + +// ExptPriorityWhiteList 决定"谁可以在发起实验时指定调度优先级"。 +// +// 为什么需要这道闸:priority 在中心调度下参与**严格优先级排序**,高优实验会持续抢占额度。 +// 字段本身在 IDL 里带 json/form 绑定标签,任何有建实验权限的调用方都能填 —— 不加限制的话 +// 一个人把自己所有实验设成 99 就能让别人的实验饿死,而这既不违反任何校验也不会报错。 +// +// ★ 三个维度,全部由**我们**维护,不接受用户自助配置: +// +// UserEmails —— 点名的自然人(邮箱,人可读) +// SpaceIDs —— 点名的空间 +// CallerPSMs —— 点名的可信服务 +// +// ⚠️ SpaceIDs 的正确用法是「**只有管理员在的私有空间**」:给那样的空间开白名单, +// 等价于给一份受控的人员名单开白名单。**绝不要**把普通业务空间填进来 —— +// 业务空间里谁都能建实验、成员还会随时增减,那等于把插队权下放给一群不确定的人, +// 而这份特权名单必须始终由我们掌握。 +// +// 与 enforce 灰度(`central_expt_scheduler_space_config`)的性质区别值得记住: +// 那份按空间/评测对象划范围是**运维范围**(谁被中心调度纳管);本表是**特权授予** +// (谁能抢资源)。前者配错只是纳管范围不对,后者配错是有人能插队。 +// +// 三个维度之间是 **OR**:命中任意一个即放行。不能取 AND —— CallerPSMs 服务的是系统调用方, +// 它没有自然人 user,取 AND 会让这一维永远走不通。 +type ExptPriorityWhiteList struct { + // UserEmails 可指定 priority 的用户邮箱。 + // + // 用邮箱而不是 user_id:这份名单由人手工维护、也要靠人 review, + // `zhangsan@bytedance.com` 一眼就知道是谁,而 `7123456789012345678` 需要另查一次才能确认 + // —— 加错人是"给了插队权",是最不该靠肉眼比对长数字来防的错误。 + // + // 邮箱取自**已验证的 ByteTIM ticket claim**(见商业版 infra/middleware/user.go), + // 不是请求体里的字段,调用方无法伪造,因此可以当授权键用。 + UserEmails []string `json:"user_emails" mapstructure:"user_emails"` + // SpaceIDs 整个空间放行。**只填管理员私有空间**,理由见类型注释。 + // 类型见 ConfigIDList:数字与字符串两种写法都接受(19 位雪花 ID 建议写字符串)。 + SpaceIDs ConfigIDList `json:"space_ids" mapstructure:"space_ids"` + // CallerPSMs 可信调用方 PSM(如 EvalX 的服务名)。用于系统调用方 —— 它们没有自然人身份。 + // + // ⚠️ 只能填**内部 RPC 直连**的 PSM。它取自 kitex 的 caller 字段,由框架按调用方身份填充, + // 调用方无法在业务参数里伪造;这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段, + // 任何人都能自称 "evalx",因此**绝不能**用 trigger_type 当授权判据。 + CallerPSMs []string `json:"caller_psms" mapstructure:"caller_psms"` + // AllowAll 全部放行。仅用于"暂时不限制"的过渡期,不建议长期开启。 + AllowAll bool `json:"allow_all" mapstructure:"allow_all"` +} + +// ConfigIDList 是人工维护配置里的 ID 列表,**数字与字符串两种写法都接受**。 +// +// 为什么需要它:19 位雪花 ID(空间 ID / 用户 ID)超出 IEEE-754 float64 安全整数范围 +// (2^53 ≈ 9.007e15),而配置的读写两侧对"该写哪种形态"的要求恰好相反: +// +// 写入侧:`bytedcli tcc config update` 把 JSON number 当 double 处理, +// 7533128632407949313 会被静默截断成 7533128632407949000(实测) +// 读取侧:服务端用 encoding/json 直接解到 []int64,裸数字精确无损, +// 但字符串形态会直接报 cannot unmarshal string into int64 +// +// 也就是说:为了写得进去要用字符串,为了读得出来要用数字。任何一种单一形态都会在 +// 某一侧出问题,而两种失败都**不报错给运维**(写入侧静默截断、读取侧整份配置解析失败后 +// 回落到缺省值),现象都是"配了却不生效",且截断后的 ID 与原值肉眼几乎一样,极难反推。 +// +// 因此这里两种都吃:运维怎么写都对。 +type ConfigIDList []int64 + +// UnmarshalJSON 用 strconv.ParseInt 而非 unmarshal 到 float64 再转, +// 后者会在解析阶段就丢掉精度 —— 那正是本类型要防的问题。 +func (l *ConfigIDList) UnmarshalJSON(data []byte) error { + var raw []json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + out := make(ConfigIDList, 0, len(raw)) + for _, item := range raw { + s := strings.TrimSpace(string(item)) + // 去掉字符串形态的引号;裸数字保持原样。 + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + s = strings.TrimSpace(s[1 : len(s)-1]) + } + if s == "" { + continue + } + id, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("invalid id %q in config id list: %w", s, err) + } + out = append(out, id) + } + + *l = out + return nil +} + +// MarshalJSON 统一输出字符串形态:服务若把配置回写或转发给下游, +// 字符串能避免在那一跳再被某个把 JSON number 当 double 的工具截断。 +func (l ConfigIDList) MarshalJSON() ([]byte, error) { + out := make([]string, 0, len(l)) + for _, id := range l { + out = append(out, strconv.FormatInt(id, 10)) + } + return json.Marshal(out) +} + +// DefaultExptPriorityWhiteList 配置缺失或解析失败时的兜底:**谁都不许指定**。 +// +// 取禁止而非放行:priority 是插队能力,配置中心抖动时宁可让所有人退回 default(表现为 +// "我设的优先级没生效",可见且无损),也不要因为读不到配置就让所有人都能插队 +// (静默、且要等资源被抢占才发现)。 +func DefaultExptPriorityWhiteList() *ExptPriorityWhiteList { + return &ExptPriorityWhiteList{} +} + +// ExptPrioritySubject 是判定的输入:一次创建实验请求里与"能否指定 priority"有关的全部身份。 +// +// 收成一个结构体而不是散着传:三者都是可选的(自然人调用时 CallerPSM 为空, +// 系统调用时 UserID 可能为空),散着传容易在新增维度时漏掉调用点。 +type ExptPrioritySubject struct { + // UserEmail 已验证的用户邮箱(来自 session)。空串表示拿不到身份。 + UserEmail string + SpaceID int64 + CallerPSM string +} + +// AllowSpecifyPriority 报告该请求是否可以指定调度优先级。 +// +// nil 白名单返回 false(与本仓其它白名单一致:没配置就是没开)。 +func (w *ExptPriorityWhiteList) AllowSpecifyPriority(subject ExptPrioritySubject) bool { + if w == nil { + return false + } + if w.AllowAll { + return true + } + if w.matchUserEmail(subject.UserEmail) { + return true + } + // spaceID=0 表示"无空间上下文",一律不匹配 —— 否则运维在 space_ids 里误填 0 + // 就会把所有无空间上下文的请求放行。 + if subject.SpaceID != 0 && slices.Contains(w.SpaceIDs, subject.SpaceID) { + return true + } + return w.matchCallerPSM(subject.CallerPSM) +} + +// matchUserEmail 忽略大小写与首尾空白比对(邮箱本身大小写不敏感,且名单由人手写)。 +// +// 空邮箱一律不匹配:拿不到身份时不得放行 —— 不把"读不到"当成有权限。 +// 注意商业版只在 ByteTIM ticket 存在时才填 Email,所以非用户态调用(纯服务间 RPC) +// 这里必然为空,那种情况该走 CallerPSMs 维度。 +func (w *ExptPriorityWhiteList) matchUserEmail(userEmail string) bool { + email := strings.TrimSpace(userEmail) + if email == "" { + return false + } + for _, e := range w.UserEmails { + if strings.EqualFold(strings.TrimSpace(e), email) { + return true + } + } + return false +} + +// matchCallerPSM 忽略大小写与首尾空白比对。 +// +// 容忍这两者是因为 PSM 由人手写进 TCC,"Stone.CozeLoop.Foo " 这类笔误的后果是静默不放行 +// —— 配了却不生效,而且两个字符串肉眼几乎一样,极难反推。 +// 空 caller 一律不匹配:非 RPC 直连(HTTP 入口)时 caller 为空,那种情况该走 user/space 维度。 +func (w *ExptPriorityWhiteList) matchCallerPSM(callerPSM string) bool { + caller := strings.TrimSpace(callerPSM) + if caller == "" { + return false + } + for _, psm := range w.CallerPSMs { + if strings.EqualFold(strings.TrimSpace(psm), caller) { + return true + } + } + return false +} + +// ExptTriggerTrustConf 决定"谁可以自称 EvalX 从而让实验进入 enforce"。 +// +// 为什么需要:enforce 的第一道闸 ShouldEnforceByTrigger 只比对请求体里的 trigger_type +// 字符串,而那是调用方**自己填的普通字段** —— 任何人都能自称 "evalx"。也就是说 +// "谁被中心调度纳管"实际上部分取决于调用方自报,而它本该完全由我们决定。 +// +// 本表把 trigger 判据从"信自报"改成"信 RPC 框架填充的 caller":声明 evalx 的请求 +// 必须同时来自名单内的 PSM,否则该 trigger 不被采信、实验退回 legacy。 +// +// ⚠️ 为什么缺省是**放行**(与 ExptPriorityWhiteList 的缺省拒绝相反):两者失败代价不同。 +// priority 配不到 → 大家退回缺省优先级,可见且无损;而 trigger 若配不到就一律拒绝, +// 会让**全部 EvalX 实验静默退回 legacy** —— 中心调度突然没有任何候选, +// 现象是"实验都在跑但一个都不受额度管控",比"配了没生效"隐蔽得多。 +// 因此取"未配置=不额外收紧",范围仍由灰度 TCC(第二道闸)兜住, +// 等 PSM 名单在灰度环境确认无误后再打开 Enabled。 +type ExptTriggerTrustConf struct { + // Enabled 是否启用 caller 校验。false(缺省)时保持原行为:只看 trigger_type 字段。 + // + // 用独立开关而不是"名单非空即启用":后者会让"配置读取失败返回空名单"与 + // "运维故意留空"这两种情况行为一致,而它们的正确行为恰好相反。 + Enabled bool `json:"enabled" mapstructure:"enabled"` + // EvalxCallerPSMs 允许声明 trigger_type=evalx 的调用方 PSM。 + EvalxCallerPSMs []string `json:"evalx_caller_psms" mapstructure:"evalx_caller_psms"` +} + +// DefaultExptTriggerTrustConf 缺省:不启用 caller 校验(保持引入本闸前的行为)。 +func DefaultExptTriggerTrustConf() *ExptTriggerTrustConf { + return &ExptTriggerTrustConf{} +} + +// TrustEvalxTrigger 报告该 caller 声明的 evalx trigger 是否可采信。 +// 未启用时恒为 true —— 理由见类型注释里的"缺省放行"。 +func (c *ExptTriggerTrustConf) TrustEvalxTrigger(callerPSM string) bool { + if c == nil || !c.Enabled { + return true + } + caller := strings.TrimSpace(callerPSM) + if caller == "" { + // 启用校验后拿不到 caller(非 RPC 直连)即不可采信: + // 我们信的是框架填充的身份,没有身份就没有可信来源。 + return false + } + for _, psm := range c.EvalxCallerPSMs { + if strings.EqualFold(strings.TrimSpace(psm), caller) { + return true + } + } + return false +} diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go b/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go new file mode 100644 index 0000000000..fcd5755e9e --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go @@ -0,0 +1,227 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExptPriorityWhiteList_NilAndDefaultDenyAll(t *testing.T) { + // nil 与缺省配置都必须拒绝 —— 这是 fail-closed 方向: + // 读不到配置时让所有人退回缺省优先级(可见、无损),而不是静默放开插队。 + var nilList *ExptPriorityWhiteList + assert.False(t, nilList.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), + "nil 白名单必须拒绝") + + assert.False(t, DefaultExptPriorityWhiteList().AllowSpecifyPriority( + ExptPrioritySubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), + "缺省白名单(读取失败时的兜底)必须拒绝") +} + +func TestExptPriorityWhiteList_AllowAll(t *testing.T) { + w := &ExptPriorityWhiteList{AllowAll: true} + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{}), "allow_all 对空 subject 也放行") + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com"})) +} + +func TestExptPriorityWhiteList_ThreeDimensionsAreOR(t *testing.T) { + // 三个维度取 OR 是刻意的:CallerPSMs 服务的是系统调用方(没有自然人 user), + // SpaceIDs 服务的是"管理员私有空间"。取 AND 会让这两条永远走不通。 + w := &ExptPriorityWhiteList{ + UserEmails: []string{"admin@bytedance.com"}, + SpaceIDs: []int64{222}, + CallerPSMs: []string{"stone.cozeloop.evalx"}, + } + + t.Run("只命中 user", func(t *testing.T) { + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) + }) + t.Run("只命中 space", func(t *testing.T) { + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 222, CallerPSM: "other.psm"})) + }) + t.Run("只命中 caller psm", func(t *testing.T) { + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "stone.cozeloop.evalx"})) + }) + t.Run("三个都不命中", func(t *testing.T) { + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) + }) +} + +// TestExptPriorityWhiteList_SpaceIDZeroNeverMatches spaceID=0 表示"无空间上下文", +// 绝不能因为运维在 space_ids 里误填 0 就把所有无空间请求放行。 +func TestExptPriorityWhiteList_SpaceIDZeroNeverMatches(t *testing.T) { + w := &ExptPriorityWhiteList{SpaceIDs: []int64{0}} + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 0}), + "空 spaceID 不得匹配,即使名单里误填了 0") +} + +func TestExptPriorityWhiteList_UserEmailMatching(t *testing.T) { + w := &ExptPriorityWhiteList{UserEmails: []string{"a@bytedance.com", "b@bytedance.com"}} + + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "a@bytedance.com"})) + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: " B@ByteDance.com "}), "容忍首尾空白与大小写(邮箱大小写不敏感,名单由人手写)") + + // 解析不出来一律不放行 —— 不把"读不到身份"当成有权限。 + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: ""}), "空邮箱不得放行 —— 拿不到身份时不把\"读不到\"当成有权限") + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: " "}), "纯空白邮箱不得放行") + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "stranger@bytedance.com"}), "不在名单内") +} + +func TestExptPriorityWhiteList_CallerPSMMatching(t *testing.T) { + w := &ExptPriorityWhiteList{CallerPSMs: []string{"stone.cozeloop.evalx", " stone.cozeloop.foo "}} + + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.evalx"})) + // 忽略大小写与空白:PSM 由人手写进 TCC,笔误的后果是静默不放行(配了却不生效), + // 而两个字符串肉眼几乎一样,极难反推。 + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "Stone.CozeLoop.EvalX"}), "忽略大小写") + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.foo"}), "忽略名单侧的首尾空白") + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: " stone.cozeloop.evalx "}), "忽略入参侧的首尾空白") + + // 空 caller 一律不匹配:HTTP 入口等非 RPC 直连场景 caller 为空, + // 那种情况该走 user 维度,不能因为"没有 caller"就放行。 + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: ""}), "空 caller 不得放行") + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: " "}), "纯空白 caller 不得放行") + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "evil.psm"})) +} + +// TestExptPriorityWhiteList_EmptyListsDoNotMatch 各维度列表为空时不得意外放行。 +// 这道回归钉住的是"空 slice 被当成通配"这类容易写错的实现。 +func TestExptPriorityWhiteList_EmptyListsDoNotMatch(t *testing.T) { + w := &ExptPriorityWhiteList{} + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com", SpaceID: 222, CallerPSM: "any.psm"})) +} + +// ---- ExptTriggerTrustConf ---- + +// TestExptTriggerTrustConf_DefaultDoesNotTighten 缺省**放行**,方向与 priority 白名单刻意相反。 +// +// 理由不对称:priority 配不到 → 大家退回缺省优先级,可见且无损; +// trigger 若配不到就一律拒绝 → 全部 EvalX 实验静默退回 legacy,中心调度突然没有候选, +// 现象是"实验都在跑但一个都不受额度管控",比"配了没生效"隐蔽得多。 +func TestExptTriggerTrustConf_DefaultDoesNotTighten(t *testing.T) { + var nilConf *ExptTriggerTrustConf + assert.True(t, nilConf.TrustEvalxTrigger("anything"), "nil 配置不得额外收紧") + assert.True(t, nilConf.TrustEvalxTrigger(""), "nil 配置下空 caller 也放行") + + assert.True(t, DefaultExptTriggerTrustConf().TrustEvalxTrigger("anything"), + "缺省配置(读取失败兜底)不得额外收紧") +} + +// TestExptTriggerTrustConf_DisabledIgnoresList Enabled=false 时名单不生效。 +// +// 用独立开关而不是"名单非空即启用":后者会让"读取失败返回空名单"与"运维故意留空" +// 行为一致,而这两种情况的正确行为恰好相反。 +func TestExptTriggerTrustConf_DisabledIgnoresList(t *testing.T) { + c := &ExptTriggerTrustConf{Enabled: false, EvalxCallerPSMs: []string{"only.this.psm"}} + assert.True(t, c.TrustEvalxTrigger("some.other.psm"), "未启用时名单不生效") +} + +func TestExptTriggerTrustConf_EnabledChecksCaller(t *testing.T) { + c := &ExptTriggerTrustConf{Enabled: true, EvalxCallerPSMs: []string{"stone.cozeloop.evalx", " stone.cozeloop.foo "}} + + assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.evalx")) + assert.True(t, c.TrustEvalxTrigger("Stone.CozeLoop.EvalX"), "忽略大小写") + assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.foo"), "忽略名单侧空白") + assert.True(t, c.TrustEvalxTrigger(" stone.cozeloop.evalx "), "忽略入参侧空白") + + assert.False(t, c.TrustEvalxTrigger("evil.psm"), "名单外的调用方不得自称 evalx") + // 启用校验后拿不到 caller 即不可采信:我们信的是框架填充的身份,没有身份就没有可信来源。 + assert.False(t, c.TrustEvalxTrigger(""), "启用后空 caller 不可采信") + assert.False(t, c.TrustEvalxTrigger(" "), "启用后纯空白 caller 不可采信") +} + +// TestExptTriggerTrustConf_EnabledWithEmptyListDeniesAll 启用但名单为空 = 谁都不可信。 +// 这是显式配置的结果(Enabled 被手动打开),与"读取失败"不同,因此拒绝是对的。 +func TestExptTriggerTrustConf_EnabledWithEmptyListDeniesAll(t *testing.T) { + c := &ExptTriggerTrustConf{Enabled: true} + assert.False(t, c.TrustEvalxTrigger("any.psm")) +} + +// ---- ConfigIDList ---- + +// TestConfigIDList_AcceptsBothFormsWithoutPrecisionLoss 19 位雪花 ID 两种写法都要精确。 +// +// 这道回归钉住的是一个双向的坑:配置写入侧(bytedcli tcc)把 JSON number 当 double、 +// 会把 7533128632407949313 静默截断成 ...949000,所以运维倾向写字符串; +// 而读取侧用 encoding/json 解到 []int64 时,字符串形态会直接报错、整份配置回落缺省值。 +// 两种失败都不报错给运维,现象都是"配了却不生效"。因此两种写法都必须吃下。 +func TestConfigIDList_AcceptsBothFormsWithoutPrecisionLoss(t *testing.T) { + const want int64 = 7533128632407949313 // 19 位,> 2^53 + + t.Run("裸数字", func(t *testing.T) { + var l ConfigIDList + require.NoError(t, json.Unmarshal([]byte(`[7533128632407949313]`), &l)) + require.Len(t, l, 1) + assert.Equal(t, want, l[0], "低位不得被截断") + }) + + t.Run("字符串", func(t *testing.T) { + var l ConfigIDList + require.NoError(t, json.Unmarshal([]byte(`["7533128632407949313"]`), &l)) + require.Len(t, l, 1) + assert.Equal(t, want, l[0]) + }) + + t.Run("混合写法", func(t *testing.T) { + var l ConfigIDList + require.NoError(t, json.Unmarshal([]byte(`[7533128632407949313, "7590103974980812802"]`), &l)) + assert.Equal(t, ConfigIDList{7533128632407949313, 7590103974980812802}, l) + }) + + t.Run("带空白的字符串", func(t *testing.T) { + var l ConfigIDList + require.NoError(t, json.Unmarshal([]byte(`[" 7533128632407949313 "]`), &l)) + require.Len(t, l, 1) + assert.Equal(t, want, l[0]) + }) +} + +func TestConfigIDList_SkipsEmptyAndRejectsGarbage(t *testing.T) { + var l ConfigIDList + require.NoError(t, json.Unmarshal([]byte(`["", 123]`), &l), "空串跳过而非报错,容忍配置里的空行") + assert.Equal(t, ConfigIDList{123}, l) + + var bad ConfigIDList + assert.Error(t, json.Unmarshal([]byte(`["not-a-number"]`), &bad), + "非数字必须报错 —— 静默跳过会让运维以为配上了") +} + +// TestConfigIDList_MarshalsAsStrings 回写统一输出字符串,避免下游再被 double 截断一次。 +func TestConfigIDList_MarshalsAsStrings(t *testing.T) { + out, err := json.Marshal(ConfigIDList{7533128632407949313}) + require.NoError(t, err) + assert.JSONEq(t, `["7533128632407949313"]`, string(out)) +} + +// TestExptPriorityWhiteList_UnmarshalFullConfig 端到端解一份完整配置, +// 确认字段名与运维实际会写的 JSON 一致(字段名写错的后果是静默不生效)。 +func TestExptPriorityWhiteList_UnmarshalFullConfig(t *testing.T) { + raw := `{ + "user_emails": ["admin@bytedance.com"], + "space_ids": ["7533128632407949313"], + "caller_psms": ["stone.cozeloop.evalx"], + "allow_all": false + }` + + var w ExptPriorityWhiteList + require.NoError(t, json.Unmarshal([]byte(raw), &w)) + + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com"})) + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 7533128632407949313})) + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.evalx"})) + assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "other@bytedance.com", SpaceID: 999})) +} + +// TestExptTriggerTrustConf_UnmarshalFullConfig 同上,钉住 trigger 配置的字段名。 +func TestExptTriggerTrustConf_UnmarshalFullConfig(t *testing.T) { + var c ExptTriggerTrustConf + require.NoError(t, json.Unmarshal([]byte(`{"enabled": true, "evalx_caller_psms": ["stone.cozeloop.evalx"]}`), &c)) + + assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.evalx")) + assert.False(t, c.TrustEvalxTrigger("someone.else")) +} diff --git a/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go b/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go index 7b18e4b263..c4810c38f6 100644 --- a/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go +++ b/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go @@ -89,6 +89,14 @@ func (f *fakeEvaluatorRecordStorageConfiger) GetExptExportWhiteList(ctx context. return nil } +func (f *fakeEvaluatorRecordStorageConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { + return nil +} + +func (f *fakeEvaluatorRecordStorageConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { + return nil +} + func (f *fakeEvaluatorRecordStorageConfiger) GetMaintainerUserIDs(ctx context.Context) map[string]bool { return nil } diff --git a/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go b/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go index 55076423be..905b86d20f 100644 --- a/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go +++ b/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go @@ -2025,6 +2025,14 @@ func (f *fakeRecordStorageConfiger) GetExptExportWhiteList(ctx context.Context) return nil } +func (f *fakeRecordStorageConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { + return nil +} + +func (f *fakeRecordStorageConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { + return nil +} + func (f *fakeRecordStorageConfiger) GetMaintainerUserIDs(ctx context.Context) map[string]bool { return nil } diff --git a/backend/modules/evaluation/infra/storage/record_data_test.go b/backend/modules/evaluation/infra/storage/record_data_test.go index af0319a3c1..fbca977ea7 100644 --- a/backend/modules/evaluation/infra/storage/record_data_test.go +++ b/backend/modules/evaluation/infra/storage/record_data_test.go @@ -54,6 +54,14 @@ func (f *fakeConfiger) GetCKDBName(ctx context.Context) *entity.CKDBConfig { ret func (f *fakeConfiger) GetExptExportWhiteList(ctx context.Context) *entity.ExptExportWhiteList { return nil } + +func (f *fakeConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { + return nil +} + +func (f *fakeConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { + return nil +} func (f *fakeConfiger) GetMaintainerUserIDs(ctx context.Context) map[string]bool { return nil } func (f *fakeConfiger) GetSchedulerAbortCtrl(ctx context.Context) *entity.SchedulerAbortCtrl { return nil diff --git a/backend/modules/evaluation/pkg/conf/configer.go b/backend/modules/evaluation/pkg/conf/configer.go index 4855ac8cdd..589a40b65a 100644 --- a/backend/modules/evaluation/pkg/conf/configer.go +++ b/backend/modules/evaluation/pkg/conf/configer.go @@ -101,6 +101,25 @@ func (c *configer) GetExptExportWhiteList(ctx context.Context) (eec *entity.Expt return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &eec) == nil, eec, entity.DefaultExptExportWhiteList()) } +// GetExptPriorityWhiteList 谁可以在发起实验时指定调度优先级(user / space / caller PSM 三维 OR)。 +// +// 读取失败时回落到 DefaultExptPriorityWhiteList()(**谁都不许指定**)而不是放行: +// priority 是插队能力,配置中心抖动时宁可让所有人退回 default,也不要静默放开插队。 +func (c *configer) GetExptPriorityWhiteList(ctx context.Context) (w *entity.ExptPriorityWhiteList) { + const key = "expt_priority_white_list" + return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &w) == nil, w, entity.DefaultExptPriorityWhiteList()) +} + +// GetExptTriggerTrustConf 谁可以自称 EvalX(按 caller PSM 判定)。 +// +// 读取失败回落到 DefaultExptTriggerTrustConf()(**不启用校验**)。这与 priority 白名单的 +// 方向刻意相反:那边配不到就退回缺省优先级、无损;这边若配不到就一律拒绝,会让全部 +// EvalX 实验静默退回 legacy —— 中心调度突然没有候选,比"配了没生效"隐蔽得多。 +func (c *configer) GetExptTriggerTrustConf(ctx context.Context) (t *entity.ExptTriggerTrustConf) { + const key = "expt_trigger_trust_conf" + return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &t) == nil, t, entity.DefaultExptTriggerTrustConf()) +} + func (c *configer) GetExptTemplateUpdateEvalSetWhiteList(ctx context.Context) (w *entity.ExptTemplateUpdateEvalSetWhiteList) { const key = "expt_template_update_eval_set_white_list" return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &w) == nil, w, entity.DefaultExptTemplateUpdateEvalSetWhiteList()) From b6c844736b5109f75c4b132c1bad1a40bd2da0ca Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 19:54:33 +0800 Subject: [PATCH 20/58] =?UTF-8?q?refactor(evaluation):=20space=5Fids=20?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E7=94=A8=20[]string=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=8F=8C=E5=BD=A2=E6=80=81=20ID=20=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先为兼容"数字与字符串两种写法"造了个 ConfigIDList 自定义类型(含 UnmarshalJSON / MarshalJSON 与一组精度回归测试)。按约定简化:TCC 里的 Space ID 本来就该写字符串, 不需要兼容层。 保留的必要部分: - 比对时把入参 int64 格式化成字符串再比 - spaceID=0 一律不匹配(防运维在名单里误填 "0" 就把所有无空间上下文的请求放行) - 字段注释保留"为什么用字符串"(雪花 ID 超 float64 安全范围、bytedcli 写入会截断), 避免后来人"顺手改成 []int64 更规范" 端到端解析测试仍在,它现在证明的是 19 位 ID 从字符串配置解出后精确匹配。 净减 96 行。 Co-Authored-By: Claude Opus 5 (1M context) --- .../experiment_priority_whitelist_test.go | 2 +- .../domain/entity/expt_priority_conf.go | 89 +++++-------------- .../domain/entity/expt_priority_conf_test.go | 65 ++------------ 3 files changed, 30 insertions(+), 126 deletions(-) diff --git a/backend/modules/evaluation/application/experiment_priority_whitelist_test.go b/backend/modules/evaluation/application/experiment_priority_whitelist_test.go index bf5ff9fa21..d263700983 100644 --- a/backend/modules/evaluation/application/experiment_priority_whitelist_test.go +++ b/backend/modules/evaluation/application/experiment_priority_whitelist_test.go @@ -73,7 +73,7 @@ func TestEnforcePriorityWhiteList_KeepsWhenSpaceAllowed(t *testing.T) { mockConfiger := componentMocks.NewMockIConfiger(ctrl) mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). - Return(&entity.ExptPriorityWhiteList{SpaceIDs: []int64{456}}) + Return(&entity.ExptPriorityWhiteList{SpaceIDs: []string{"456"}}) app := &experimentApplication{configer: mockConfiger} req := &expt.CreateExperimentRequest{ diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf.go b/backend/modules/evaluation/domain/entity/expt_priority_conf.go index d9a3b235f9..dfd9c6f66a 100644 --- a/backend/modules/evaluation/domain/entity/expt_priority_conf.go +++ b/backend/modules/evaluation/domain/entity/expt_priority_conf.go @@ -4,9 +4,6 @@ package entity import ( - "encoding/json" - "fmt" - "slices" "strconv" "strings" ) @@ -45,8 +42,11 @@ type ExptPriorityWhiteList struct { // 不是请求体里的字段,调用方无法伪造,因此可以当授权键用。 UserEmails []string `json:"user_emails" mapstructure:"user_emails"` // SpaceIDs 整个空间放行。**只填管理员私有空间**,理由见类型注释。 - // 类型见 ConfigIDList:数字与字符串两种写法都接受(19 位雪花 ID 建议写字符串)。 - SpaceIDs ConfigIDList `json:"space_ids" mapstructure:"space_ids"` + // + // 用 []string 而非 []int64:19 位雪花 ID 超出 float64 安全整数范围, + // 任何把 JSON number 当 double 的环节都会静默截断低位(实测 bytedcli tcc 写入 + // 7533128632407949313 会回读成 ...949000)。配置里一律写 ["7533..."]。 + SpaceIDs []string `json:"space_ids" mapstructure:"space_ids"` // CallerPSMs 可信调用方 PSM(如 EvalX 的服务名)。用于系统调用方 —— 它们没有自然人身份。 // // ⚠️ 只能填**内部 RPC 直连**的 PSM。它取自 kitex 的 caller 字段,由框架按调用方身份填充, @@ -57,62 +57,6 @@ type ExptPriorityWhiteList struct { AllowAll bool `json:"allow_all" mapstructure:"allow_all"` } -// ConfigIDList 是人工维护配置里的 ID 列表,**数字与字符串两种写法都接受**。 -// -// 为什么需要它:19 位雪花 ID(空间 ID / 用户 ID)超出 IEEE-754 float64 安全整数范围 -// (2^53 ≈ 9.007e15),而配置的读写两侧对"该写哪种形态"的要求恰好相反: -// -// 写入侧:`bytedcli tcc config update` 把 JSON number 当 double 处理, -// 7533128632407949313 会被静默截断成 7533128632407949000(实测) -// 读取侧:服务端用 encoding/json 直接解到 []int64,裸数字精确无损, -// 但字符串形态会直接报 cannot unmarshal string into int64 -// -// 也就是说:为了写得进去要用字符串,为了读得出来要用数字。任何一种单一形态都会在 -// 某一侧出问题,而两种失败都**不报错给运维**(写入侧静默截断、读取侧整份配置解析失败后 -// 回落到缺省值),现象都是"配了却不生效",且截断后的 ID 与原值肉眼几乎一样,极难反推。 -// -// 因此这里两种都吃:运维怎么写都对。 -type ConfigIDList []int64 - -// UnmarshalJSON 用 strconv.ParseInt 而非 unmarshal 到 float64 再转, -// 后者会在解析阶段就丢掉精度 —— 那正是本类型要防的问题。 -func (l *ConfigIDList) UnmarshalJSON(data []byte) error { - var raw []json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - out := make(ConfigIDList, 0, len(raw)) - for _, item := range raw { - s := strings.TrimSpace(string(item)) - // 去掉字符串形态的引号;裸数字保持原样。 - if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { - s = strings.TrimSpace(s[1 : len(s)-1]) - } - if s == "" { - continue - } - id, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return fmt.Errorf("invalid id %q in config id list: %w", s, err) - } - out = append(out, id) - } - - *l = out - return nil -} - -// MarshalJSON 统一输出字符串形态:服务若把配置回写或转发给下游, -// 字符串能避免在那一跳再被某个把 JSON number 当 double 的工具截断。 -func (l ConfigIDList) MarshalJSON() ([]byte, error) { - out := make([]string, 0, len(l)) - for _, id := range l { - out = append(out, strconv.FormatInt(id, 10)) - } - return json.Marshal(out) -} - // DefaultExptPriorityWhiteList 配置缺失或解析失败时的兜底:**谁都不许指定**。 // // 取禁止而非放行:priority 是插队能力,配置中心抖动时宁可让所有人退回 default(表现为 @@ -125,7 +69,7 @@ func DefaultExptPriorityWhiteList() *ExptPriorityWhiteList { // ExptPrioritySubject 是判定的输入:一次创建实验请求里与"能否指定 priority"有关的全部身份。 // // 收成一个结构体而不是散着传:三者都是可选的(自然人调用时 CallerPSM 为空, -// 系统调用时 UserID 可能为空),散着传容易在新增维度时漏掉调用点。 +// 系统调用时 UserEmail 为空),散着传容易在新增维度时漏掉调用点。 type ExptPrioritySubject struct { // UserEmail 已验证的用户邮箱(来自 session)。空串表示拿不到身份。 UserEmail string @@ -146,14 +90,29 @@ func (w *ExptPriorityWhiteList) AllowSpecifyPriority(subject ExptPrioritySubject if w.matchUserEmail(subject.UserEmail) { return true } - // spaceID=0 表示"无空间上下文",一律不匹配 —— 否则运维在 space_ids 里误填 0 - // 就会把所有无空间上下文的请求放行。 - if subject.SpaceID != 0 && slices.Contains(w.SpaceIDs, subject.SpaceID) { + if w.matchSpaceID(subject.SpaceID) { return true } return w.matchCallerPSM(subject.CallerPSM) } +// matchSpaceID 把入参格式化成字符串再比。 +// +// spaceID=0 表示"无空间上下文",一律不匹配 —— 否则运维在 space_ids 里误填 "0" +// 就会把所有无空间上下文的请求放行。 +func (w *ExptPriorityWhiteList) matchSpaceID(spaceID int64) bool { + if spaceID == 0 { + return false + } + want := strconv.FormatInt(spaceID, 10) + for _, id := range w.SpaceIDs { + if strings.TrimSpace(id) == want { + return true + } + } + return false +} + // matchUserEmail 忽略大小写与首尾空白比对(邮箱本身大小写不敏感,且名单由人手写)。 // // 空邮箱一律不匹配:拿不到身份时不得放行 —— 不把"读不到"当成有权限。 diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go b/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go index fcd5755e9e..fbb893d8a7 100644 --- a/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go +++ b/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go @@ -34,7 +34,7 @@ func TestExptPriorityWhiteList_ThreeDimensionsAreOR(t *testing.T) { // SpaceIDs 服务的是"管理员私有空间"。取 AND 会让这两条永远走不通。 w := &ExptPriorityWhiteList{ UserEmails: []string{"admin@bytedance.com"}, - SpaceIDs: []int64{222}, + SpaceIDs: []string{"222"}, CallerPSMs: []string{"stone.cozeloop.evalx"}, } @@ -55,9 +55,9 @@ func TestExptPriorityWhiteList_ThreeDimensionsAreOR(t *testing.T) { // TestExptPriorityWhiteList_SpaceIDZeroNeverMatches spaceID=0 表示"无空间上下文", // 绝不能因为运维在 space_ids 里误填 0 就把所有无空间请求放行。 func TestExptPriorityWhiteList_SpaceIDZeroNeverMatches(t *testing.T) { - w := &ExptPriorityWhiteList{SpaceIDs: []int64{0}} + w := &ExptPriorityWhiteList{SpaceIDs: []string{"0"}} assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 0}), - "空 spaceID 不得匹配,即使名单里误填了 0") + "空 spaceID 不得匹配,即使名单里误填了 \"0\"") } func TestExptPriorityWhiteList_UserEmailMatching(t *testing.T) { @@ -142,62 +142,6 @@ func TestExptTriggerTrustConf_EnabledWithEmptyListDeniesAll(t *testing.T) { assert.False(t, c.TrustEvalxTrigger("any.psm")) } -// ---- ConfigIDList ---- - -// TestConfigIDList_AcceptsBothFormsWithoutPrecisionLoss 19 位雪花 ID 两种写法都要精确。 -// -// 这道回归钉住的是一个双向的坑:配置写入侧(bytedcli tcc)把 JSON number 当 double、 -// 会把 7533128632407949313 静默截断成 ...949000,所以运维倾向写字符串; -// 而读取侧用 encoding/json 解到 []int64 时,字符串形态会直接报错、整份配置回落缺省值。 -// 两种失败都不报错给运维,现象都是"配了却不生效"。因此两种写法都必须吃下。 -func TestConfigIDList_AcceptsBothFormsWithoutPrecisionLoss(t *testing.T) { - const want int64 = 7533128632407949313 // 19 位,> 2^53 - - t.Run("裸数字", func(t *testing.T) { - var l ConfigIDList - require.NoError(t, json.Unmarshal([]byte(`[7533128632407949313]`), &l)) - require.Len(t, l, 1) - assert.Equal(t, want, l[0], "低位不得被截断") - }) - - t.Run("字符串", func(t *testing.T) { - var l ConfigIDList - require.NoError(t, json.Unmarshal([]byte(`["7533128632407949313"]`), &l)) - require.Len(t, l, 1) - assert.Equal(t, want, l[0]) - }) - - t.Run("混合写法", func(t *testing.T) { - var l ConfigIDList - require.NoError(t, json.Unmarshal([]byte(`[7533128632407949313, "7590103974980812802"]`), &l)) - assert.Equal(t, ConfigIDList{7533128632407949313, 7590103974980812802}, l) - }) - - t.Run("带空白的字符串", func(t *testing.T) { - var l ConfigIDList - require.NoError(t, json.Unmarshal([]byte(`[" 7533128632407949313 "]`), &l)) - require.Len(t, l, 1) - assert.Equal(t, want, l[0]) - }) -} - -func TestConfigIDList_SkipsEmptyAndRejectsGarbage(t *testing.T) { - var l ConfigIDList - require.NoError(t, json.Unmarshal([]byte(`["", 123]`), &l), "空串跳过而非报错,容忍配置里的空行") - assert.Equal(t, ConfigIDList{123}, l) - - var bad ConfigIDList - assert.Error(t, json.Unmarshal([]byte(`["not-a-number"]`), &bad), - "非数字必须报错 —— 静默跳过会让运维以为配上了") -} - -// TestConfigIDList_MarshalsAsStrings 回写统一输出字符串,避免下游再被 double 截断一次。 -func TestConfigIDList_MarshalsAsStrings(t *testing.T) { - out, err := json.Marshal(ConfigIDList{7533128632407949313}) - require.NoError(t, err) - assert.JSONEq(t, `["7533128632407949313"]`, string(out)) -} - // TestExptPriorityWhiteList_UnmarshalFullConfig 端到端解一份完整配置, // 确认字段名与运维实际会写的 JSON 一致(字段名写错的后果是静默不生效)。 func TestExptPriorityWhiteList_UnmarshalFullConfig(t *testing.T) { @@ -212,7 +156,8 @@ func TestExptPriorityWhiteList_UnmarshalFullConfig(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(raw), &w)) assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com"})) - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 7533128632407949313})) + assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 7533128632407949313}), + "19 位雪花 ID 从字符串配置解出后必须精确匹配") assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.evalx"})) assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "other@bytedance.com", SpaceID: 999})) } From fc1e3687d6f1be48482f676054ca78672cdc8814 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 19:57:45 +0800 Subject: [PATCH 21/58] =?UTF-8?q?feat(evaluation):=20=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E6=88=90=E9=A2=9D=E5=BA=A6=E7=BB=B4=E5=BA=A6?= =?UTF-8?q?=20concurrency|item?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 并发也是一种「单 item 占一份、终态归还一份」的资源,因此不新造计数器,而是登记进额度 体系当一个维度,直接复用现成的预占 / 释放 / 木桶 / 优先级排序。 为什么必须有这一维:资源向量(sandbox seat / model token)管的是"耗多少外部资源", 管不住"同时有多少 item 在跑"。若某类实验只申报 model token 不申报 sandbox,并发就完全 失控 —— 中心调度此前只受单实验 deficit + 资源额度两道约束,全 Scope 在跑的 item 总数 没有任何上限。有了这一维,TCC 里给 concurrency|item 配 global_quota=N 就等于 "全 Scope 最多 N 个 item 同时在跑",且高优先级实验先拿并发名额。 WithConcurrencyDimension 幂等:若已显式申报 concurrency|item(未来支持"重型 item 占 2 份并发")则保留申报值不覆盖。不原地改 receiver —— ExpectedQuotaConsumption 是创建期 冻结进 eval_conf 的快照,原地修改会让"冻结"语义失效。 注:本提交是接手另一会话的未提交工作(用户要求一起提交)。实现与 4 个测试均已核对, build 与测试全绿。commercial 侧的调用点随后一并提交。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/expt.go | 95 ++++++++++ .../entity/expt_quota_concurrency_test.go | 165 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index 2d173b8e78..3725a3cd27 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -391,6 +391,60 @@ type ExpectedQuotaConsumption struct { Resources []*ExpectedResourceConsumption `json:"resources,omitempty"` } +// 并发也是一种「单 item 占用一份、终态归还一份」的资源,因此不新造计数器, +// 而是登记成额度体系里的一个维度,直接复用预占 / 释放 / 木桶 / 优先级排序。 +// +// 为什么必须有这一维:资源向量(sandbox seat / model token)管的是"耗多少外部资源", +// 管不住"同时有多少 item 在跑"。若某类实验只申报 model token 不申报 sandbox, +// 并发就完全失控 —— 实测中心调度只受单实验 deficit + 资源额度两道约束, +// 全 Scope 在跑 item 总数没有任何上限。 +const ( + // QuotaCategoryConcurrency 并发维度的 category。 + QuotaCategoryConcurrency = "concurrency" + // QuotaResourceKeyItem 并发维度下的资源键:一个正在执行的 item 占一份。 + QuotaResourceKeyItem = "item" + // concurrencyAmountPerItem 单 item 的并发占用量恒为 1。 + // 抽成常量而非字面量,是为了让"一个 item 占一份并发"这条语义在代码里可搜到。 + concurrencyAmountPerItem = 1 +) + +// WithConcurrencyDimension 返回在原向量基础上补齐并发维度的**新**向量。 +// +// 幂等:若调用方已显式申报 concurrency|item(例如未来支持"重型 item 占 2 份并发"), +// 保留其申报值不覆盖 —— 这正是把额度下沉到 item 粒度后要留的扩展口。 +// +// 不原地改 receiver:ExpectedQuotaConsumption 是创建期冻结进 eval_conf 的快照, +// 原地修改会让"冻结"语义失效(同一份快照在不同调用后变形)。 +func (c *ExpectedQuotaConsumption) WithConcurrencyDimension() *ExpectedQuotaConsumption { + if c == nil { + return &ExpectedQuotaConsumption{ + Resources: []*ExpectedResourceConsumption{{ + Category: QuotaCategoryConcurrency, + ResourceKey: QuotaResourceKeyItem, + Amount: concurrencyAmountPerItem, + }}, + } + } + + out := &ExpectedQuotaConsumption{Resources: make([]*ExpectedResourceConsumption, 0, len(c.Resources)+1)} + for _, r := range c.Resources { + if r == nil { + continue + } + if r.Category == QuotaCategoryConcurrency && r.ResourceKey == QuotaResourceKeyItem { + // 已显式申报:原样保留,返回的向量与入参等价(幂等) + return c + } + out.Resources = append(out.Resources, r) + } + out.Resources = append(out.Resources, &ExpectedResourceConsumption{ + Category: QuotaCategoryConcurrency, + ResourceKey: QuotaResourceKeyItem, + Amount: concurrencyAmountPerItem, + }) + return out +} + // RunMode 实验级评测模式 (跑法)。与 runtime domain RunMode / IDL ExptRunMode 对齐。 type RunMode = string @@ -918,6 +972,47 @@ type ExptItemConfig struct { // 执行 target / hydrate 评测集大字段时据此切到各自来源空间; 0=同调用方空间。 EvalSetSourceSpaceID int64 `json:"eval_set_source_space_id,omitempty"` // 本行评测集来源空间 TargetSourceSpaceID int64 `json:"target_source_space_id,omitempty"` // 本行评测对象来源空间 + + // ExpectedQuotaConsumption 本行的资源消耗向量 (中心化调度用)。 + // + // nil = 沿用实验级 eval_conf 里那份 (存量 item 全是 nil, 自动回落, 无需数据迁移)。 + // 非 nil 时**完全覆盖**实验级向量, 不做逐维 merge —— 逐维 merge 会让"这行不用沙箱" + // 这类意图无法表达 (省略即继承, 无法显式清零)。 + // + // 与 ItemTargetConf.RunConf 同构: 都是"首次调度冻结进 item_config、执行期只读"的 + // 行级快照, 随既有 JSON 列走, 加字段无需 DDL。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption `json:"expected_quota_consumption,omitempty"` +} + +// SameAs 判定两个向量是否**等价**(维度集合与各维用量完全一致, 不计顺序)。 +// +// 中心调度按此把候选 item 分批预占: ReserveBatch 的 Requirements 是"同批共享"的, +// Lua 侧靠 floor(available/amount) 一次除法算本批能放几个 —— 该算法要求同批各 item +// 的 amount 严格相同, 否则除法失去意义。故只有等价向量才能进同一批。 +func (c *ExpectedQuotaConsumption) SameAs(other *ExpectedQuotaConsumption) bool { + toMap := func(v *ExpectedQuotaConsumption) map[string]int64 { + m := map[string]int64{} + if v == nil { + return m + } + for _, r := range v.Resources { + if r == nil { + continue + } + m[r.Category+"|"+r.ResourceKey] = r.Amount + } + return m + } + a, b := toMap(c), toMap(other) + if len(a) != len(b) { + return false + } + for k, av := range a { + if bv, ok := b[k]; !ok || bv != av { + return false + } + } + return true } // ItemTargetConf per-item target 运行配置 diff --git a/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go b/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go new file mode 100644 index 0000000000..47a2244c9b --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// findResource 在向量里找指定维度,找不到返回 nil。 +func findResource(c *ExpectedQuotaConsumption, category, key string) *ExpectedResourceConsumption { + if c == nil { + return nil + } + for _, r := range c.Resources { + if r != nil && r.Category == category && r.ResourceKey == key { + return r + } + } + return nil +} + +// TestWithConcurrencyDimension_InjectsWhenAbsent 守住核心不变量: +// 并发维度必须被补齐,否则"全 Scope 在跑 item 总数"没有任何上限 +// —— 实测中心调度只受单实验 deficit + 资源额度约束,某实验只申报 model token +// 不申报 sandbox 时并发完全失控。 +func TestWithConcurrencyDimension_InjectsWhenAbsent(t *testing.T) { + in := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + {Category: "model", ResourceKey: "gpt5.5", Amount: 1000}, + }} + + out := in.WithConcurrencyDimension() + + got := findResource(out, QuotaCategoryConcurrency, QuotaResourceKeyItem) + require.NotNil(t, got, "必须补齐 concurrency|item,否则并发无上限") + assert.Equal(t, int64(1), got.Amount, "单 item 恒占 1 份并发") + + // 原有维度不能丢 + assert.NotNil(t, findResource(out, "sandbox", "default")) + assert.NotNil(t, findResource(out, "model", "gpt5.5")) + assert.Len(t, out.Resources, 3) + + // 不得原地修改入参:它是创建期冻结进 eval_conf 的快照,改了"冻结"语义就失效 + assert.Nil(t, findResource(in, QuotaCategoryConcurrency, QuotaResourceKeyItem), + "WithConcurrencyDimension 不能原地改 receiver") + assert.Len(t, in.Resources, 2) +} + +// TestWithConcurrencyDimension_IdempotentOnExplicitDeclaration 已显式申报时保留申报值。 +// 这是把额度下沉到 item 粒度后要留的扩展口:未来"重型 item 占 2 份并发"靠它表达。 +func TestWithConcurrencyDimension_IdempotentOnExplicitDeclaration(t *testing.T) { + in := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + {Category: QuotaCategoryConcurrency, ResourceKey: QuotaResourceKeyItem, Amount: 2}, + }} + + out := in.WithConcurrencyDimension() + + got := findResource(out, QuotaCategoryConcurrency, QuotaResourceKeyItem) + require.NotNil(t, got) + assert.Equal(t, int64(2), got.Amount, "显式申报的并发占用量不得被覆盖成 1") + assert.Len(t, out.Resources, 2, "不得重复追加同一维度") + + // 再调一次仍然稳定(幂等) + again := out.WithConcurrencyDimension() + assert.Len(t, again.Resources, 2) + assert.Equal(t, int64(2), findResource(again, QuotaCategoryConcurrency, QuotaResourceKeyItem).Amount) +} + +// TestWithConcurrencyDimension_NilReceiver nil 向量也要产出并发维度。 +// 若返回 nil,调度器会把该实验当"无向量"跳过,enforce 实验永远不跑。 +func TestWithConcurrencyDimension_NilReceiver(t *testing.T) { + var in *ExpectedQuotaConsumption + + out := in.WithConcurrencyDimension() + + require.NotNil(t, out) + got := findResource(out, QuotaCategoryConcurrency, QuotaResourceKeyItem) + require.NotNil(t, got) + assert.Equal(t, int64(1), got.Amount) +} + +// TestWithConcurrencyDimension_SkipsNilEntries 向量里的 nil 项不得导致 panic 或被带出。 +func TestWithConcurrencyDimension_SkipsNilEntries(t *testing.T) { + in := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + nil, + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + nil, + }} + + out := in.WithConcurrencyDimension() + + assert.Len(t, out.Resources, 2, "nil 项应被过滤,只留 sandbox + 注入的 concurrency") + for _, r := range out.Resources { + assert.NotNil(t, r) + } +} + +// TestSameAs 守住切批的正确性判据。 +// +// 中心调度按 SameAs 把候选 item 分批预占:ReserveBatch 的 Requirements 是"同批共享"的, +// Lua 靠 floor(available/amount) 一次除法算本批能放几个 —— 若把 amount 不同的 item +// 混进同一批,该除法会按其中一个 amount 算,导致**多扣或少扣**。 +func TestSameAs(t *testing.T) { + v := func(pairs ...any) *ExpectedQuotaConsumption { + out := &ExpectedQuotaConsumption{} + for i := 0; i+2 < len(pairs)+1; i += 3 { + out.Resources = append(out.Resources, &ExpectedResourceConsumption{ + Category: pairs[i].(string), + ResourceKey: pairs[i+1].(string), + Amount: int64(pairs[i+2].(int)), + }) + } + return out + } + + t.Run("完全相同 → 等价", func(t *testing.T) { + a := v("sandbox", "default", 1, "model", "gpt5.5", 1000) + b := v("sandbox", "default", 1, "model", "gpt5.5", 1000) + assert.True(t, a.SameAs(b)) + }) + + t.Run("顺序不同但内容相同 → 等价(不能因排序误切批)", func(t *testing.T) { + a := v("sandbox", "default", 1, "model", "gpt5.5", 1000) + b := v("model", "gpt5.5", 1000, "sandbox", "default", 1) + assert.True(t, a.SameAs(b), "维度顺序不该影响等价判定") + }) + + t.Run("用量不同 → 不等价(混批会多扣/少扣)", func(t *testing.T) { + a := v("sandbox", "default", 1) + b := v("sandbox", "default", 2) + assert.False(t, a.SameAs(b)) + }) + + t.Run("维度数不同 → 不等价", func(t *testing.T) { + a := v("sandbox", "default", 1) + b := v("sandbox", "default", 1, "model", "gpt5.5", 1000) + assert.False(t, a.SameAs(b)) + }) + + t.Run("资源键不同 → 不等价", func(t *testing.T) { + a := v("model", "gpt5.5", 1000) + b := v("model", "gpt4", 1000) + assert.False(t, a.SameAs(b)) + }) + + t.Run("两个 nil → 等价;nil 与非空 → 不等价", func(t *testing.T) { + var n *ExpectedQuotaConsumption + assert.True(t, n.SameAs(nil), "都无向量时应可进同一批") + assert.False(t, n.SameAs(v("sandbox", "default", 1))) + assert.False(t, v("sandbox", "default", 1).SameAs(nil)) + }) + + t.Run("nil 项被忽略,不影响等价", func(t *testing.T) { + a := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + nil, {Category: "sandbox", ResourceKey: "default", Amount: 1}, nil, + }} + b := v("sandbox", "default", 1) + assert.True(t, a.SameAs(b)) + }) +} From fab4167d89fba42e685aa0eb1689addfad8549db Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 20:30:47 +0800 Subject: [PATCH 22/58] =?UTF-8?q?fix(evaluation):=20default=5Fpriority=20?= =?UTF-8?q?=E4=BB=8E=E6=AD=BB=E9=85=8D=E7=BD=AE=E6=94=B9=E4=B8=BA=E7=9C=9F?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TCC 里的 central_expt_scheduler_space_config.default_priority 此前**完全无效且静默**: GetDefaultPriority() 在生产代码里零个调用点,实际生效的是 entity.NormalizeExptPriorityLevel, 它把未申报的优先级**硬编码**收敛成 1。配成 5 看着生效,实际所有实验还是落 1,不报错不告警 —— 性质同雪花 ID 截断那类坑。 为什么现在必须修:priority 授权白名单落地后,非白名单调用方的申报值会被丢弃、强制走 default,这条路成了**主路径** —— 大部分实验的优先级都由这个值决定,而它锁死在 1。 改法(跨仓的 OSS 侧): - ICentralAdmissionPolicy.AllowCentralScheduling 返回 bool → CentralAdmissionDecision {Admitted, DefaultPriority} - 新增 entity.NormalizeExptPriorityLevelWithDefault;旧入口保留为 WithDefault(p, 0) 的薄包装,读路径与 DB 转换那些既有调用点一个都不用改 ★ 为什么回结构体而不加第二个 getter:两次独立读 TCC 之间配置可能热变更, 那样同一个实验会出现"按 A 配置准入、按 B 配置定优先级"。一次读取一起返回没有这个窗口。 ★ DefaultPriority=0 统一表示"没有意见",回落到 DefaultExptPriorityLevel(=1)。 一个约定同时覆盖三种情况:TCC 没配该字段、noop policy(开源部署)、policy 未注入。 ★ 越界的 default_priority(如 999)回落到 1,**不截断到 99**。它来自人工配置, 999 更可能是笔误而非"想要最高优";截断会让一次笔误静默变成"该空间所有实验都最高优" —— 最难发现的一类事故(每个实验单看都正常,只有整体排序全乱)。 注意这与**申报值**越界的处理故意不同:调用方显式传 150 则截断到 99,那是明确意图。 ★ 只在 enforce 分支采纳缺省值:legacy 实验不参与优先级排序,给它套非 1 的值 只会让 DB 里多一堆无意义数据、干扰排查。 顺带:go generate 补齐了 central_scheduler 四个此前从未生成的 mock(本仓 mocks 入 git)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/central_reservation_guard.go | 25 +++++-- .../central_reservation_guard_test.go | 12 ++-- .../mocks/central_admission_policy.go | 57 +++++++++++++++ .../mocks/central_reservation_guard.go | 70 +++++++++++++++++++ .../component/mocks/central_scope_owner.go | 56 +++++++++++++++ .../component/mocks/central_scope_provider.go | 56 +++++++++++++++ .../domain/entity/expt_dispatch_mode.go | 26 ++++++- .../entity/expt_priority_default_test.go | 57 +++++++++++++++ .../domain/service/expt_manage_impl.go | 24 ++++--- 9 files changed, 366 insertions(+), 17 deletions(-) create mode 100644 backend/modules/evaluation/domain/component/mocks/central_admission_policy.go create mode 100644 backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go create mode 100644 backend/modules/evaluation/domain/component/mocks/central_scope_owner.go create mode 100644 backend/modules/evaluation/domain/component/mocks/central_scope_provider.go create mode 100644 backend/modules/evaluation/domain/entity/expt_priority_default_test.go diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index 894ec28832..ad047d8461 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -134,6 +134,22 @@ type CentralAdmissionSubject struct { TargetID int64 } +// CentralAdmissionDecision 是 admission 判定的结果。 +// +// 为什么回结构体而不是 bool:缺省优先级也存放在 commercial 的同一份灰度配置里 +// (`central_expt_scheduler_space_config.default_priority`),而 OSS 拿不到那份配置。 +// 让它随准入结论一起回来,比再加一个 `GetDefaultPriority` 方法更安全 —— +// 两次独立调用之间配置可能热变更,那样同一个实验就可能按"A 配置准入、B 配置定优先级"落库。 +type CentralAdmissionDecision struct { + // Admitted 该实验是否纳入中心调度。 + Admitted bool + // DefaultPriority 未申报优先级时使用的缺省值(1-99)。 + // + // 0 表示 policy 未给出意见,调用方按 entity.DefaultExptPriorityLevel(=1)处理。 + // 这条约定让 noop 实现与"TCC 里没配这个字段"两种情况自然收敛到同一行为。 + DefaultPriority int32 +} + // ICentralAdmissionPolicy 在创建期收窄中心调度的准入范围。 // // 它是 trigger 判据之上的**第二道闸**,语义是 AND 而非 OR: @@ -148,11 +164,11 @@ type CentralAdmissionSubject struct { // // 开源部署注入 noop(恒定放行):开源侧不产生 EvalX trigger,本 policy 不会被咨询到。 type ICentralAdmissionPolicy interface { - // AllowCentralScheduling 报告该实验是否纳入中心调度。 + // AllowCentralScheduling 报告该实验是否纳入中心调度,并给出缺省优先级。 // // 返回 error 表示配置不可判定。调用方应拒绝创建 enforce 实验而非放行 —— // 放行会让一个本该受额度管控的实验绕过管控,且无从发现。 - AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (bool, error) + AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (CentralAdmissionDecision, error) } // NewNoopCentralAdmissionPolicy 返回开源部署使用的 noop 实现:恒定放行。 @@ -165,6 +181,7 @@ func NewNoopCentralAdmissionPolicy() ICentralAdmissionPolicy { type noopCentralAdmissionPolicy struct{} -func (noopCentralAdmissionPolicy) AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (bool, error) { - return true, nil +// AllowCentralScheduling 放行,且不指定缺省优先级(DefaultPriority=0 → 调用方按 1 处理)。 +func (noopCentralAdmissionPolicy) AllowCentralScheduling(ctx context.Context, subject CentralAdmissionSubject) (CentralAdmissionDecision, error) { + return CentralAdmissionDecision{Admitted: true}, nil } diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go index 9008f118f2..2838de5a9f 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard_test.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard_test.go @@ -59,16 +59,20 @@ func TestNoopCentralAdmissionPolicy_AlwaysAllows(t *testing.T) { // noop 取放行而非拒绝:本 policy 的职责是"在已申报向量的实验里再筛一遍", // 缺省不筛等于保持引入本闸之前的语义(trigger 判据单独生效)。 // 若取拒绝,开源部署一旦出现 EvalX trigger 就再也建不出 enforce 实验。 - allowed, err := policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{ + decision, err := policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{ SpaceID: 123, TargetType: "sandbox_agent", TargetID: 456, }) require.NoError(t, err) - assert.True(t, allowed) + assert.True(t, decision.Admitted) + // noop 不指定缺省优先级:DefaultPriority=0 表示"没有意见", + // 由 NormalizeExptPriorityLevelWithDefault 回落到 1,与引入该字段之前的行为一致。 + assert.Zero(t, decision.DefaultPriority) // 零值 subject(skip-target 实验:无评测对象类型与 ID)同样放行。 - allowed, err = policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{}) + decision, err = policy.AllowCentralScheduling(context.Background(), CentralAdmissionSubject{}) require.NoError(t, err) - assert.True(t, allowed) + assert.True(t, decision.Admitted) + assert.Zero(t, decision.DefaultPriority) } diff --git a/backend/modules/evaluation/domain/component/mocks/central_admission_policy.go b/backend/modules/evaluation/domain/component/mocks/central_admission_policy.go new file mode 100644 index 0000000000..1b70f6923a --- /dev/null +++ b/backend/modules/evaluation/domain/component/mocks/central_admission_policy.go @@ -0,0 +1,57 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component (interfaces: ICentralAdmissionPolicy) +// +// Generated by this command: +// +// mockgen -destination=mocks/central_admission_policy.go -package=mocks . ICentralAdmissionPolicy +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + component "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component" + gomock "go.uber.org/mock/gomock" +) + +// MockICentralAdmissionPolicy is a mock of ICentralAdmissionPolicy interface. +type MockICentralAdmissionPolicy struct { + ctrl *gomock.Controller + recorder *MockICentralAdmissionPolicyMockRecorder + isgomock struct{} +} + +// MockICentralAdmissionPolicyMockRecorder is the mock recorder for MockICentralAdmissionPolicy. +type MockICentralAdmissionPolicyMockRecorder struct { + mock *MockICentralAdmissionPolicy +} + +// NewMockICentralAdmissionPolicy creates a new mock instance. +func NewMockICentralAdmissionPolicy(ctrl *gomock.Controller) *MockICentralAdmissionPolicy { + mock := &MockICentralAdmissionPolicy{ctrl: ctrl} + mock.recorder = &MockICentralAdmissionPolicyMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockICentralAdmissionPolicy) EXPECT() *MockICentralAdmissionPolicyMockRecorder { + return m.recorder +} + +// AllowCentralScheduling mocks base method. +func (m *MockICentralAdmissionPolicy) AllowCentralScheduling(ctx context.Context, subject component.CentralAdmissionSubject) (component.CentralAdmissionDecision, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AllowCentralScheduling", ctx, subject) + ret0, _ := ret[0].(component.CentralAdmissionDecision) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AllowCentralScheduling indicates an expected call of AllowCentralScheduling. +func (mr *MockICentralAdmissionPolicyMockRecorder) AllowCentralScheduling(ctx, subject any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AllowCentralScheduling", reflect.TypeOf((*MockICentralAdmissionPolicy)(nil).AllowCentralScheduling), ctx, subject) +} diff --git a/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go b/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go new file mode 100644 index 0000000000..6903356a2b --- /dev/null +++ b/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go @@ -0,0 +1,70 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component (interfaces: ICentralReservationGuard) +// +// Generated by this command: +// +// mockgen -destination=mocks/central_reservation_guard.go -package=mocks . ICentralReservationGuard +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockICentralReservationGuard is a mock of ICentralReservationGuard interface. +type MockICentralReservationGuard struct { + ctrl *gomock.Controller + recorder *MockICentralReservationGuardMockRecorder + isgomock struct{} +} + +// MockICentralReservationGuardMockRecorder is the mock recorder for MockICentralReservationGuard. +type MockICentralReservationGuardMockRecorder struct { + mock *MockICentralReservationGuard +} + +// NewMockICentralReservationGuard creates a new mock instance. +func NewMockICentralReservationGuard(ctrl *gomock.Controller) *MockICentralReservationGuard { + mock := &MockICentralReservationGuard{ctrl: ctrl} + mock.recorder = &MockICentralReservationGuardMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockICentralReservationGuard) EXPECT() *MockICentralReservationGuardMockRecorder { + return m.recorder +} + +// ConfirmRunning mocks base method. +func (m *MockICentralReservationGuard) ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConfirmRunning", ctx, schedulerScope, exptRunID, itemID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ConfirmRunning indicates an expected call of ConfirmRunning. +func (mr *MockICentralReservationGuardMockRecorder) ConfirmRunning(ctx, schedulerScope, exptRunID, itemID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfirmRunning", reflect.TypeOf((*MockICentralReservationGuard)(nil).ConfirmRunning), ctx, schedulerScope, exptRunID, itemID) +} + +// Release mocks base method. +func (m *MockICentralReservationGuard) Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Release", ctx, schedulerScope, exptRunID, itemID, reason) + ret0, _ := ret[0].(error) + return ret0 +} + +// Release indicates an expected call of Release. +func (mr *MockICentralReservationGuardMockRecorder) Release(ctx, schedulerScope, exptRunID, itemID, reason any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Release", reflect.TypeOf((*MockICentralReservationGuard)(nil).Release), ctx, schedulerScope, exptRunID, itemID, reason) +} diff --git a/backend/modules/evaluation/domain/component/mocks/central_scope_owner.go b/backend/modules/evaluation/domain/component/mocks/central_scope_owner.go new file mode 100644 index 0000000000..f4915e429e --- /dev/null +++ b/backend/modules/evaluation/domain/component/mocks/central_scope_owner.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component (interfaces: ICentralSchedulerScopeOwner) +// +// Generated by this command: +// +// mockgen -destination=mocks/central_scope_owner.go -package=mocks . ICentralSchedulerScopeOwner +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockICentralSchedulerScopeOwner is a mock of ICentralSchedulerScopeOwner interface. +type MockICentralSchedulerScopeOwner struct { + ctrl *gomock.Controller + recorder *MockICentralSchedulerScopeOwnerMockRecorder + isgomock struct{} +} + +// MockICentralSchedulerScopeOwnerMockRecorder is the mock recorder for MockICentralSchedulerScopeOwner. +type MockICentralSchedulerScopeOwnerMockRecorder struct { + mock *MockICentralSchedulerScopeOwner +} + +// NewMockICentralSchedulerScopeOwner creates a new mock instance. +func NewMockICentralSchedulerScopeOwner(ctrl *gomock.Controller) *MockICentralSchedulerScopeOwner { + mock := &MockICentralSchedulerScopeOwner{ctrl: ctrl} + mock.recorder = &MockICentralSchedulerScopeOwnerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockICentralSchedulerScopeOwner) EXPECT() *MockICentralSchedulerScopeOwnerMockRecorder { + return m.recorder +} + +// OwnsSchedulerScope mocks base method. +func (m *MockICentralSchedulerScopeOwner) OwnsSchedulerScope(ctx context.Context, schedulerScope string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OwnsSchedulerScope", ctx, schedulerScope) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// OwnsSchedulerScope indicates an expected call of OwnsSchedulerScope. +func (mr *MockICentralSchedulerScopeOwnerMockRecorder) OwnsSchedulerScope(ctx, schedulerScope any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OwnsSchedulerScope", reflect.TypeOf((*MockICentralSchedulerScopeOwner)(nil).OwnsSchedulerScope), ctx, schedulerScope) +} diff --git a/backend/modules/evaluation/domain/component/mocks/central_scope_provider.go b/backend/modules/evaluation/domain/component/mocks/central_scope_provider.go new file mode 100644 index 0000000000..ef2decfdea --- /dev/null +++ b/backend/modules/evaluation/domain/component/mocks/central_scope_provider.go @@ -0,0 +1,56 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component (interfaces: ICentralSchedulerScopeProvider) +// +// Generated by this command: +// +// mockgen -destination=mocks/central_scope_provider.go -package=mocks . ICentralSchedulerScopeProvider +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockICentralSchedulerScopeProvider is a mock of ICentralSchedulerScopeProvider interface. +type MockICentralSchedulerScopeProvider struct { + ctrl *gomock.Controller + recorder *MockICentralSchedulerScopeProviderMockRecorder + isgomock struct{} +} + +// MockICentralSchedulerScopeProviderMockRecorder is the mock recorder for MockICentralSchedulerScopeProvider. +type MockICentralSchedulerScopeProviderMockRecorder struct { + mock *MockICentralSchedulerScopeProvider +} + +// NewMockICentralSchedulerScopeProvider creates a new mock instance. +func NewMockICentralSchedulerScopeProvider(ctrl *gomock.Controller) *MockICentralSchedulerScopeProvider { + mock := &MockICentralSchedulerScopeProvider{ctrl: ctrl} + mock.recorder = &MockICentralSchedulerScopeProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockICentralSchedulerScopeProvider) EXPECT() *MockICentralSchedulerScopeProviderMockRecorder { + return m.recorder +} + +// ResolveSchedulerScope mocks base method. +func (m *MockICentralSchedulerScopeProvider) ResolveSchedulerScope(ctx context.Context, spaceID int64) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveSchedulerScope", ctx, spaceID) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveSchedulerScope indicates an expected call of ResolveSchedulerScope. +func (mr *MockICentralSchedulerScopeProviderMockRecorder) ResolveSchedulerScope(ctx, spaceID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveSchedulerScope", reflect.TypeOf((*MockICentralSchedulerScopeProvider)(nil).ResolveSchedulerScope), ctx, spaceID) +} diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go index 7cae81c599..68039978fa 100644 --- a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go @@ -63,9 +63,33 @@ func NormalizeExptDispatchMode(mode ExptDispatchMode) ExptDispatchMode { // 0 值(未申报)收敛为缺省 1;越界值按边界截断而非报错,因为读路径不应因历史脏数据中断调度。 // 写入路径的合法性校验在 application 层做,会显式返回参数错误。 func NormalizeExptPriorityLevel(priority int32) int32 { + return NormalizeExptPriorityLevelWithDefault(priority, 0) +} + +// NormalizeExptPriorityLevelWithDefault 同上,但允许调用方指定"未申报时用哪个缺省值"。 +// +// 存在意义:缺省优先级是可运维配置的(commercial 的 +// `central_expt_scheduler_space_config.default_priority`),而这个收敛函数在 OSS entity 层、 +// 拿不到那份配置。让调用方把值传进来,避免在 entity 层反向依赖配置中心。 +// +// defaultPriority 的三种取值都收敛到安全行为: +// +// 0 或越界 —— 视为"没有意见",回落到 DefaultExptPriorityLevel(=1), +// 这同时覆盖了"TCC 里没配这个字段"与 noop policy 两种情况 +// 1-99 —— 采纳 +// +// 为什么越界的 defaultPriority 也回落到 1 而不是截断到 99:它来自人工维护的配置, +// 配成 999 更可能是笔误而非"想要最高优先级",而截断到 99 会让一次笔误静默变成 +// "该空间所有实验都最高优",那是最难发现的一类事故。 +func NormalizeExptPriorityLevelWithDefault(priority, defaultPriority int32) int32 { + fallback := DefaultExptPriorityLevel + if defaultPriority >= MinExptPriorityLevel && defaultPriority <= MaxExptPriorityLevel { + fallback = defaultPriority + } + switch { case priority < MinExptPriorityLevel: - return DefaultExptPriorityLevel + return fallback case priority > MaxExptPriorityLevel: return MaxExptPriorityLevel default: diff --git a/backend/modules/evaluation/domain/entity/expt_priority_default_test.go b/backend/modules/evaluation/domain/entity/expt_priority_default_test.go new file mode 100644 index 0000000000..d8651ca812 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_priority_default_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestNormalizeExptPriorityLevelWithDefault_UsesConfiguredDefault 未申报时采纳配置的缺省值。 +// +// 这是本次修复的核心:此前缺省值硬编码为 1,TCC 里的 default_priority 配了完全无效 +// 且静默 —— 现象是"配了看着生效实际没生效"。 +func TestNormalizeExptPriorityLevelWithDefault_UsesConfiguredDefault(t *testing.T) { + assert.Equal(t, int32(5), NormalizeExptPriorityLevelWithDefault(0, 5), "未申报应采纳配置缺省值") + assert.Equal(t, int32(5), NormalizeExptPriorityLevelWithDefault(-3, 5), "负值同样视为未申报") + assert.Equal(t, int32(99), NormalizeExptPriorityLevelWithDefault(0, 99), "边界值 99 可采纳") + assert.Equal(t, int32(1), NormalizeExptPriorityLevelWithDefault(0, 1), "边界值 1 可采纳") +} + +// TestNormalizeExptPriorityLevelWithDefault_FallsBackWhenDefaultAbsent +// defaultPriority=0 表示"没有意见",回落到 1。 +// 这一条同时覆盖两种真实情况:TCC 里没配这个字段、以及 noop policy(开源部署)。 +func TestNormalizeExptPriorityLevelWithDefault_FallsBackWhenDefaultAbsent(t *testing.T) { + assert.Equal(t, DefaultExptPriorityLevel, NormalizeExptPriorityLevelWithDefault(0, 0)) + assert.Equal(t, int32(1), NormalizeExptPriorityLevelWithDefault(0, 0)) +} + +// TestNormalizeExptPriorityLevelWithDefault_RejectsOutOfRangeDefault +// 越界的 defaultPriority 回落到 1,**不截断到 99**。 +// +// 方向很关键:这个值来自人工维护的配置,配成 999 更可能是笔误而非"想要最高优先级"。 +// 若截断到 99,一次笔误会静默变成"该空间所有实验都最高优" —— 那是最难发现的一类事故, +// 因为每个实验单看都正常,只有整体排序全乱。 +func TestNormalizeExptPriorityLevelWithDefault_RejectsOutOfRangeDefault(t *testing.T) { + assert.Equal(t, int32(1), NormalizeExptPriorityLevelWithDefault(0, 999), "越界缺省值回落 1 而非截断 99") + assert.Equal(t, int32(1), NormalizeExptPriorityLevelWithDefault(0, 100), "刚越界也回落") + assert.Equal(t, int32(1), NormalizeExptPriorityLevelWithDefault(0, -5), "负缺省值回落") +} + +// TestNormalizeExptPriorityLevelWithDefault_DeclaredValueWins 已申报时缺省值不参与。 +func TestNormalizeExptPriorityLevelWithDefault_DeclaredValueWins(t *testing.T) { + assert.Equal(t, int32(7), NormalizeExptPriorityLevelWithDefault(7, 5), "申报值优先") + assert.Equal(t, int32(99), NormalizeExptPriorityLevelWithDefault(150, 5), + "申报值越界按边界截断(不是回落缺省)—— 调用方明确表达了'要最高优'") +} + +// TestNormalizeExptPriorityLevel_BackwardCompatible 旧入口行为不变。 +// 它现在是 WithDefault(priority, 0) 的薄包装,既有调用点(读路径、DB 转换)无需改动。 +func TestNormalizeExptPriorityLevel_BackwardCompatible(t *testing.T) { + assert.Equal(t, int32(1), NormalizeExptPriorityLevel(0)) + assert.Equal(t, int32(1), NormalizeExptPriorityLevel(-1)) + assert.Equal(t, int32(50), NormalizeExptPriorityLevel(50)) + assert.Equal(t, int32(99), NormalizeExptPriorityLevel(1000)) +} diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index 3923417185..6adc8fd3e7 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -1322,16 +1322,19 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP dispatchMode := entity.ExptDispatchModeLegacy schedulerScope := "" expectedQuota := req.ExpectedQuotaConsumption + // defaultPriority 未申报优先级时的缺省值。0 表示"没有意见",由 + // NormalizeExptPriorityLevelWithDefault 回落到 entity.DefaultExptPriorityLevel。 + var defaultPriority int32 if entity.ShouldEnforceByTrigger(triggerType) { // 灰度收窄闸。先判 policy 再校验向量:policy 不放行时该实验走 legacy, // 此时缺向量是正常的(EvalX 对未纳管空间也可能不传),不该报错。 - admitted, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple) + decision, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple) if err != nil { // 配置不可判定时拒绝创建 enforce 实验,而不是放行或降级 legacy: // 放行会让本该受额度管控的实验绕过管控;静默降级会让 EvalX 以为受管控。 return nil, err } - if admitted { + if decision.Admitted { // enforce 实验必须有合法的资源消耗向量:没有向量就无法预占额度, // 调度器只能永远跳过它 —— 表现为"实验建好了但一个 item 都不跑",且那条分支静默。 // 因此在创建时就拒绝,把问题暴露在调用方能看到的地方。 @@ -1352,6 +1355,10 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP } dispatchMode = entity.ExptDispatchModeEnforce schedulerScope = scope + // 缺省优先级由灰度配置给出(commercial 的 default_priority)。 + // 只在 enforce 分支采纳:legacy 实验不参与优先级排序,给它套一个非 1 的缺省 + // 只会让 DB 里出现一堆没有意义的值,反而干扰排查。 + defaultPriority = decision.DefaultPriority } } @@ -1378,7 +1385,7 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP // 中心化调度冻结值。legacy 时 mode=legacy / scope="" / priority 仍落 1(DB 默认值语义)。 // expected_quota_consumption 不在此处 —— 它属于 EvalConf(序列化进 eval_conf 列), // 见下方赋值;调度侧 frozenConsumptionOf 正是从 EvalConf 读取。 - PriorityLevel: entity.NormalizeExptPriorityLevel(req.PriorityLevel), + PriorityLevel: entity.NormalizeExptPriorityLevelWithDefault(req.PriorityLevel, defaultPriority), ExptDispatchMode: dispatchMode, SchedulerScope: schedulerScope, @@ -1783,16 +1790,17 @@ func (e *ExptMangerImpl) Clone(ctx context.Context, exptID, spaceID int64, sessi return expt, e.Create(ctx, expt, session) } -// allowCentralScheduling 判定该实验是否落在中心调度的灰度范围内。 +// allowCentralScheduling 判定该实验是否落在中心调度的灰度范围内,并取回缺省优先级。 // // 只在 trigger 已判定为 EvalX 后调用,因此它的语义是"收窄"而非"准入": -// 返回 false 表示该实验本轮不纳管,走 legacy —— 这是正常结果,不是错误。 +// Admitted=false 表示该实验本轮不纳管,走 legacy —— 这是正常结果,不是错误。 // // policy 未注入时放行:保持引入本闸之前的行为(trigger 判据单独生效)。 -// 开源部署注入 noop 也是恒定放行,二者一致。 -func (e *ExptMangerImpl) allowCentralScheduling(ctx context.Context, spaceID int64, tuple *entity.ExptTuple) (bool, error) { +// 开源部署注入 noop 也是恒定放行,二者一致。两种情况都不指定缺省优先级 +// (DefaultPriority=0 → 调用方按 1 处理),与引入 default_priority 之前的行为一致。 +func (e *ExptMangerImpl) allowCentralScheduling(ctx context.Context, spaceID int64, tuple *entity.ExptTuple) (component.CentralAdmissionDecision, error) { if e.centralAdmissionPolicy == nil { - return true, nil + return component.CentralAdmissionDecision{Admitted: true}, nil } subject := component.CentralAdmissionSubject{SpaceID: spaceID} From c23412e4ea848a3a82a7dddf90c03ac26c57e607 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 21:59:38 +0800 Subject: [PATCH 23/58] =?UTF-8?q?feat(evaluation):=20=E4=B8=89=E6=A0=B7?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E7=89=B9=E6=9D=83=E5=8F=82=E6=95=B0=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E5=88=B0=E4=B8=80=E4=BB=BD=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=AF=B9=20OpenAPI=20=E5=BC=80=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品口径:priority / 资源消耗向量 / trigger_type=evalx 都可以让调用方申报, 但**必须是白名单里的身份**。于是把上一轮单独做的 expt_trigger_trust_conf 删掉, 判据统一收进一份名单 expt_scheduling_privilege_white_list: priority_level 未授权 → 清空,走缺省优先级 expected_quota_consumption 未授权 → 清空 trigger_type = evalx 未授权 → 降级 manual,走 legacy ★ 为什么必须同一判据:这三样共同决定"这个实验拿多少资源、排在谁前面"。 只挡其中一两样等于没挡 —— 只挡 priority 却放开 trigger,任何人仍能自称 evalx 把实验塞进中心调度;只挡 trigger 却放开 quota,纳管范围内的实验仍能虚报消耗。 顺带这也消掉了上一轮"两份配置缺省方向刻意相反"的别扭设计:现在只有一份名单、一个方向。 ★ 判定条件覆盖"只申报向量"这一形态(有专门用例钉住):调用方不设 priority、 trigger 也不是 evalx、只塞一个资源向量时同样要过闸。向量在 legacy 下不生效, 但会被冻结进 eval_conf —— 该实验后续一旦被纳管就直接按虚报值扣额度。 ★ 只降级 evalx,不动 openapi/schedule/manual:只有 evalx 带来特权, 伪造其它 trigger 没有好处,而一并降级会把"调用来源"这个排查依据抹掉。 OpenAPI 侧新增三个字段(60/61/62),并把 SubmitExperimentOApi 的 trigger_type 从硬编码 openapi 改为可申报(未传仍回落 openapi,与改动前一致)。透传后由下游 唯一汇聚点 CreateExperiment 裁决 —— 门控不在每个入口各判一次。 ExpectedQuotaConsumption 结构此前做读视图时已在 domain_openapi 建好,直接复用, 本次只补反向转换 ExpectedQuotaConsumptionOpenAPI2Domain。 key 改名(priority → scheduling_privilege)是因为它现在管三样,叫 priority 会误导; 该 key 尚未在任何环境创建过,改名零成本。 附带:三个包的手写 fake configer 跟着接口改名同步(非 mockgen 生成,不会自动更新)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../openapi/coze.loop.evaluation.openapi.go | 240 +++++++++++++++++- .../coze.loop.evaluation.openapi_validator.go | 5 + .../openapi/k-coze.loop.evaluation.openapi.go | 164 ++++++++++++ .../convertor/experiment/openapi.go | 29 +++ .../application/eval_openapi_app.go | 23 +- .../evaluation/application/experiment_app.go | 100 ++++---- .../experiment_priority_whitelist_test.go | 230 ----------------- .../experiment_scheduling_privilege_test.go | 218 ++++++++++++++++ .../evaluation/domain/component/conf.go | 11 +- .../domain/component/mocks/expt_configer.go | 28 +- .../domain/entity/expt_priority_conf.go | 200 --------------- .../domain/entity/expt_priority_conf_test.go | 172 ------------- .../entity/expt_scheduling_privilege.go | 167 ++++++++++++ .../entity/expt_scheduling_privilege_test.go | 118 +++++++++ .../evaluator/evaluator_record_impl_test.go | 6 +- .../repo/target/eval_target_repo_impl_test.go | 6 +- .../infra/storage/record_data_test.go | 6 +- .../modules/evaluation/pkg/conf/configer.go | 23 +- .../coze.loop.evaluation.openapi.thrift | 19 ++ 19 files changed, 1047 insertions(+), 718 deletions(-) delete mode 100644 backend/modules/evaluation/application/experiment_priority_whitelist_test.go create mode 100644 backend/modules/evaluation/application/experiment_scheduling_privilege_test.go delete mode 100644 backend/modules/evaluation/domain/entity/expt_priority_conf.go delete mode 100644 backend/modules/evaluation/domain/entity/expt_priority_conf_test.go create mode 100644 backend/modules/evaluation/domain/entity/expt_scheduling_privilege.go create mode 100644 backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go index 1289c913ca..6ddb3ada5b 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go @@ -29121,7 +29121,25 @@ type SubmitExperimentOApiRequest struct { RunModeConfig *experiment.RunModeConfig `thrift:"run_mode_config,47,optional" frugal:"47,optional,experiment.RunModeConfig" form:"run_mode_config" json:"run_mode_config,omitempty"` // 通知配置 NotificationConf *experiment.ExptNotificationConf `thrift:"notification_conf,50,optional" frugal:"50,optional,experiment.ExptNotificationConf" form:"notification_conf" json:"notification_conf,omitempty"` - Ext map[string]string `thrift:"ext,100,optional" frugal:"100,optional,map" form:"ext" json:"ext,omitempty"` + // ★ 中心化调度特权参数 60~62 + // + // ⚠️ 三者都是**特权申报**: 仅当调用方身份命中服务端白名单 + // (TCC expt_scheduling_privilege_white_list 的 user_emails / space_ids / caller_psms) + // 时才生效。未授权的调用方传了**不报错**, 但会被静默丢弃 —— + // priority 走缺省值、向量丢弃、trigger 降级 manual (实验走 legacy 链路)。 + // 不报错是为了兼容: 这几个字段可能已有调用方在传, 突然报错会打挂它们。 + // + // 调度优先级: 1-99, 数值越大越优先; 缺省取服务端配置的 default_priority (未配则 1)。 + // 仅在中心调度模式下参与排序, legacy 模式忽略。 + PriorityLevel *int32 `thrift:"priority_level,60,optional" frugal:"60,optional,i32" form:"priority_level" json:"priority_level,omitempty"` + // 单 item 预期资源消耗向量: 进入中心调度 (enforce) 的实验必填且非空。 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*", 随后冻结进 eval_conf; + // Retry 继承不可覆盖。category/resource_key 须与服务端 TCC 资源配置对得上。 + ExpectedQuotaConsumption *experiment.ExpectedQuotaConsumption `thrift:"expected_quota_consumption,61,optional" frugal:"61,optional,experiment.ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty"` + // 触发来源: 填 "evalx" 且身份获授权时, 实验可进入中心调度 (enforce); + // 其余取值或未授权一律走 legacy。不填则按 openapi 处理。 + TriggerType *string `thrift:"trigger_type,62,optional" frugal:"62,optional,string" form:"trigger_type" json:"trigger_type,omitempty"` + Ext map[string]string `thrift:"ext,100,optional" frugal:"100,optional,map" form:"ext" json:"ext,omitempty"` // 实验分组 key 默认以实验 ID 兜底;填写 ref_group_experiment_id 时复用该引用实验的 group key(归入同一分组)。 // 引用分组实验 id: 填写时校验其为当前空间内的实验 id。 RefGroupExperimentID *int64 `thrift:"ref_group_experiment_id,102,optional" frugal:"102,optional,i64" json:"ref_group_experiment_id" form:"ref_group_experiment_id" ` @@ -29328,6 +29346,42 @@ func (p *SubmitExperimentOApiRequest) GetNotificationConf() (v *experiment.ExptN return p.NotificationConf } +var SubmitExperimentOApiRequest_PriorityLevel_DEFAULT int32 + +func (p *SubmitExperimentOApiRequest) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return SubmitExperimentOApiRequest_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var SubmitExperimentOApiRequest_ExpectedQuotaConsumption_DEFAULT *experiment.ExpectedQuotaConsumption + +func (p *SubmitExperimentOApiRequest) GetExpectedQuotaConsumption() (v *experiment.ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return SubmitExperimentOApiRequest_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + +var SubmitExperimentOApiRequest_TriggerType_DEFAULT string + +func (p *SubmitExperimentOApiRequest) GetTriggerType() (v string) { + if p == nil { + return + } + if !p.IsSetTriggerType() { + return SubmitExperimentOApiRequest_TriggerType_DEFAULT + } + return *p.TriggerType +} + var SubmitExperimentOApiRequest_Ext_DEFAULT map[string]string func (p *SubmitExperimentOApiRequest) GetExt() (v map[string]string) { @@ -29423,6 +29477,15 @@ func (p *SubmitExperimentOApiRequest) SetRunModeConfig(val *experiment.RunModeCo func (p *SubmitExperimentOApiRequest) SetNotificationConf(val *experiment.ExptNotificationConf) { p.NotificationConf = val } +func (p *SubmitExperimentOApiRequest) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *SubmitExperimentOApiRequest) SetExpectedQuotaConsumption(val *experiment.ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} +func (p *SubmitExperimentOApiRequest) SetTriggerType(val *string) { + p.TriggerType = val +} func (p *SubmitExperimentOApiRequest) SetExt(val map[string]string) { p.Ext = val } @@ -29453,6 +29516,9 @@ var fieldIDToName_SubmitExperimentOApiRequest = map[int16]string{ 46: "enable_extract_trajectory", 47: "run_mode_config", 50: "notification_conf", + 60: "priority_level", + 61: "expected_quota_consumption", + 62: "trigger_type", 100: "ext", 102: "ref_group_experiment_id", 254: "extra", @@ -29523,6 +29589,18 @@ func (p *SubmitExperimentOApiRequest) IsSetNotificationConf() bool { return p.NotificationConf != nil } +func (p *SubmitExperimentOApiRequest) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *SubmitExperimentOApiRequest) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + +func (p *SubmitExperimentOApiRequest) IsSetTriggerType() bool { + return p.TriggerType != nil +} + func (p *SubmitExperimentOApiRequest) IsSetExt() bool { return p.Ext != nil } @@ -29685,6 +29763,30 @@ func (p *SubmitExperimentOApiRequest) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 60: + if fieldTypeId == thrift.I32 { + if err = p.ReadField60(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 61: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField61(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 62: + if fieldTypeId == thrift.STRING { + if err = p.ReadField62(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 100: if fieldTypeId == thrift.MAP { if err = p.ReadField100(iprot); err != nil { @@ -29940,6 +30042,36 @@ func (p *SubmitExperimentOApiRequest) ReadField50(iprot thrift.TProtocol) error p.NotificationConf = _field return nil } +func (p *SubmitExperimentOApiRequest) ReadField60(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *SubmitExperimentOApiRequest) ReadField61(iprot thrift.TProtocol) error { + _field := experiment.NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} +func (p *SubmitExperimentOApiRequest) ReadField62(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.TriggerType = _field + return nil +} func (p *SubmitExperimentOApiRequest) ReadField100(iprot thrift.TProtocol) error { _, _, size, err := iprot.ReadMapBegin() if err != nil { @@ -30067,6 +30199,18 @@ func (p *SubmitExperimentOApiRequest) Write(oprot thrift.TProtocol) (err error) fieldId = 50 goto WriteFieldError } + if err = p.writeField60(oprot); err != nil { + fieldId = 60 + goto WriteFieldError + } + if err = p.writeField61(oprot); err != nil { + fieldId = 61 + goto WriteFieldError + } + if err = p.writeField62(oprot); err != nil { + fieldId = 62 + goto WriteFieldError + } if err = p.writeField100(oprot); err != nil { fieldId = 100 goto WriteFieldError @@ -30413,6 +30557,60 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 50 end error: ", p), err) } +func (p *SubmitExperimentOApiRequest) writeField60(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 60); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 60 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 60 end error: ", p), err) +} +func (p *SubmitExperimentOApiRequest) writeField61(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 61); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 61 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 61 end error: ", p), err) +} +func (p *SubmitExperimentOApiRequest) writeField62(oprot thrift.TProtocol) (err error) { + if p.IsSetTriggerType() { + if err = oprot.WriteFieldBegin("trigger_type", thrift.STRING, 62); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.TriggerType); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 62 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 62 end error: ", p), err) +} func (p *SubmitExperimentOApiRequest) writeField100(oprot thrift.TProtocol) (err error) { if p.IsSetExt() { if err = oprot.WriteFieldBegin("ext", thrift.MAP, 100); err != nil { @@ -30559,6 +30757,15 @@ func (p *SubmitExperimentOApiRequest) DeepEqual(ano *SubmitExperimentOApiRequest if !p.Field50DeepEqual(ano.NotificationConf) { return false } + if !p.Field60DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field61DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } + if !p.Field62DeepEqual(ano.TriggerType) { + return false + } if !p.Field100DeepEqual(ano.Ext) { return false } @@ -30739,6 +30946,37 @@ func (p *SubmitExperimentOApiRequest) Field50DeepEqual(src *experiment.ExptNotif } return true } +func (p *SubmitExperimentOApiRequest) Field60DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *SubmitExperimentOApiRequest) Field61DeepEqual(src *experiment.ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} +func (p *SubmitExperimentOApiRequest) Field62DeepEqual(src *string) bool { + + if p.TriggerType == src { + return true + } else if p.TriggerType == nil || src == nil { + return false + } + if strings.Compare(*p.TriggerType, *src) != 0 { + return false + } + return true +} func (p *SubmitExperimentOApiRequest) Field100DeepEqual(src map[string]string) bool { if len(p.Ext) != len(src) { diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go index ff610358ed..5052786299 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go @@ -938,6 +938,11 @@ func (p *SubmitExperimentOApiRequest) IsValid() error { return fmt.Errorf("field NotificationConf not valid, %w", err) } } + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.Extra != nil { if err := p.Extra.IsValid(); err != nil { return fmt.Errorf("field Extra not valid, %w", err) diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go b/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go index 72a4af1c5a..ac1dd98640 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go @@ -21069,6 +21069,48 @@ func (p *SubmitExperimentOApiRequest) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 60: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField60(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 61: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField61(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 62: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField62(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 100: if fieldTypeId == thrift.MAP { l, err = p.FastReadField100(buf[offset:]) @@ -21388,6 +21430,46 @@ func (p *SubmitExperimentOApiRequest) FastReadField50(buf []byte) (int, error) { return offset, nil } +func (p *SubmitExperimentOApiRequest) FastReadField60(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *SubmitExperimentOApiRequest) FastReadField61(buf []byte) (int, error) { + offset := 0 + _field := experiment.NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + +func (p *SubmitExperimentOApiRequest) FastReadField62(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.TriggerType = _field + return offset, nil +} + func (p *SubmitExperimentOApiRequest) FastReadField100(buf []byte) (int, error) { offset := 0 @@ -21469,6 +21551,7 @@ func (p *SubmitExperimentOApiRequest) FastWriteNocopy(buf []byte, w thrift.Nocop offset += p.fastWriteField20(buf[offset:], w) offset += p.fastWriteField45(buf[offset:], w) offset += p.fastWriteField46(buf[offset:], w) + offset += p.fastWriteField60(buf[offset:], w) offset += p.fastWriteField102(buf[offset:], w) offset += p.fastWriteField2(buf[offset:], w) offset += p.fastWriteField3(buf[offset:], w) @@ -21482,6 +21565,8 @@ func (p *SubmitExperimentOApiRequest) FastWriteNocopy(buf []byte, w thrift.Nocop offset += p.fastWriteField22(buf[offset:], w) offset += p.fastWriteField47(buf[offset:], w) offset += p.fastWriteField50(buf[offset:], w) + offset += p.fastWriteField61(buf[offset:], w) + offset += p.fastWriteField62(buf[offset:], w) offset += p.fastWriteField100(buf[offset:], w) offset += p.fastWriteField254(buf[offset:], w) offset += p.fastWriteField255(buf[offset:], w) @@ -21509,6 +21594,9 @@ func (p *SubmitExperimentOApiRequest) BLength() int { l += p.field46Length() l += p.field47Length() l += p.field50Length() + l += p.field60Length() + l += p.field61Length() + l += p.field62Length() l += p.field100Length() l += p.field102Length() l += p.field254Length() @@ -21683,6 +21771,33 @@ func (p *SubmitExperimentOApiRequest) fastWriteField50(buf []byte, w thrift.Noco return offset } +func (p *SubmitExperimentOApiRequest) fastWriteField60(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 60) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *SubmitExperimentOApiRequest) fastWriteField61(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 61) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + +func (p *SubmitExperimentOApiRequest) fastWriteField62(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetTriggerType() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 62) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.TriggerType) + } + return offset +} + func (p *SubmitExperimentOApiRequest) fastWriteField100(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetExt() { @@ -21883,6 +21998,33 @@ func (p *SubmitExperimentOApiRequest) field50Length() int { return l } +func (p *SubmitExperimentOApiRequest) field60Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *SubmitExperimentOApiRequest) field61Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + +func (p *SubmitExperimentOApiRequest) field62Length() int { + l := 0 + if p.IsSetTriggerType() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.TriggerType) + } + return l +} + func (p *SubmitExperimentOApiRequest) field100Length() int { l := 0 if p.IsSetExt() { @@ -22071,6 +22213,28 @@ func (p *SubmitExperimentOApiRequest) DeepCopy(s interface{}) error { } p.NotificationConf = _notificationConf + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + var _expectedQuotaConsumption *experiment.ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &experiment.ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + + if src.TriggerType != nil { + var tmp string + if *src.TriggerType != "" { + tmp = kutils.StringDeepCopy(*src.TriggerType) + } + p.TriggerType = &tmp + } + if src.Ext != nil { p.Ext = make(map[string]string, len(src.Ext)) for key, val := range src.Ext { diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi.go b/backend/modules/evaluation/application/convertor/experiment/openapi.go index 80daa85ec1..e87457e3f5 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi.go @@ -3291,6 +3291,35 @@ func ExpectedQuotaConsumptionDomain2OpenAPI(c *domainExpt.ExpectedQuotaConsumpti return &openapiExperiment.ExpectedQuotaConsumption{Resources: resources} } +// ExpectedQuotaConsumptionOpenAPI2Domain 是 ExpectedQuotaConsumptionDomain2OpenAPI 的反向映射。 +// +// 只做形态搬运,不做合法性校验 —— 校验((category,resource_key) 唯一、amount>0、 +// 禁止 resource_key="*")统一在创建期的 Validate 里做,那里能对内部与 OpenAPI 两条入口 +// 一视同仁。在此提前校验会让同一条规则散成两处、且两处的错误码可能不一致。 +// +// 返回 nil 表示"没有申报"(空向量与未传等价):下游据此判断 enforce 缺向量并报错, +// 而 &ExpectedQuotaConsumption{Resources: nil} 会让"传了空数组"看起来像"申报过"。 +func ExpectedQuotaConsumptionOpenAPI2Domain(c *openapiExperiment.ExpectedQuotaConsumption) *domainExpt.ExpectedQuotaConsumption { + if c == nil || len(c.GetResources()) == 0 { + return nil + } + resources := make([]*domainExpt.ExpectedResourceConsumption, 0, len(c.GetResources())) + for _, r := range c.GetResources() { + if r == nil { + continue + } + resources = append(resources, &domainExpt.ExpectedResourceConsumption{ + Category: r.GetCategory(), + ResourceKey: r.GetResourceKey(), + Amount: r.GetAmount(), + }) + } + if len(resources) == 0 { + return nil + } + return &domainExpt.ExpectedQuotaConsumption{Resources: resources} +} + // domainRunModeToOpenAPI 是 openAPIRunModeToDomain 的反向映射。 // // 两套枚举在**这一层**恰好一一对应 (都只有四个对外形态), 所以看起来像可以直接强转 —— diff --git a/backend/modules/evaluation/application/eval_openapi_app.go b/backend/modules/evaluation/application/eval_openapi_app.go index aa69cf2abf..6240dd1b9d 100644 --- a/backend/modules/evaluation/application/eval_openapi_app.go +++ b/backend/modules/evaluation/application/eval_openapi_app.go @@ -1708,6 +1708,18 @@ func openapiSharedOptionDTO2Entity(opt *openapiCommon.SharedResourceOption) *ent } } +// openAPITriggerType 归一化 OpenAPI 侧申报的 trigger_type。 +// +// 空值回落 openapi(与引入该字段之前的行为一致);非空则原样透传 —— +// 合法性不在此处判,因为"哪些 trigger 有特权"是服务端策略、会变, +// 而这里若维护一份枚举白名单,每次策略调整都要改两个地方。 +func openAPITriggerType(declared string) string { + if strings.TrimSpace(declared) == "" { + return domain_expt.OpenAPI + } + return strings.TrimSpace(declared) +} + func (e *EvalOpenAPIApplication) SubmitExperimentOApi(ctx context.Context, req *openapi.SubmitExperimentOApiRequest) (r *openapi.SubmitExperimentOApiResponse, err error) { startTime := time.Now().UnixNano() / int64(time.Millisecond) defer func() { @@ -1773,7 +1785,13 @@ func (e *EvalOpenAPIApplication) SubmitExperimentOApi(ctx context.Context, req * ItemConcurNum: req.ItemConcurNum, TargetRuntimeParam: experiment_convertor.OpenAPIRuntimeParamDTO2Domain(req.TargetRuntimeParam), ItemRetryNum: req.ItemRetryNum, - TriggerType: gptr.Of(domain_expt.OpenAPI), + // trigger_type 允许调用方申报(目前只有 "evalx" 有实际效果:进入中心调度); + // 未传时按 openapi 处理,与改动前一致。 + // + // ⚠️ 申报只是"请求",不是"授权":下游 CreateExperiment 的 + // enforceSchedulingPrivilege 会按白名单裁决,未授权的 evalx 会被降级成 manual。 + // 因此这里直接透传是安全的 —— 门控收在唯一汇聚点,不在每个入口各判一次。 + TriggerType: gptr.Of(openAPITriggerType(req.GetTriggerType())), EnableExtractTrajectory: req.EnableExtractTrajectory, Ext: req.GetExt(), // ★ 透传分流依据: OpenAPI 字符串枚举 → kitex enum, 供下游平台层统一以 source_type 分流。 @@ -1781,6 +1799,9 @@ func (e *EvalOpenAPIApplication) SubmitExperimentOApi(ctx context.Context, req * // ★ 透传引用分组实验 id: 命中当前空间实验则复用其 group key(归入同一分组); 缺省则以实验 id 兜底。 RefGroupExperimentID: req.RefGroupExperimentID, RunModeConfig: runModeConfig, + // ★ 中心化调度特权参数:同上,透传后由下游按白名单裁决。 + PriorityLevel: req.PriorityLevel, + ExpectedQuotaConsumption: experiment_convertor.ExpectedQuotaConsumptionOpenAPI2Domain(req.ExpectedQuotaConsumption), } if isNewPath { diff --git a/backend/modules/evaluation/application/experiment_app.go b/backend/modules/evaluation/application/experiment_app.go index 5130c03c0a..2972336c37 100644 --- a/backend/modules/evaluation/application/experiment_app.go +++ b/backend/modules/evaluation/application/experiment_app.go @@ -208,17 +208,14 @@ func (e *experimentApplication) CreateExperiment(ctx context.Context, req *expt. errorx.WithExtraMsg(fmt.Sprintf("space %d is not allowed to create multi-set experiments", req.GetWorkspaceID()))) } - // ★ 调度优先级白名单闸:未获授权的调用方申报的 priority 一律丢弃、强制走缺省值。 + // ★ 中心调度特权参数闸:未获授权的调用方申报的 priority / quota 向量 / evalx trigger + // 一律丢弃或降级。 // // 放在这里(而不是 Authorization 层)有硬性原因:商业版的 auth allowlist decorator // 对特定 caller+method 会直接跳过整个 Authorization,门控做在那一层会被整条绕过。 // CreateExperiment 是四个入口(EvalX / 控制台 / OpenAPI / 定时)的唯一汇聚点, - // SubmitExperiment 也是转成本请求后调进来的,卡在此处即全覆盖。 - e.enforcePriorityWhiteList(ctx, req) - - // ★ trigger 可信来源闸:不可信调用方自称 evalx 时,把 trigger 降级掉。 - // 与上面同理放在此处 —— "谁被中心调度纳管"必须由我们的名单决定,不能靠调用方自报。 - e.enforceTriggerTrust(ctx, req) + // SubmitExperiment 与 OpenAPI 也都是转成本请求后调进来的,卡在此处即全覆盖。 + e.enforceSchedulingPrivilege(ctx, req) // 收集 evaluator_version_id(包含顺序解析 EvaluatorIDVersionList)、runconfig 和 score weight evalVersionIDs, evaluatorVersionRunConfigs, evaluatorScoreWeights, err := e.resolveEvaluatorVersionIDsFromCreateReq(ctx, req) @@ -261,80 +258,71 @@ func (e *experimentApplication) CreateExperiment(ctx context.Context, req *expt. }, nil } -// enforcePriorityWhiteList 未获授权的调用方申报的 priority 一律清空,改由下游按缺省值处理。 +// enforceSchedulingPrivilege 未获授权的调用方申报的中心调度特权参数一律作废。 +// +// 管三样(同一份名单判据): +// +// priority_level → 清空,由下游按缺省优先级处理 +// expected_quota_consumption → 清空,没有向量就进不了 enforce(创建期会校验) +// trigger_type = evalx → 降级 manual,实验走 legacy 链路 // -// 为什么需要这道闸:priority 在中心调度下参与严格优先级排序,高优实验持续抢占额度。 -// 该字段在 IDL 里带 json/form 绑定标签,任何有建实验权限的调用方都能填 —— 不设闸的话 -// 一个人把自己所有实验设成 99 就能让别人的实验饿死,且不违反任何校验、不报错。 +// 为什么这三样必须同一判据:它们共同决定"这个实验拿多少资源、排在谁前面"。 +// 只挡其中一两样等于没挡 —— 例如只挡 priority 却放开 trigger,任何人仍能自称 evalx +// 把实验塞进中心调度;只挡 trigger 却放开 quota,纳管范围内的实验仍能虚报消耗。 // // **静默降级而不报错**,这是刻意的取舍: -// - 报错的好处是调用方立刻知道无权限,但会破坏兼容 —— priority 字段已在 IDL 里存在一段时间, -// 可能已有内部调用方在传(它们原本只是"传了个不生效的字段"),上线后突然报错会把它们打挂 -// - 因此改为清空 + Warn 日志(带 user/space/caller/被丢弃的值)。灰度期从日志即可看出 -// "谁在尝试指定 priority",据此决定要不要加进白名单,而不是等对方来报障 +// - 报错的好处是调用方立刻知道无权限,但会破坏兼容 —— 这几个字段已在 IDL 里存在一段时间, +// 可能已有内部调用方在传(它们原本只是"传了个不生效的字段"),上线后突然报错会打挂它们 +// - 因此改为作废 + Warn 日志(带 user/space/caller 与被丢弃的值)。灰度期从日志即可看出 +// "谁在尝试申报特权",据此决定要不要加进白名单,而不是等对方来报障 // -// 清空而非改写成 DefaultExptPriorityLevel:缺省值的权威定义在下游 -// (entity.NormalizeExptPriorityLevel 会把 0 收敛为缺省),在此写死会多出一处需要同步的常量。 -func (e *experimentApplication) enforcePriorityWhiteList(ctx context.Context, req *expt.CreateExperimentRequest) { - // 没申报就无需判定:省掉一次配置读取,也避免给未使用该字段的调用方刷无关日志。 - if req == nil || !req.IsSetPriorityLevel() { +// priority 取清空而非改写成 DefaultExptPriorityLevel:缺省值的权威定义在下游 +// (NormalizeExptPriorityLevelWithDefault 会把 0 收敛为配置的缺省值), +// 在此写死会多出一处需要同步的常量、还会盖掉 TCC 里配的 default_priority。 +func (e *experimentApplication) enforceSchedulingPrivilege(ctx context.Context, req *expt.CreateExperimentRequest) { + if req == nil { + return + } + + declaresPriority := req.IsSetPriorityLevel() + declaresQuota := req.GetExpectedQuotaConsumption() != nil + declaresEvalxTrigger := entity.ShouldEnforceByTrigger(req.GetTriggerType()) + if !declaresPriority && !declaresQuota && !declaresEvalxTrigger { + // 什么都没申报:省掉一次配置读取,也避免给未使用这些字段的调用方刷无关日志。 return } // caller PSM 取自 RPC 框架填充的 caller 字段,调用方无法在业务参数里伪造。 // 这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段,任何人都能自称 "evalx", - // 因此绝不能拿 trigger_type 当授权判据。 + // 因此绝不能反过来拿 trigger_type 当授权判据。 callerPSM, _ := kitexutil.GetCaller(ctx) - // 邮箱取自已验证的 ByteTIM ticket claim(商业版 CtxUser 中间件写入),不是请求体字段, - // 因此可以当授权键用。用邮箱而非 user_id 是为了让这份名单人可读、便于 review。 + // 邮箱取自已验证的 ByteTIM ticket claim(商业版 CtxUser 中间件写入),不是请求体字段。 userEmail := "" if u, ok := session.UserInCtx(ctx); ok && u != nil { userEmail = u.Email } - subject := entity.ExptPrioritySubject{ + subject := entity.ExptSchedulingPrivilegeSubject{ UserEmail: userEmail, SpaceID: req.GetWorkspaceID(), CallerPSM: callerPSM, } - if e.configer.GetExptPriorityWhiteList(ctx).AllowSpecifyPriority(subject) { + if e.configer.GetExptSchedulingPrivilegeWhiteList(ctx).AllowSchedulingPrivilege(subject) { return } - logs.CtxWarn(ctx, "[ExptPriority] caller not allowed to specify priority_level, falling back to default; "+ - "requested: %d, user_email: %v, space_id: %v, caller_psm: %v", - req.GetPriorityLevel(), userEmail, subject.SpaceID, callerPSM) - req.PriorityLevel = nil -} + logs.CtxWarn(ctx, "[ExptSchedulingPrivilege] caller not allowed to declare scheduling params, dropping them; "+ + "priority: %v, has_quota: %v, trigger: %v, user_email: %v, space_id: %v, caller_psm: %v", + req.GetPriorityLevel(), declaresQuota, req.GetTriggerType(), userEmail, subject.SpaceID, callerPSM) -// enforceTriggerTrust 不可信调用方自称 evalx 时,把 trigger_type 降级为 manual。 -// -// 为什么需要:enforce 的第一道闸只比对请求体里的 trigger_type 字符串,而那是调用方 -// **自己填的普通字段** —— 任何人都能自称 "evalx" 从而让实验进入中心调度。也就是说 -// "谁被中心调度纳管"部分取决于调用方自报,而它本该完全由我们的名单决定。 -// 本闸把判据换成 RPC 框架填充的 caller,那个调用方无法在业务参数里伪造。 -// -// 降级为 manual 而不是报错:与 priority 闸同样的兼容考虑 —— 报错会打挂已经在自报 evalx 的 -// 调用方(它们此前是被接受的)。降级后实验照常创建、只是走 legacy 链路,功能不受影响, -// 而 Warn 日志让我们能在灰度期看清"谁在自称 evalx",据此补名单。 -// -// 只处理 evalx:其它 trigger(manual / openapi / schedule)不触发 enforce, -// 伪造它们不产生特权,没必要为此增加一道可能误伤的校验。 -func (e *experimentApplication) enforceTriggerTrust(ctx context.Context, req *expt.CreateExperimentRequest) { - if req == nil || !entity.ShouldEnforceByTrigger(req.GetTriggerType()) { - return - } - - callerPSM, _ := kitexutil.GetCaller(ctx) - if e.configer.GetExptTriggerTrustConf(ctx).TrustEvalxTrigger(callerPSM) { - return + req.PriorityLevel = nil + req.ExpectedQuotaConsumption = nil + if declaresEvalxTrigger { + // 降级为 manual 而非清空:TriggerType 为空时下游会按 Manual 兜底, + // 但显式写入让落库值与日志一致、排查时不必再推导一层。 + req.TriggerType = gptr.Of(domain_expt.Manual) } - - logs.CtxWarn(ctx, "[ExptTrigger] caller not trusted to declare evalx trigger, downgrading to manual; "+ - "declared: %v, space_id: %v, caller_psm: %v", - req.GetTriggerType(), req.GetWorkspaceID(), callerPSM) - req.TriggerType = gptr.Of(domain_expt.Manual) } func (e *experimentApplication) CreateExperimentTemplate(ctx context.Context, req *expt.CreateExperimentTemplateRequest) (r *expt.CreateExperimentTemplateResponse, err error) { diff --git a/backend/modules/evaluation/application/experiment_priority_whitelist_test.go b/backend/modules/evaluation/application/experiment_priority_whitelist_test.go deleted file mode 100644 index d263700983..0000000000 --- a/backend/modules/evaluation/application/experiment_priority_whitelist_test.go +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright (c) 2025 coze-dev Authors -// SPDX-License-Identifier: Apache-2.0 - -package application - -import ( - "context" - "testing" - - "github.com/bytedance/gg/gptr" - "github.com/stretchr/testify/assert" - "go.uber.org/mock/gomock" - - domain_expt "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/domain/expt" - "github.com/coze-dev/coze-loop/backend/infra/middleware/session" - "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/expt" - componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" - "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" -) - -// ctxWithEmail 造一个带已验证邮箱的 ctx。生产链路里这份邮箱由商业版 CtxUser 中间件 -// 从 ByteTIM ticket claim 写入(不是请求体字段),所以它可以当授权键用。 -func ctxWithEmail(email string) context.Context { - return session.WithCtxUser(context.Background(), &session.User{ID: "123", Email: email}) -} - -// enforcePriorityWhiteList 的行为是**静默清空**而不是报错,所以这些用例断言的是 -// "req.PriorityLevel 有没有被清掉",而不是返回值 —— 该函数无返回值正是这个设计的体现。 - -func TestEnforcePriorityWhiteList_ClearsWhenNotAllowed(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - // 空白名单 = 谁都不许指定 - mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()).Return(&entity.ExptPriorityWhiteList{}) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - PriorityLevel: gptr.Of(int32(99)), - } - - app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) - - assert.Nil(t, req.PriorityLevel, - "未获授权的申报值必须被清空,否则任何人都能设 99 插队") -} - -func TestEnforcePriorityWhiteList_KeepsWhenUserAllowed(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). - Return(&entity.ExptPriorityWhiteList{UserEmails: []string{"admin@bytedance.com"}}) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - PriorityLevel: gptr.Of(int32(99)), - } - - app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) - - assert.NotNil(t, req.PriorityLevel, "白名单用户的申报值必须保留") - assert.Equal(t, int32(99), req.GetPriorityLevel()) -} - -func TestEnforcePriorityWhiteList_KeepsWhenSpaceAllowed(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). - Return(&entity.ExptPriorityWhiteList{SpaceIDs: []string{"456"}}) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - PriorityLevel: gptr.Of(int32(50)), - } - - // user 不在名单里,靠 space 维度放行(验证 OR 语义在真实调用链上也成立) - app.enforcePriorityWhiteList(ctxWithEmail("nobody@bytedance.com"), req) - - assert.Equal(t, int32(50), req.GetPriorityLevel()) -} - -// TestEnforcePriorityWhiteList_SkipsConfigReadWhenUnset 没申报 priority 时不读配置。 -// -// 断言方式是"不给 mock 设 EXPECT" —— gomock 在发生未预期调用时会失败, -// 因此这个用例同时钉住了"不发无谓的配置读取"和"不给未使用该字段的调用方刷日志"。 -func TestEnforcePriorityWhiteList_SkipsConfigReadWhenUnset(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - app := &experimentApplication{configer: mockConfiger} - - req := &expt.CreateExperimentRequest{WorkspaceID: 456} // 未设 PriorityLevel - app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) - - assert.Nil(t, req.PriorityLevel) -} - -// TestEnforcePriorityWhiteList_NilSafe req/session 为 nil 时不得 panic。 -// 这道闸在热路径上,一次 panic 会打挂整个创建实验接口。 -func TestEnforcePriorityWhiteList_NilSafe(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - app := &experimentApplication{configer: mockConfiger} - - // req 为 nil:直接返回,不读配置 - app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), nil) - - // ctx 里没有用户信息(拿不到邮箱)但有申报值:需要读配置,邮箱按空串处理 - mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()). - Return(&entity.ExptPriorityWhiteList{UserEmails: []string{"admin@bytedance.com"}}) - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - PriorityLevel: gptr.Of(int32(99)), - } - app.enforcePriorityWhiteList(context.Background(), req) - assert.Nil(t, req.PriorityLevel, "拿不到用户身份时不得放行") -} - -// TestEnforcePriorityWhiteList_ConfigReadFailureDeniesByDefault 配置读取失败(返回 nil)时拒绝。 -// -// configer 的实现在读取失败时回落到 DefaultExptPriorityWhiteList(),但这里直接喂 nil -// 做纵深防御:万一将来某个实现真的返回了 nil,行为也必须是拒绝而不是 panic 或放行。 -func TestEnforcePriorityWhiteList_ConfigReadFailureDeniesByDefault(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - mockConfiger.EXPECT().GetExptPriorityWhiteList(gomock.Any()).Return(nil) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - PriorityLevel: gptr.Of(int32(99)), - } - - app.enforcePriorityWhiteList(ctxWithEmail("admin@bytedance.com"), req) - - assert.Nil(t, req.PriorityLevel, - "配置不可判定时必须拒绝 —— priority 是插队能力,读不到配置宁可不给") -} - - -// ---- enforceTriggerTrust ---- - -// TestEnforceTriggerTrust_DowngradesUntrustedEvalx 不可信调用方自称 evalx 时降级为 manual。 -// -// 这道闸的意义:enforce 的第一道判据只看请求体里的 trigger_type 字符串,那是调用方自己填的 -// —— 不设闸的话"谁被中心调度纳管"部分取决于调用方自报,而它本该完全由我们的名单决定。 -func TestEnforceTriggerTrust_DowngradesUntrustedEvalx(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - // 启用校验且名单里没有本次 caller(测试环境下 caller 为空) - mockConfiger.EXPECT().GetExptTriggerTrustConf(gomock.Any()). - Return(&entity.ExptTriggerTrustConf{Enabled: true, EvalxCallerPSMs: []string{"stone.cozeloop.evalx"}}) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - TriggerType: gptr.Of(domain_expt.Evalx), - } - - app.enforceTriggerTrust(context.Background(), req) - - assert.Equal(t, domain_expt.Manual, req.GetTriggerType(), - "不可信调用方声明的 evalx 必须被降级,否则任何人都能让实验进 enforce") -} - -// TestEnforceTriggerTrust_KeepsWhenDisabled 未启用校验时保持原样(缺省行为,不额外收紧)。 -func TestEnforceTriggerTrust_KeepsWhenDisabled(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - mockConfiger.EXPECT().GetExptTriggerTrustConf(gomock.Any()). - Return(&entity.ExptTriggerTrustConf{Enabled: false}) - - app := &experimentApplication{configer: mockConfiger} - req := &expt.CreateExperimentRequest{ - WorkspaceID: 456, - TriggerType: gptr.Of(domain_expt.Evalx), - } - - app.enforceTriggerTrust(context.Background(), req) - - assert.Equal(t, domain_expt.Evalx, req.GetTriggerType(), "未启用校验时不得改动 trigger") -} - -// TestEnforceTriggerTrust_IgnoresNonEvalxTriggers 只处理 evalx。 -// -// 其它 trigger 不触发 enforce,伪造它们不产生特权 —— 为此增加校验只会带来误伤风险。 -// 断言方式是"不给 mock 设 EXPECT":一旦实现去读了配置,gomock 会因未预期调用而失败。 -func TestEnforceTriggerTrust_IgnoresNonEvalxTriggers(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - mockConfiger := componentMocks.NewMockIConfiger(ctrl) - app := &experimentApplication{configer: mockConfiger} - - for _, trigger := range []string{domain_expt.Manual, domain_expt.OpenAPI, domain_expt.Schedule} { - req := &expt.CreateExperimentRequest{WorkspaceID: 456, TriggerType: gptr.Of(trigger)} - app.enforceTriggerTrust(context.Background(), req) - assert.Equal(t, trigger, req.GetTriggerType(), "非 evalx trigger 不得被改动") - } - - // 未设 trigger 同样不该读配置 - reqUnset := &expt.CreateExperimentRequest{WorkspaceID: 456} - app.enforceTriggerTrust(context.Background(), reqUnset) -} - -// TestEnforceTriggerTrust_NilSafe req 为 nil 时不得 panic(这道闸在创建实验热路径上)。 -func TestEnforceTriggerTrust_NilSafe(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - app := &experimentApplication{configer: componentMocks.NewMockIConfiger(ctrl)} - app.enforceTriggerTrust(context.Background(), nil) -} diff --git a/backend/modules/evaluation/application/experiment_scheduling_privilege_test.go b/backend/modules/evaluation/application/experiment_scheduling_privilege_test.go new file mode 100644 index 0000000000..8ce4312816 --- /dev/null +++ b/backend/modules/evaluation/application/experiment_scheduling_privilege_test.go @@ -0,0 +1,218 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package application + +import ( + "context" + "testing" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/infra/middleware/session" + domain_expt "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/domain/expt" + "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/expt" + componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// enforceSchedulingPrivilege 的行为是**静默作废**而不是报错,所以这些用例断言的是 +// "字段有没有被清掉/降级",而不是返回值 —— 该函数无返回值正是这个设计的体现。 + +// ctxWithEmail 造一个带已验证邮箱的 ctx。生产链路里这份邮箱由商业版 CtxUser 中间件 +// 从 ByteTIM ticket claim 写入(不是请求体字段),所以它可以当授权键用。 +func ctxWithEmail(email string) context.Context { + return session.WithCtxUser(context.Background(), &session.User{ID: "123", Email: email}) +} + +// privilegedReq 造一个申报了全部三样特权参数的请求。 +func privilegedReq() *expt.CreateExperimentRequest { + return &expt.CreateExperimentRequest{ + WorkspaceID: 456, + PriorityLevel: gptr.Of(int32(99)), + TriggerType: gptr.Of(domain_expt.Evalx), + ExpectedQuotaConsumption: &domain_expt.ExpectedQuotaConsumption{ + Resources: []*domain_expt.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + }, + }, + } +} + +// TestEnforceSchedulingPrivilege_DropsAllThreeWhenNotAllowed 未获授权时三样全部作废。 +// +// 三样必须同时挡住:只挡其中一两样等于没挡 —— 只挡 priority 却放开 trigger, +// 任何人仍能自称 evalx 把实验塞进中心调度;只挡 trigger 却放开 quota, +// 纳管范围内的实验仍能虚报消耗。 +func TestEnforceSchedulingPrivilege_DropsAllThreeWhenNotAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + // 空白名单 = 谁都没有特权 + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{}) + + app := &experimentApplication{configer: mockConfiger} + req := privilegedReq() + + app.enforceSchedulingPrivilege(ctxWithEmail("stranger@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel, "未授权的 priority 必须清空,否则任何人都能设 99 插队") + assert.Nil(t, req.ExpectedQuotaConsumption, "未授权的资源向量必须清空,否则可虚报消耗") + assert.Equal(t, domain_expt.Manual, req.GetTriggerType(), + "未授权的 evalx 必须降级,否则任何人都能让实验进 enforce") +} + +// TestEnforceSchedulingPrivilege_KeepsAllThreeWhenUserAllowed 白名单用户三样全保留。 +func TestEnforceSchedulingPrivilege_KeepsAllThreeWhenUserAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + + app := &experimentApplication{configer: mockConfiger} + req := privilegedReq() + + app.enforceSchedulingPrivilege(ctxWithEmail("admin@bytedance.com"), req) + + assert.Equal(t, int32(99), req.GetPriorityLevel()) + assert.NotNil(t, req.ExpectedQuotaConsumption) + assert.Equal(t, domain_expt.Evalx, req.GetTriggerType()) +} + +// TestEnforceSchedulingPrivilege_KeepsWhenSpaceAllowed 靠 space 维度放行 +// (验证 OR 语义在真实调用链上也成立:user 不在名单里)。 +func TestEnforceSchedulingPrivilege_KeepsWhenSpaceAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{SpaceIDs: []string{"456"}}) + + app := &experimentApplication{configer: mockConfiger} + req := privilegedReq() + + app.enforceSchedulingPrivilege(ctxWithEmail("nobody@bytedance.com"), req) + + assert.Equal(t, int32(99), req.GetPriorityLevel()) + assert.Equal(t, domain_expt.Evalx, req.GetTriggerType()) +} + +// TestEnforceSchedulingPrivilege_SkipsConfigReadWhenNothingDeclared 什么都没申报时不读配置。 +// +// 断言方式是"不给 mock 设 EXPECT" —— gomock 在发生未预期调用时会失败, +// 因此这个用例同时钉住了"不发无谓的配置读取"和"不给未使用这些字段的调用方刷日志"。 +func TestEnforceSchedulingPrivilege_SkipsConfigReadWhenNothingDeclared(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + app := &experimentApplication{configer: mockConfiger} + + // 只有 manual trigger、无 priority、无向量 —— 控制台创建实验的典型形态 + req := &expt.CreateExperimentRequest{WorkspaceID: 456, TriggerType: gptr.Of(domain_expt.Manual)} + app.enforceSchedulingPrivilege(ctxWithEmail("nobody@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel) + assert.Equal(t, domain_expt.Manual, req.GetTriggerType(), "非 evalx 的 trigger 不得被改动") +} + +// TestEnforceSchedulingPrivilege_NonEvalxTriggerUntouched 未授权时也不动非 evalx 的 trigger。 +// +// 只有 evalx 能带来特权(进 enforce),伪造 openapi/schedule 不产生任何好处 —— +// 若把它们也一并降级成 manual,会把"调用来源"这个排查依据抹掉。 +func TestEnforceSchedulingPrivilege_NonEvalxTriggerUntouched(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + // 申报了 priority 所以会读配置,但 trigger 不该被动 + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + TriggerType: gptr.Of(domain_expt.OpenAPI), + PriorityLevel: gptr.Of(int32(50)), + } + + app.enforceSchedulingPrivilege(ctxWithEmail("nobody@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel, "priority 仍要清") + assert.Equal(t, domain_expt.OpenAPI, req.GetTriggerType(), "openapi trigger 必须原样保留") +} + +// TestEnforceSchedulingPrivilege_NilSafe req 为 nil 时不得 panic(这道闸在创建实验热路径上)。 +func TestEnforceSchedulingPrivilege_NilSafe(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + app := &experimentApplication{configer: mockConfiger} + + app.enforceSchedulingPrivilege(ctxWithEmail("admin@bytedance.com"), nil) + + // ctx 里没有用户信息(拿不到邮箱)但有申报值:需要读配置,邮箱按空串处理 + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + req := privilegedReq() + app.enforceSchedulingPrivilege(context.Background(), req) + assert.Nil(t, req.PriorityLevel, "拿不到用户身份时不得放行") +} + +// TestEnforceSchedulingPrivilege_ConfigReadFailureDeniesByDefault 配置读取失败(返回 nil)时拒绝。 +// +// configer 的实现在读取失败时回落到 DefaultExptSchedulingPrivilegeWhiteList(),但这里直接 +// 喂 nil 做纵深防御:万一将来某个实现真的返回了 nil,行为也必须是拒绝而不是 panic 或放行。 +func TestEnforceSchedulingPrivilege_ConfigReadFailureDeniesByDefault(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()).Return(nil) + + app := &experimentApplication{configer: mockConfiger} + req := privilegedReq() + + app.enforceSchedulingPrivilege(ctxWithEmail("admin@bytedance.com"), req) + + assert.Nil(t, req.PriorityLevel, + "配置不可判定时必须拒绝 —— 这些参数被滥用的后果都是悄悄多占资源/插队") + assert.Nil(t, req.ExpectedQuotaConsumption) + assert.Equal(t, domain_expt.Manual, req.GetTriggerType()) +} + +// TestEnforceSchedulingPrivilege_QuotaOnlyDeclarationStillGated 只申报向量也要过闸。 +// +// 容易漏的一种形态:调用方不设 priority、trigger 也不是 evalx,只塞一个资源向量。 +// 若判定条件只看 priority 与 trigger,这条会绕过闸门 —— 向量虽然在 legacy 下不生效, +// 但它会被冻结进 eval_conf,一旦该实验后续被纳管就直接按虚报值扣额度。 +func TestEnforceSchedulingPrivilege_QuotaOnlyDeclarationStillGated(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{}) + + app := &experimentApplication{configer: mockConfiger} + req := &expt.CreateExperimentRequest{ + WorkspaceID: 456, + ExpectedQuotaConsumption: &domain_expt.ExpectedQuotaConsumption{ + Resources: []*domain_expt.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 1}, + }, + }, + } + + app.enforceSchedulingPrivilege(ctxWithEmail("nobody@bytedance.com"), req) + + assert.Nil(t, req.ExpectedQuotaConsumption, "只申报向量同样要被挡") +} diff --git a/backend/modules/evaluation/domain/component/conf.go b/backend/modules/evaluation/domain/component/conf.go index 0d4a412f10..ecab2cfb35 100644 --- a/backend/modules/evaluation/domain/component/conf.go +++ b/backend/modules/evaluation/domain/component/conf.go @@ -35,12 +35,11 @@ type IConfiger interface { GetExptTurnResultFilterBmqProducerCfg(ctx context.Context) *entity.BmqProducerCfg GetCKDBName(ctx context.Context) *entity.CKDBConfig GetExptExportWhiteList(ctx context.Context) *entity.ExptExportWhiteList - // GetExptPriorityWhiteList 谁可以在发起实验时指定调度优先级(user / space / caller PSM 三维 OR)。 - // 未命中者的申报值被忽略、强制走缺省优先级。读取失败时按"谁都不许"处理。 - GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList - // GetExptTriggerTrustConf 谁可以自称 EvalX 从而让实验进入 enforce(按 caller PSM 判定)。 - // 缺省不启用校验 —— 一律拒绝会让全部 EvalX 实验静默退回 legacy,比"配了没生效"隐蔽。 - GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf + // GetExptSchedulingPrivilegeWhiteList 谁可以申报中心调度的特权参数 + // (priority / expected_quota_consumption / trigger_type=evalx 三者同一判据)。 + // 未命中者的申报值一律被忽略:priority 走缺省、向量丢弃、trigger 降级 manual。 + // 读取失败时按"谁都没有特权"处理。 + GetExptSchedulingPrivilegeWhiteList(ctx context.Context) *entity.ExptSchedulingPrivilegeWhiteList GetMaintainerUserIDs(ctx context.Context) map[string]bool GetSchedulerAbortCtrl(ctx context.Context) *entity.SchedulerAbortCtrl GetTargetTrajectoryConf(ctx context.Context) *entity.TargetTrajectoryConf diff --git a/backend/modules/evaluation/domain/component/mocks/expt_configer.go b/backend/modules/evaluation/domain/component/mocks/expt_configer.go index 0fd5aa59a5..a8e988a677 100644 --- a/backend/modules/evaluation/domain/component/mocks/expt_configer.go +++ b/backend/modules/evaluation/domain/component/mocks/expt_configer.go @@ -197,18 +197,18 @@ func (mr *MockIConfigerMockRecorder) GetExptMultiSetWhiteList(ctx any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptMultiSetWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptMultiSetWhiteList), ctx) } -// GetExptPriorityWhiteList mocks base method. -func (m *MockIConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { +// GetExptSchedulingPrivilegeWhiteList mocks base method. +func (m *MockIConfiger) GetExptSchedulingPrivilegeWhiteList(ctx context.Context) *entity.ExptSchedulingPrivilegeWhiteList { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptPriorityWhiteList", ctx) - ret0, _ := ret[0].(*entity.ExptPriorityWhiteList) + ret := m.ctrl.Call(m, "GetExptSchedulingPrivilegeWhiteList", ctx) + ret0, _ := ret[0].(*entity.ExptSchedulingPrivilegeWhiteList) return ret0 } -// GetExptPriorityWhiteList indicates an expected call of GetExptPriorityWhiteList. -func (mr *MockIConfigerMockRecorder) GetExptPriorityWhiteList(ctx any) *gomock.Call { +// GetExptSchedulingPrivilegeWhiteList indicates an expected call of GetExptSchedulingPrivilegeWhiteList. +func (mr *MockIConfigerMockRecorder) GetExptSchedulingPrivilegeWhiteList(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptPriorityWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptPriorityWhiteList), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptSchedulingPrivilegeWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptSchedulingPrivilegeWhiteList), ctx) } // GetExptTemplateUpdateEvalSetWhiteList mocks base method. @@ -225,20 +225,6 @@ func (mr *MockIConfigerMockRecorder) GetExptTemplateUpdateEvalSetWhiteList(ctx a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTemplateUpdateEvalSetWhiteList", reflect.TypeOf((*MockIConfiger)(nil).GetExptTemplateUpdateEvalSetWhiteList), ctx) } -// GetExptTriggerTrustConf mocks base method. -func (m *MockIConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTriggerTrustConf", ctx) - ret0, _ := ret[0].(*entity.ExptTriggerTrustConf) - return ret0 -} - -// GetExptTriggerTrustConf indicates an expected call of GetExptTriggerTrustConf. -func (mr *MockIConfigerMockRecorder) GetExptTriggerTrustConf(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTriggerTrustConf", reflect.TypeOf((*MockIConfiger)(nil).GetExptTriggerTrustConf), ctx) -} - // GetExptTurnResultFilterBmqProducerCfg mocks base method. func (m *MockIConfiger) GetExptTurnResultFilterBmqProducerCfg(ctx context.Context) *entity.BmqProducerCfg { m.ctrl.T.Helper() diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf.go b/backend/modules/evaluation/domain/entity/expt_priority_conf.go deleted file mode 100644 index dfd9c6f66a..0000000000 --- a/backend/modules/evaluation/domain/entity/expt_priority_conf.go +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) 2025 coze-dev Authors -// SPDX-License-Identifier: Apache-2.0 - -package entity - -import ( - "strconv" - "strings" -) - -// ExptPriorityWhiteList 决定"谁可以在发起实验时指定调度优先级"。 -// -// 为什么需要这道闸:priority 在中心调度下参与**严格优先级排序**,高优实验会持续抢占额度。 -// 字段本身在 IDL 里带 json/form 绑定标签,任何有建实验权限的调用方都能填 —— 不加限制的话 -// 一个人把自己所有实验设成 99 就能让别人的实验饿死,而这既不违反任何校验也不会报错。 -// -// ★ 三个维度,全部由**我们**维护,不接受用户自助配置: -// -// UserEmails —— 点名的自然人(邮箱,人可读) -// SpaceIDs —— 点名的空间 -// CallerPSMs —— 点名的可信服务 -// -// ⚠️ SpaceIDs 的正确用法是「**只有管理员在的私有空间**」:给那样的空间开白名单, -// 等价于给一份受控的人员名单开白名单。**绝不要**把普通业务空间填进来 —— -// 业务空间里谁都能建实验、成员还会随时增减,那等于把插队权下放给一群不确定的人, -// 而这份特权名单必须始终由我们掌握。 -// -// 与 enforce 灰度(`central_expt_scheduler_space_config`)的性质区别值得记住: -// 那份按空间/评测对象划范围是**运维范围**(谁被中心调度纳管);本表是**特权授予** -// (谁能抢资源)。前者配错只是纳管范围不对,后者配错是有人能插队。 -// -// 三个维度之间是 **OR**:命中任意一个即放行。不能取 AND —— CallerPSMs 服务的是系统调用方, -// 它没有自然人 user,取 AND 会让这一维永远走不通。 -type ExptPriorityWhiteList struct { - // UserEmails 可指定 priority 的用户邮箱。 - // - // 用邮箱而不是 user_id:这份名单由人手工维护、也要靠人 review, - // `zhangsan@bytedance.com` 一眼就知道是谁,而 `7123456789012345678` 需要另查一次才能确认 - // —— 加错人是"给了插队权",是最不该靠肉眼比对长数字来防的错误。 - // - // 邮箱取自**已验证的 ByteTIM ticket claim**(见商业版 infra/middleware/user.go), - // 不是请求体里的字段,调用方无法伪造,因此可以当授权键用。 - UserEmails []string `json:"user_emails" mapstructure:"user_emails"` - // SpaceIDs 整个空间放行。**只填管理员私有空间**,理由见类型注释。 - // - // 用 []string 而非 []int64:19 位雪花 ID 超出 float64 安全整数范围, - // 任何把 JSON number 当 double 的环节都会静默截断低位(实测 bytedcli tcc 写入 - // 7533128632407949313 会回读成 ...949000)。配置里一律写 ["7533..."]。 - SpaceIDs []string `json:"space_ids" mapstructure:"space_ids"` - // CallerPSMs 可信调用方 PSM(如 EvalX 的服务名)。用于系统调用方 —— 它们没有自然人身份。 - // - // ⚠️ 只能填**内部 RPC 直连**的 PSM。它取自 kitex 的 caller 字段,由框架按调用方身份填充, - // 调用方无法在业务参数里伪造;这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段, - // 任何人都能自称 "evalx",因此**绝不能**用 trigger_type 当授权判据。 - CallerPSMs []string `json:"caller_psms" mapstructure:"caller_psms"` - // AllowAll 全部放行。仅用于"暂时不限制"的过渡期,不建议长期开启。 - AllowAll bool `json:"allow_all" mapstructure:"allow_all"` -} - -// DefaultExptPriorityWhiteList 配置缺失或解析失败时的兜底:**谁都不许指定**。 -// -// 取禁止而非放行:priority 是插队能力,配置中心抖动时宁可让所有人退回 default(表现为 -// "我设的优先级没生效",可见且无损),也不要因为读不到配置就让所有人都能插队 -// (静默、且要等资源被抢占才发现)。 -func DefaultExptPriorityWhiteList() *ExptPriorityWhiteList { - return &ExptPriorityWhiteList{} -} - -// ExptPrioritySubject 是判定的输入:一次创建实验请求里与"能否指定 priority"有关的全部身份。 -// -// 收成一个结构体而不是散着传:三者都是可选的(自然人调用时 CallerPSM 为空, -// 系统调用时 UserEmail 为空),散着传容易在新增维度时漏掉调用点。 -type ExptPrioritySubject struct { - // UserEmail 已验证的用户邮箱(来自 session)。空串表示拿不到身份。 - UserEmail string - SpaceID int64 - CallerPSM string -} - -// AllowSpecifyPriority 报告该请求是否可以指定调度优先级。 -// -// nil 白名单返回 false(与本仓其它白名单一致:没配置就是没开)。 -func (w *ExptPriorityWhiteList) AllowSpecifyPriority(subject ExptPrioritySubject) bool { - if w == nil { - return false - } - if w.AllowAll { - return true - } - if w.matchUserEmail(subject.UserEmail) { - return true - } - if w.matchSpaceID(subject.SpaceID) { - return true - } - return w.matchCallerPSM(subject.CallerPSM) -} - -// matchSpaceID 把入参格式化成字符串再比。 -// -// spaceID=0 表示"无空间上下文",一律不匹配 —— 否则运维在 space_ids 里误填 "0" -// 就会把所有无空间上下文的请求放行。 -func (w *ExptPriorityWhiteList) matchSpaceID(spaceID int64) bool { - if spaceID == 0 { - return false - } - want := strconv.FormatInt(spaceID, 10) - for _, id := range w.SpaceIDs { - if strings.TrimSpace(id) == want { - return true - } - } - return false -} - -// matchUserEmail 忽略大小写与首尾空白比对(邮箱本身大小写不敏感,且名单由人手写)。 -// -// 空邮箱一律不匹配:拿不到身份时不得放行 —— 不把"读不到"当成有权限。 -// 注意商业版只在 ByteTIM ticket 存在时才填 Email,所以非用户态调用(纯服务间 RPC) -// 这里必然为空,那种情况该走 CallerPSMs 维度。 -func (w *ExptPriorityWhiteList) matchUserEmail(userEmail string) bool { - email := strings.TrimSpace(userEmail) - if email == "" { - return false - } - for _, e := range w.UserEmails { - if strings.EqualFold(strings.TrimSpace(e), email) { - return true - } - } - return false -} - -// matchCallerPSM 忽略大小写与首尾空白比对。 -// -// 容忍这两者是因为 PSM 由人手写进 TCC,"Stone.CozeLoop.Foo " 这类笔误的后果是静默不放行 -// —— 配了却不生效,而且两个字符串肉眼几乎一样,极难反推。 -// 空 caller 一律不匹配:非 RPC 直连(HTTP 入口)时 caller 为空,那种情况该走 user/space 维度。 -func (w *ExptPriorityWhiteList) matchCallerPSM(callerPSM string) bool { - caller := strings.TrimSpace(callerPSM) - if caller == "" { - return false - } - for _, psm := range w.CallerPSMs { - if strings.EqualFold(strings.TrimSpace(psm), caller) { - return true - } - } - return false -} - -// ExptTriggerTrustConf 决定"谁可以自称 EvalX 从而让实验进入 enforce"。 -// -// 为什么需要:enforce 的第一道闸 ShouldEnforceByTrigger 只比对请求体里的 trigger_type -// 字符串,而那是调用方**自己填的普通字段** —— 任何人都能自称 "evalx"。也就是说 -// "谁被中心调度纳管"实际上部分取决于调用方自报,而它本该完全由我们决定。 -// -// 本表把 trigger 判据从"信自报"改成"信 RPC 框架填充的 caller":声明 evalx 的请求 -// 必须同时来自名单内的 PSM,否则该 trigger 不被采信、实验退回 legacy。 -// -// ⚠️ 为什么缺省是**放行**(与 ExptPriorityWhiteList 的缺省拒绝相反):两者失败代价不同。 -// priority 配不到 → 大家退回缺省优先级,可见且无损;而 trigger 若配不到就一律拒绝, -// 会让**全部 EvalX 实验静默退回 legacy** —— 中心调度突然没有任何候选, -// 现象是"实验都在跑但一个都不受额度管控",比"配了没生效"隐蔽得多。 -// 因此取"未配置=不额外收紧",范围仍由灰度 TCC(第二道闸)兜住, -// 等 PSM 名单在灰度环境确认无误后再打开 Enabled。 -type ExptTriggerTrustConf struct { - // Enabled 是否启用 caller 校验。false(缺省)时保持原行为:只看 trigger_type 字段。 - // - // 用独立开关而不是"名单非空即启用":后者会让"配置读取失败返回空名单"与 - // "运维故意留空"这两种情况行为一致,而它们的正确行为恰好相反。 - Enabled bool `json:"enabled" mapstructure:"enabled"` - // EvalxCallerPSMs 允许声明 trigger_type=evalx 的调用方 PSM。 - EvalxCallerPSMs []string `json:"evalx_caller_psms" mapstructure:"evalx_caller_psms"` -} - -// DefaultExptTriggerTrustConf 缺省:不启用 caller 校验(保持引入本闸前的行为)。 -func DefaultExptTriggerTrustConf() *ExptTriggerTrustConf { - return &ExptTriggerTrustConf{} -} - -// TrustEvalxTrigger 报告该 caller 声明的 evalx trigger 是否可采信。 -// 未启用时恒为 true —— 理由见类型注释里的"缺省放行"。 -func (c *ExptTriggerTrustConf) TrustEvalxTrigger(callerPSM string) bool { - if c == nil || !c.Enabled { - return true - } - caller := strings.TrimSpace(callerPSM) - if caller == "" { - // 启用校验后拿不到 caller(非 RPC 直连)即不可采信: - // 我们信的是框架填充的身份,没有身份就没有可信来源。 - return false - } - for _, psm := range c.EvalxCallerPSMs { - if strings.EqualFold(strings.TrimSpace(psm), caller) { - return true - } - } - return false -} diff --git a/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go b/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go deleted file mode 100644 index fbb893d8a7..0000000000 --- a/backend/modules/evaluation/domain/entity/expt_priority_conf_test.go +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright (c) 2025 coze-dev Authors -// SPDX-License-Identifier: Apache-2.0 - -package entity - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExptPriorityWhiteList_NilAndDefaultDenyAll(t *testing.T) { - // nil 与缺省配置都必须拒绝 —— 这是 fail-closed 方向: - // 读不到配置时让所有人退回缺省优先级(可见、无损),而不是静默放开插队。 - var nilList *ExptPriorityWhiteList - assert.False(t, nilList.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), - "nil 白名单必须拒绝") - - assert.False(t, DefaultExptPriorityWhiteList().AllowSpecifyPriority( - ExptPrioritySubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), - "缺省白名单(读取失败时的兜底)必须拒绝") -} - -func TestExptPriorityWhiteList_AllowAll(t *testing.T) { - w := &ExptPriorityWhiteList{AllowAll: true} - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{}), "allow_all 对空 subject 也放行") - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com"})) -} - -func TestExptPriorityWhiteList_ThreeDimensionsAreOR(t *testing.T) { - // 三个维度取 OR 是刻意的:CallerPSMs 服务的是系统调用方(没有自然人 user), - // SpaceIDs 服务的是"管理员私有空间"。取 AND 会让这两条永远走不通。 - w := &ExptPriorityWhiteList{ - UserEmails: []string{"admin@bytedance.com"}, - SpaceIDs: []string{"222"}, - CallerPSMs: []string{"stone.cozeloop.evalx"}, - } - - t.Run("只命中 user", func(t *testing.T) { - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) - }) - t.Run("只命中 space", func(t *testing.T) { - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 222, CallerPSM: "other.psm"})) - }) - t.Run("只命中 caller psm", func(t *testing.T) { - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "stone.cozeloop.evalx"})) - }) - t.Run("三个都不命中", func(t *testing.T) { - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) - }) -} - -// TestExptPriorityWhiteList_SpaceIDZeroNeverMatches spaceID=0 表示"无空间上下文", -// 绝不能因为运维在 space_ids 里误填 0 就把所有无空间请求放行。 -func TestExptPriorityWhiteList_SpaceIDZeroNeverMatches(t *testing.T) { - w := &ExptPriorityWhiteList{SpaceIDs: []string{"0"}} - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 0}), - "空 spaceID 不得匹配,即使名单里误填了 \"0\"") -} - -func TestExptPriorityWhiteList_UserEmailMatching(t *testing.T) { - w := &ExptPriorityWhiteList{UserEmails: []string{"a@bytedance.com", "b@bytedance.com"}} - - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "a@bytedance.com"})) - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: " B@ByteDance.com "}), "容忍首尾空白与大小写(邮箱大小写不敏感,名单由人手写)") - - // 解析不出来一律不放行 —— 不把"读不到身份"当成有权限。 - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: ""}), "空邮箱不得放行 —— 拿不到身份时不把\"读不到\"当成有权限") - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: " "}), "纯空白邮箱不得放行") - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "stranger@bytedance.com"}), "不在名单内") -} - -func TestExptPriorityWhiteList_CallerPSMMatching(t *testing.T) { - w := &ExptPriorityWhiteList{CallerPSMs: []string{"stone.cozeloop.evalx", " stone.cozeloop.foo "}} - - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.evalx"})) - // 忽略大小写与空白:PSM 由人手写进 TCC,笔误的后果是静默不放行(配了却不生效), - // 而两个字符串肉眼几乎一样,极难反推。 - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "Stone.CozeLoop.EvalX"}), "忽略大小写") - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.foo"}), "忽略名单侧的首尾空白") - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: " stone.cozeloop.evalx "}), "忽略入参侧的首尾空白") - - // 空 caller 一律不匹配:HTTP 入口等非 RPC 直连场景 caller 为空, - // 那种情况该走 user 维度,不能因为"没有 caller"就放行。 - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: ""}), "空 caller 不得放行") - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: " "}), "纯空白 caller 不得放行") - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "evil.psm"})) -} - -// TestExptPriorityWhiteList_EmptyListsDoNotMatch 各维度列表为空时不得意外放行。 -// 这道回归钉住的是"空 slice 被当成通配"这类容易写错的实现。 -func TestExptPriorityWhiteList_EmptyListsDoNotMatch(t *testing.T) { - w := &ExptPriorityWhiteList{} - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com", SpaceID: 222, CallerPSM: "any.psm"})) -} - -// ---- ExptTriggerTrustConf ---- - -// TestExptTriggerTrustConf_DefaultDoesNotTighten 缺省**放行**,方向与 priority 白名单刻意相反。 -// -// 理由不对称:priority 配不到 → 大家退回缺省优先级,可见且无损; -// trigger 若配不到就一律拒绝 → 全部 EvalX 实验静默退回 legacy,中心调度突然没有候选, -// 现象是"实验都在跑但一个都不受额度管控",比"配了没生效"隐蔽得多。 -func TestExptTriggerTrustConf_DefaultDoesNotTighten(t *testing.T) { - var nilConf *ExptTriggerTrustConf - assert.True(t, nilConf.TrustEvalxTrigger("anything"), "nil 配置不得额外收紧") - assert.True(t, nilConf.TrustEvalxTrigger(""), "nil 配置下空 caller 也放行") - - assert.True(t, DefaultExptTriggerTrustConf().TrustEvalxTrigger("anything"), - "缺省配置(读取失败兜底)不得额外收紧") -} - -// TestExptTriggerTrustConf_DisabledIgnoresList Enabled=false 时名单不生效。 -// -// 用独立开关而不是"名单非空即启用":后者会让"读取失败返回空名单"与"运维故意留空" -// 行为一致,而这两种情况的正确行为恰好相反。 -func TestExptTriggerTrustConf_DisabledIgnoresList(t *testing.T) { - c := &ExptTriggerTrustConf{Enabled: false, EvalxCallerPSMs: []string{"only.this.psm"}} - assert.True(t, c.TrustEvalxTrigger("some.other.psm"), "未启用时名单不生效") -} - -func TestExptTriggerTrustConf_EnabledChecksCaller(t *testing.T) { - c := &ExptTriggerTrustConf{Enabled: true, EvalxCallerPSMs: []string{"stone.cozeloop.evalx", " stone.cozeloop.foo "}} - - assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.evalx")) - assert.True(t, c.TrustEvalxTrigger("Stone.CozeLoop.EvalX"), "忽略大小写") - assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.foo"), "忽略名单侧空白") - assert.True(t, c.TrustEvalxTrigger(" stone.cozeloop.evalx "), "忽略入参侧空白") - - assert.False(t, c.TrustEvalxTrigger("evil.psm"), "名单外的调用方不得自称 evalx") - // 启用校验后拿不到 caller 即不可采信:我们信的是框架填充的身份,没有身份就没有可信来源。 - assert.False(t, c.TrustEvalxTrigger(""), "启用后空 caller 不可采信") - assert.False(t, c.TrustEvalxTrigger(" "), "启用后纯空白 caller 不可采信") -} - -// TestExptTriggerTrustConf_EnabledWithEmptyListDeniesAll 启用但名单为空 = 谁都不可信。 -// 这是显式配置的结果(Enabled 被手动打开),与"读取失败"不同,因此拒绝是对的。 -func TestExptTriggerTrustConf_EnabledWithEmptyListDeniesAll(t *testing.T) { - c := &ExptTriggerTrustConf{Enabled: true} - assert.False(t, c.TrustEvalxTrigger("any.psm")) -} - -// TestExptPriorityWhiteList_UnmarshalFullConfig 端到端解一份完整配置, -// 确认字段名与运维实际会写的 JSON 一致(字段名写错的后果是静默不生效)。 -func TestExptPriorityWhiteList_UnmarshalFullConfig(t *testing.T) { - raw := `{ - "user_emails": ["admin@bytedance.com"], - "space_ids": ["7533128632407949313"], - "caller_psms": ["stone.cozeloop.evalx"], - "allow_all": false - }` - - var w ExptPriorityWhiteList - require.NoError(t, json.Unmarshal([]byte(raw), &w)) - - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "admin@bytedance.com"})) - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{SpaceID: 7533128632407949313}), - "19 位雪花 ID 从字符串配置解出后必须精确匹配") - assert.True(t, w.AllowSpecifyPriority(ExptPrioritySubject{CallerPSM: "stone.cozeloop.evalx"})) - assert.False(t, w.AllowSpecifyPriority(ExptPrioritySubject{UserEmail: "other@bytedance.com", SpaceID: 999})) -} - -// TestExptTriggerTrustConf_UnmarshalFullConfig 同上,钉住 trigger 配置的字段名。 -func TestExptTriggerTrustConf_UnmarshalFullConfig(t *testing.T) { - var c ExptTriggerTrustConf - require.NoError(t, json.Unmarshal([]byte(`{"enabled": true, "evalx_caller_psms": ["stone.cozeloop.evalx"]}`), &c)) - - assert.True(t, c.TrustEvalxTrigger("stone.cozeloop.evalx")) - assert.False(t, c.TrustEvalxTrigger("someone.else")) -} diff --git a/backend/modules/evaluation/domain/entity/expt_scheduling_privilege.go b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege.go new file mode 100644 index 0000000000..c3915b7944 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege.go @@ -0,0 +1,167 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "strconv" + "strings" +) + +// ExptSchedulingPrivilegeWhiteList 决定"谁可以申报中心调度的特权参数"。 +// +// 它管**三样**自报参数,判据统一为同一份名单: +// +// priority_level —— 调度优先级(参与严格优先级排序,高优持续抢占额度) +// expected_quota_consumption —— 单 item 预期资源消耗(决定扣多少额度) +// trigger_type = evalx —— 能否让实验进入 enforce(被中心调度纳管) +// +// 为什么必须收在一处:这三样都是**调用方在请求体里自报的**,而它们共同决定"这个实验 +// 拿多少资源、排在谁前面"。三样各配一套判据只会让"谁有特权"散成三处、缺省方向还可能 +// 不一致;收成一份名单,答案只有一个地方。 +// +// 各自不设限的后果: +// - priority → 一个人把自己所有实验设成 99 就能让别人的实验饿死 +// - quota → 虚报消耗(少报偷跑、多报占死额度) +// - trigger → 任何人自称 evalx 就能进 enforce,"谁被纳管"不再由我们决定 +// +// 三样都不违反任何校验、不报错,所以都必须靠名单挡。 +// +// ★ 三个身份维度,全部由**我们**维护,不接受用户自助配置: +// +// UserEmails —— 点名的自然人(邮箱,人可读) +// SpaceIDs —— 点名的空间 +// CallerPSMs —— 点名的可信服务(EvalX 等系统调用方走这一维) +// +// ⚠️ SpaceIDs 的正确用法是「**只有管理员在的私有空间**」:给那样的空间开白名单, +// 等价于给一份受控的人员名单开白名单。**绝不要**把普通业务空间填进来 —— +// 业务空间里谁都能建实验、成员还会随时增减,那等于把特权下放给一群不确定的人。 +// +// 与 enforce 灰度(`central_expt_scheduler_space_config`)的性质区别值得记住: +// 那份按空间/评测对象划范围是**运维范围**(谁被中心调度纳管);本表是**特权授予** +// (谁能自报参数)。前者配错只是纳管范围不对,后者配错是有人能插队/虚报资源。 +// +// 三个维度之间是 **OR**:命中任意一个即放行。不能取 AND —— CallerPSMs 服务的是系统调用方, +// 它没有自然人 user,取 AND 会让这一维永远走不通。 +type ExptSchedulingPrivilegeWhiteList struct { + // UserEmails 可申报特权参数的用户邮箱。 + // + // 用邮箱而不是 user_id:这份名单由人手工维护、也要靠人 review, + // `zhangsan@bytedance.com` 一眼就知道是谁,而 `7123456789012345678` 需要另查一次才能确认 + // —— 加错人是"给了特权",最不该靠肉眼比对长数字来防。 + // + // 邮箱取自**已验证的 ByteTIM ticket claim**(见商业版 infra/middleware/user.go), + // 不是请求体里的字段,调用方无法伪造,因此可以当授权键用。 + UserEmails []string `json:"user_emails" mapstructure:"user_emails"` + // SpaceIDs 整个空间放行。**只填管理员私有空间**,理由见类型注释。 + // + // 用 []string 而非 []int64:19 位雪花 ID 超出 float64 安全整数范围, + // 任何把 JSON number 当 double 的环节都会静默截断低位(实测 bytedcli tcc 写入 + // 7533128632407949313 会回读成 ...949000)。配置里一律写 ["7533..."]。 + SpaceIDs []string `json:"space_ids" mapstructure:"space_ids"` + // CallerPSMs 可信调用方 PSM。EvalX 是 `stone.cozeloop.evalx`。 + // + // ⚠️ 只能填**内部 RPC 直连**的 PSM。它取自 kitex 的 caller 字段,由框架按调用方身份填充, + // 调用方无法在业务参数里伪造;这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段, + // 任何人都能自称 "evalx",因此**绝不能**反过来用 trigger_type 当授权判据。 + // + // ⚠️ **上线检查项**:EvalX 的 PSM 必须在部署前填进这里。缺了它,全部 EvalX 实验会 + // 静默退回 legacy —— 中心调度一个候选都没有,现象是"实验都在跑但一个都不受额度管控", + // 而这个方向的失败是无声的。 + CallerPSMs []string `json:"caller_psms" mapstructure:"caller_psms"` + // AllowAll 全部放行。仅用于"暂时不限制"的过渡期,不建议长期开启。 + AllowAll bool `json:"allow_all" mapstructure:"allow_all"` +} + +// DefaultExptSchedulingPrivilegeWhiteList 配置缺失或解析失败时的兜底:**谁都没有特权**。 +// +// 取禁止而非放行:这三样参数被滥用的后果都是"悄悄多占资源/插队"。配置中心抖动时宁可让 +// 大家退回缺省行为(priority=1、无向量、legacy 链路 —— 都可见且无损),也不要因为 +// 读不到配置就放开特权(静默、且要等资源被抢占才发现)。 +func DefaultExptSchedulingPrivilegeWhiteList() *ExptSchedulingPrivilegeWhiteList { + return &ExptSchedulingPrivilegeWhiteList{} +} + +// ExptSchedulingPrivilegeSubject 是判定的输入:一次创建实验请求里与"能否申报特权"有关的全部身份。 +// +// 收成一个结构体而不是散着传:三者都是可选的(自然人调用时 CallerPSM 为空, +// 系统调用时 UserEmail 为空),散着传容易在新增维度时漏掉调用点。 +type ExptSchedulingPrivilegeSubject struct { + // UserEmail 已验证的用户邮箱(来自 session)。空串表示拿不到身份。 + UserEmail string + SpaceID int64 + CallerPSM string +} + +// AllowSchedulingPrivilege 报告该请求是否可以申报中心调度的特权参数 +// (priority / quota 向量 / evalx trigger 三者同一判据)。 +// +// nil 白名单返回 false(与本仓其它白名单一致:没配置就是没开)。 +func (w *ExptSchedulingPrivilegeWhiteList) AllowSchedulingPrivilege(subject ExptSchedulingPrivilegeSubject) bool { + if w == nil { + return false + } + if w.AllowAll { + return true + } + if w.matchUserEmail(subject.UserEmail) { + return true + } + if w.matchSpaceID(subject.SpaceID) { + return true + } + return w.matchCallerPSM(subject.CallerPSM) +} + +// matchSpaceID 把入参格式化成字符串再比。 +// +// spaceID=0 表示"无空间上下文",一律不匹配 —— 否则运维在 space_ids 里误填 "0" +// 就会把所有无空间上下文的请求放行。 +func (w *ExptSchedulingPrivilegeWhiteList) matchSpaceID(spaceID int64) bool { + if spaceID == 0 { + return false + } + want := strconv.FormatInt(spaceID, 10) + for _, id := range w.SpaceIDs { + if strings.TrimSpace(id) == want { + return true + } + } + return false +} + +// matchUserEmail 忽略大小写与首尾空白比对(邮箱本身大小写不敏感,且名单由人手写)。 +// +// 空邮箱一律不匹配:拿不到身份时不得放行 —— 不把"读不到"当成有权限。 +// 注意商业版只在 ByteTIM ticket 存在时才填 Email,所以非用户态调用(纯服务间 RPC) +// 这里必然为空,那种情况该走 CallerPSMs 维度。 +func (w *ExptSchedulingPrivilegeWhiteList) matchUserEmail(userEmail string) bool { + email := strings.TrimSpace(userEmail) + if email == "" { + return false + } + for _, e := range w.UserEmails { + if strings.EqualFold(strings.TrimSpace(e), email) { + return true + } + } + return false +} + +// matchCallerPSM 忽略大小写与首尾空白比对。 +// +// 容忍这两者是因为 PSM 由人手写进 TCC,"Stone.CozeLoop.Foo " 这类笔误的后果是静默不放行 +// —— 配了却不生效,而且两个字符串肉眼几乎一样,极难反推。 +// 空 caller 一律不匹配:非 RPC 直连(HTTP 入口)时 caller 为空,那种情况该走 email/space 维度。 +func (w *ExptSchedulingPrivilegeWhiteList) matchCallerPSM(callerPSM string) bool { + caller := strings.TrimSpace(callerPSM) + if caller == "" { + return false + } + for _, psm := range w.CallerPSMs { + if strings.EqualFold(strings.TrimSpace(psm), caller) { + return true + } + } + return false +} diff --git a/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go new file mode 100644 index 0000000000..285a404a96 --- /dev/null +++ b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package entity + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExptSchedulingPrivilegeWhiteList_NilAndDefaultDenyAll(t *testing.T) { + // nil 与缺省配置都必须拒绝 —— 这是 fail-closed 方向: + // 读不到配置时让所有人退回缺省优先级(可见、无损),而不是静默放开插队。 + var nilList *ExptSchedulingPrivilegeWhiteList + assert.False(t, nilList.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), + "nil 白名单必须拒绝") + + assert.False(t, DefaultExptSchedulingPrivilegeWhiteList().AllowSchedulingPrivilege( + ExptSchedulingPrivilegeSubject{UserEmail: "zhangsan@bytedance.com", CallerPSM: "a.b.c"}), + "缺省白名单(读取失败时的兜底)必须拒绝") +} + +func TestExptSchedulingPrivilegeWhiteList_AllowAll(t *testing.T) { + w := &ExptSchedulingPrivilegeWhiteList{AllowAll: true} + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{}), "allow_all 对空 subject 也放行") + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "nobody@bytedance.com"})) +} + +func TestExptSchedulingPrivilegeWhiteList_ThreeDimensionsAreOR(t *testing.T) { + // 三个维度取 OR 是刻意的:CallerPSMs 服务的是系统调用方(没有自然人 user), + // SpaceIDs 服务的是"管理员私有空间"。取 AND 会让这两条永远走不通。 + w := &ExptSchedulingPrivilegeWhiteList{ + UserEmails: []string{"admin@bytedance.com"}, + SpaceIDs: []string{"222"}, + CallerPSMs: []string{"stone.cozeloop.evalx"}, + } + + t.Run("只命中 user", func(t *testing.T) { + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "admin@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) + }) + t.Run("只命中 space", func(t *testing.T) { + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "nobody@bytedance.com", SpaceID: 222, CallerPSM: "other.psm"})) + }) + t.Run("只命中 caller psm", func(t *testing.T) { + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "stone.cozeloop.evalx"})) + }) + t.Run("三个都不命中", func(t *testing.T) { + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "nobody@bytedance.com", SpaceID: 999, CallerPSM: "other.psm"})) + }) +} + +// TestExptSchedulingPrivilegeWhiteList_SpaceIDZeroNeverMatches spaceID=0 表示"无空间上下文", +// 绝不能因为运维在 space_ids 里误填 0 就把所有无空间请求放行。 +func TestExptSchedulingPrivilegeWhiteList_SpaceIDZeroNeverMatches(t *testing.T) { + w := &ExptSchedulingPrivilegeWhiteList{SpaceIDs: []string{"0"}} + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{SpaceID: 0}), + "空 spaceID 不得匹配,即使名单里误填了 \"0\"") +} + +func TestExptSchedulingPrivilegeWhiteList_UserEmailMatching(t *testing.T) { + w := &ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"a@bytedance.com", "b@bytedance.com"}} + + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "a@bytedance.com"})) + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: " B@ByteDance.com "}), "容忍首尾空白与大小写(邮箱大小写不敏感,名单由人手写)") + + // 解析不出来一律不放行 —— 不把"读不到身份"当成有权限。 + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: ""}), "空邮箱不得放行 —— 拿不到身份时不把\"读不到\"当成有权限") + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: " "}), "纯空白邮箱不得放行") + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "stranger@bytedance.com"}), "不在名单内") +} + +func TestExptSchedulingPrivilegeWhiteList_CallerPSMMatching(t *testing.T) { + w := &ExptSchedulingPrivilegeWhiteList{CallerPSMs: []string{"stone.cozeloop.evalx", " stone.cozeloop.foo "}} + + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "stone.cozeloop.evalx"})) + // 忽略大小写与空白:PSM 由人手写进 TCC,笔误的后果是静默不放行(配了却不生效), + // 而两个字符串肉眼几乎一样,极难反推。 + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "Stone.CozeLoop.EvalX"}), "忽略大小写") + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "stone.cozeloop.foo"}), "忽略名单侧的首尾空白") + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: " stone.cozeloop.evalx "}), "忽略入参侧的首尾空白") + + // 空 caller 一律不匹配:HTTP 入口等非 RPC 直连场景 caller 为空, + // 那种情况该走 user 维度,不能因为"没有 caller"就放行。 + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: ""}), "空 caller 不得放行") + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: " "}), "纯空白 caller 不得放行") + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "evil.psm"})) +} + +// TestExptSchedulingPrivilegeWhiteList_EmptyListsDoNotMatch 各维度列表为空时不得意外放行。 +// 这道回归钉住的是"空 slice 被当成通配"这类容易写错的实现。 +func TestExptSchedulingPrivilegeWhiteList_EmptyListsDoNotMatch(t *testing.T) { + w := &ExptSchedulingPrivilegeWhiteList{} + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "admin@bytedance.com", SpaceID: 222, CallerPSM: "any.psm"})) +} + +// TestExptSchedulingPrivilegeWhiteList_UnmarshalFullConfig 端到端解一份完整配置, +// 确认字段名与运维实际会写的 JSON 一致(字段名写错的后果是静默不生效)。 +func TestExptSchedulingPrivilegeWhiteList_UnmarshalFullConfig(t *testing.T) { + raw := `{ + "user_emails": ["admin@bytedance.com"], + "space_ids": ["7533128632407949313"], + "caller_psms": ["stone.cozeloop.evalx"], + "allow_all": false + }` + + var w ExptSchedulingPrivilegeWhiteList + require.NoError(t, json.Unmarshal([]byte(raw), &w)) + + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "admin@bytedance.com"})) + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{SpaceID: 7533128632407949313}), + "19 位雪花 ID 从字符串配置解出后必须精确匹配") + assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "stone.cozeloop.evalx"})) + assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "other@bytedance.com", SpaceID: 999})) +} + diff --git a/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go b/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go index c4810c38f6..fc77c27da1 100644 --- a/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go +++ b/backend/modules/evaluation/infra/repo/evaluator/evaluator_record_impl_test.go @@ -89,11 +89,7 @@ func (f *fakeEvaluatorRecordStorageConfiger) GetExptExportWhiteList(ctx context. return nil } -func (f *fakeEvaluatorRecordStorageConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { - return nil -} - -func (f *fakeEvaluatorRecordStorageConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { +func (f *fakeEvaluatorRecordStorageConfiger) GetExptSchedulingPrivilegeWhiteList(ctx context.Context) *entity.ExptSchedulingPrivilegeWhiteList { return nil } diff --git a/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go b/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go index 905b86d20f..14909ad33a 100644 --- a/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go +++ b/backend/modules/evaluation/infra/repo/target/eval_target_repo_impl_test.go @@ -2025,11 +2025,7 @@ func (f *fakeRecordStorageConfiger) GetExptExportWhiteList(ctx context.Context) return nil } -func (f *fakeRecordStorageConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { - return nil -} - -func (f *fakeRecordStorageConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { +func (f *fakeRecordStorageConfiger) GetExptSchedulingPrivilegeWhiteList(ctx context.Context) *entity.ExptSchedulingPrivilegeWhiteList { return nil } diff --git a/backend/modules/evaluation/infra/storage/record_data_test.go b/backend/modules/evaluation/infra/storage/record_data_test.go index fbca977ea7..e3d611e2fd 100644 --- a/backend/modules/evaluation/infra/storage/record_data_test.go +++ b/backend/modules/evaluation/infra/storage/record_data_test.go @@ -55,11 +55,7 @@ func (f *fakeConfiger) GetExptExportWhiteList(ctx context.Context) *entity.ExptE return nil } -func (f *fakeConfiger) GetExptPriorityWhiteList(ctx context.Context) *entity.ExptPriorityWhiteList { - return nil -} - -func (f *fakeConfiger) GetExptTriggerTrustConf(ctx context.Context) *entity.ExptTriggerTrustConf { +func (f *fakeConfiger) GetExptSchedulingPrivilegeWhiteList(ctx context.Context) *entity.ExptSchedulingPrivilegeWhiteList { return nil } func (f *fakeConfiger) GetMaintainerUserIDs(ctx context.Context) map[string]bool { return nil } diff --git a/backend/modules/evaluation/pkg/conf/configer.go b/backend/modules/evaluation/pkg/conf/configer.go index 589a40b65a..243b8d6295 100644 --- a/backend/modules/evaluation/pkg/conf/configer.go +++ b/backend/modules/evaluation/pkg/conf/configer.go @@ -101,23 +101,14 @@ func (c *configer) GetExptExportWhiteList(ctx context.Context) (eec *entity.Expt return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &eec) == nil, eec, entity.DefaultExptExportWhiteList()) } -// GetExptPriorityWhiteList 谁可以在发起实验时指定调度优先级(user / space / caller PSM 三维 OR)。 +// GetExptSchedulingPrivilegeWhiteList 谁可以申报中心调度的特权参数 +// (priority / expected_quota_consumption / trigger_type=evalx 三者同一判据)。 // -// 读取失败时回落到 DefaultExptPriorityWhiteList()(**谁都不许指定**)而不是放行: -// priority 是插队能力,配置中心抖动时宁可让所有人退回 default,也不要静默放开插队。 -func (c *configer) GetExptPriorityWhiteList(ctx context.Context) (w *entity.ExptPriorityWhiteList) { - const key = "expt_priority_white_list" - return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &w) == nil, w, entity.DefaultExptPriorityWhiteList()) -} - -// GetExptTriggerTrustConf 谁可以自称 EvalX(按 caller PSM 判定)。 -// -// 读取失败回落到 DefaultExptTriggerTrustConf()(**不启用校验**)。这与 priority 白名单的 -// 方向刻意相反:那边配不到就退回缺省优先级、无损;这边若配不到就一律拒绝,会让全部 -// EvalX 实验静默退回 legacy —— 中心调度突然没有候选,比"配了没生效"隐蔽得多。 -func (c *configer) GetExptTriggerTrustConf(ctx context.Context) (t *entity.ExptTriggerTrustConf) { - const key = "expt_trigger_trust_conf" - return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &t) == nil, t, entity.DefaultExptTriggerTrustConf()) +// 读取失败时回落到"谁都没有特权"而不是放行:这三样被滥用的后果都是"悄悄多占资源/插队", +// 配置中心抖动时宁可让大家退回缺省行为(可见且无损),也不要静默放开特权。 +func (c *configer) GetExptSchedulingPrivilegeWhiteList(ctx context.Context) (w *entity.ExptSchedulingPrivilegeWhiteList) { + const key = "expt_scheduling_privilege_white_list" + return lo.Ternary(c.loader.UnmarshalKey(ctx, key, &w) == nil, w, entity.DefaultExptSchedulingPrivilegeWhiteList()) } func (c *configer) GetExptTemplateUpdateEvalSetWhiteList(ctx context.Context) (w *entity.ExptTemplateUpdateEvalSetWhiteList) { diff --git a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift index 3701c639db..732fede6f6 100644 --- a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift +++ b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift @@ -648,6 +648,25 @@ struct SubmitExperimentOApiRequest { // 通知配置 50: optional experiment.ExptNotificationConf notification_conf (api.body = 'notification_conf') + // ★ 中心化调度特权参数 60~62 + // + // ⚠️ 三者都是**特权申报**: 仅当调用方身份命中服务端白名单 + // (TCC expt_scheduling_privilege_white_list 的 user_emails / space_ids / caller_psms) + // 时才生效。未授权的调用方传了**不报错**, 但会被静默丢弃 —— + // priority 走缺省值、向量丢弃、trigger 降级 manual (实验走 legacy 链路)。 + // 不报错是为了兼容: 这几个字段可能已有调用方在传, 突然报错会打挂它们。 + // + // 调度优先级: 1-99, 数值越大越优先; 缺省取服务端配置的 default_priority (未配则 1)。 + // 仅在中心调度模式下参与排序, legacy 模式忽略。 + 60: optional i32 priority_level (api.body = 'priority_level') + // 单 item 预期资源消耗向量: 进入中心调度 (enforce) 的实验必填且非空。 + // 服务端校验 (category,resource_key) 唯一、amount>0、禁止 resource_key="*", 随后冻结进 eval_conf; + // Retry 继承不可覆盖。category/resource_key 须与服务端 TCC 资源配置对得上。 + 61: optional experiment.ExpectedQuotaConsumption expected_quota_consumption (api.body = 'expected_quota_consumption') + // 触发来源: 填 "evalx" 且身份获授权时, 实验可进入中心调度 (enforce); + // 其余取值或未授权一律走 legacy。不填则按 openapi 处理。 + 62: optional string trigger_type (api.body = 'trigger_type') + 100: optional map ext (api.body = 'ext') // 实验分组 key 默认以实验 ID 兜底;填写 ref_group_experiment_id 时复用该引用实验的 group key(归入同一分组)。 From 936986fe71888d10528a02a8699f54fb79c0ec0d Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 22:15:42 +0800 Subject: [PATCH 24/58] =?UTF-8?q?fix(evaluation):=20CheckBenefit=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=AF=B9=20nil=20session=20=E8=A7=A3?= =?UTF-8?q?=E5=BC=95=E7=94=A8=EF=BC=88BOE=20=E5=AE=9E=E6=B5=8B=20panic?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BOE 泳道实测到 panic: CheckBenefit → session.UserID → invalid memory address or nil pointer dereference 栈上是 HandleCentralAdmission → HandleEventLock → HandleCentralReservation → HandleEventExec,即**中心调度这条新派发链路**。legacy 不受影响。 根因在派发侧(中心调度构造 item 事件时漏填 Session,另提交修复),但这一层也不该 因为调用方漏填而 panic —— 该 panic 发生在 item 执行链里、被 HandleEventErr 的 recover 转成 error,现象是"每个派发出去的 item 都失败"而非进程崩溃,从现象极难反推到 "某个字段没填"。而且额度此时已预占,item 到不了终态,配额会被持续占住。 取"退化成匿名"而不是提前报错:权益校验拿到空 UserID 会返回明确的业务错误,那是可读的 失败;在此自造 error 会掩盖真实原因(调用方漏填),下次再有新链路漏填时同样难查。 同时打 Error 日志点出 nil session,让根因直接可见。 回归测试已反向验证:把 nil 保护去掉后,TestCheckBenefit_NilSessionDoesNotPanic 会复现日志里那个一模一样的 panic。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_check_benefit_nil_session_test.go | 67 +++++++++++++++++++ .../domain/service/expt_run_item_turn_impl.go | 20 +++++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_check_benefit_nil_session_test.go diff --git a/backend/modules/evaluation/domain/service/expt_check_benefit_nil_session_test.go b/backend/modules/evaluation/domain/service/expt_check_benefit_nil_session_test.go new file mode 100644 index 0000000000..e7fd1fafc7 --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_check_benefit_nil_session_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/infra/external/benefit" + benefitMocks "github.com/coze-dev/coze-loop/backend/infra/external/benefit/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// TestCheckBenefit_NilSessionDoesNotPanic 回归:nil session 曾在此处直接 panic。 +// +// 触发路径是"item 事件的 Session 字段没被填" —— 中心调度那条派发链路漏过一次 +// (legacy 链路一直填着 Session: event.Session)。该 panic 发生在 item 执行链里、 +// 被 HandleEventErr 的 recover 转成 error,现象是"每个派发出去的 item 都失败" +// 而不是进程崩溃,从现象极难反推到"某个字段没填"。 +// +// 修法取"退化成匿名"而不是提前报错:权益校验拿到空 UserID 会返回明确的业务错误, +// 那是可读的失败;而在此自造 error 会掩盖真实原因(调用方漏填)。 +func TestCheckBenefit_NilSessionDoesNotPanic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockBenefit := benefitMocks.NewMockIBenefitService(ctrl) + var gotUID string + mockBenefit.EXPECT().CheckAndDeductEvalBenefit(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *benefit.CheckAndDeductEvalBenefitParams) (*benefit.CheckAndDeductEvalBenefitResult, error) { + gotUID = req.ConnectorUID + return &benefit.CheckAndDeductEvalBenefitResult{}, nil + }) + + impl := &DefaultExptTurnEvaluationImpl{benefitService: mockBenefit} + + // 不 panic 即为通过;这里同时断言它按匿名继续、而不是自造一个错误。 + err := impl.CheckBenefit(context.Background(), 1001, 2002, false, nil) + require.NoError(t, err, "nil session 应退化成匿名校验,而不是返回自造的错误") + assert.Equal(t, "", gotUID, "拿不到身份时 ConnectorUID 应为空串") +} + +// TestCheckBenefit_PassesSessionUserID 正常 session 的 UserID 必须原样透传给权益服务。 +// 与上一条成对:既要容忍 nil,又不能因为容忍而丢掉真实身份。 +func TestCheckBenefit_PassesSessionUserID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockBenefit := benefitMocks.NewMockIBenefitService(ctrl) + var gotUID string + mockBenefit.EXPECT().CheckAndDeductEvalBenefit(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, req *benefit.CheckAndDeductEvalBenefitParams) (*benefit.CheckAndDeductEvalBenefitResult, error) { + gotUID = req.ConnectorUID + return &benefit.CheckAndDeductEvalBenefitResult{}, nil + }) + + impl := &DefaultExptTurnEvaluationImpl{benefitService: mockBenefit} + + err := impl.CheckBenefit(context.Background(), 1001, 2002, false, &entity.Session{UserID: "7123456789"}) + require.NoError(t, err) + assert.Equal(t, "7123456789", gotUID) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go index cc052d7096..ecb64cda61 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go @@ -171,8 +171,24 @@ func (e *DefaultExptTurnEvaluationImpl) skipEvaluatorNode(expt *entity.Experimen } func (e *DefaultExptTurnEvaluationImpl) CheckBenefit(ctx context.Context, exptID, spaceID int64, freeCost bool, session *entity.Session) error { + // nil session 不再解引用 —— 这里曾经直接写 session.UserID 而 panic。 + // + // 触发路径:item 事件的 Session 字段没被填(中心调度那条派发链路漏过一次)。 + // 该 panic 发生在 item 执行链里、被 HandleEventErr 的 recover 转成 error, + // 表现为"每个 item 都失败"而不是进程崩溃,从现象极难反推到"某个字段没填"。 + // + // 取空 UserID 继续而不是提前报错:权益校验拿到空 UserID 会按匿名处理并返回 + // 明确的业务错误,那是可读的失败;而在此处自己造一个 error 会掩盖真实原因 + // (调用方漏填),下次再有新链路漏填时同样难查。 + userID := "" + if session != nil { + userID = session.UserID + } else { + logs.CtxError(ctx, "[CheckBenefit] nil session, falling back to anonymous; expt_id: %v, space_id: %v", exptID, spaceID) + } + req := &benefit.CheckAndDeductEvalBenefitParams{ - ConnectorUID: session.UserID, + ConnectorUID: userID, SpaceID: spaceID, ExperimentID: exptID, Ext: map[string]string{benefit.ExtKeyExperimentFreeCost: strconv.FormatBool(freeCost)}, @@ -181,7 +197,7 @@ func (e *DefaultExptTurnEvaluationImpl) CheckBenefit(ctx context.Context, exptID result, err := e.benefitService.CheckAndDeductEvalBenefit(ctx, req) logs.CtxInfo(ctx, "[CheckAndDeductEvalBenefit][req = %s] [res = %s] [err = %v]", json.Jsonify(req), json.Jsonify(result)) if err != nil { - return errorx.Wrapf(err, "CheckAndDeductEvalBenefit fail, expt_id: %v, user_id: %v", exptID, session.UserID) + return errorx.Wrapf(err, "CheckAndDeductEvalBenefit fail, expt_id: %v, user_id: %v", exptID, userID) } if result != nil && result.DenyReason != nil && result.DenyReason.ToErr() != nil { From eec8b058576c58a1dd4bf87277969581184a5670 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Thu, 20 Aug 2026 22:20:26 +0800 Subject: [PATCH 25/58] =?UTF-8?q?test(evaluation):=20=E8=A1=A5=E4=B8=BB?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E7=82=B9=E7=9A=84=E7=BB=88=E6=80=81=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E9=97=B8=E6=B5=8B=E8=AF=95=EF=BC=88=E7=9F=A9=E9=98=B5?= =?UTF-8?q?=20R2/R3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit releaseQuotaIfItemTerminal 是 item 正常跑完/正常失败时的**主释放点**,此前没有任何 测试。既有 expt_central_quota_release_test.go 覆盖的是 HandleEventErr 那层的兜底释放, 两者是不同的分支。 这一层同时是测试矩阵三格的共同判据: R2 终态即释放,不分成败 R3 MQ 重试不得释放(代码里刻意的反直觉设计) R8 不重复释放 为什么用单测而不是泳道 E2E 覆盖:本层全部分支都由"回查 run log 投影拿到什么状态" 决定,单测能精确摆出每种状态(含"查不到"与"仍在 Processing");而在泳道上构造 "失败但可重试"与"失败且已终态"的区别要靠让评测对象按特定方式报错,既慢又不稳定。 真机负责验证链路连通(已完成:22:05 实测 used 1808→708),分支穷举交给这里。 R3 那条尤其值得钉死:判据刻意是"回查投影的真实状态"而不是"execErr 是否为空" —— execErr != nil 既可能是可重试的瞬时错、也可能是已落终态的失败,只看 err 无法区分。 错误地在此释放会让重投消息在 ConfirmRunning 处被丢弃,item 永久停在 Processing, 比"额度多占一会"严重得多。用例特意带上 execErr + status=Processing 这个最危险的组合。 变异验证(三个守卫逐个确认承重): - 去掉 IsItemRunFinished 判定(无条件释放)→ KeepsReservationWhileRetriable FAIL - 改成只按 execErr 判(失败就不释放) → ReleasesOnEveryTerminalState FAIL - 去掉 len(obs)==0 守卫 → SkipsWhenProjectionMissing FAIL(并 panic: index out of range —— 该守卫同时在防一个真实的越界崩溃) Co-Authored-By: Claude Opus 5 (1M context) --- .../service/expt_quota_terminal_gate_test.go | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go diff --git a/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go b/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go new file mode 100644 index 0000000000..cf05a75014 --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" +) + +// 本文件覆盖**主释放点** releaseQuotaIfItemTerminal 的判定闸。 +// +// 既有 expt_central_quota_release_test.go 覆盖的是 HandleEventErr 那层的兜底释放; +// 而 item 正常跑完/正常失败走的是这一层,它此前没有任何测试 —— 而它恰好是测试矩阵 +// R2(失败即释放)/ R3(重试不得释放)/ R8(不重复释放)三格共同的判据所在。 +// +// 为什么用单测而不是泳道 E2E 覆盖这三格:这一层的全部分支都由「回查 run log 投影拿到 +// 什么状态」决定,单测能精确摆出每种状态(含"查不到"、"仍在 Processing"), +// 而在泳道上构造"失败但可重试"与"失败且终态"的区别要靠让评测对象按特定方式报错, +// 既慢又不稳定。真机负责验证链路连通(已完成),分支穷举交给这里。 + +// terminalGateFixture 装配一个只关心额度闸的 service。 +type terminalGateFixture struct { + svc *ExptItemEventEvalServiceImpl + guard *fakeGuard + event *entity.ExptItemEvalEvent +} + +// newTerminalGateFixture 让 MGetDispatchObservations 回指定的投影观测。 +// obs 为 nil 表示"查不到记录"。 +func newTerminalGateFixture(t *testing.T, obs []*repo.ExptDispatchObservation) *terminalGateFixture { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT(). + MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(obs, nil).AnyTimes() + + guard := &fakeGuard{} + return &terminalGateFixture{ + svc: &ExptItemEventEvalServiceImpl{centralGuard: guard, dispatchRepo: dispatchRepo}, + guard: guard, + event: &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4}, + } +} + +func observation(status entity.ItemRunState) []*repo.ExptDispatchObservation { + return []*repo.ExptDispatchObservation{{ItemID: 4, Status: int32(status)}} +} + +// TestReleaseQuotaIfItemTerminal_ReleasesOnEveryTerminalState 矩阵 R2: +// **终态即释放,不分成败**。 +// +// Fail 与 Terminal 必须和 Success 一样释放:额度是"占着资源的凭据",item 无论以哪种方式 +// 结束都不再占资源。漏掉失败态是最容易犯的错(直觉上"失败了就不用管了"), +// 后果是失败越多、泄漏越多,而失败在评测里是常态。 +func TestReleaseQuotaIfItemTerminal_ReleasesOnEveryTerminalState(t *testing.T) { + for _, tc := range []struct { + name string + status entity.ItemRunState + execErr error + wantSub string + }{ + {"成功 → 释放", entity.ItemRunState_Success, nil, "item success"}, + {"失败 → 同样释放", entity.ItemRunState_Fail, errors.New("evaluator boom"), "item failed"}, + {"提前终止 → 同样释放", entity.ItemRunState_Terminal, nil, "item success"}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newTerminalGateFixture(t, observation(tc.status)) + + f.svc.releaseQuotaIfItemTerminal(context.Background(), f.event, testScope, f.guard, tc.execErr) + + releases := f.guard.releases() + require.Len(t, releases, 1, "终态必须释放额度,否则该 item 的配额永久泄漏") + assert.Equal(t, testScope, releases[0].Scope) + assert.Equal(t, f.event.ExptRunID, releases[0].RunID) + assert.Equal(t, f.event.EvalSetItemID, releases[0].ItemID) + // reason 会进日志,是排查"额度什么时候被谁放掉的"唯一线索,必须能区分成败。 + assert.Contains(t, releases[0].Reason, tc.wantSub) + }) + } +} + +// TestReleaseQuotaIfItemTerminal_KeepsReservationWhileRetriable 矩阵 R3: +// **MQ 重试路径不得释放** —— 这是代码里刻意的反直觉设计,最容易被后来人当 bug 改掉。 +// +// 若在此释放:重投消息稍后到达,ConfirmRunning 会因 reservation 不存在而**丢弃消息**, +// item 就永久停在 Processing(既不完成也不失败),比"额度多占一会"严重得多。 +// +// 判据刻意是"回查投影的真实状态"而不是"execErr 是否为空":execErr != nil 既可能是 +// 可重试的瞬时错、也可能是已落终态的失败,只看 err 无法区分。 +func TestReleaseQuotaIfItemTerminal_KeepsReservationWhileRetriable(t *testing.T) { + for _, tc := range []struct { + name string + status entity.ItemRunState + }{ + {"仍在执行/等待重投", entity.ItemRunState_Processing}, + {"排队中", entity.ItemRunState_Queueing}, + } { + t.Run(tc.name, func(t *testing.T) { + // 带上 execErr:模拟"执行报错了,但 item 还没被判终态"这个最危险的组合 —— + // 只看 err 就释放的实现会在这里放掉额度。 + f := newTerminalGateFixture(t, observation(tc.status)) + + f.svc.releaseQuotaIfItemTerminal(context.Background(), f.event, testScope, f.guard, errors.New("transient")) + + assert.Empty(t, f.guard.releases(), + "未终态就释放会让重投消息在 ConfirmRunning 处被丢弃,item 永久卡在 %v", tc.status) + }) + } +} + +// TestReleaseQuotaIfItemTerminal_SkipsWhenProjectionMissing 投影查不到时不得释放。 +// +// "查不到"通常意味着 run log 已被清理(重跑清表等),此时手上这条 reservation 可能属于 +// **新一轮 run**,贸然释放等于放掉别人的额度 —— 而账本 key 是 (run_id,item_id), +// 新一轮 run 的 item 完成时会再释放一次,就成了双重释放。交给对账处理。 +func TestReleaseQuotaIfItemTerminal_SkipsWhenProjectionMissing(t *testing.T) { + f := newTerminalGateFixture(t, nil) + + f.svc.releaseQuotaIfItemTerminal(context.Background(), f.event, testScope, f.guard, nil) + + assert.Empty(t, f.guard.releases(), "投影查不到时释放可能放掉新一轮 run 的额度") +} + +// TestReleaseQuotaIfItemTerminal_NoopWithoutGuardOrRepo 依赖缺失时必须安静返回。 +// +// legacy 部署(未接中心调度)里这两个依赖都是 nil,此处 panic 会打挂整条 item 执行链, +// 把"中心调度没启用"变成"所有实验都跑不了"。 +func TestReleaseQuotaIfItemTerminal_NoopWithoutGuardOrRepo(t *testing.T) { + t.Run("guard 为 nil", func(t *testing.T) { + svc := &ExptItemEventEvalServiceImpl{} + assert.NotPanics(t, func() { + svc.releaseQuotaIfItemTerminal(context.Background(), + &entity.ExptItemEvalEvent{}, testScope, nil, nil) + }) + }) + + t.Run("dispatchRepo 为 nil", func(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptItemEventEvalServiceImpl{centralGuard: guard} + assert.NotPanics(t, func() { + svc.releaseQuotaIfItemTerminal(context.Background(), + &entity.ExptItemEvalEvent{}, testScope, guard, nil) + }) + assert.Empty(t, guard.releases(), "拿不到投影就不该释放") + }) +} + +// TestReleaseQuotaIfItemTerminal_ReleaseErrorIsSwallowed 释放失败只记日志,不得冒泡。 +// +// 这一层在中间件出口,返回 error 会覆盖掉 item 真正的执行结果 —— 一次 Redis 抖动就会把 +// 成功的 item 变成失败。释放失败留给对账兜底(现已实现)。 +func TestReleaseQuotaIfItemTerminal_ReleaseErrorIsSwallowed(t *testing.T) { + f := newTerminalGateFixture(t, observation(entity.ItemRunState_Success)) + f.guard.releaseErr = errors.New("redis down") + + assert.NotPanics(t, func() { + f.svc.releaseQuotaIfItemTerminal(context.Background(), f.event, testScope, f.guard, nil) + }) + assert.Len(t, f.guard.releases(), 1, "失败也应记录已尝试释放") +} From c0b4e2a43826b8e8c2e900aafcbb4340c49ee23e Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Fri, 21 Aug 2026 11:27:14 +0800 Subject: [PATCH 26/58] =?UTF-8?q?fix(evaluation):=20=E9=87=8A=E6=94=BE=20r?= =?UTF-8?q?eason=20=E6=94=B9=E7=94=B1=20run=20log=20=E7=BB=88=E6=80=81?= =?UTF-8?q?=E6=8E=A8=E5=AF=BC=EF=BC=9B=E4=BF=AE=E4=B8=A4=E5=A4=84=E5=A4=B1?= =?UTF-8?q?=E7=9C=9F=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 释放 reason 说谎(可观测性失真,非额度算错) BOE 实测:129 条 `[CentralReservation] quota released on item terminal` **全部**写着 `reason: item success`,而其中 104 个 item 的 DB 状态是 `status=3`(Fail)—— 失败被伪装成成功,靠日志根本发现不了实验在大面积失败。我自己就据此对外报过 "107/107 全部成功",实际 105 个在失败。 根因:`CompleteItemRun` 写完 `status=Fail + err_msg` 之后,只有 in-debt 错误 (`evalErrNeedTerminateExpt`)才 `return evalErr`,普通 item 失败一律 `return nil`; `Eval` 又只透传其返回值。于是普通失败对释放点**完全不可见**,`execErr` 恒为 nil, 旧实现 `reason := "item success"; if execErr != nil {...}` 永远走第一行。 修法:抽出 `terminalReleaseReason(status, execErr)`,由**已经回查到的** run log 终态 推导(Success/Fail/Terminal 各自成文),execErr 非 nil 时附错误文本(in-debt 路径仍有 信息量)。未识别的终态带出原始状态码而非静默标 success —— 同样的错不该换个形式再犯。 **只修标签,不改行为**:终态判定仍是 `IsItemRunFinished`,释放时机与幂等性不变。 也没有让 `HandleEventErr` 的错误分支重新执行 —— 那需要改 `CompleteItemRun` 的返回契约, 影响面大得多(每个失败 item 都会进重试判定),单独评估。 ⚠️ 由此推论一条容易误读的事实:既有观测「129 条全是 retry:false、无重投」**不能**读作 "重试机制健康",而是重试判定压根没运行过(nextErr 恒 nil 直接 return)。 变异验证:把 reason 改回 execErr 推导 → ReleasesOnEveryTerminalState + FailedItemGetsFailedReason 两个用例 FAIL。 ## 两处注释与实现不符(审计发现,本仓部分) 1. `expt_run_item_event_impl.go`「额度对账(spec §3.11)是最终防线」→ **对账不存在**。 目前只有调度器每拍在 Reserve 前跑的一小段(只清"投影 none + 账本 reserved"、 且只覆盖当前 LatestRunID)。本函数释放失败后那条 reservation **无任何兜底**, Warn 日志是唯一线索。 2. `central_reservation_guard.go`「取得**一次性**执行权」→ `ConfirmRunning` 实际是 **幂等**的(对已 Running 返回 true、不做 CAS)。防重复执行靠的是 consumer 侧的 item 锁 `expt_item_eval_run_lock`,不是它。 这两处注释让代码读起来比实际更安全,我自己已被误导过(写悬挂预占对账时就建立在 "对账是最终防线"这个假设上)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/central_reservation_guard.go | 6 +- .../service/expt_quota_terminal_gate_test.go | 59 ++++++++++++++++++- .../service/expt_run_item_event_impl.go | 51 ++++++++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index ad047d8461..9e2f0a504b 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -17,7 +17,11 @@ import "context" // reservation 就执行,那么迟到的、重复的、账本已重建过的消息都会变成"无额度执行",全局额度 // 保护即失效。 type ICentralReservationGuard interface { - // ConfirmRunning 取得该 item 的一次性执行权。 + // ConfirmRunning 确认该 item 持有额度,取得执行资格。 + // + // 注意它是**幂等**的而非"一次性":实现只校验 reservation 存在、并把状态推进到 Running, + // 对已 Running 的重复调用同样返回 true(见下)。防止同一 item 被并发执行两次**不靠它**, + // 靠的是 consumer 侧的 item 锁(expt_item_eval_run_lock)。 // // 返回 false 表示 reservation 不存在 —— 调用方**必须放弃执行并丢弃消息**,不得继续跑 item。 // 已是 Running 的重复投递返回 true:同一 item 的合法原地重试要继续持有原额度, diff --git a/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go b/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go index cf05a75014..36df59904e 100644 --- a/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go +++ b/backend/modules/evaluation/domain/service/expt_quota_terminal_gate_test.go @@ -74,7 +74,7 @@ func TestReleaseQuotaIfItemTerminal_ReleasesOnEveryTerminalState(t *testing.T) { }{ {"成功 → 释放", entity.ItemRunState_Success, nil, "item success"}, {"失败 → 同样释放", entity.ItemRunState_Fail, errors.New("evaluator boom"), "item failed"}, - {"提前终止 → 同样释放", entity.ItemRunState_Terminal, nil, "item success"}, + {"提前终止 → 同样释放", entity.ItemRunState_Terminal, nil, "item terminated"}, } { t.Run(tc.name, func(t *testing.T) { f := newTerminalGateFixture(t, observation(tc.status)) @@ -171,3 +171,60 @@ func TestReleaseQuotaIfItemTerminal_ReleaseErrorIsSwallowed(t *testing.T) { }) assert.Len(t, f.guard.releases(), 1, "失败也应记录已尝试释放") } + +// TestTerminalReleaseReason_DerivedFromStatusNotExecErr 矩阵 R2 的补强: +// 释放原因必须由**回查到的 run log 终态**推导,不能由 execErr 推导。 +// +// 这是一个真机踩出来的 bug:`CompleteItemRun` 对普通 item 失败 `return nil` +// (只有 in-debt 错误才向上抛),`Eval` 又只透传其返回值,于是 execErr 恒为 nil。 +// 旧实现 `reason := "item success"; if execErr != nil {...}` 因此永远走第一行 —— +// BOE 实测 129 条释放日志**全部**写着 item success,其中 104 个 item 的 DB 状态是 Fail。 +// +// 后果不是额度算错(释放本身按 IsItemRunFinished 判定,是对的),而是**可观测性失真**: +// 靠日志查"额度被谁吃了"会得到完全错误的图景,也发现不了实验在大面积失败。 +func TestTerminalReleaseReason_DerivedFromStatusNotExecErr(t *testing.T) { + for _, tc := range []struct { + name string + status entity.ItemRunState + execErr error + want string + }{ + // ★ 核心:execErr 为 nil(普通失败被吞掉后的真实形态)时,Fail 仍须标成 failed。 + {"Fail + execErr=nil(被吞掉的普通失败)", entity.ItemRunState_Fail, nil, "item failed"}, + {"Success + execErr=nil", entity.ItemRunState_Success, nil, "item success"}, + {"Terminal(提前终止)", entity.ItemRunState_Terminal, nil, "item terminated"}, + // in-debt 一类会向上抛的错误:状态与错误文本都要带上。 + {"Fail + execErr 非 nil(in-debt)", entity.ItemRunState_Fail, errors.New("in debt"), "item failed: in debt"}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, terminalReleaseReason(tc.status, tc.execErr)) + }) + } +} + +// TestTerminalReleaseReason_UnknownTerminalStateCarriesCode 终态集合扩展但本函数没跟上时, +// 必须带出原始状态码而不是静默标成 success。 +// +// 静默标 success 正是上一个 bug 的形态 —— 同样的错不该以另一种方式再犯一次。 +func TestTerminalReleaseReason_UnknownTerminalStateCarriesCode(t *testing.T) { + got := terminalReleaseReason(entity.ItemRunState(99), nil) + assert.Contains(t, got, "99", "未识别的终态必须带出状态码,便于反查是哪个新状态") + assert.NotContains(t, got, "success", "不得把未知终态静默标成成功") +} + +// TestReleaseQuotaIfItemTerminal_FailedItemGetsFailedReason 端到端串起来: +// 投影是 Fail、execErr 为 nil 时,实际传给 guard.Release 的 reason 必须能看出是失败。 +// +// 单独测纯函数不够 —— 要防的是"函数改对了但调用点还在用 execErr"。 +func TestReleaseQuotaIfItemTerminal_FailedItemGetsFailedReason(t *testing.T) { + f := newTerminalGateFixture(t, observation(entity.ItemRunState_Fail)) + + // execErr 刻意传 nil:这正是普通 item 失败到达本层时的真实形态。 + f.svc.releaseQuotaIfItemTerminal(context.Background(), f.event, testScope, f.guard, nil) + + releases := f.guard.releases() + require.Len(t, releases, 1, "Fail 是终态,额度必须释放") + assert.Contains(t, releases[0].Reason, "failed", + "execErr 为 nil 但投影是 Fail 时,reason 仍须标成失败 —— 否则失败会伪装成成功") + assert.NotContains(t, releases[0].Reason, "success") +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 235fdde8be..a5db690eee 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -356,8 +356,13 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE // 用状态判定才能两个方向都不出错 —— 少释放会泄漏额度,多释放会让重投消息被丢弃、 // item 卡死 Processing。 // -// 全程 best-effort:释放失败只告警。额度对账(spec §3.11)是最终防线, -// 而让终态收口因为额度模块失败而报错会把"额度泄漏"升级成"实验不收敛"。 +// 全程 best-effort:释放失败只告警 —— 让终态收口因为额度模块失败而报错,会把 +// "额度泄漏"升级成"实验不收敛",后者严重得多。 +// +// ⚠️ 此前这里写着"额度对账(spec §3.11)是最终防线",那是**规划而非现状**: +// 对账目前只有调度器每拍在 Reserve 前跑的一小段(只清"投影 none + 账本 reserved"、 +// 且只覆盖当前 LatestRunID)。本函数释放失败后的那条 reservation **没有任何兜底**, +// 会一直占着额度。所以这里的 Warn 日志是唯一线索,排查额度异常时必须查它。 func (e *ExptItemEventEvalServiceImpl) releaseQuotaIfItemTerminal( ctx context.Context, event *entity.ExptItemEvalEvent, @@ -391,10 +396,7 @@ func (e *ExptItemEventEvalServiceImpl) releaseQuotaIfItemTerminal( return } - reason := "item success" - if execErr != nil { - reason = "item failed: " + execErr.Error() - } + reason := terminalReleaseReason(entity.ItemRunState(obs[0].Status), execErr) if err := guard.Release(relCtx, schedulerScope, event.ExptRunID, event.EvalSetItemID, reason); err != nil { logs.CtxWarn(relCtx, "[CentralReservation] release quota fail, expt_run_id: %v, item_id: %v, err: %v", event.ExptRunID, event.EvalSetItemID, err) @@ -404,6 +406,43 @@ func (e *ExptItemEventEvalServiceImpl) releaseQuotaIfItemTerminal( schedulerScope, event.ExptRunID, event.EvalSetItemID, reason) } +// terminalReleaseReason 由**回查到的 run log 终态**推导释放原因,而不是由 execErr 推导。 +// +// ★ 为什么不能用 execErr:`CompleteItemRun` 在写完 status=Fail + err_msg 之后,只有 +// in-debt 错误(evalErrNeedTerminateExpt)才 return evalErr,普通 item 失败一律 return nil; +// 而 `Eval` 只透传它的返回值。于是普通失败对本层**完全不可见**,execErr 恒为 nil。 +// +// 实测代价:BOE 129 条释放日志**全部**写着 `item success`,其中 104 个 item 的 DB 状态 +// 是 Fail —— 失败被伪装成成功,靠日志根本发现不了实验在大面积失败, +// 而"额度都被谁吃了"这类排查会得到完全错误的图景。 +// +// 用 status 推导后,标签与 DB 事实一致。execErr 仍然带上(非 nil 时附错误文本): +// 它在 in-debt 那条路径上是有信息量的,能区分"失败"与"失败且触发了实验终止"。 +// +// 注意这**只修标签**,不改变释放行为(终态判定仍是 IsItemRunFinished),也没有让 +// HandleEventErr 的错误分支重新执行 —— 后者需要改 CompleteItemRun 的返回契约, +// 影响面大得多(见 AUDIT-FINDINGS-2026-08-21.md P1-1 方向 2)。 +func terminalReleaseReason(status entity.ItemRunState, execErr error) string { + var reason string + switch status { + case entity.ItemRunState_Success: + reason = "item success" + case entity.ItemRunState_Fail: + reason = "item failed" + case entity.ItemRunState_Terminal: + reason = "item terminated" + default: + // 走到这里说明 IsItemRunFinished 的终态集合扩展了但本函数没跟上。 + // 回落时带上原始状态码,便于反查是哪个新状态。 + reason = fmt.Sprintf("item terminal(status=%d)", int32(status)) + } + if execErr != nil { + // execErr 非 nil 说明是 in-debt 一类会向上抛的错误,附上文本便于定位。 + reason += ": " + execErr.Error() + } + return reason +} + func (e *ExptItemEventEvalServiceImpl) HandleEventErr(next RecordEvalEndPoint) RecordEvalEndPoint { return func(ctx context.Context, event *entity.ExptItemEvalEvent) error { nextErr := func(ctx context.Context, event *entity.ExptItemEvalEvent) (err error) { From 8eae9115c9c4f493dbf8a303726d44d735e86e04 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Fri, 21 Aug 2026 12:24:03 +0800 Subject: [PATCH 27/58] =?UTF-8?q?fix(evaluation):=20=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E7=BB=88=E6=80=81=E6=97=B6=E9=87=8A=E6=94=BE=E6=9C=AA=E8=B7=91?= =?UTF-8?q?=E5=AE=8C=20item=20=E7=9A=84=E4=B8=AD=E5=BF=83=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E9=A2=9D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现的确定泄漏:`expt_manage_execution_impl.go` 里 `centralGuard` 出现 **0 次** —— `Kill()` / `terminateItemTurns()` 都不释放中心额度。 为什么 consumer 侧那个释放点兜不住:它在 item 执行链的出口,靠"item 到终态"触发。 而实验被 Kill / Cancel 或落 Failed 之后,这些 item **不会再被执行** —— 消息可能已被丢弃、 可能压根还没投递、也可能 consumer 早已放弃,于是那个出口永远走不到。 更糟的是 running 态的悬挂连调度器每拍的对账都不碰 (`reapDanglingReservations` 显式 `if !view.IsReserved() { continue }`)—— **这条路径没有任何兜底**。而"取消实验"是常规操作,线上必然被走到: 用户取消一个正在跑 100 个 item 的 enforce 实验 = 100 份额度永久泄漏。 ## 实现 新增 `releaseCentralQuotaForIncompleteItems`,挂在 `CompleteExpt` 的 `incompleteTurnIDs` 之后、终态 switch **之前**: - 放在 switch 外只写一次,Terminated 与 Failed(default) 两条分支都覆盖, 且将来新增终态分支自动生效 —— "新增分支漏掉清理"正是本文件 default 分支注释里 记录过的历史教训(沙箱曾因此漏回收,一次漏两个) - 按 **item 去重**:reservation 是 item 粒度而 incompleteTurnIDs 是 turn 粒度, 不去重会对同一条 reservation 发多次释放(Release 幂等,但会放大往返、且日志计数失真) - `exptRunID` 为 nil 时回落 `LatestRunID`:账本 key 是 (run_id, item_id),run 号错了 释放就变成**静默 no-op**(比报错更糟)。`Kill` 的签名里 exptRunID 是 *int64 - legacy 实验直接返回:它们从不预占,而 CompleteExpt 是所有实验的公共收口, 白打 Redis 往返会按实验数放大 - enforce 却无 Scope 时**跳过而非猜**:猜错会归还别人的额度 → 超发,比不归还更糟 - best-effort:单个失败只告警、继续释放其余(一个失败就放弃剩下 99 个, 等于把"泄漏 1 份"放大成"泄漏 100 份") 构造函数新增 `centralGuard` 参数,wire 重新生成(未手改 wire_gen.go,diff 仅 6 行:把 guard 的构造提到 NewExptManager 之前)。开源部署注入既有的 noop 实现。 ## 变异验证 - 去掉 legacy 判断 → SkipsLegacy FAIL - 把去重的 key 从 ItemID 改成 TurnID → DedupesItemIDs FAIL - **删掉 CompleteExpt 里的整行调用 → CompleteExpt_ReleasesCentralQuota FAIL** ★ 最后那条是补上的一道防线:前 7 个用例都直接调那个函数,所以删掉调用点它们**依然全绿** (变异时实测过)。没有调用点测试,"函数写对了但没接上"这种失败模式毫无防线 —— 而这恰恰就是本次修复要补的缺口本身的形态。 Co-Authored-By: Claude Opus 5 (1M context) --- .../evaluation/application/wire_gen.go | 12 +- .../expt_complete_quota_release_test.go | 300 ++++++++++++++++++ .../service/expt_manage_execution_impl.go | 84 +++++ .../domain/service/expt_manage_impl.go | 10 + .../domain/service/expt_manage_impl_test.go | 1 + 5 files changed, 401 insertions(+), 6 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 1b0d04ea74..92db164ad8 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -177,12 +177,12 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() - icentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, icentralAdmissionPolicy) + iCentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() + iCentralReservationGuard := component.NewNoopCentralReservationGuard() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, iCentralAdmissionPolicy, iCentralReservationGuard) v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, componentIConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - iCentralReservationGuard := component.NewNoopCentralReservationGuard() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) @@ -454,12 +454,12 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL } resourceAccessAuthorizer := service.NewResourceAccessAuthorizer(iAuthProvider, sharedResourceConfigProvider) iCentralSchedulerScopeProvider := component.NewNoopCentralSchedulerScopeProvider() - icentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() - iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, icentralAdmissionPolicy) + iCentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() + iCentralReservationGuard := component.NewNoopCentralReservationGuard() + iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, iCentralAdmissionPolicy, iCentralReservationGuard) v3 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, iConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - iCentralReservationGuard := component.NewNoopCentralReservationGuard() exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) diff --git a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go new file mode 100644 index 0000000000..642559587c --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go @@ -0,0 +1,300 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "testing" + "time" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + idemMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/idem/mocks" + metricsMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/metrics/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + eventsMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/events/mocks" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + svcMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service/mocks" +) + +// 本文件守住「实验终态时,未跑完 item 的中心额度必须被释放」。 +// +// 为什么这条缺口在线上必然被走到:Kill / Cancel 是常规操作。用户取消一个正在跑 +// 100 个 item 的 enforce 实验后,那些 item **不会再被执行** —— 消息可能已被丢弃、 +// 可能还没投递、也可能 consumer 早已放弃。于是 consumer 侧那个"item 到终态才释放"的 +// 出口永远不会被走到,reservation 永久留在账本里。 +// +// 更糟的是 running 态的悬挂连调度器每拍的对账都不碰 +// (reapDanglingReservations 显式 `if !view.IsReserved() { continue }`)—— +// 也就是说这条路径**没有任何兜底**。审计前本文件 grep centralGuard 为 0。 + +func incompleteTurns(itemIDs ...int64) []*entity.ItemTurnID { + out := make([]*entity.ItemTurnID, 0, len(itemIDs)) + for _, id := range itemIDs { + out = append(out, &entity.ItemTurnID{ItemID: id, TurnID: id * 10}) + } + return out +} + +func enforceExpt() *entity.Experiment { + return &entity.Experiment{ + ID: 100, + LatestRunID: 200, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + } +} + +// TestReleaseCentralQuotaForIncompleteItems_ReleasesOnTerminal 核心回归: +// enforce 实验终态时,未完成 item 的额度必须逐个释放。 +func TestReleaseCentralQuotaForIncompleteItems_ReleasesOnTerminal(t *testing.T) { + for _, tc := range []struct { + name string + status entity.ExptStatus + }{ + {"Terminated(用户主动取消)", entity.ExptStatus_Terminated}, + {"Failed(调度锁超时 / run 级僵尸等)", entity.ExptStatus_Failed}, + {"Success(正常完成但仍有未完成 item)", entity.ExptStatus_Success}, + } { + t.Run(tc.name, func(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11, 12, 13), tc.status) + + releases := guard.releases() + require.Len(t, releases, 3, "三个未完成 item 的额度都必须释放,否则永久泄漏") + for _, r := range releases { + assert.Equal(t, testScope, r.Scope) + assert.Equal(t, int64(200), r.RunID) + // reason 会进日志,是排查"这批额度什么时候被谁放掉的"唯一线索。 + assert.Contains(t, r.Reason, "terminal") + } + }) + } +} + +// TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs 同一 item 的多个未完成 turn +// 只能释放一次。 +// +// reservation 是 **item 粒度**而 incompleteTurnIDs 是 **turn 粒度**:一个 item 有 5 个 +// 未完成 turn 时,不去重就会对同一条 reservation 发 5 次释放。Release 本身幂等 +// (HDEL 后 field 不存在),但会放大 Redis 往返、且日志计数失真(看起来泄漏了 5 份)。 +func TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + + // item 11 有三个未完成 turn,item 12 有两个。 + turns := []*entity.ItemTurnID{ + {ItemID: 11, TurnID: 1}, {ItemID: 11, TurnID: 2}, {ItemID: 11, TurnID: 3}, + {ItemID: 12, TurnID: 4}, {ItemID: 12, TurnID: 5}, + } + + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), turns, entity.ExptStatus_Terminated) + + releases := guard.releases() + assert.Len(t, releases, 2, "5 个 turn 只对应 2 个 item,必须按 item 去重") + got := []int64{releases[0].ItemID, releases[1].ItemID} + assert.ElementsMatch(t, []int64{11, 12}, got) +} + +// TestReleaseCentralQuotaForIncompleteItems_SkipsLegacy legacy 实验不得触发释放。 +// +// 它们从不预占额度,调用释放只是无谓的 Redis 往返 —— 而 CompleteExpt 是所有实验 +// (含大量 legacy)的公共收口,白打往返会按实验数放大。 +func TestReleaseCentralQuotaForIncompleteItems_SkipsLegacy(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + + legacy := enforceExpt() + legacy.ExptDispatchMode = entity.ExptDispatchModeLegacy + + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + legacy, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + + assert.Empty(t, guard.releases(), "legacy 实验从不预占,不该发释放请求") +} + +// TestReleaseCentralQuotaForIncompleteItems_FallsBackToLatestRunID +// exptRunID 为 nil 时必须回落 LatestRunID。 +// +// 账本 key 是 (run_id, item_id):run 号错了就找不到那条 reservation, +// 释放变成**静默 no-op** —— 比报错更糟,因为看起来成功了。 +// 而 CompleteExpt 的调用方并不总会传 exptRunID(Kill 的签名里它是 *int64)。 +func TestReleaseCentralQuotaForIncompleteItems_FallsBackToLatestRunID(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), nil, incompleteTurns(11), entity.ExptStatus_Terminated) + + releases := guard.releases() + require.Len(t, releases, 1) + assert.Equal(t, int64(200), releases[0].RunID, "exptRunID 为 nil 时应回落 LatestRunID") +} + +// TestReleaseCentralQuotaForIncompleteItems_SkipsWhenScopeMissing +// enforce 却无 Scope 时不得瞎猜一本账。 +// +// 猜错会归还**别人的**额度 —— 那比不归还严重得多(直接导致超发,且静默)。 +func TestReleaseCentralQuotaForIncompleteItems_SkipsWhenScopeMissing(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + + noScope := enforceExpt() + noScope.SchedulerScope = "" + + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + noScope, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + + assert.Empty(t, guard.releases(), "无 Scope 时释放会归还别人的额度,必须跳过") +} + +// TestReleaseCentralQuotaForIncompleteItems_NoopOnEmptyInputs 边界: +// guard 为 nil(legacy 部署)、无未完成 item、expt 为 nil 时都必须安静返回。 +// +// CompleteExpt 是所有实验终态的公共收口,这里 panic 会让**实验无法收敛** —— +// 用户看到"点了取消但状态不变",比额度泄漏严重得多。 +func TestReleaseCentralQuotaForIncompleteItems_NoopOnEmptyInputs(t *testing.T) { + t.Run("guard 为 nil", func(t *testing.T) { + svc := &ExptMangerImpl{} + assert.NotPanics(t, func() { + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + }) + }) + + t.Run("无未完成 item", func(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), nil, entity.ExptStatus_Success) + assert.Empty(t, guard.releases()) + }) + + t.Run("expt 为 nil", func(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + assert.NotPanics(t, func() { + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + nil, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + }) + assert.Empty(t, guard.releases()) + }) + + t.Run("turn 列表含 nil 项", func(t *testing.T) { + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard} + turns := []*entity.ItemTurnID{nil, {ItemID: 11, TurnID: 1}, nil} + assert.NotPanics(t, func() { + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), turns, entity.ExptStatus_Terminated) + }) + assert.Len(t, guard.releases(), 1, "nil 项应被跳过,有效项仍要释放") + }) +} + +// TestReleaseCentralQuotaForIncompleteItems_ReleaseErrorDoesNotPanic +// 单个 item 释放失败不得中断其余 item 的释放。 +// +// best-effort 的方向很关键:一个 item 释放失败就放弃剩下 99 个,等于把"泄漏 1 份" +// 放大成"泄漏 100 份"。 +func TestReleaseCentralQuotaForIncompleteItems_ReleaseErrorDoesNotPanic(t *testing.T) { + guard := &fakeGuard{releaseErr: assert.AnError} + svc := &ExptMangerImpl{centralGuard: guard} + + assert.NotPanics(t, func() { + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11, 12, 13), entity.ExptStatus_Terminated) + }) + assert.Len(t, guard.releases(), 3, "前一个失败不该阻断后续 item 的释放尝试") +} + +// TestCompleteExpt_ReleasesCentralQuotaForIncompleteItems ★ 守住**调用点**,不只是那个函数。 +// +// 为什么必须单独有这条:上面那些用例都直接调 releaseCentralQuotaForIncompleteItems, +// 所以把 CompleteExpt 里的调用整行删掉,它们**依然全绿** —— 变异验证时实测过。 +// 也就是说没有这条,"函数写对了但根本没被接上"这种失败模式完全没有防线, +// 而这恰恰是本次修复要补的缺口本身的形态(审计前 grep centralGuard 为 0)。 +func TestCompleteExpt_ReleasesCentralQuotaForIncompleteItems(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + session := &entity.Session{UserID: "test_user"} + const exptID, spaceID, runID = int64(123), int64(789), int64(456) + + mgr.idem.(*idemMocks.MockIdempotentService).EXPECT().Exist(ctx, gomock.Any()).AnyTimes().Return(false, nil) + + // ★ enforce 实验 + 冻结 Scope:这是走到释放逻辑的前提。 + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().GetByID(ctx, exptID, spaceID).Return(&entity.Experiment{ + ID: exptID, + SpaceID: spaceID, + ExptType: entity.ExptType_Offline, + StartAt: gptr.Of(time.Now()), + LatestRunID: runID, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + }, nil) + + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + CalculateStats(ctx, exptID, spaceID, session). + Return(&entity.ExptCalculateStats{ProcessingItemCnt: 2}, nil) + + // 两个 item 尚未跑完 —— 实验被终止后它们永远不会执行,额度必须在这里归还。 + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + GetIncompleteTurns(ctx, exptID, spaceID, session). + Return([]*entity.ItemTurnID{{ItemID: 11, TurnID: 1}, {ItemID: 12, TurnID: 2}}, nil) + + mgr.statsRepo.(*repoMocks.MockIExptStatsRepo).EXPECT(). + UpdateByExptID(ctx, exptID, spaceID, gomock.Any()).Return(nil) + // 这两个用 AnyTimes:Terminated 路径下是否调用取决于分支细节,本用例只关心额度释放。 + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.quotaRepo.(*repoMocks.MockQuotaRepo).EXPECT(). + CreateOrUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.exptAggrResultService.(*svcMocks.MockExptAggrResultService).EXPECT(). + PublishExptAggrResultEvent(ctx, gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.mtr.(*metricsMocks.MockExptMetric).EXPECT(). + EmitExptExecResult(spaceID, gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mgr.publisher.(*eventsMocks.MockExptEventPublisher).EXPECT(). + PublishExptLifecycleEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + // Terminated 走 terminateItemTurns 分支,这些是该分支的附带调用。 + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + SaveTurnRunLogs(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + UpdateTurnResults(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + MGetItemTurnRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + UpdateItemRunLog(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + err := mgr.CompleteExpt(ctx, exptID, gptr.Of(runID), spaceID, session, + entity.WithStatus(entity.ExptStatus_Terminated)) + assert.NoError(t, err) + + releases := guard.releases() + require.Len(t, releases, 2, + "CompleteExpt 必须为未完成的 item 释放额度 —— 少了这一步,取消实验就是永久泄漏") + assert.ElementsMatch(t, []int64{11, 12}, + []int64{releases[0].ItemID, releases[1].ItemID}) + for _, r := range releases { + assert.Equal(t, testScope, r.Scope) + assert.Equal(t, runID, r.RunID) + } +} diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index dc0dfefddd..bc7623d43c 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -608,6 +608,23 @@ func (e *ExptMangerImpl) CompleteExpt(ctx context.Context, exptID int64, exptRun return err } + // ★ 实验终态时,未跑完的 item 的中心调度额度必须在这里释放。 + // + // 为什么不能只靠 consumer 侧那个释放点:那个点在 item 执行链的出口,靠"item 到终态" + // 触发。而实验被 Kill / Cancel 或落 Failed 之后,这些 item **不会再被执行** —— + // 消息可能已被丢弃、可能压根还没投递、也可能 consumer 早已放弃, + // 于是那个出口永远不会被走到,reservation 就永久留在账本里。 + // + // 实测确认过缺口:本文件此前 grep centralGuard 为 0 —— 用户取消一个正在跑 100 个 item + // 的 enforce 实验,那 100 份额度全部泄漏。而 running 态的悬挂连调度器每拍的对账都不碰 + // (reapDanglingReservations 显式只处理 state=reserved),也就是说**没有任何兜底**。 + // 取消实验是常规操作,这条路径在线上必然被走到。 + // + // 放在这里而不是各终态分支内:Terminated 与 Failed(default) 两条分支都需要释放, + // 且都基于同一份 incompleteTurnIDs。放在分支外只写一次,将来新增终态分支也自动覆盖 —— + // 而"新增分支漏掉清理"正是本文件 default 分支注释里记录过的历史教训(沙箱曾因此漏回收)。 + e.releaseCentralQuotaForIncompleteItems(ctx, got, exptRunID, incompleteTurnIDs, status) + switch status { case entity.ExptStatus_Terminated: terminatedItemIDSet := make(map[int64]bool) @@ -821,6 +838,73 @@ func (e *ExptMangerImpl) sendExptCompleteEvent(ctx context.Context, expt *entity return nil } +// releaseCentralQuotaForIncompleteItems 在实验进入终态时,释放"仍未跑完"的 item 的中心调度额度。 +// +// 与 daemon 侧 releaseCentralQuotaForItems(zombie / 沙箱提前终态)的分工:那两条按 item 粒度 +// 判定终态,这条按**实验**粒度 —— 实验一旦终态,未完成的 item 就再也不会被执行, +// 它们的 reservation 也就再也不会被 consumer 侧的释放点碰到。 +// +// legacy 实验直接返回:它们从不预占额度,调用释放只是无谓的 Redis 往返。 +// +// 全程 best-effort:单个 item 释放失败只告警,不让实验收敛失败 —— +// 实验不收敛(用户看到"取消了但状态不变")比额度泄漏严重得多。 +// 但这里的 Warn 必须留:**目前没有任何对账会兜住释放失败的这条 reservation**。 +func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( + ctx context.Context, + expt *entity.Experiment, + exptRunID *int64, + incompleteTurnIDs []*entity.ItemTurnID, + status entity.ExptStatus, +) { + if e.centralGuard == nil || expt == nil || len(incompleteTurnIDs) == 0 { + return + } + if !entity.IsCentralDispatch(expt.ExptDispatchMode) { + return + } + if expt.SchedulerScope == "" { + // enforce 却无 Scope:数据异常,无法确定去哪本账释放。宁可不释放也不猜一本 —— + // 猜错会归还别人的额度,那比不归还更糟(会导致超发)。 + logs.CtxError(ctx, "[CentralReservation] enforce experiment without scheduler_scope on complete, skip release, expt_id: %v", expt.ID) + return + } + + // exptRunID 为 nil 时回落 LatestRunID:账本 key 是 (run_id, item_id),run 号错了就找不到 + // 那条 reservation(释放变成静默 no-op)。CompleteExpt 的调用方并不总会传 run。 + runID := gptr.Indirect(exptRunID) + if runID == 0 { + runID = expt.LatestRunID + } + if runID == 0 { + logs.CtxError(ctx, "[CentralReservation] cannot resolve expt_run_id on complete, skip release, expt_id: %v", expt.ID) + return + } + + // 同一 item 可能有多个未完成 turn,而 reservation 是 item 粒度 —— 必须先去重, + // 否则会对同一条 reservation 发多次释放。Release 本身幂等(HDEL 后 field 不存在), + // 但重复调用会放大 Redis 往返,且日志里的计数会失真。 + itemIDSet := make(map[int64]bool, len(incompleteTurnIDs)) + for _, it := range incompleteTurnIDs { + if it == nil { + continue + } + itemIDSet[it.ItemID] = true + } + itemIDs := maps.ToSlice(itemIDSet, func(k int64, v bool) int64 { return k }) + + reason := fmt.Sprintf("experiment reached terminal status=%d with incomplete items", int32(status)) + failed := 0 + for _, itemID := range itemIDs { + if err := e.centralGuard.Release(ctx, expt.SchedulerScope, runID, itemID, reason); err != nil { + failed++ + logs.CtxWarn(ctx, "[CentralReservation] release quota fail on expt complete, scope: %v, expt_run_id: %v, item_id: %v, err: %v", + expt.SchedulerScope, runID, itemID, err) + } + } + logs.CtxInfo(ctx, "[CentralReservation] quota released for incomplete items on expt complete, scope: %v, expt_id: %v, expt_run_id: %v, items: %v, failed: %v, status: %v", + expt.SchedulerScope, expt.ID, runID, len(itemIDs), failed, status) +} + func (e *ExptMangerImpl) terminateItemTurns(ctx context.Context, exptID int64, itemTurnIDs []*entity.ItemTurnID, spaceID int64, session *entity.Session) error { itemIDs := make([]int64, 0, len(itemTurnIDs)) for _, itemTurnID := range itemTurnIDs { diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index 6adc8fd3e7..c654528e01 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -73,6 +73,9 @@ func NewExptManager( // centralAdmissionPolicy 在 trigger 判据之上收窄 enforce 范围(按空间/评测对象类型/ID 灰度)。 // 开源部署注入 noop(恒定放行)。 centralAdmissionPolicy component.ICentralAdmissionPolicy, + // centralGuard 额度闸。CompleteExpt 用它释放实验终态时仍未跑完的 item 预占。 + // 开源部署注入 noop(Release 直接返回 nil)。 + centralGuard component.ICentralReservationGuard, ) IExptManager { return &ExptMangerImpl{ // tupleSvc: tupleSvc, @@ -107,6 +110,7 @@ func NewExptManager( sandboxAgentMetrics: sandboxAgentMetrics, centralScopeProvider: centralScopeProvider, centralAdmissionPolicy: centralAdmissionPolicy, + centralGuard: centralGuard, } } @@ -146,6 +150,12 @@ type ExptMangerImpl struct { centralScopeProvider component.ICentralSchedulerScopeProvider // centralAdmissionPolicy 在 trigger 判据之上收窄 enforce 范围。 centralAdmissionPolicy component.ICentralAdmissionPolicy + // centralGuard 中心调度额度闸,仅用于 CompleteExpt 释放"实验终态时仍未跑完"的 item 预占。 + // + // 为什么这里必须有一份:Kill / Cancel / 实验级 Failed 都收口在 CompleteExpt,而那些 + // item 的 consumer 消息可能永远不会到达(实验已终态,item 不再被执行), + // 于是 consumer 侧的释放点根本不会被触发 —— 不在这里释放就是永久泄漏。 + centralGuard component.ICentralReservationGuard } func (e *ExptMangerImpl) MGetDetail(ctx context.Context, exptIDs []int64, spaceID int64, session *entity.Session) ([]*entity.Experiment, error) { diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go index c6d3152357..81b89603f4 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl_test.go @@ -1788,6 +1788,7 @@ func TestNewExptManager(t *testing.T) { nil, component.NewNoopCentralSchedulerScopeProvider(), component.NewNoopCentralAdmissionPolicy(), + component.NewNoopCentralReservationGuard(), ) impl, ok := mgr.(*ExptMangerImpl) From 6d1b590bc65b358d434d7cbe8a8e2f0f1feb46b4 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Fri, 21 Aug 2026 18:43:03 +0800 Subject: [PATCH 28/58] =?UTF-8?q?[feat][evaluation]=20trigger=5Ftype=3Deva?= =?UTF-8?q?lx=20=E7=9A=84=E5=AE=9E=E9=AA=8C=E4=B8=8D=E5=8F=91=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EvalX 批量发起的实验结果在它自己的面板消费,逐个实验给创建者推飞书卡 只造成打扰。新增 isFeishuNotifySuppressedByTrigger 作为唯一判据,接到 实验生命周期相关的 4 张卡上: - 生命周期终态/filter 卡 (expt_lifecycle_event_impl):闸放在最前面, 优先于 NotificationConf —— 否则 evalx 建的老实验 (conf=nil) 仍会 走 legacy 终态分支发卡 - 提交/试跑卡 (expt_manage_execution_impl.Run 老逻辑分支) - 沙箱 agent 进度卡 + 单行失败卡 (sandbox_agent_notifier.enabled) 洞察分析完成卡不在范围内:那是人在页面主动点分析后的回执,不属实验 生命周期通知,挡掉会让操作没有反馈。 判据读落库的 expt.TriggerType 而非请求字段:写入路径会裁决调用方申报的 trigger(enforceSchedulingPrivilege 把未授权的自称 evalx 降级成 manual), 因此既不会被伪造绕过、也不会误伤普通实验。判据本身复用 entity.ShouldEnforceByTrigger,不另起一份字面量比较,避免与 IDL 常量漂移。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/expt.go | 3 +- .../service/expt_lifecycle_event_impl.go | 7 ++ .../service/expt_lifecycle_event_impl_test.go | 77 +++++++++++++++++++ .../service/expt_manage_execution_impl.go | 5 ++ .../expt_manage_execution_impl_test.go | 77 +++++++++++++++++++ .../domain/service/notify_trigger_gate.go | 31 ++++++++ .../service/notify_trigger_gate_test.go | 42 ++++++++++ .../domain/service/sandbox_agent_notifier.go | 7 +- .../service/sandbox_agent_notifier_test.go | 13 ++++ 9 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/notify_trigger_gate.go create mode 100644 backend/modules/evaluation/domain/service/notify_trigger_gate_test.go diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index 3725a3cd27..8ba8bbc8f8 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -202,7 +202,8 @@ type Experiment struct { MaxAliveTime int64 SourceType SourceType SourceID string - // TriggerType 实验触发方式,与表字段 trigger_type 一致:manual / openapi / schedule / evalx + // TriggerType 实验触发方式,与表字段 trigger_type 一致:manual / openapi / schedule / evalx。 + // evalx 来源的实验一律不发飞书通知,见 service.isFeishuNotifySuppressedByTrigger。 TriggerType string // ExptSource 查询时填充:与一级字段 source_type/source_id 一致;Workflow 时由 Pipeline 补充 span_filter / scheduler / sampler ExptSource *ExptSource diff --git a/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl.go b/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl.go index 4a5abbdf5b..9476452aa7 100644 --- a/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl.go @@ -86,6 +86,13 @@ func (h *ExptLifecycleEventHandlerImpl) handleFeishuNotification(ctx context.Con logs.CtxInfo(ctx, "feishu_notification: enter, expt_id: %d, to_status: %v, has_notification_conf: %v", expt.ID, event.ToStatus, expt.NotificationConf != nil) + // 触发来源闸:evalx 实验一律不发。放在最前面 —— 它优先于 NotificationConf, + // 老实验(conf 为 nil)也一样挡住,否则 evalx 建的存量实验仍会在终态发卡。 + if isFeishuNotifySuppressedByTrigger(expt) { + logs.CtxInfo(ctx, "feishu_notification: suppressed by trigger_type=%s, skip notify, expt_id: %d", expt.TriggerType, expt.ID) + return + } + // 兼容旧实验:NotificationConf 为 nil 时,保持旧行为(仅终态发送) if expt.NotificationConf == nil { switch event.ToStatus { diff --git a/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl_test.go index 3c7e89cc74..77e3455ea5 100644 --- a/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_lifecycle_event_impl_test.go @@ -517,6 +517,83 @@ func TestHandleFeishuNotification_Disabled_BitsUT(t *testing.T) { assert.NoError(t, err) } +// TestHandleFeishuNotification_EvalxTrigger_Suppressed_BitsUT trigger_type=evalx 一律不发卡, +// 且优先于 NotificationConf —— 显式 Enable=true + 命中 filter 的实验也挡,老实验(conf=nil)终态也挡。 +func TestHandleFeishuNotification_EvalxTrigger_Suppressed_BitsUT(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("feishu explicitly enabled but trigger is evalx", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, mocks := newTestLifecycleEventHandler(ctrl) + + event := &entity.ExptLifecycleEvent{ + ExptID: 1, SpaceID: 100, + ToStatus: entity.ExptStatus_Success, + } + expt := &entity.Experiment{ + ID: 1, SpaceID: 100, CreatedBy: "user1", + TriggerType: "evalx", + NotificationConf: &entity.ExptNotificationConf{ + FeishuNotification: &entity.FeishuNotificationConf{Enable: true}, + }, + } + mocks.exptRepo.EXPECT().GetByID(ctx, int64(1), int64(100)).Return(expt, nil) + // No SendMessageCard expected — suppressed by trigger_type + + err := handler.HandleLifecycleEvent(ctx, event) + assert.NoError(t, err) + }) + + t.Run("legacy expt without NotificationConf, terminal status, trigger is evalx", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, mocks := newTestLifecycleEventHandler(ctrl) + + event := &entity.ExptLifecycleEvent{ + ExptID: 1, SpaceID: 100, + ToStatus: entity.ExptStatus_Success, + } + expt := &entity.Experiment{ + ID: 1, SpaceID: 100, CreatedBy: "user1", + TriggerType: "evalx", + NotificationConf: nil, + } + mocks.exptRepo.EXPECT().GetByID(ctx, int64(1), int64(100)).Return(expt, nil) + // No SendMessageCard expected — trigger gate precedes the legacy terminal-status branch + + err := handler.HandleLifecycleEvent(ctx, event) + assert.NoError(t, err) + }) + + t.Run("manual trigger still sends", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, mocks := newTestLifecycleEventHandler(ctrl) + + event := &entity.ExptLifecycleEvent{ + ExptID: 1, SpaceID: 100, + ToStatus: entity.ExptStatus_Success, + } + expt := &entity.Experiment{ + ID: 1, SpaceID: 100, CreatedBy: "user1", + TriggerType: "manual", + NotificationConf: &entity.ExptNotificationConf{ + FeishuNotification: &entity.FeishuNotificationConf{Enable: true}, + }, + } + mocks.exptRepo.EXPECT().GetByID(ctx, int64(1), int64(100)).Return(expt, nil) + mocks.userProvider.EXPECT().MGetUserInfo(ctx, []string{"user1"}).Return([]*entity.UserInfo{ + {Email: gptr.Of("user1@test.com")}, + }, nil) + mocks.notifyRPCAdapter.EXPECT().SendMessageCard(ctx, "user1@test.com", "email", gomock.Any(), gomock.Any()).Return(nil) + + err := handler.HandleLifecycleEvent(ctx, event) + assert.NoError(t, err) + }) +} + func TestHandleFeishuNotification_LegacyNoConf_BitsUT(t *testing.T) { t.Parallel() ctx := context.Background() diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index bc7623d43c..72c377baed 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -338,6 +338,11 @@ func (e *ExptMangerImpl) Run(ctx context.Context, exptID, runID, spaceID int64, if expt.NotificationConf == nil { switch runMode { case entity.EvaluationModeSubmit, entity.EvaluationModeTrialRun: + // 触发来源闸:evalx 实验一律不发飞书卡 + if isFeishuNotifySuppressedByTrigger(expt) { + logs.CtxInfo(ctx, "NotifyCard suppressed by trigger_type=%s, expt_id: %v", expt.TriggerType, exptID) + break + } if err := e.sendNotifyCard(ctx, expt); err != nil { logs.CtxWarn(ctx, "NotifyCard send failed, expt_id: %v, error: %v", exptID, err) } diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl_test.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl_test.go index ce2bae1384..0568e1c3ec 100755 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl_test.go @@ -3301,6 +3301,83 @@ func TestExptMangerImpl_Run_OnlineExpt(t *testing.T) { }) } +// TestExptMangerImpl_Run_NotifyCardTriggerGate Run 的老逻辑发卡分支(NotificationConf==nil)按 +// trigger_type 抑制:evalx 不发,manual 照发。 +func TestExptMangerImpl_Run_NotifyCardTriggerGate(t *testing.T) { + ctx := context.Background() + session := &entity.Session{UserID: "test_user"} + + // setupRun 装好 Run 走到发卡分支所需的全部依赖,返回构造好的 manager。 + // 实验类型留默认(非 Online)以跳过心跳锁分支;NotificationConf 为 nil 以命中老逻辑发卡。 + setupRun := func(ctrl *gomock.Controller, triggerType string) *ExptMangerImpl { + mgr := newTestExptManager(ctrl) + + mgr.quotaRepo.(*repoMocks.MockQuotaRepo). + EXPECT(). + CreateOrUpdate(ctx, int64(789), gomock.Any(), session). + Return(nil) + mgr.configer.(*componentMocks.MockIConfiger). + EXPECT(). + GetExptExecConf(ctx, int64(789)).AnyTimes(). + Return(&entity.ExptExecConf{SpaceExptConcurLimit: 10}) + mgr.lwt.(*lwtMocks.MockILatestWriteTracker). + EXPECT(). + CheckWriteFlagByID(ctx, gomock.Any(), int64(123)). + Return(false).AnyTimes() + mgr.exptRepo.(*repoMocks.MockIExperimentRepo). + EXPECT(). + MGetByID(ctx, []int64{123}, int64(789)). + Return([]*entity.Experiment{{ + ID: 123, + SpaceID: 789, + CreatedBy: "user1@test.com", + TriggerType: triggerType, + StartAt: gptr.Of(time.Now()), + }}, nil).AnyTimes() + mgr.evaluationSetService.(*svcMocks.MockIEvaluationSetService). + EXPECT(). + GetEvaluationSet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Nil()). + Return(&entity.EvaluationSet{}, nil).AnyTimes() + mgr.exptResultService.(*svcMocks.MockExptResultService). + EXPECT(). + MGetStats(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptStats{}, nil).AnyTimes() + mgr.exptAggrResultService.(*svcMocks.MockExptAggrResultService). + EXPECT(). + BatchGetExptAggrResultByExperimentIDs(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptAggregateResult{}, nil).AnyTimes() + mgr.publisher.(*eventsMocks.MockExptEventPublisher). + EXPECT(). + PublishExptScheduleEvent(ctx, gomock.Any(), gptr.Of(time.Second*3)). + Return(nil) + + return mgr + } + + t.Run("evalx trigger does not send notify card", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := setupRun(ctrl, "evalx") + // notifyRPCAdapter 不设 EXPECT:被调用即 gomock 失败。 + + err := mgr.Run(ctx, 123, 456, 789, 0, session, entity.EvaluationModeSubmit, nil) + assert.NoError(t, err) + }) + + t.Run("manual trigger still sends notify card", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := setupRun(ctrl, "manual") + mgr.notifyRPCAdapter.(*mocks.MockINotifyRPCAdapter). + EXPECT(). + SendMessageCard(ctx, "user1@test.com", "email", gomock.Any(), gomock.Any()). + Return(nil) + + err := mgr.Run(ctx, 123, 456, 789, 0, session, entity.EvaluationModeSubmit, nil) + assert.NoError(t, err) + }) +} + func TestExptMangerImpl_RecordExptData(t *testing.T) { ctx := context.Background() session := &entity.Session{UserID: "test_user"} diff --git a/backend/modules/evaluation/domain/service/notify_trigger_gate.go b/backend/modules/evaluation/domain/service/notify_trigger_gate.go new file mode 100644 index 0000000000..f74fe32ada --- /dev/null +++ b/backend/modules/evaluation/domain/service/notify_trigger_gate.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// isFeishuNotifySuppressedByTrigger 判断该实验是否因触发来源而整体抑制飞书通知。 +// +// 当前规则:trigger_type == evalx 的实验一律不发实验生命周期相关的飞书卡片 +// (提交/终态卡、沙箱 agent 进度卡与单行失败卡)。EvalX 是内部平台,实验由它批量发起、 +// 结果也在它自己的面板消费,逐个实验给创建者推飞书卡只会造成打扰。 +// +// 判据取落库后的 expt.TriggerType 而非请求字段:写入路径会对调用方申报的 trigger 做裁决 +// (enforceSchedulingPrivilege 会把未获授权的自称 evalx 降级成 manual),因此读落库值 +// 既不会被伪造绕过、也不会误伤普通实验。 +// +// 复用 entity.ShouldEnforceByTrigger 而不是自己再比一次字面量:它已经是本仓库 +// "这个 trigger 是不是 evalx" 的唯一判据(含大小写/空白容忍,取值由 +// TestExptTriggerTypeEvalxValue 守住与 IDL 常量一致),另起一份会漂移。 +// +// 不覆盖洞察分析完成卡(insight analysis):那是人在页面上主动点分析后的回执, +// 不属于实验生命周期通知,挡掉会让操作没有反馈。 +func isFeishuNotifySuppressedByTrigger(expt *entity.Experiment) bool { + if expt == nil { + return false + } + return entity.ShouldEnforceByTrigger(expt.TriggerType) +} diff --git a/backend/modules/evaluation/domain/service/notify_trigger_gate_test.go b/backend/modules/evaluation/domain/service/notify_trigger_gate_test.go new file mode 100644 index 0000000000..085c54b74c --- /dev/null +++ b/backend/modules/evaluation/domain/service/notify_trigger_gate_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +func TestIsFeishuNotifySuppressedByTrigger(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expt *entity.Experiment + want bool + }{ + {name: "nil expt", expt: nil, want: false}, + {name: "empty trigger", expt: &entity.Experiment{TriggerType: ""}, want: false}, + {name: "manual", expt: &entity.Experiment{TriggerType: "manual"}, want: false}, + {name: "openapi", expt: &entity.Experiment{TriggerType: "openapi"}, want: false}, + {name: "schedule", expt: &entity.Experiment{TriggerType: "schedule"}, want: false}, + {name: "evalx", expt: &entity.Experiment{TriggerType: "evalx"}, want: true}, + // trigger_type 跨系统传递,上游可能带空白或大小写差异,一律按 evalx 处理。 + {name: "evalx with spaces", expt: &entity.Experiment{TriggerType: " evalx "}, want: true}, + {name: "evalx upper case", expt: &entity.Experiment{TriggerType: "EvalX"}, want: true}, + // 只有完全等于 evalx 才抑制,前后缀不同的值不误伤。 + {name: "evalx prefix only", expt: &entity.Experiment{TriggerType: "evalx_v2"}, want: false}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, isFeishuNotifySuppressedByTrigger(tt.expt)) + }) + } +} diff --git a/backend/modules/evaluation/domain/service/sandbox_agent_notifier.go b/backend/modules/evaluation/domain/service/sandbox_agent_notifier.go index 569b1e3b5f..dd1c0142f7 100644 --- a/backend/modules/evaluation/domain/service/sandbox_agent_notifier.go +++ b/backend/modules/evaluation/domain/service/sandbox_agent_notifier.go @@ -26,7 +26,7 @@ import ( // - 每行终态失败一张 (NotifyItemFail): CompleteItemRun fail 分支同步调用, 不限流。 // // 两张卡都要求实验是沙箱 agent 类型, 且 NotificationConf.FeishuNotification.Enable == true。 -// 非沙箱 agent / Enable=false / 接收人无法解析 时, 方法内部静默返回 nil (不阻塞主流程)。 +// 非沙箱 agent / Enable=false / trigger_type=evalx / 接收人无法解析 时, 方法内部静默返回 nil (不阻塞主流程)。 // // 日志前缀: // - 进度卡路径统一 [SandboxAgentProgress] @@ -173,6 +173,11 @@ func (s *sandboxAgentNotifier) enabled(expt *entity.Experiment, tag string) bool if expt == nil { return false } + // 触发来源闸:evalx 实验一律不发。放在沙箱判定之前 —— 抑制依据与实验类型无关。 + if isFeishuNotifySuppressedByTrigger(expt) { + logs.CtxInfo(context.Background(), "%s skip: suppressed by trigger_type=%s, expt_id=%v", tag, expt.TriggerType, expt.ID) + return false + } if !isSandboxAgentExperiment(expt) { logs.CtxInfo(context.Background(), "%s skip: not sandbox agent experiment, expt_id=%v", tag, expt.ID) return false diff --git a/backend/modules/evaluation/domain/service/sandbox_agent_notifier_test.go b/backend/modules/evaluation/domain/service/sandbox_agent_notifier_test.go index 3d6e358614..b52c309ea8 100644 --- a/backend/modules/evaluation/domain/service/sandbox_agent_notifier_test.go +++ b/backend/modules/evaluation/domain/service/sandbox_agent_notifier_test.go @@ -90,6 +90,19 @@ func TestSandboxAgentNotifier_NotifyProgressIfDue_EnableFalse_Skip(t *testing.T) assert.NoError(t, err) } +// TestSandboxAgentNotifier_EvalxTrigger_Skip trigger_type=evalx 时两张卡都不发, +// 即便实验是沙箱 agent 且 FeishuNotification.Enable=true —— 一个依赖都不该被调用。 +func TestSandboxAgentNotifier_EvalxTrigger_Skip(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + n, _, _, _, _ := newTestSandboxAgentNotifier(ctrl) + expt := buildSandboxAgentExpt(true, "ou_abc") + expt.TriggerType = "evalx" + + assert.NoError(t, n.NotifyProgressIfDue(context.Background(), expt)) + assert.NoError(t, n.NotifyItemFail(context.Background(), expt, 1, errors.New("boom"))) +} + func TestSandboxAgentNotifier_NotifyProgressIfDue_EmptyCardID_Skip(t *testing.T) { // consts.SandboxAgentProgressNotifyCardID 默认为 "",此测试保护 card 未配置时应静默。 // 若将来把 template ID 填进 consts,该测试需要一起改为设置临时空值再复位。 From eef010a3f1601cb151c7ff742b1aef8deeef8beb Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Sat, 22 Aug 2026 12:34:14 +0800 Subject: [PATCH 29/58] =?UTF-8?q?feat(evaluation):=20=E6=8A=BD=E5=87=BA=20?= =?UTF-8?q?ISchedulerClock=EF=BC=8C=E8=AE=A9=E6=97=B6=E9=92=9F=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=8F=AF=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中心调度的分钟级 tick 目前只能由外部调度平台驱动。要支持"进程内自循环" 这种不依赖任何平台的时钟,现有 IExptScheduleAdapter 装不下:它的 BizKey / CallbackMethod / CallbackPayload 三个字段都以「有另一个系统稍后回调我」为 前提,而进程内 ticker 没有远端注册表、不需要回调方法名、调度域从本进程解析。 硬塞进去只能全填空值、两个方法退化成 no-op。 因此新增一层更高的抽象:ISchedulerClock 只承诺「tick 会按周期发生」,不预设 由谁触发。Name/Start/Stop 三个方法、零平台概念,外部平台与进程内循环都能 实现它。IExptScheduleAdapter 保持原样、不做改动 —— 它降级为"外部平台"这一类 实现的内部细节,其既有调用方与语义完全不变。 Start 约定为非阻塞且幂等:外部触发型在此完成注册,进程内型在此启动后台循环, 重复调用不产生第二个时钟。Stop 刻意不要求"注销远端 job"——常驻 job 在无候选 时空跑的代价通常可忽略,而反复注销/重建会引入 job 定义漂移。 本次只加接口与 mock,不含任何实现,也不改动既有代码。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/rpc/mocks/scheduler_clock.go | 83 +++++++++++++++++++ .../domain/component/rpc/scheduler_clock.go | 38 +++++++++ 2 files changed, 121 insertions(+) create mode 100644 backend/modules/evaluation/domain/component/rpc/mocks/scheduler_clock.go create mode 100644 backend/modules/evaluation/domain/component/rpc/scheduler_clock.go diff --git a/backend/modules/evaluation/domain/component/rpc/mocks/scheduler_clock.go b/backend/modules/evaluation/domain/component/rpc/mocks/scheduler_clock.go new file mode 100644 index 0000000000..8cda68d73f --- /dev/null +++ b/backend/modules/evaluation/domain/component/rpc/mocks/scheduler_clock.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/rpc (interfaces: ISchedulerClock) +// +// Generated by this command: +// +// mockgen -destination=modules/evaluation/domain/component/rpc/mocks/scheduler_clock.go -package=mocks github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/rpc ISchedulerClock +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockISchedulerClock is a mock of ISchedulerClock interface. +type MockISchedulerClock struct { + ctrl *gomock.Controller + recorder *MockISchedulerClockMockRecorder + isgomock struct{} +} + +// MockISchedulerClockMockRecorder is the mock recorder for MockISchedulerClock. +type MockISchedulerClockMockRecorder struct { + mock *MockISchedulerClock +} + +// NewMockISchedulerClock creates a new mock instance. +func NewMockISchedulerClock(ctrl *gomock.Controller) *MockISchedulerClock { + mock := &MockISchedulerClock{ctrl: ctrl} + mock.recorder = &MockISchedulerClockMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockISchedulerClock) EXPECT() *MockISchedulerClockMockRecorder { + return m.recorder +} + +// Name mocks base method. +func (m *MockISchedulerClock) Name() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(string) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockISchedulerClockMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockISchedulerClock)(nil).Name)) +} + +// Start mocks base method. +func (m *MockISchedulerClock) Start(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockISchedulerClockMockRecorder) Start(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockISchedulerClock)(nil).Start), ctx) +} + +// Stop mocks base method. +func (m *MockISchedulerClock) Stop(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Stop", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Stop indicates an expected call of Stop. +func (mr *MockISchedulerClockMockRecorder) Stop(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockISchedulerClock)(nil).Stop), ctx) +} diff --git a/backend/modules/evaluation/domain/component/rpc/scheduler_clock.go b/backend/modules/evaluation/domain/component/rpc/scheduler_clock.go new file mode 100644 index 0000000000..8fc3963088 --- /dev/null +++ b/backend/modules/evaluation/domain/component/rpc/scheduler_clock.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package rpc + +import "context" + +// ISchedulerClock 让本进程所属调度域的周期性 tick 发生。 +// +// 与 IExptScheduleAdapter 的分工:后者是「向外部平台注册一个 job」,它的 BizKey / +// CallbackMethod / CallbackPayload 都以「有另一个系统稍后回调我」为前提;本接口只承诺 +// 「tick 会按周期发生」,不预设由谁触发。因此进程内 ticker(无 BizKey、无回调、直接函数调用) +// 与外部调度平台可以是同一个抽象下的两种实现,而不必让前者伪造后者的字段。 +// +// 实现方负责决定周期来源、并发互斥与故障恢复;调用方只做 Start / Stop。 +// +//go:generate mockgen -destination=mocks/scheduler_clock.go -package=mocks . ISchedulerClock +type ISchedulerClock interface { + // Name 返回实现标识(如 "bytescheduler"、"ticker"),用于日志与可观测。 + // 排障时第一个要确认的就是「这个 pod 到底在用哪种时钟」。 + Name() string + + // Start 启动时钟,非阻塞。 + // + // 外部触发型实现在此完成注册;进程内型实现在此启动后台循环。 + // 必须幂等:重复调用不产生第二个时钟。 + // + // 返回 error 表示时钟未能启动(调用方应告警但通常不应阻止服务启动 —— + // 缺少时钟只影响调度推进,不影响已在途的执行)。 + Start(ctx context.Context) error + + // Stop 停止时钟,用于优雅退出。已停止或从未启动时返回 nil。 + // + // 注意:对外部触发型实现,Stop 不必等同于「注销远端 job」—— + // 是否保留远端 job 由实现自行决定(常驻 job 在无候选时空跑的代价通常可忽略, + // 而反复注销/重建会引入 job 定义漂移的风险)。 + Stop(ctx context.Context) error +} From 6052539b71c32818f9de1acb1fd41fb4562c0b29 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Sun, 23 Aug 2026 16:25:38 +0800 Subject: [PATCH 30/58] =?UTF-8?q?fix(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E6=BC=8F=E5=86=99=E4=B8=BB=E8=A1=A8=20?= =?UTF-8?q?=E2=86=92=20=E6=AD=A3=E5=9C=A8=E6=89=A7=E8=A1=8C=E7=9A=84=20ite?= =?UTF-8?q?m=20=E5=AF=B9=E7=94=A8=E6=88=B7=E6=98=BE=E7=A4=BA=E6=88=90=20qu?= =?UTF-8?q?eueing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPE 实测:5 个 item 已 Processing 14 小时,而 results 接口返回 run_state=queueing ×30。 现象是「实验看着没动」,实际跑得很正常——这个误导今天带偏了一整天的排查。 根因是平行实现漏写。legacy 的 handleToSubmits 一直成对写两张表 (UpdateItemRunLog + UpdateItemsResult,expt_run_scheduler_event_impl.go:673/678), 而中心调度这条新派发路径的 StartReservedItem 只写 run log 一张 (expt_item_dispatch.go:162)。run log 是执行真值,但用户看到的是主表 ——MGetExperimentResult 走 expt_item_result 构造 run_state。 修法:取得执行权后同步把主表推进到 Processing。失败只告警不阻断——主表是展示投影, 写不进去不影响执行与额度正确性,而返回错误会让已拿到执行权的 item 被 MQ 重投一遍。 配一条用例,断言主表被推进且 ufields 里 status 确为 Processing(只调方法不算修好)。 反向变异验证:关掉该修复 → 用例 FAIL;恢复 → 通过。全包测试绿。 与团队记忆 mem-20260820-new-dispatch-path-missing-enum-field 同族: 老路径对、新路径漏,且单测不显式断言就发现不了。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_central_quota_release_test.go | 60 +++++++++++++++++++ .../service/expt_run_item_event_impl.go | 21 +++++++ 2 files changed, 81 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go index 3eeafcfdf7..65b16f6c58 100644 --- a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go @@ -483,3 +483,63 @@ func (r *recordingGuard) Release(ctx context.Context, schedulerScope string, exp *r.seq = append(*r.seq, "release") return nil } + +// TestHandleCentralReservation_AdvancesMainTableForDisplay 中心调度取得执行权后, +// **主表 expt_item_result 也必须推进到 Processing**,否则用户看到的状态是错的。 +// +// ★ 这条守的是一个真实缺陷(2026-08-23 PPE 实测): +// run log 是执行真值,但用户看到的是主表 —— MGetExperimentResult 走 expt_item_result +// 构造 run_state。中心调度这条新派发路径只写了 run log,于是 5 个已 Processing 14 小时的 +// item 在 results 接口里**全部显示 queueing**,现象是"实验看着没动",而它跑得很正常。 +// +// legacy 的 handleToSubmits 一直成对写这两张表(UpdateItemRunLog + UpdateItemsResult), +// 新路径漏了后者 —— 平行实现漏字段的又一例,与团队记忆里 +// 「新增派发路径漏设 ExptRunMode」同族:**老路径对、新路径漏,且单测不断言就发现不了**。 +// +// 断言的是"主表被推进到 Processing"这件事本身,而不只是"某个方法被调过", +// 所以把 ufields 的内容也钉住:只写别的字段不算修好。 +func TestHandleCentralReservation_AdvancesMainTableForDisplay(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + guard := &fakeGuard{confirmResult: true} + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Success)}, + }, nil) + + var gotFields map[string]any + var gotItemIDs []int64 + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, itemIDs []int64, ufields map[string]any) error { + gotItemIDs = itemIDs + gotFields = ufields + return nil + }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: guard, + dispatchRepo: dispatchRepo, + exptItemResultRepo: itemResultRepo, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + + handler := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return nil + }) + assert.NoError(t, handler(ctx, event)) + + assert.NotNil(t, gotFields, "主表必须被推进 —— 只写 run log 会让用户一直看到 queueing") + assert.Equal(t, []int64{4}, gotItemIDs, "推进的必须是本次取得执行权的那个 item") + assert.Equal(t, int32(entity.ItemRunState_Processing), gotFields["status"], + "主表状态必须是 Processing,与 run log 一致") +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index a5db690eee..2b1ba5afa0 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -319,6 +319,27 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE logs.CtxInfo(ctx, "[CentralReservation] run log projection not claimed (duplicate delivery or repaired), continue, expt_run_id: %v, item_id: %v", event.ExptRunID, event.EvalSetItemID) } + + // 主表 expt_item_result 也要跟着推进到 Processing。 + // + // run log 是执行真值,但**用户看到的是主表** —— MGetExperimentResult 走 + // expt_item_result 构造 run_state。只写 run log 会让实验详情把正在执行的 item + // 一直显示成 Queueing(实测:5 个 item 已 Processing 14 小时,results 接口仍全部 + // 报 queueing),现象是"实验看着没动",而它其实跑得很正常。 + // + // legacy 的 handleToSubmits 一直是成对写这两张表的 + // (expt_run_scheduler_event_impl.go 的 UpdateItemRunLog + UpdateItemsResult), + // 中心调度这条新派发路径漏了后者 —— 平行实现漏字段的又一例。 + // + // 失败只告警不阻断:主表是展示投影,写不进去不影响执行与额度正确性, + // 而这里返回错误会让已经拿到执行权的 item 被 MQ 重投一遍。 + if e.exptItemResultRepo != nil { + if err := e.exptItemResultRepo.UpdateItemsResult(ctx, event.SpaceID, event.ExptID, + []int64{event.EvalSetItemID}, map[string]any{"status": int32(entity.ItemRunState_Processing)}); err != nil { + logs.CtxWarn(ctx, "[CentralReservation] advance main table to Processing failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, err) + } + } } // 执行链返回后释放额度:这是 consumer 侧的主释放点。 From 28cfadcecd201d7bd8c6452dde662c4c9844fc63 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 15:14:19 +0800 Subject: [PATCH 31/58] =?UTF-8?q?feat(evaluation):=20admission=20subject?= =?UTF-8?q?=20=E5=B8=A6=E4=B8=8A=E7=94=B3=E6=8A=A5=E7=9A=84=20quota=20cate?= =?UTF-8?q?gory=EF=BC=8C=E4=BE=9B=E5=95=86=E4=B8=9A=E7=89=88=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么要传这个 打错 `category` 是唯一一条「打错字导致真实资源失控」的路径: - 打错 `resource_key`(`claude-opus-5` → `claude-opus5`)时**类级 wildcard 兜得住** —— 展开会同时记 `model|claude-opus5` 与 `model|*` 两笔,总量约束照样生效。 - 打错 `category`(`sandbox` → `sanbox`)时**连 wildcard 都兜不住** —— 记的是 `sanbox|*` 而不是 `sandbox|*`,两个 key 在账本里毫不相干。 该 item 在沙箱这一维上完全不受限,却真的会去占沙箱。 所以 category 需要在创建期就拒掉,而 resource_key 刻意不做名称校验 (真源是平台侧资源目录,迭代远快于发版节奏;做成白名单等于"平台上了新模型 要等评测侧发版",而卡住的表现是创建报参数错误、排查方向指不到"某个常量没加")。 ## 为什么校验放在 commercial 而不是这里 category 的登记表是内部资源目录(含内部模型/机型标识),不进开源仓。 本仓只把「申报了哪些 category」这个事实交出去,判定留给 policy 实现。 ## 改动 - `CentralAdmissionSubject` 加 `QuotaCategories`(去重、已 TrimSpace、顺序不保证) - `ExpectedQuotaConsumption.Categories()`:nil 安全、TrimSpace 后去重、跳过空值 去重是必需的 —— 同 category 下申报多个具体资源是正常形态(model|A + model|B), 不去重会让 policy 对同一 category 反复判定、错误信息里也重复列出 - `allowCentralScheduling` 多收一个向量参数并填进 subject policy 未注入 / noop 时行为不变(恒定放行、不看这个字段)。 ## 验证 `go build ./modules/evaluation/...` 零输出;`domain/entity` + `domain/component` + `domain/service` 全 PASS(service 包 225s 全量跑过)。 `Categories()` 三条用例:nil 安全、去重保序、trim 后同名去重。 Co-Authored-By: Claude Opus 5 (1M context) --- .../component/central_reservation_guard.go | 10 ++++++ .../domain/entity/expt_quota_consumption.go | 31 ++++++++++++++++++ .../entity/expt_quota_consumption_test.go | 32 +++++++++++++++++++ .../domain/service/expt_manage_impl.go | 12 +++++-- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index 9e2f0a504b..23d64dde32 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -136,6 +136,16 @@ type CentralAdmissionSubject struct { TargetType string // TargetID 评测对象 ID。0 表示无评测对象。 TargetID int64 + // QuotaCategories 该实验申报的资源 category 去重列表(已 TrimSpace,顺序不保证)。 + // + // 为什么 policy 需要它:category 的登记表在 commercial(额度维度是内部资源目录, + // 不进开源仓),而"申报了一个不存在的 category"必须在**创建期**就拒掉 —— + // 打错 category 时连类级 wildcard 都兜不住(`sanbox|*` 与 `sandbox|*` 在账本里 + // 毫不相干),该 item 在那一维上完全不受限却真的会去占资源。 + // + // 只带 category 不带 resource_key:后者的真源是平台侧资源目录(模型/机型清单), + // 迭代远快于本仓发版,刻意不做名称校验 —— 且它打错时 wildcard 仍然兜得住。 + QuotaCategories []string } // CentralAdmissionDecision 是 admission 判定的结果。 diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go index 721786701a..c6c22300eb 100644 --- a/backend/modules/evaluation/domain/entity/expt_quota_consumption.go +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go @@ -60,6 +60,37 @@ func (c *ExpectedQuotaConsumption) Validate() error { return nil } +// Categories 返回申报的 category 去重列表(已 TrimSpace)。 +// +// 供 admission policy 校验 category 是否已登记 —— 登记表在 commercial(额度维度属内部 +// 资源目录),OSS 只负责把"申报了哪些维度"这个事实交出去。 +// +// 顺序不保证:调用方只做集合判定,依赖顺序会引入一个没人声明的耦合。 +// 去重是必要的:同 category 下申报多个具体资源是正常形态(model|A + model|B), +// 不去重会让 policy 对同一个 category 反复判定、错误信息里也会重复列出。 +func (c *ExpectedQuotaConsumption) Categories() []string { + if c == nil { + return nil + } + seen := make(map[string]struct{}, len(c.Resources)) + out := make([]string, 0, len(c.Resources)) + for _, r := range c.Resources { + if r == nil { + continue + } + category := strings.TrimSpace(r.Category) + if category == "" { + continue + } + if _, dup := seen[category]; dup { + continue + } + seen[category] = struct{}{} + out = append(out, category) + } + return out +} + // Normalize 返回一份 category/resource_key 已去空白的副本,供落库前调用。 // 冻结进 eval_conf 的值必须是规范形态:调度期按 category|resource_key 拼 constraint key 时不再 trim, // 若带前后空白会与上限配置中的同名资源匹配不上,静默变成"未登记资源"而被放行。 diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go index e2581a8f42..f10e5fedcb 100644 --- a/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go @@ -181,3 +181,35 @@ func TestQuotaSpaceExpt_Clone(t *testing.T) { assert.Equal(t, map[int64]int64{2: 200, 3: 300}, cloned.ExptID2RunTime) }) } + +// TestExpectedQuotaConsumption_Categories 去重 + TrimSpace + nil 安全。 +// +// 去重是必需的:同 category 下申报多个具体资源是正常形态(model|A + model|B), +// 不去重会让 admission policy 对同一 category 反复判定、错误信息里也重复列出。 +func TestExpectedQuotaConsumption_Categories(t *testing.T) { + t.Run("nil 与空返回 nil/空", func(t *testing.T) { + var nilC *ExpectedQuotaConsumption + assert.Nil(t, nilC.Categories(), "nil receiver 必须安全 —— 调用方在 policy 不放行时会传 nil") + assert.Empty(t, (&ExpectedQuotaConsumption{}).Categories()) + }) + + t.Run("去重且保持首次出现顺序", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "a", Amount: 1}, + {Category: "sandbox", ResourceKey: "mac", Amount: 1}, + {Category: "model", ResourceKey: "b", Amount: 1}, // 同 category 第二个资源 + }} + assert.Equal(t, []string{"model", "sandbox"}, c.Categories()) + }) + + t.Run("TrimSpace 且跳过空 category 与 nil 元素", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: " model ", ResourceKey: "a", Amount: 1}, + nil, + {Category: " ", ResourceKey: "b", Amount: 1}, + {Category: "model", ResourceKey: "c", Amount: 1}, // trim 后与第一个同名,应去重 + }} + assert.Equal(t, []string{"model"}, c.Categories(), + "trim 后同名必须去重 —— 否则 policy 会把同一 category 判两次") + }) +} diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index c654528e01..cbb637a570 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -1338,7 +1338,7 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP if entity.ShouldEnforceByTrigger(triggerType) { // 灰度收窄闸。先判 policy 再校验向量:policy 不放行时该实验走 legacy, // 此时缺向量是正常的(EvalX 对未纳管空间也可能不传),不该报错。 - decision, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple) + decision, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple, expectedQuota) if err != nil { // 配置不可判定时拒绝创建 enforce 实验,而不是放行或降级 legacy: // 放行会让本该受额度管控的实验绕过管控;静默降级会让 EvalX 以为受管控。 @@ -1808,12 +1808,18 @@ func (e *ExptMangerImpl) Clone(ctx context.Context, exptID, spaceID int64, sessi // policy 未注入时放行:保持引入本闸之前的行为(trigger 判据单独生效)。 // 开源部署注入 noop 也是恒定放行,二者一致。两种情况都不指定缺省优先级 // (DefaultPriority=0 → 调用方按 1 处理),与引入 default_priority 之前的行为一致。 -func (e *ExptMangerImpl) allowCentralScheduling(ctx context.Context, spaceID int64, tuple *entity.ExptTuple) (component.CentralAdmissionDecision, error) { +func (e *ExptMangerImpl) allowCentralScheduling(ctx context.Context, spaceID int64, tuple *entity.ExptTuple, expectedQuota *entity.ExpectedQuotaConsumption) (component.CentralAdmissionDecision, error) { if e.centralAdmissionPolicy == nil { return component.CentralAdmissionDecision{Admitted: true}, nil } - subject := component.CentralAdmissionSubject{SpaceID: spaceID} + subject := component.CentralAdmissionSubject{ + SpaceID: spaceID, + // 申报的 category 交给 policy 校验:登记表在 commercial,OSS 只交出事实。 + // expectedQuota 为 nil 时是空列表 —— 那种情况下 policy 不该因"没有 category" + // 而拒绝:缺向量本身由调用方在 Admitted 之后单独判(见 CreateExpt)。 + QuotaCategories: expectedQuota.Categories(), + } // tuple.Target 为 nil 是合法场景:--skip-target 允许创建无评测对象的实验。 // 此时 TargetType/TargetID 保持零值,由 policy 决定这类实验算不算命中 // (通常不该命中按 target 维度配置的灰度规则)。 From e82b7a76104b6f06807205c72d2bed48abc8ac1d Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 15:43:06 +0800 Subject: [PATCH 32/58] =?UTF-8?q?fix(evaluation):=20=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E7=BB=88=E6=80=81=E9=87=8A=E6=94=BE=E9=A2=9D=E5=BA=A6=E6=94=B9?= =?UTF-8?q?=E6=8C=89=20item=20=E5=88=A4=E6=8D=AE=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=8C=89=20turn=20=E5=88=A4=E4=BC=9A=E6=BC=8F=E6=8E=89?= =?UTF-8?q?=E6=B2=99=E7=AE=B1=E6=AD=BB=E4=BA=A1=E7=9A=84=E9=82=A3=E6=89=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kill 一个 enforce 实验后额度一条不还,PPE 实测 9 个实验 45 条 reservation 卡了 11~19 小时。不是释放逻辑缺失(它存在且被调用),是**判据用错了维度**。 reservation 是 item 粒度(账本 key = res::),而待释放集合此前来自 GetIncompleteTurns —— 它只收 turn_status ∈ {Queueing, Processing}。于是 **turn 已终态、item 仍 Processing** 的那些 item 拿不进列表,一条都不释放。 那不是边缘情形,而是沙箱执行进程死亡的典型形态:turn 被判完(超时/失败落终态), item 的 run log 却没人去改。而这一格没有任何兜底 —— 此时 Redis 侧 state 已是 running: reap 只处理 reserved(显式 `if !view.IsReserved() { continue }`); 对账的 isReleasableWithoutEvidence = {reserved, dispatched},刻意排除 running (判据只能用预占时刻,分不出"卡死很久"与"刚被接管",按它释放会真超发); zombie 清理只扫 Processing,但实验已终态、daemon 不再跳。 三条路都不接 ⇒ 永久泄漏。 改动: 1. 待释放集合改由 incompleteItemIDsForRelease 按 **item run log status** 自查 (Queueing ∪ Processing,即 !IsItemRunFinished),不再依赖调用方传 turn 列表。 扫描失败时**跳过释放而不猜 item** —— 释放不属于本实验的 item 会归还别人的额度, 那是超发,比泄漏严重。 2. 释放调用移到 `if !opt.NoCompleteItemTurn` **之外**。额度释放与"要不要改写 turn 状态" 是两件无关的事,没理由被同一开关控制;该选项当前零 caller,但一旦有人加 caller, 把释放留在块内就会连带静默失效。 穷举审计确认这一处同时是 4 个场景的根因:沙箱进程死亡、用户 kill、 实验落 Failed(尤其 run 级 36h 僵尸)、consumer SIGKILL 后实验已终态。 测试:既有 8 条全部改为经 repo 注入 run log(谁改回 turn 判据,mock 期望就不被满足), 并新增两条: - ReleasesWhenTurnTerminalButItemNot —— 核心回归,turn 侧刻意返回空、只有 item 有数据 - ScanFailDoesNotGuessItems —— 扫描失败不得瞎释放 变异验证(三个都被抓到): 待释放集合恒为空 → ReleasesOnTerminal ×3 + ReleasesWhenTurnTerminalButItemNot + DedupesItemIDs 红 判据改回 turn 反推 → ReleasesOnTerminal 红 删掉 CompleteExpt 调用 → TestCompleteExpt_Releases 红(它专守"有没有被接上") 55 个包全绿。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_complete_quota_release_test.go | 180 ++++++++++++++---- .../service/expt_manage_execution_impl.go | 108 ++++++++--- 2 files changed, 221 insertions(+), 67 deletions(-) diff --git a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go index 642559587c..177b7f5958 100644 --- a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go @@ -31,18 +31,31 @@ import ( // 更糟的是 running 态的悬挂连调度器每拍的对账都不碰 // (reapDanglingReservations 显式 `if !view.IsReserved() { continue }`)—— // 也就是说这条路径**没有任何兜底**。审计前本文件 grep centralGuard 为 0。 +// +// ★★ 2026-08-24 修正了判据维度:待释放集合改由 **item run log 状态**决定, +// 不再用"未完成 turn"反推。原因见 TestReleaseCentralQuota_ReleasesWhenTurnTerminalButItemNot。 -func incompleteTurns(itemIDs ...int64) []*entity.ItemTurnID { - out := make([]*entity.ItemTurnID, 0, len(itemIDs)) +// stubIncompleteItems 让 itemResultRepo 返回指定 item 的未终态 run log。 +// +// 判据从 turn 换成 item 之后,这些用例必须经 repo 注入数据 —— 这本身就是一层保障: +// 谁把实现改回按 turn 判,这些 mock 期望就不会被满足(gomock 报 missing call)。 +func stubIncompleteItems(t *testing.T, ctrl *gomock.Controller, itemIDs ...int64) *repoMocks.MockIExptItemResultRepo { + t.Helper() + repo := repoMocks.NewMockIExptItemResultRepo(ctrl) + logs := make([]*entity.ExptItemResultRunLog, 0, len(itemIDs)) for _, id := range itemIDs { - out = append(out, &entity.ItemTurnID{ItemID: id, TurnID: id * 10}) + logs = append(logs, &entity.ExptItemResultRunLog{ItemID: id}) } - return out + repo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(logs, int64(len(logs)), nil).AnyTimes() + return repo } func enforceExpt() *entity.Experiment { return &entity.Experiment{ ID: 100, + SpaceID: 7, LatestRunID: 200, SchedulerScope: testScope, ExptDispatchMode: entity.ExptDispatchModeEnforce, @@ -61,11 +74,13 @@ func TestReleaseCentralQuotaForIncompleteItems_ReleasesOnTerminal(t *testing.T) {"Success(正常完成但仍有未完成 item)", entity.ExptStatus_Success}, } { t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl, 11, 12, 13)} svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11, 12, 13), tc.status) + enforceExpt(), gptr.Of(int64(200)), tc.status) releases := guard.releases() require.Len(t, releases, 3, "三个未完成 item 的额度都必须释放,否则永久泄漏") @@ -79,27 +94,80 @@ func TestReleaseCentralQuotaForIncompleteItems_ReleasesOnTerminal(t *testing.T) } } -// TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs 同一 item 的多个未完成 turn -// 只能释放一次。 +// ★★★ TestReleaseCentralQuota_ReleasesWhenTurnTerminalButItemNot 本次修复的核心回归。 // -// reservation 是 **item 粒度**而 incompleteTurnIDs 是 **turn 粒度**:一个 item 有 5 个 -// 未完成 turn 时,不去重就会对同一条 reservation 发 5 次释放。Release 本身幂等 -// (HDEL 后 field 不存在),但会放大 Redis 往返、且日志计数失真(看起来泄漏了 5 份)。 -func TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs(t *testing.T) { +// 场景:**turn 已经落终态,而 item 的 run log 仍停在 Processing。** +// 这不是边缘情形,而是沙箱执行进程死亡的典型形态 —— turn 被判完(超时/失败), +// item 那一行却没人去改。2026-08-24 PPE 实测:9 个实验 45 条 reservation +// 就是这么卡住的(11~19 小时),kill 实验完全无效。 +// +// 旧实现用 GetIncompleteTurns(只收 turn_status ∈ {Queueing, Processing})反推待释放 item, +// 于是这一格拿到空列表、一条都不释放。而此时 Redis 侧 state 已是 running: +// reap 只处理 reserved;对账的 isReleasableWithoutEvidence 刻意排除 running; +// zombie 只扫 Processing,但实验已终态、daemon 不再跳。 +// 三条兜底全不接 ⇒ 永久泄漏。 +// +// 这条用例的构造刻意让 **turn 侧完全没有可用信息**(GetIncompleteTurns 若被调用会回空), +// 只有 item run log 有数据 —— 谁把判据改回 turn,这里必然变红。 +func TestReleaseCentralQuota_ReleasesWhenTurnTerminalButItemNot(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + // item run log 说有两个 item 未终态;turn 侧(若有人去查)什么都给不出来。 + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl, 21, 22)} - // item 11 有三个未完成 turn,item 12 有两个。 - turns := []*entity.ItemTurnID{ - {ItemID: 11, TurnID: 1}, {ItemID: 11, TurnID: 2}, {ItemID: 11, TurnID: 3}, - {ItemID: 12, TurnID: 4}, {ItemID: 12, TurnID: 5}, - } + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) + + releases := guard.releases() + require.Len(t, releases, 2, + "★ turn 已终态、item 仍 Processing 时**必须**按 item 释放 —— "+ + "用 turn 反推会拿到空列表,那正是 PPE 上 45 条额度卡死 11~19 小时的原因") + assert.ElementsMatch(t, []int64{21, 22}, + []int64{releases[0].ItemID, releases[1].ItemID}) +} + +// TestReleaseCentralQuota_ScanFailDoesNotGuessItems 查不到未终态 item 时不得瞎释放。 +// +// 方向很关键:宁可这次不释放(留给对账/人工),也不能凭空构造 item 列表 —— +// 释放不属于本实验的 item 会归还**别人的**额度,那是超发,比泄漏严重。 +func TestReleaseCentralQuota_ScanFailDoesNotGuessItems(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + repo := repoMocks.NewMockIExptItemResultRepo(ctrl) + repo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, int64(0), assert.AnError).AnyTimes() + + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: repo} + + assert.NotPanics(t, func() { + svc.releaseCentralQuotaForIncompleteItems(context.Background(), + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) + }) + assert.Empty(t, guard.releases(), "扫描失败时必须跳过释放,不能猜 item") +} + +// TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs 同一 item 只释放一次。 +// +// reservation 是 **item 粒度**,重复释放虽幂等(HDEL 后 field 不存在), +// 但会放大 Redis 往返、且日志计数失真(看起来泄漏了多份)。 +func TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + guard := &fakeGuard{} + // 同一 item 出现多行(重复投递等异常形态),必须去重。 + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl, 11, 11, 11, 12, 12)} svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), turns, entity.ExptStatus_Terminated) + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) releases := guard.releases() - assert.Len(t, releases, 2, "5 个 turn 只对应 2 个 item,必须按 item 去重") + assert.Len(t, releases, 2, "5 行只对应 2 个 item,必须按 item 去重") got := []int64{releases[0].ItemID, releases[1].ItemID} assert.ElementsMatch(t, []int64{11, 12}, got) } @@ -108,15 +176,22 @@ func TestReleaseCentralQuotaForIncompleteItems_DedupesItemIDs(t *testing.T) { // // 它们从不预占额度,调用释放只是无谓的 Redis 往返 —— 而 CompleteExpt 是所有实验 // (含大量 legacy)的公共收口,白打往返会按实验数放大。 +// +// ⚠️ 这条还额外保证:legacy 实验**连 run log 都不该去扫**(省一次大表查询)。 +// 所以这里刻意用一个"任何调用都会失败"的 repo —— 谁把 legacy 判断挪到扫描之后, +// gomock 会因为出现未预期的调用而报错。 func TestReleaseCentralQuotaForIncompleteItems_SkipsLegacy(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: repoMocks.NewMockIExptItemResultRepo(ctrl)} legacy := enforceExpt() legacy.ExptDispatchMode = entity.ExptDispatchModeLegacy svc.releaseCentralQuotaForIncompleteItems(context.Background(), - legacy, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + legacy, gptr.Of(int64(200)), entity.ExptStatus_Terminated) assert.Empty(t, guard.releases(), "legacy 实验从不预占,不该发释放请求") } @@ -128,11 +203,14 @@ func TestReleaseCentralQuotaForIncompleteItems_SkipsLegacy(t *testing.T) { // 释放变成**静默 no-op** —— 比报错更糟,因为看起来成功了。 // 而 CompleteExpt 的调用方并不总会传 exptRunID(Kill 的签名里它是 *int64)。 func TestReleaseCentralQuotaForIncompleteItems_FallsBackToLatestRunID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl, 11)} svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), nil, incompleteTurns(11), entity.ExptStatus_Terminated) + enforceExpt(), nil, entity.ExptStatus_Terminated) releases := guard.releases() require.Len(t, releases, 1) @@ -143,15 +221,20 @@ func TestReleaseCentralQuotaForIncompleteItems_FallsBackToLatestRunID(t *testing // enforce 却无 Scope 时不得瞎猜一本账。 // // 猜错会归还**别人的**额度 —— 那比不归还严重得多(直接导致超发,且静默)。 +// +// ⚠️ 同 legacy 那条:无 Scope 时也不该去扫 run log。用零期望的 mock 保证这一点。 func TestReleaseCentralQuotaForIncompleteItems_SkipsWhenScopeMissing(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: repoMocks.NewMockIExptItemResultRepo(ctrl)} noScope := enforceExpt() noScope.SchedulerScope = "" svc.releaseCentralQuotaForIncompleteItems(context.Background(), - noScope, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + noScope, gptr.Of(int64(200)), entity.ExptStatus_Terminated) assert.Empty(t, guard.releases(), "无 Scope 时释放会归还别人的额度,必须跳过") } @@ -166,15 +249,18 @@ func TestReleaseCentralQuotaForIncompleteItems_NoopOnEmptyInputs(t *testing.T) { svc := &ExptMangerImpl{} assert.NotPanics(t, func() { svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) }) }) t.Run("无未完成 item", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} + // 扫描成功但结果为空 —— 全部 item 都已终态,无需释放。 + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl)} svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), nil, entity.ExptStatus_Success) + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Success) assert.Empty(t, guard.releases()) }) @@ -183,18 +269,24 @@ func TestReleaseCentralQuotaForIncompleteItems_NoopOnEmptyInputs(t *testing.T) { svc := &ExptMangerImpl{centralGuard: guard} assert.NotPanics(t, func() { svc.releaseCentralQuotaForIncompleteItems(context.Background(), - nil, gptr.Of(int64(200)), incompleteTurns(11), entity.ExptStatus_Terminated) + nil, gptr.Of(int64(200)), entity.ExptStatus_Terminated) }) assert.Empty(t, guard.releases()) }) - t.Run("turn 列表含 nil 项", func(t *testing.T) { + t.Run("run log 列表含 nil 项", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + repo := repoMocks.NewMockIExptItemResultRepo(ctrl) + repo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{nil, {ItemID: 11}, nil}, int64(3), nil).AnyTimes() + guard := &fakeGuard{} - svc := &ExptMangerImpl{centralGuard: guard} - turns := []*entity.ItemTurnID{nil, {ItemID: 11, TurnID: 1}, nil} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: repo} assert.NotPanics(t, func() { svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), turns, entity.ExptStatus_Terminated) + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) }) assert.Len(t, guard.releases(), 1, "nil 项应被跳过,有效项仍要释放") }) @@ -206,12 +298,15 @@ func TestReleaseCentralQuotaForIncompleteItems_NoopOnEmptyInputs(t *testing.T) { // best-effort 的方向很关键:一个 item 释放失败就放弃剩下 99 个,等于把"泄漏 1 份" // 放大成"泄漏 100 份"。 func TestReleaseCentralQuotaForIncompleteItems_ReleaseErrorDoesNotPanic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + guard := &fakeGuard{releaseErr: assert.AnError} - svc := &ExptMangerImpl{centralGuard: guard} + svc := &ExptMangerImpl{centralGuard: guard, itemResultRepo: stubIncompleteItems(t, ctrl, 11, 12, 13)} assert.NotPanics(t, func() { svc.releaseCentralQuotaForIncompleteItems(context.Background(), - enforceExpt(), gptr.Of(int64(200)), incompleteTurns(11, 12, 13), entity.ExptStatus_Terminated) + enforceExpt(), gptr.Of(int64(200)), entity.ExptStatus_Terminated) }) assert.Len(t, guard.releases(), 3, "前一个失败不该阻断后续 item 的释放尝试") } @@ -251,10 +346,19 @@ func TestCompleteExpt_ReleasesCentralQuotaForIncompleteItems(t *testing.T) { CalculateStats(ctx, exptID, spaceID, session). Return(&entity.ExptCalculateStats{ProcessingItemCnt: 2}, nil) - // 两个 item 尚未跑完 —— 实验被终止后它们永远不会执行,额度必须在这里归还。 + // ★★ turn 侧**刻意返回空**:模拟"turn 已全部落终态、item 仍 Processing"。 + // + // 这是本次修复的核心场景(沙箱进程死亡的典型形态)。旧实现拿这个空列表去释放, + // 于是一条都不放;改成按 item run log 查之后,下面的 ScanItemRunLogs 才是真值来源。 + // 谁把判据改回 turn,这条用例会因为 releases 为空而变红。 mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). GetIncompleteTurns(ctx, exptID, spaceID, session). - Return([]*entity.ItemTurnID{{ItemID: 11, TurnID: 1}, {ItemID: 12, TurnID: 2}}, nil) + Return(nil, nil).AnyTimes() + + // item run log 才是待释放集合的真值:两个 item 未终态 → 两份额度必须归还。 + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 11}, {ItemID: 12}}, int64(2), nil).AnyTimes() mgr.statsRepo.(*repoMocks.MockIExptStatsRepo).EXPECT(). UpdateByExptID(ctx, exptID, spaceID, gomock.Any()).Return(nil) diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index 72c377baed..275986a2d0 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -607,29 +607,35 @@ func (e *ExptMangerImpl) CompleteExpt(ctx context.Context, exptID int64, exptRun } } + // ★ 实验终态时,未跑完的 item 的中心调度额度必须在这里释放。 + // + // 为什么不能只靠 consumer 侧那个释放点:那个点在 item 执行链的出口,靠"item 到终态" + // 触发。而实验被 Kill / Cancel 或落 Failed 之后,这些 item **不会再被执行** —— + // 消息可能已被丢弃、可能压根还没投递、也可能 consumer 早已放弃, + // 于是那个出口永远不会被走到,reservation 就永久留在账本里。 + // + // 实测确认过缺口:本文件此前 grep centralGuard 为 0 —— 用户取消一个正在跑 100 个 item + // 的 enforce 实验,那 100 份额度全部泄漏。而 running 态的悬挂连调度器每拍的对账都不碰 + // (reapDanglingReservations 显式只处理 state=reserved),也就是说**没有任何兜底**。 + // 取消实验是常规操作,这条路径在线上必然被走到。 + // + // ★★ 刻意放在 `if !opt.NoCompleteItemTurn` **之外**: + // 额度释放与"要不要改写 turn 状态"是两件无关的事,没有理由被同一个开关控制。 + // 该选项当前零 caller,但一旦有人加一个 caller,把释放留在块内就会连带失效 —— + // 而那种失效是静默的(额度慢慢泄漏,看起来像上限配小了)。 + // + // 也不再依赖 GetIncompleteTurns 的结果:待释放集合改由本函数按 **item run log 状态** + // 自查(见 incompleteItemIDsForRelease)。原因是 reservation 是 item 粒度, + // 而 turn 与 item 可以不同步 —— turn 已终态、item 仍 Processing 时用 turn 判据会漏, + // 那正是沙箱执行进程死亡的典型形态(2026-08-24 PPE 实测 45 条卡住)。 + e.releaseCentralQuotaForIncompleteItems(ctx, got, exptRunID, status) + if !opt.NoCompleteItemTurn { incompleteTurnIDs, err := e.exptResultService.GetIncompleteTurns(ctx, exptID, spaceID, session) if err != nil { return err } - // ★ 实验终态时,未跑完的 item 的中心调度额度必须在这里释放。 - // - // 为什么不能只靠 consumer 侧那个释放点:那个点在 item 执行链的出口,靠"item 到终态" - // 触发。而实验被 Kill / Cancel 或落 Failed 之后,这些 item **不会再被执行** —— - // 消息可能已被丢弃、可能压根还没投递、也可能 consumer 早已放弃, - // 于是那个出口永远不会被走到,reservation 就永久留在账本里。 - // - // 实测确认过缺口:本文件此前 grep centralGuard 为 0 —— 用户取消一个正在跑 100 个 item - // 的 enforce 实验,那 100 份额度全部泄漏。而 running 态的悬挂连调度器每拍的对账都不碰 - // (reapDanglingReservations 显式只处理 state=reserved),也就是说**没有任何兜底**。 - // 取消实验是常规操作,这条路径在线上必然被走到。 - // - // 放在这里而不是各终态分支内:Terminated 与 Failed(default) 两条分支都需要释放, - // 且都基于同一份 incompleteTurnIDs。放在分支外只写一次,将来新增终态分支也自动覆盖 —— - // 而"新增分支漏掉清理"正是本文件 default 分支注释里记录过的历史教训(沙箱曾因此漏回收)。 - e.releaseCentralQuotaForIncompleteItems(ctx, got, exptRunID, incompleteTurnIDs, status) - switch status { case entity.ExptStatus_Terminated: terminatedItemIDSet := make(map[int64]bool) @@ -858,10 +864,9 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( ctx context.Context, expt *entity.Experiment, exptRunID *int64, - incompleteTurnIDs []*entity.ItemTurnID, status entity.ExptStatus, ) { - if e.centralGuard == nil || expt == nil || len(incompleteTurnIDs) == 0 { + if e.centralGuard == nil || expt == nil { return } if !entity.IsCentralDispatch(expt.ExptDispatchMode) { @@ -885,17 +890,33 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( return } - // 同一 item 可能有多个未完成 turn,而 reservation 是 item 粒度 —— 必须先去重, - // 否则会对同一条 reservation 发多次释放。Release 本身幂等(HDEL 后 field 不存在), - // 但重复调用会放大 Redis 往返,且日志里的计数会失真。 - itemIDSet := make(map[int64]bool, len(incompleteTurnIDs)) - for _, it := range incompleteTurnIDs { - if it == nil { - continue - } - itemIDSet[it.ItemID] = true + // ★ 待释放集合按 **item run log 的 status** 查,不能用未完成 turn 反推。 + // + // reservation 是 **item 粒度**(账本 key = res::),所以判据必须也用 + // item 自己的状态。此前这里的入参来自 GetIncompleteTurns(只收 + // turn_status ∈ {Queueing, Processing}),于是**turn 已终态而 item 未终态**的那些 item + // 拿不进列表,额度一条不还。 + // + // 那不是边缘情形,而是沙箱执行进程死亡的**典型形态**:turn 被判完(超时/失败落终态), + // item 的 run log 却没人去改,永远停在 Processing。2026-08-24 PPE 实测:9 个实验 45 条 + // reservation 就是这么卡住的(11~19 小时),kill 实验完全无效。 + // + // 而这一格没有任何兜底:此时 Redis 侧 state 已是 running —— + // reap 只处理 reserved(scheduler.go 显式 `if !view.IsReserved() { continue }`); + // 对账的 isReleasableWithoutEvidence = {reserved, dispatched},刻意排除 running + // (判据只能用预占时刻,分不出"卡死很久"与"刚被接管",按它释放会真超发); + // zombie 清理只扫 Processing,但实验已终态、daemon 不再跳。 + // 三条路都不接 ⇒ 永久泄漏。 + itemIDs, err := e.incompleteItemIDsForRelease(ctx, expt.ID, runID, expt.SpaceID) + if err != nil { + // 查不到就不猜:宁可这次不释放(留给对账/人工),也不能凭空构造 item 列表。 + logs.CtxError(ctx, "[CentralReservation] scan incomplete items fail on expt complete, skip release, expt_id: %v, expt_run_id: %v, err: %v", + expt.ID, runID, err) + return + } + if len(itemIDs) == 0 { + return } - itemIDs := maps.ToSlice(itemIDSet, func(k int64, v bool) int64 { return k }) reason := fmt.Sprintf("experiment reached terminal status=%d with incomplete items", int32(status)) failed := 0 @@ -910,6 +931,35 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( expt.SchedulerScope, expt.ID, runID, len(itemIDs), failed, status) } +// incompleteItemIDsForRelease 取该 run 下**仍未走到终态**的 item id(去重)。 +// +// 口径就是 `!IsItemRunFinished(status)`,即 Queueing ∪ Processing: +// - Queueing:已预占未开跑,或已投递但 consumer 没接管 +// - Processing:consumer 接管过(Redis 侧已是 running),但执行可能已经死了 +// 两者的额度都还挂在账本上,实验既已终态、它们都不会再被执行,必须归还。 +// +// 终态(Success/Fail/Terminal)刻意排除:它们的额度已由 consumer 侧释放点归还过, +// 重复释放虽然幂等(HDEL 后 field 不存在),但会放大 Redis 往返、让日志计数失真。 +func (e *ExptMangerImpl) incompleteItemIDsForRelease(ctx context.Context, exptID, exptRunID, spaceID int64) ([]int64, error) { + runLogs, _, err := e.itemResultRepo.ScanItemRunLogs(ctx, exptID, exptRunID, &entity.ExptItemRunLogFilter{ + Status: []entity.ItemRunState{entity.ItemRunState_Queueing, entity.ItemRunState_Processing}, + }, 0, 0, spaceID) + if err != nil { + return nil, err + } + + // 去重:同一 item 在该 run 下只该有一行,但 reservation 是 item 粒度, + // 保守去重可避免任何重复投递导致的重复释放。 + itemIDSet := make(map[int64]bool, len(runLogs)) + for _, rl := range runLogs { + if rl == nil { + continue + } + itemIDSet[rl.ItemID] = true + } + return maps.ToSlice(itemIDSet, func(k int64, v bool) int64 { return k }), nil +} + func (e *ExptMangerImpl) terminateItemTurns(ctx context.Context, exptID int64, itemTurnIDs []*entity.ItemTurnID, spaceID int64, session *entity.Session) error { itemIDs := make([]int64, 0, len(itemTurnIDs)) for _, itemTurnID := range itemTurnIDs { From 589a78c0469bd499243327838c3edd848594e574 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 16:05:35 +0800 Subject: [PATCH 33/58] =?UTF-8?q?fix(evaluation):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E6=97=B6=E5=BD=92=E8=BF=98=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E9=A2=9D=E5=BA=A6=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=AD=A4=E5=89=8D=E6=98=AF=20100%=20=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E4=B8=94=E4=B8=8D=E5=8F=AF=E9=80=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除是一条**独立于 CompleteExpt 的泄漏路径**,此前两个删除入口(Delete / MDelete) grep centralGuard 均为 0。三条独立原因让它比其它泄漏更彻底: ① 实验软删后 consumer 侧 GetByID 拿不到实验、直接退出 ⇒ 那些 item 永不执行, "item 到终态才释放"的出口永远不会被走到; ② 删除**压根不经过** CompleteExpt,那里的释放不在这条路径上;而 CompleteExpt 对已删实验还有一条 early return,所以"先删再 kill"同样救不回来; ③ 软删后 ScanSchedulerQueue 带 deleted_at IS NULL ⇒ **连 full recovery 都扫不到**, 这些 reservation 会永久留在账本里 —— 其它泄漏至少还能靠运维 token 触发整本重建抹掉。 改动: 1. Delete 与 MDelete 都在**软删之前**调 releaseCentralQuotaForIncompleteItems。 顺序不是风格问题:删完再释放的话,中途任何失败都让额度彻底失去归还机会 (实验已不可查,SchedulerScope / LatestRunID 都拿不到)。反过来"先释放但删除失败" 只是让一个仍存在的实验少占额度,下一拍调度会重新预占,无损。 best-effort:释放失败只告警不阻断删除 —— 让额度问题挡住用户删实验是把后台问题 升级成前台故障。 2. CompleteExpt 对已删实验的 early return **刻意不补释放**(拿不到 scope/run 只能瞎猜, 猜错会归还别人的额度=超发),改为写明它不再是泄漏点的前提是"删除自己会释放", 并点出谁删掉那处会让这条路径重新变成永久泄漏。 测试(4 条,两个入口各自独立守): - TestDelete_ReleasesCentralQuota 单实验入口 - TestMDelete_ReleasesCentralQuotaForEachExpt 批量入口,且断言 run 号各归各的 (账本 key 是 (run_id,item_id),串了就找不到) - TestMDelete_ReleasesBeforeSoftDelete ★ 用 gomock .After() 钉死顺序 - TestMDelete_LegacyExptSkipsRelease legacy 连 run log 都不该扫(零期望 mock 保证) 变异验证(三个,且互不掩盖): 删 MDelete 的释放 → ReleasesCentralQuotaForEachExpt + ReleasesBeforeSoftDelete 红 删 Delete 的释放 → TestDelete_ReleasesCentralQuota 红(批量那条不会替它兜底) 释放挪到软删之后 → **只有** ReleasesBeforeSoftDelete 红 —— 坐实只测"释放了几条"的用例测不出顺序 55 个包全绿。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_complete_quota_release_test.go | 168 ++++++++++++++++++ .../service/expt_manage_execution_impl.go | 8 + .../domain/service/expt_manage_impl.go | 29 +++ 3 files changed, 205 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go index 177b7f5958..bc4f3dc8d0 100644 --- a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go @@ -402,3 +402,171 @@ func TestCompleteExpt_ReleasesCentralQuotaForIncompleteItems(t *testing.T) { assert.Equal(t, runID, r.RunID) } } + +// ============================================================================ +// 删除路径的额度释放(P0-2) +// +// 删除是**独立于 CompleteExpt 的一条泄漏路径**,三条独立原因: +// ① 实验软删后 consumer 侧 GetByID 拿不到实验、直接退出 ⇒ 那些 item 永不执行, +// "item 到终态才释放"的出口永远不会被走到; +// ② 删除压根不经过 CompleteExpt,那里的释放不在这条路径上;而 CompleteExpt 对已删实验 +// 还有一条 early return,所以"先删再 kill"同样救不回来; +// ③ 软删后 ScanSchedulerQueue 带 deleted_at IS NULL ⇒ **连 full recovery 都扫不到**, +// 这些 reservation 会永久留在账本里。 +// +// 两个删除入口(Delete / MDelete)都要有防线 —— 只守一个等于留一半泄漏。 +// ============================================================================ + +// stubDeletableExpt 造一个"仍有未跑完 item"的 enforce 实验,供删除路径用例复用。 +func stubDeletableExpt(exptID, runID int64) *entity.Experiment { + return &entity.Experiment{ + ID: exptID, + SpaceID: 7, + LatestRunID: runID, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + } +} + +// TestDelete_ReleasesCentralQuota 单实验删除必须归还额度。 +func TestDelete_ReleasesCentralQuota(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + const exptID, spaceID, runID = int64(321), int64(7), int64(654) + + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + GetByID(ctx, exptID, spaceID).Return(stubDeletableExpt(exptID, runID), nil) + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 31}, {ItemID: 32}}, int64(2), nil).AnyTimes() + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + Delete(ctx, exptID, spaceID).Return(nil) + + require.NoError(t, mgr.Delete(ctx, exptID, spaceID, &entity.Session{UserID: "u"})) + + releases := guard.releases() + require.Len(t, releases, 2, + "删除实验必须归还未跑完 item 的额度 —— 软删后 full recovery 都扫不到它们(deleted_at IS NULL),"+ + "不在这里放就是永久泄漏") + assert.ElementsMatch(t, []int64{31, 32}, []int64{releases[0].ItemID, releases[1].ItemID}) + for _, r := range releases { + assert.Equal(t, testScope, r.Scope) + assert.Equal(t, runID, r.RunID, "exptRunID 传 nil 时应回落 LatestRunID") + } +} + +// TestMDelete_ReleasesCentralQuotaForEachExpt 批量删除要对**每个**实验都释放。 +// +// 单独测批量版:它与 Delete 是两份独立实现(一个 GetByID + Delete、一个 MGetByID + MDelete), +// 只改一处会留一半泄漏,而"批量入口漏了"在线上更常见(用户多选删除)。 +func TestMDelete_ReleasesCentralQuotaForEachExpt(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + const spaceID = int64(7) + exptIDs := []int64{401, 402} + + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MGetByID(ctx, exptIDs, spaceID). + Return([]*entity.Experiment{ + stubDeletableExpt(401, 4010), + stubDeletableExpt(402, 4020), + }, nil) + // 每个实验各有一个未跑完 item。 + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), int64(401), int64(4010), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 41}}, int64(1), nil) + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), int64(402), int64(4020), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 42}}, int64(1), nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MDelete(ctx, exptIDs, spaceID).Return(nil) + + require.NoError(t, mgr.MDelete(ctx, exptIDs, spaceID, &entity.Session{UserID: "u"})) + + releases := guard.releases() + require.Len(t, releases, 2, "批量删除必须逐个实验释放,漏一个就是那个实验的额度永久泄漏") + assert.ElementsMatch(t, []int64{41, 42}, []int64{releases[0].ItemID, releases[1].ItemID}) + // run 号必须各归各的 —— 账本 key 是 (run_id, item_id),串了就找不到那条 reservation。 + byItem := map[int64]int64{} + for _, r := range releases { + byItem[r.ItemID] = r.RunID + } + assert.Equal(t, int64(4010), byItem[41]) + assert.Equal(t, int64(4020), byItem[42]) +} + +// ★ TestMDelete_ReleasesBeforeSoftDelete 释放必须发生在软删**之前**。 +// +// 顺序不是风格问题:删完再释放的话,中途任何失败都让额度彻底失去归还机会 +// (实验已不可查,SchedulerScope / LatestRunID 都拿不到了)。反过来"先释放但删除失败" +// 只是让一个仍存在的实验少占额度,下一拍调度会重新预占,无损。 +// +// 用 gomock 的调用顺序断言钉死它 —— 只测"释放了几条"是测不出顺序的。 +func TestMDelete_ReleasesBeforeSoftDelete(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + const spaceID = int64(7) + exptIDs := []int64{501} + + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MGetByID(ctx, exptIDs, spaceID). + Return([]*entity.Experiment{stubDeletableExpt(501, 5010)}, nil) + + scan := mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 51}}, int64(1), nil) + + // ★ After(scan):软删必须在扫描(= 释放的前置步骤)之后发生。 + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MDelete(ctx, exptIDs, spaceID).Return(nil).After(scan) + + require.NoError(t, mgr.MDelete(ctx, exptIDs, spaceID, &entity.Session{UserID: "u"})) + assert.Len(t, guard.releases(), 1) +} + +// TestMDelete_LegacyExptSkipsRelease legacy 实验删除时不该产生任何额度调用。 +// +// 删除是全量实验(含大量 legacy)的公共入口,白打 Redis 往返会按删除量放大。 +// 这里用零期望的 itemResultRepo mock:legacy 判断必须在扫 run log **之前**短路, +// 否则 gomock 会因未预期的 ScanItemRunLogs 调用而报错。 +func TestMDelete_LegacyExptSkipsRelease(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + const spaceID = int64(7) + exptIDs := []int64{601} + + legacy := stubDeletableExpt(601, 6010) + legacy.ExptDispatchMode = entity.ExptDispatchModeLegacy + + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MGetByID(ctx, exptIDs, spaceID).Return([]*entity.Experiment{legacy}, nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + MDelete(ctx, exptIDs, spaceID).Return(nil) + + require.NoError(t, mgr.MDelete(ctx, exptIDs, spaceID, &entity.Session{UserID: "u"})) + assert.Empty(t, guard.releases(), "legacy 实验从不预占,删除时不该发释放请求") +} diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index 275986a2d0..73bfa6f70e 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -577,6 +577,14 @@ func (e *ExptMangerImpl) CompleteExpt(ctx context.Context, exptID int64, exptRun got, err := e.exptRepo.GetByID(ctx, exptID, spaceID) if err != nil { if se, ok := errorx.FromStatusError(err); ok && se.Code() == errno.ResourceNotFoundCode { + // 实验已被删除。这里**刻意不补释放额度**:拿不到实验就拿不到 + // SchedulerScope / LatestRunID,硬释放只能瞎猜一本账,而猜错会归还 + // 别人的额度(超发,比泄漏严重)。 + // + // 这条路径不再是泄漏点的前提是:**删除自己会释放**(见 MDelete 里的 + // releaseCentralQuotaForIncompleteItems 调用,且它放在软删之前执行)。 + // 谁把那处删掉,"先删实验再收口"就会重新变成永久泄漏 —— + // 而且软删后 ScanSchedulerQueue 带 deleted_at IS NULL,连 full recovery 都扫不到。 logs.CtxInfo(ctx, "[ExptEval] CompleteExpt abort with deleted expt, expt_id: %v", exptID) return nil } diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index cbb637a570..13f64513c7 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -440,6 +440,30 @@ func (e *ExptMangerImpl) MDelete(ctx context.Context, exptIDs []int64, spaceID i return err } + // ★ 删除前必须归还中心调度额度,且**必须在 MDelete 之前**。 + // + // 为什么删除是一条独立的泄漏路径(不能指望别处兜住): + // ① 实验被软删后 consumer 侧 GetByID 拿不到实验、直接退出,那些 item **永不执行**, + // 于是"item 到终态才释放"的出口永远不会被走到; + // ② 删除**不经过** CompleteExpt —— 那里的释放(见 releaseCentralQuotaForIncompleteItems) + // 压根不在这条路径上。而 CompleteExpt 自己对已删实验还有一条 early return, + // 所以"先删再 kill"同样救不回来; + // ③ 软删后 `ScanSchedulerQueue` 带 `deleted_at IS NULL`,**连 full recovery 都扫不到它** —— + // 也就是说这些 reservation 会永久留在账本里,比其它泄漏更彻底。 + // + // 顺序放在删除之前而不是之后:删完再释放的话,中途任何失败都让额度彻底失去归还机会 + // (实验已不可查,`SchedulerScope` / `LatestRunID` 都拿不到了)。 + // 反过来"先释放但删除失败"只是让一个仍存在的实验少占额度,下一拍调度会重新预占,无损。 + // + // best-effort:释放失败只告警不阻断删除 —— 让"额度归还失败"挡住用户删实验, + // 是把一个后台问题升级成前台故障。 + for _, expt := range expts { + if expt == nil { + continue + } + e.releaseCentralQuotaForIncompleteItems(ctx, expt, nil, entity.ExptStatus_Terminated) + } + // 批量删除实验 if err := e.exptRepo.MDelete(ctx, exptIDs, spaceID); err != nil { return err @@ -1767,6 +1791,11 @@ func (e *ExptMangerImpl) Delete(ctx context.Context, exptID, spaceID int64, sess return err } + // ★ 与 MDelete 同理:软删前必须归还中心调度额度,且必须在删除之前。 + // 完整论证见 MDelete 里那段注释(三条独立原因 + 为什么顺序不能反)。 + // 两个删除入口都要改 —— 只改一个等于留一半泄漏。 + e.releaseCentralQuotaForIncompleteItems(ctx, expt, nil, entity.ExptStatus_Terminated) + // 删除实验 if err := e.exptRepo.Delete(ctx, exptID, spaceID); err != nil { return err From 1d453c540f5e57b0fd4758dc6e8a6bdd015fc8c7 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 18:29:04 +0800 Subject: [PATCH 34/58] =?UTF-8?q?feat(evaluation):=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=94=B3=E6=8A=A5=E5=8A=A0=20source=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E8=B5=84=E6=BA=90=E4=B8=8D=E5=90=8C=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E6=98=AF=E4=B8=8D=E5=90=8C=E7=9A=84=E6=B1=A0=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 同一个 `resource_key` 经不同来源拿到的可能是**不同的池子**:同一个模型走 LiteLLM 与走业务方自备通道,配额各自独立。混在一个账本条目里记账会让两边互相挤占。 字段对**所有 category** 一视同仁,不只模型 —— sandbox 也可以区分自建集群与平台池。 ## ★ 空 source 与该字段引入之前**完全等价** 这是它能安全加进已有账本的前提,也是本次改动的核心约束: - 存量 reservation 的 field 名不变 → 释放照样找得到 - 存量 TCC 上限的登记 key 不变 → 照样查得到 - 不申报 source 的调用方零改动 对应的 key 拼法(commercial 侧):空 source 不追加任何分隔符,产出与旧的**字节级 相同**的 key。若图省事写成无条件 `key + "@" + source`,后果是两条静默故障 —— 释放按新 key 找而账本存旧 key(HDEL 扑空 → 额度永久泄漏)、上限按新 key 查而 TCC 登记旧 key(查不到 → 按不受限放行)。 ## 改动 - `ExpectedResourceConsumption.Source`(`omitempty`:不申报时不写空串, 让"没有 source"在数据里可区分、也让存量快照字节形态不变) - `Validate()`:source 不得为 `*`(同 resource_key,通配只允许出现在上限配置里); **去重键带上 source** —— 不带会把"同一模型分来源申报"这个合法形态误判成重复键而拒绝创建, 那恰恰是本字段要支持的用法 - `Normalize()`:source 一并 TrimSpace(它进账本 key,带空白会与上限配置里的同名来源匹配不上) - 两份 thrift 加 `4: optional string source` ## 验证 `go build ./modules/evaluation/...` 零输出;`domain/entity` 全 PASS。 新增 6 条用例:同资源分来源合法、有无 source 算不同键、同来源重复仍判重、 通配拒绝、空 source 合法、Normalize 去空白。 **变异验证**:把去重键改回不带 source → `TestValidate_SourceRules` 三个子用例 FAIL。 convertor 与 `kitex_gen` 生成物在下一个 commit(本 commit 只含手写且可独立编译的部分)。 Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/evaluation/domain/entity/expt.go | 13 ++++ .../domain/entity/expt_quota_consumption.go | 21 ++++++- .../entity/expt_quota_consumption_test.go | 63 +++++++++++++++++++ .../coze/loop/evaluation/domain/expt.thrift | 5 ++ .../domain_openapi/experiment.thrift | 4 ++ 5 files changed, 104 insertions(+), 2 deletions(-) diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index 8ba8bbc8f8..caf44e0a54 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -385,6 +385,19 @@ type ExpectedResourceConsumption struct { Category string `json:"category"` ResourceKey string `json:"resource_key"` Amount int64 `json:"amount"` + // Source 该资源的来源/提供方(如 "litellm"、业务方自定义标识)。**可选**。 + // + // 为什么需要它:同一个 resource_key 经不同来源拿到的可能是**不同的池子** —— + // 同一个模型走 LiteLLM 与走业务方自备通道,配额是各自独立的, + // 混在一个账本条目里记账会让两边互相挤占。 + // + // ★ **空 source 不参与 key、不改变任何既有行为**(见 BuildQuotaConstraintKey 的论证): + // 这是本字段能安全加进已有账本的前提 —— 存量 reservation 与存量上限配置都没有 source, + // 它们的 key 必须保持字节级不变,否则释放会找不到条目、上限会查不到登记。 + // + // omitempty:不申报时不要在冻结进 eval_conf 的 JSON 里写空串 —— + // 让"没有 source"在数据里可区分,也让存量快照的字节形态不变。 + Source string `json:"source,omitempty"` } // ExpectedQuotaConsumption 单 item 的多资源消耗向量。 diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go index c6c22300eb..fc80a2a550 100644 --- a/backend/modules/evaluation/domain/entity/expt_quota_consumption.go +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption.go @@ -22,7 +22,9 @@ const WildcardResourceKey = "*" // - category / resource_key 去空白后非空:空串会污染 constraint key,导致账本 key 冲撞 // - resource_key != "*":见 WildcardResourceKey // - amount > 0:0 或负数会让 maxGrant 的木桶计算失去意义(除零 / 负额度) -// - (category, resource_key) 唯一:重复键在按 constraint key 聚合时会双计,释放时又只释放一份 +// - source 可选,但不得为 "*"(同 resource_key) +// - (category, resource_key, source) 唯一:重复键在按 constraint key 聚合时会双计, +// 释放时又只释放一份。**注意去重键含 source** —— 同一资源分来源申报是合法形态 func (c *ExpectedQuotaConsumption) Validate() error { if c == nil || len(c.Resources) == 0 { return fmt.Errorf("expected_quota_consumption is required and must not be empty") @@ -50,9 +52,22 @@ func (c *ExpectedQuotaConsumption) Validate() error { return fmt.Errorf("expected_quota_consumption.resources[%d].amount must be positive, got %d", i, r.Amount) } + // source 可选。为空表示"不区分来源",此时行为与引入该字段之前完全一致。 + source := strings.TrimSpace(r.Source) + if source == WildcardResourceKey { + // 与 resource_key 同理:通配只允许出现在上限配置里。申报了通配就无从按 key 释放。 + return fmt.Errorf("expected_quota_consumption.resources[%d].source must not be %q", i, WildcardResourceKey) + } + + // ★ 去重键**必须带上 source**:同一 (category, resource_key) 经不同来源是 + // 两个独立的池子(同一模型走 LiteLLM 与走业务方自备通道,配额各自独立), + // 不带 source 会把合法的分来源申报误判成重复键而拒绝创建。 dedupKey := category + "|" + resourceKey + if source != "" { + dedupKey += "@" + source + } if _, dup := seen[dedupKey]; dup { - return fmt.Errorf("expected_quota_consumption has duplicated (category,resource_key): %s", dedupKey) + return fmt.Errorf("expected_quota_consumption has duplicated (category,resource_key,source): %s", dedupKey) } seen[dedupKey] = struct{}{} } @@ -107,6 +122,8 @@ func (c *ExpectedQuotaConsumption) Normalize() *ExpectedQuotaConsumption { Category: strings.TrimSpace(r.Category), ResourceKey: strings.TrimSpace(r.ResourceKey), Amount: r.Amount, + // source 同样要 trim:它进账本 key,带空白会与上限配置里的同名来源匹配不上。 + Source: strings.TrimSpace(r.Source), }) } return normalized diff --git a/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go index f10e5fedcb..3992d4f6d2 100644 --- a/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go +++ b/backend/modules/evaluation/domain/entity/expt_quota_consumption_test.go @@ -213,3 +213,66 @@ func TestExpectedQuotaConsumption_Categories(t *testing.T) { "trim 后同名必须去重 —— 否则 policy 会把同一 category 判两次") }) } + +// --------------------------------------------------------------------------- +// source +// --------------------------------------------------------------------------- + +// TestValidate_SourceRules source 可选、不得为通配、去重键必须含它。 +// +// ★ 去重键含 source 是本组最要紧的一条:同一 (category, resource_key) 经不同来源是 +// **两个独立的池子**(同一模型走 LiteLLM 与走业务方自备通道,配额各自独立)。 +// 去重键不带 source 会把这个合法形态误判成"重复键"而拒绝创建 —— 那正是加该字段要支持的用法。 +func TestValidate_SourceRules(t *testing.T) { + t.Run("同资源分来源申报是合法的", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: "litellm"}, + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: "self-hosted"}, + }} + assert.NoError(t, c.Validate(), "同一模型不同来源是两个池子,不得判成重复键") + }) + + t.Run("有无 source 也算不同键", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6}, + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: "litellm"}, + }} + assert.NoError(t, c.Validate()) + }) + + t.Run("同来源重复仍要拒绝", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: "litellm"}, + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: "litellm"}, + }} + err := c.Validate() + require.Error(t, err, "完全相同的三元组必须判重 —— 否则按 key 聚合会双计、释放只释放一份") + assert.Contains(t, err.Error(), "litellm", "错误信息要能定位到是哪个键重复") + }) + + t.Run("source 不得为通配", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: WildcardResourceKey}, + }} + require.Error(t, c.Validate(), "申报了通配来源就无从按 key 释放") + }) + + t.Run("空 source 合法(等于不区分来源)", func(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6}, + }} + assert.NoError(t, c.Validate()) + }) +} + +// TestNormalize_TrimsSource source 进账本 key,带空白会与上限配置里的同名来源匹配不上。 +func TestNormalize_TrimsSource(t *testing.T) { + c := &ExpectedQuotaConsumption{Resources: []*ExpectedResourceConsumption{ + {Category: " model ", ResourceKey: " kimi-k3 ", Amount: 6, Source: " litellm "}, + }} + got := c.Normalize() + require.Len(t, got.Resources, 1) + assert.Equal(t, "model", got.Resources[0].Category) + assert.Equal(t, "kimi-k3", got.Resources[0].ResourceKey) + assert.Equal(t, "litellm", got.Resources[0].Source) +} diff --git a/idl/thrift/coze/loop/evaluation/domain/expt.thrift b/idl/thrift/coze/loop/evaluation/domain/expt.thrift index 22c2e817a8..10648ef3be 100644 --- a/idl/thrift/coze/loop/evaluation/domain/expt.thrift +++ b/idl/thrift/coze/loop/evaluation/domain/expt.thrift @@ -137,6 +137,11 @@ struct ExpectedResourceConsumption { 1: required string category // 资源类别:sandbox / agent_account / model / evaluator 2: required string resource_key // 具体资源:default / doubao_pro / gpt5.5 ...;不允许传 "*"(通配仅用于上限配置) 3: required i64 amount // 单 item 的预期占用量,必须 > 0 + // 资源来源/提供方(如 "litellm"、业务方自定义标识)。可选。 + // 同一 resource_key 经不同来源可能是不同的池子(同一模型走 LiteLLM 与走业务方 + // 自备通道,配额各自独立),带上它才能分开记账。 + // 不填等于"不区分来源",行为与该字段引入之前完全一致。不允许传 "*"。 + 4: optional string source } struct ExpectedQuotaConsumption { diff --git a/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift b/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift index fc647e0a53..40254cfe82 100644 --- a/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift +++ b/idl/thrift/coze/loop/evaluation/domain_openapi/experiment.thrift @@ -331,6 +331,10 @@ struct ExpectedResourceConsumption { 2: optional string resource_key // 单 item 预期占用量; 单位由服务端 TCC 资源配置定义, 不由调用方指定 3: optional i64 amount (api.js_conv = 'true', go.tag = 'json:"amount"') + // 资源来源/提供方 (如 "litellm"、业务方自定义标识)。可选。 + // 同一 resource_key 经不同来源可能是不同的池子, 带上它才能分开记账。 + // 不填等于"不区分来源", 行为与该字段引入之前完全一致。不允许传 "*"。 + 4: optional string source } // 单 item 的多资源预期消耗向量。 From 9b6ffb4cfa1447fbc18dbeab9c5bdddfc685ea1d Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 18:38:22 +0800 Subject: [PATCH 35/58] =?UTF-8?q?feat(evaluation):=20=E7=94=9F=E6=88=90=20?= =?UTF-8?q?source=20=E5=AD=97=E6=AE=B5=E7=9A=84=20kitex=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=20+=20=E5=9B=9B=E4=B8=AA=E8=BD=AC=E6=8D=A2=E7=82=B9?= =?UTF-8?q?=E6=90=AC=E8=BF=90=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承接上一个 commit(IDL 与 entity 已改,convertor 因缺生成物无法编译)。 ## 生成物范围:恰好 4 个文件、273 行**纯新增**、零删除 跑 `backend/script/cloudwego/kitex_tool.sh`(`NO_PUSH_REMOTE=true`)。该脚本会 `rm -rf kitex_gen` 后全量重生成,所以先确认了生成物基线干净、再逐项核对 diff: kitex_gen/coze/loop/evaluation/domain/expt/expt.go +81 kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go +56 kitex_gen/coze/loop/evaluation/domain_openapi/experiment/*.go +136 全部是 field 4 的读写脚手架(ReadField4 / writeField4 / GetSource / IsSetSource / fastRead / fastWrite)。**没有一行删除、没有触及其它服务** —— 工具链版本与仓库 约定一致(thriftgo v0.4.1 / kitex v0.13.1 / hz v0.9.7 / validator v0.2.6), 不存在版本差异导致的重排。 ## 转换层:四个搬运点都补上 source `expectedQuotaConsumptionDTO2DO` / `DO2DTO`、`ExpectedQuotaConsumptionDomain2OpenAPI` / `OpenAPI2Domain`。四处都是逐字段手写赋值,漏一个的后果极隐蔽 —— 申报方传了来源、 落库被吞掉,于是不同来源的用量记进同一账本条目互相挤占,而接口回显看起来 "没申报过来源"、与调用方真的没传完全一样。 ## 验证 `go build ./...`(**全仓**)零输出;`domain/entity` + `convertor/experiment` 全 PASS。 新增端到端往返用例,覆盖内部 DTO↔DO 与 OpenAPI↔domain 两条链,且刻意包含 「同资源有来源 + 同资源无来源」并存的形态(那正是本字段要支持的用法)。 **变异验证**:把 DTO2DO 那处的 `Source` 赋值删掉 → 用例 FAIL。 用例里用 sandbox 而非 model 验 OpenAPI 那条链,顺带钉住"本字段不是模型专用"。 Co-Authored-By: Claude Opus 5 (1M context) --- .../coze/loop/evaluation/domain/expt/expt.go | 81 +++++++++++++++++++ .../loop/evaluation/domain/expt/k-expt.go | 56 +++++++++++++ .../domain_openapi/experiment/experiment.go | 80 ++++++++++++++++++ .../domain_openapi/experiment/k-experiment.go | 56 +++++++++++++ .../application/convertor/experiment/expt.go | 4 + .../convertor/experiment/openapi.go | 2 + .../experiment/openapi_new_fields_test.go | 49 +++++++++++ 7 files changed, 328 insertions(+) diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go index e882d5828e..364b4a1a37 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/expt.go @@ -2218,6 +2218,11 @@ type ExpectedResourceConsumption struct { ResourceKey string `thrift:"resource_key,2,required" frugal:"2,required,string" form:"resource_key,required" json:"resource_key,required" query:"resource_key,required"` // 单 item 的预期占用量,必须 > 0 Amount int64 `thrift:"amount,3,required" frugal:"3,required,i64" form:"amount,required" json:"amount,required" query:"amount,required"` + // 资源来源/提供方(如 "litellm"、业务方自定义标识)。可选。 + // 同一 resource_key 经不同来源可能是不同的池子(同一模型走 LiteLLM 与走业务方 + // 自备通道,配额各自独立),带上它才能分开记账。 + // 不填等于"不区分来源",行为与该字段引入之前完全一致。不允许传 "*"。 + Source *string `thrift:"source,4,optional" frugal:"4,optional,string" form:"source" json:"source,omitempty" query:"source"` } func NewExpectedResourceConsumption() *ExpectedResourceConsumption { @@ -2247,6 +2252,18 @@ func (p *ExpectedResourceConsumption) GetAmount() (v int64) { } return } + +var ExpectedResourceConsumption_Source_DEFAULT string + +func (p *ExpectedResourceConsumption) GetSource() (v string) { + if p == nil { + return + } + if !p.IsSetSource() { + return ExpectedResourceConsumption_Source_DEFAULT + } + return *p.Source +} func (p *ExpectedResourceConsumption) SetCategory(val string) { p.Category = val } @@ -2256,11 +2273,19 @@ func (p *ExpectedResourceConsumption) SetResourceKey(val string) { func (p *ExpectedResourceConsumption) SetAmount(val int64) { p.Amount = val } +func (p *ExpectedResourceConsumption) SetSource(val *string) { + p.Source = val +} var fieldIDToName_ExpectedResourceConsumption = map[int16]string{ 1: "category", 2: "resource_key", 3: "amount", + 4: "source", +} + +func (p *ExpectedResourceConsumption) IsSetSource() bool { + return p.Source != nil } func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { @@ -2311,6 +2336,14 @@ func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 4: + if fieldTypeId == thrift.STRING { + if err = p.ReadField4(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } default: if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError @@ -2389,6 +2422,17 @@ func (p *ExpectedResourceConsumption) ReadField3(iprot thrift.TProtocol) error { p.Amount = _field return nil } +func (p *ExpectedResourceConsumption) ReadField4(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.Source = _field + return nil +} func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) { var fieldId int16 @@ -2408,6 +2452,10 @@ func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) fieldId = 3 goto WriteFieldError } + if err = p.writeField4(oprot); err != nil { + fieldId = 4 + goto WriteFieldError + } } if err = oprot.WriteFieldStop(); err != nil { goto WriteFieldStopError @@ -2474,6 +2522,24 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 3 end error: ", p), err) } +func (p *ExpectedResourceConsumption) writeField4(oprot thrift.TProtocol) (err error) { + if p.IsSetSource() { + if err = oprot.WriteFieldBegin("source", thrift.STRING, 4); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.Source); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 4 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 4 end error: ", p), err) +} func (p *ExpectedResourceConsumption) String() string { if p == nil { @@ -2498,6 +2564,9 @@ func (p *ExpectedResourceConsumption) DeepEqual(ano *ExpectedResourceConsumption if !p.Field3DeepEqual(ano.Amount) { return false } + if !p.Field4DeepEqual(ano.Source) { + return false + } return true } @@ -2522,6 +2591,18 @@ func (p *ExpectedResourceConsumption) Field3DeepEqual(src int64) bool { } return true } +func (p *ExpectedResourceConsumption) Field4DeepEqual(src *string) bool { + + if p.Source == src { + return true + } else if p.Source == nil || src == nil { + return false + } + if strings.Compare(*p.Source, *src) != 0 { + return false + } + return true +} type ExpectedQuotaConsumption struct { Resources []*ExpectedResourceConsumption `thrift:"resources,1,required" frugal:"1,required,list" form:"resources,required" json:"resources,required" query:"resources,required"` diff --git a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go index 6043e809e3..5bd3d48b46 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain/expt/k-expt.go @@ -724,6 +724,20 @@ func (p *ExpectedResourceConsumption) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 4: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField4(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } default: l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) offset += l @@ -800,6 +814,20 @@ func (p *ExpectedResourceConsumption) FastReadField3(buf []byte) (int, error) { return offset, nil } +func (p *ExpectedResourceConsumption) FastReadField4(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.Source = _field + return offset, nil +} + func (p *ExpectedResourceConsumption) FastWrite(buf []byte) int { return p.FastWriteNocopy(buf, nil) } @@ -810,6 +838,7 @@ func (p *ExpectedResourceConsumption) FastWriteNocopy(buf []byte, w thrift.Nocop offset += p.fastWriteField3(buf[offset:], w) offset += p.fastWriteField1(buf[offset:], w) offset += p.fastWriteField2(buf[offset:], w) + offset += p.fastWriteField4(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) return offset @@ -821,6 +850,7 @@ func (p *ExpectedResourceConsumption) BLength() int { l += p.field1Length() l += p.field2Length() l += p.field3Length() + l += p.field4Length() } l += thrift.Binary.FieldStopLength() return l @@ -847,6 +877,15 @@ func (p *ExpectedResourceConsumption) fastWriteField3(buf []byte, w thrift.Nocop return offset } +func (p *ExpectedResourceConsumption) fastWriteField4(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSource() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 4) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.Source) + } + return offset +} + func (p *ExpectedResourceConsumption) field1Length() int { l := 0 l += thrift.Binary.FieldBeginLength() @@ -868,6 +907,15 @@ func (p *ExpectedResourceConsumption) field3Length() int { return l } +func (p *ExpectedResourceConsumption) field4Length() int { + l := 0 + if p.IsSetSource() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.Source) + } + return l +} + func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { src, ok := s.(*ExpectedResourceConsumption) if !ok { @@ -884,6 +932,14 @@ func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { p.Amount = src.Amount + if src.Source != nil { + var tmp string + if *src.Source != "" { + tmp = kutils.StringDeepCopy(*src.Source) + } + p.Source = &tmp + } + return nil } diff --git a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go index cda517c2a5..5a2a6ed953 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/experiment.go @@ -10056,6 +10056,10 @@ type ExpectedResourceConsumption struct { ResourceKey *string `thrift:"resource_key,2,optional" frugal:"2,optional,string" form:"resource_key" json:"resource_key,omitempty" query:"resource_key"` // 单 item 预期占用量; 单位由服务端 TCC 资源配置定义, 不由调用方指定 Amount *int64 `thrift:"amount,3,optional" frugal:"3,optional,i64" json:"amount" form:"amount" query:"amount"` + // 资源来源/提供方 (如 "litellm"、业务方自定义标识)。可选。 + // 同一 resource_key 经不同来源可能是不同的池子, 带上它才能分开记账。 + // 不填等于"不区分来源", 行为与该字段引入之前完全一致。不允许传 "*"。 + Source *string `thrift:"source,4,optional" frugal:"4,optional,string" form:"source" json:"source,omitempty" query:"source"` } func NewExpectedResourceConsumption() *ExpectedResourceConsumption { @@ -10100,6 +10104,18 @@ func (p *ExpectedResourceConsumption) GetAmount() (v int64) { } return *p.Amount } + +var ExpectedResourceConsumption_Source_DEFAULT string + +func (p *ExpectedResourceConsumption) GetSource() (v string) { + if p == nil { + return + } + if !p.IsSetSource() { + return ExpectedResourceConsumption_Source_DEFAULT + } + return *p.Source +} func (p *ExpectedResourceConsumption) SetCategory(val *string) { p.Category = val } @@ -10109,11 +10125,15 @@ func (p *ExpectedResourceConsumption) SetResourceKey(val *string) { func (p *ExpectedResourceConsumption) SetAmount(val *int64) { p.Amount = val } +func (p *ExpectedResourceConsumption) SetSource(val *string) { + p.Source = val +} var fieldIDToName_ExpectedResourceConsumption = map[int16]string{ 1: "category", 2: "resource_key", 3: "amount", + 4: "source", } func (p *ExpectedResourceConsumption) IsSetCategory() bool { @@ -10128,6 +10148,10 @@ func (p *ExpectedResourceConsumption) IsSetAmount() bool { return p.Amount != nil } +func (p *ExpectedResourceConsumption) IsSetSource() bool { + return p.Source != nil +} + func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { var fieldTypeId thrift.TType var fieldId int16 @@ -10170,6 +10194,14 @@ func (p *ExpectedResourceConsumption) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 4: + if fieldTypeId == thrift.STRING { + if err = p.ReadField4(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } default: if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError @@ -10232,6 +10264,17 @@ func (p *ExpectedResourceConsumption) ReadField3(iprot thrift.TProtocol) error { p.Amount = _field return nil } +func (p *ExpectedResourceConsumption) ReadField4(iprot thrift.TProtocol) error { + + var _field *string + if v, err := iprot.ReadString(); err != nil { + return err + } else { + _field = &v + } + p.Source = _field + return nil +} func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) { var fieldId int16 @@ -10251,6 +10294,10 @@ func (p *ExpectedResourceConsumption) Write(oprot thrift.TProtocol) (err error) fieldId = 3 goto WriteFieldError } + if err = p.writeField4(oprot); err != nil { + fieldId = 4 + goto WriteFieldError + } } if err = oprot.WriteFieldStop(); err != nil { goto WriteFieldStopError @@ -10323,6 +10370,24 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 3 end error: ", p), err) } +func (p *ExpectedResourceConsumption) writeField4(oprot thrift.TProtocol) (err error) { + if p.IsSetSource() { + if err = oprot.WriteFieldBegin("source", thrift.STRING, 4); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteString(*p.Source); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 4 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 4 end error: ", p), err) +} func (p *ExpectedResourceConsumption) String() string { if p == nil { @@ -10347,6 +10412,9 @@ func (p *ExpectedResourceConsumption) DeepEqual(ano *ExpectedResourceConsumption if !p.Field3DeepEqual(ano.Amount) { return false } + if !p.Field4DeepEqual(ano.Source) { + return false + } return true } @@ -10386,6 +10454,18 @@ func (p *ExpectedResourceConsumption) Field3DeepEqual(src *int64) bool { } return true } +func (p *ExpectedResourceConsumption) Field4DeepEqual(src *string) bool { + + if p.Source == src { + return true + } else if p.Source == nil || src == nil { + return false + } + if strings.Compare(*p.Source, *src) != 0 { + return false + } + return true +} // 单 item 的多资源预期消耗向量。 type ExpectedQuotaConsumption struct { diff --git a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go index 82d6d74ca2..8e457b3c23 100644 --- a/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go +++ b/backend/kitex_gen/coze/loop/evaluation/domain_openapi/experiment/k-experiment.go @@ -7135,6 +7135,20 @@ func (p *ExpectedResourceConsumption) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 4: + if fieldTypeId == thrift.STRING { + l, err = p.FastReadField4(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } default: l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) offset += l @@ -7195,6 +7209,20 @@ func (p *ExpectedResourceConsumption) FastReadField3(buf []byte) (int, error) { return offset, nil } +func (p *ExpectedResourceConsumption) FastReadField4(buf []byte) (int, error) { + offset := 0 + + var _field *string + if v, l, err := thrift.Binary.ReadString(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.Source = _field + return offset, nil +} + func (p *ExpectedResourceConsumption) FastWrite(buf []byte) int { return p.FastWriteNocopy(buf, nil) } @@ -7205,6 +7233,7 @@ func (p *ExpectedResourceConsumption) FastWriteNocopy(buf []byte, w thrift.Nocop offset += p.fastWriteField3(buf[offset:], w) offset += p.fastWriteField1(buf[offset:], w) offset += p.fastWriteField2(buf[offset:], w) + offset += p.fastWriteField4(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) return offset @@ -7216,6 +7245,7 @@ func (p *ExpectedResourceConsumption) BLength() int { l += p.field1Length() l += p.field2Length() l += p.field3Length() + l += p.field4Length() } l += thrift.Binary.FieldStopLength() return l @@ -7248,6 +7278,15 @@ func (p *ExpectedResourceConsumption) fastWriteField3(buf []byte, w thrift.Nocop return offset } +func (p *ExpectedResourceConsumption) fastWriteField4(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetSource() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRING, 4) + offset += thrift.Binary.WriteStringNocopy(buf[offset:], w, *p.Source) + } + return offset +} + func (p *ExpectedResourceConsumption) field1Length() int { l := 0 if p.IsSetCategory() { @@ -7275,6 +7314,15 @@ func (p *ExpectedResourceConsumption) field3Length() int { return l } +func (p *ExpectedResourceConsumption) field4Length() int { + l := 0 + if p.IsSetSource() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.StringLengthNocopy(*p.Source) + } + return l +} + func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { src, ok := s.(*ExpectedResourceConsumption) if !ok { @@ -7302,6 +7350,14 @@ func (p *ExpectedResourceConsumption) DeepCopy(s interface{}) error { p.Amount = &tmp } + if src.Source != nil { + var tmp string + if *src.Source != "" { + tmp = kutils.StringDeepCopy(*src.Source) + } + p.Source = &tmp + } + return nil } diff --git a/backend/modules/evaluation/application/convertor/experiment/expt.go b/backend/modules/evaluation/application/convertor/experiment/expt.go index 3fc0ce8b1d..7afaf8a789 100644 --- a/backend/modules/evaluation/application/convertor/experiment/expt.go +++ b/backend/modules/evaluation/application/convertor/experiment/expt.go @@ -1574,6 +1574,7 @@ func expectedQuotaConsumptionDTO2DO(dto *domain_expt.ExpectedQuotaConsumption) * Category: r.GetCategory(), ResourceKey: r.GetResourceKey(), Amount: r.GetAmount(), + Source: r.GetSource(), }) } if len(resources) == 0 { @@ -1602,6 +1603,9 @@ func expectedQuotaConsumptionDO2DTO(do *entity.ExpectedQuotaConsumption) *domain Category: r.Category, ResourceKey: r.ResourceKey, Amount: r.Amount, + // 空 source 序列化时省略(IDL optional + entity omitempty), + // 让"没申报来源"与"申报了空来源"在回显里不可混淆。 + Source: gptr.Of(r.Source), }) } if len(resources) == 0 { diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi.go b/backend/modules/evaluation/application/convertor/experiment/openapi.go index e87457e3f5..ef9dd9babb 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi.go @@ -3283,6 +3283,7 @@ func ExpectedQuotaConsumptionDomain2OpenAPI(c *domainExpt.ExpectedQuotaConsumpti Category: gptr.Of(r.GetCategory()), ResourceKey: gptr.Of(r.GetResourceKey()), Amount: gptr.Of(r.GetAmount()), + Source: gptr.Of(r.GetSource()), }) } if len(resources) == 0 { @@ -3312,6 +3313,7 @@ func ExpectedQuotaConsumptionOpenAPI2Domain(c *openapiExperiment.ExpectedQuotaCo Category: r.GetCategory(), ResourceKey: r.GetResourceKey(), Amount: r.GetAmount(), + Source: gptr.Of(r.GetSource()), }) } if len(resources) == 0 { diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go index 7d9faddd2b..04c8715fac 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go @@ -795,3 +795,52 @@ func TestExpectedQuotaConsumptionDomain2OpenAPI(t *testing.T) { assert.Equal(t, "evaluator", got.GetResources()[0].GetCategory()) assert.Equal(t, int64(3), got.GetResources()[0].GetAmount()) } + +// TestExpectedQuotaConsumption_SourceSurvivesRoundTrip +// ★ source 必须在四个转换点上都不丢。 +// +// 为什么值得端到端钉:转换层有**四个**独立的搬运点(内部 DTO↔DO 两个、OpenAPI↔domain 两个), +// 每个都是逐字段手写赋值。漏掉 source 的后果极隐蔽 —— 申报方明明传了来源, +// 落库时被吞掉,于是不同来源的用量记进同一个账本条目、互相挤占, +// 而接口回显看起来"没申报过来源",与调用方真的没传完全一样。 +func TestExpectedQuotaConsumption_SourceSurvivesRoundTrip(t *testing.T) { + t.Run("内部 DTO→DO→DTO", func(t *testing.T) { + dto := &domainExpt.ExpectedQuotaConsumption{ + Resources: []*domainExpt.ExpectedResourceConsumption{ + {Category: "model", ResourceKey: "kimi-k3", Amount: 6, Source: gptr.Of("litellm")}, + {Category: "model", ResourceKey: "kimi-k3", Amount: 6}, // 同资源、无来源 + }, + } + + do := expectedQuotaConsumptionDTO2DO(dto) + if assert.NotNil(t, do) && assert.Len(t, do.Resources, 2) { + assert.Equal(t, "litellm", do.Resources[0].Source, "source 必须搬进 DO") + assert.Empty(t, do.Resources[1].Source, "没申报来源时 DO 侧应为空串,不得凭空造值") + } + + back := expectedQuotaConsumptionDO2DTO(do) + if assert.NotNil(t, back) && assert.Len(t, back.Resources, 2) { + assert.Equal(t, "litellm", back.Resources[0].GetSource(), "source 必须回显") + assert.Empty(t, back.Resources[1].GetSource()) + } + }) + + t.Run("OpenAPI↔domain 双向", func(t *testing.T) { + domainDTO := &domainExpt.ExpectedQuotaConsumption{ + Resources: []*domainExpt.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "mac", Amount: 1, Source: gptr.Of("self-hosted")}, + }, + } + + openapiDTO := ExpectedQuotaConsumptionDomain2OpenAPI(domainDTO) + if assert.NotNil(t, openapiDTO) && assert.Len(t, openapiDTO.GetResources(), 1) { + assert.Equal(t, "self-hosted", openapiDTO.GetResources()[0].GetSource(), + "source 必须搬到 OpenAPI DTO —— sandbox 也能有来源,本字段不是模型专用") + } + + back := ExpectedQuotaConsumptionOpenAPI2Domain(openapiDTO) + if assert.NotNil(t, back) && assert.Len(t, back.GetResources(), 1) { + assert.Equal(t, "self-hosted", back.GetResources()[0].GetSource()) + } + }) +} From 60c2d3781896ab128648661597bee4c8cf3455f6 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 20:33:46 +0800 Subject: [PATCH 36/58] =?UTF-8?q?style(evaluation):=20gofmt=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=A1=A5=E4=B8=8A=E5=9B=9B=E5=A4=84=E5=9B=A0?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B3=A8=E9=87=8A=E8=80=8C=E5=A4=B1=E6=95=88?= =?UTF-8?q?=E7=9A=84=E5=AF=B9=E9=BD=90=E4=B8=8E=E7=BC=A9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../evaluation/application/eval_openapi_app.go | 14 +++++++------- .../entity/expt_scheduling_privilege_test.go | 1 - .../service/expt_complete_quota_release_test.go | 6 ++++-- .../domain/service/expt_manage_execution_impl.go | 1 + 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/modules/evaluation/application/eval_openapi_app.go b/backend/modules/evaluation/application/eval_openapi_app.go index 6240dd1b9d..9f4891a444 100644 --- a/backend/modules/evaluation/application/eval_openapi_app.go +++ b/backend/modules/evaluation/application/eval_openapi_app.go @@ -1778,13 +1778,13 @@ func (e *EvalOpenAPIApplication) SubmitExperimentOApi(ctx context.Context, req * } createReq := &exptpb.SubmitExperimentRequest{ - WorkspaceID: req.GetWorkspaceID(), - Name: req.Name, - Desc: req.Description, - TargetFieldMapping: experiment_convertor.OpenAPITargetFieldMappingDTO2Domain(req.TargetFieldMapping), - ItemConcurNum: req.ItemConcurNum, - TargetRuntimeParam: experiment_convertor.OpenAPIRuntimeParamDTO2Domain(req.TargetRuntimeParam), - ItemRetryNum: req.ItemRetryNum, + WorkspaceID: req.GetWorkspaceID(), + Name: req.Name, + Desc: req.Description, + TargetFieldMapping: experiment_convertor.OpenAPITargetFieldMappingDTO2Domain(req.TargetFieldMapping), + ItemConcurNum: req.ItemConcurNum, + TargetRuntimeParam: experiment_convertor.OpenAPIRuntimeParamDTO2Domain(req.TargetRuntimeParam), + ItemRetryNum: req.ItemRetryNum, // trigger_type 允许调用方申报(目前只有 "evalx" 有实际效果:进入中心调度); // 未传时按 openapi 处理,与改动前一致。 // diff --git a/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go index 285a404a96..62a97a9e9e 100644 --- a/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go +++ b/backend/modules/evaluation/domain/entity/expt_scheduling_privilege_test.go @@ -115,4 +115,3 @@ func TestExptSchedulingPrivilegeWhiteList_UnmarshalFullConfig(t *testing.T) { assert.True(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{CallerPSM: "stone.cozeloop.evalx"})) assert.False(t, w.AllowSchedulingPrivilege(ExptSchedulingPrivilegeSubject{UserEmail: "other@bytedance.com", SpaceID: 999})) } - diff --git a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go index bc4f3dc8d0..fe960b06a0 100644 --- a/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_complete_quota_release_test.go @@ -103,8 +103,10 @@ func TestReleaseCentralQuotaForIncompleteItems_ReleasesOnTerminal(t *testing.T) // // 旧实现用 GetIncompleteTurns(只收 turn_status ∈ {Queueing, Processing})反推待释放 item, // 于是这一格拿到空列表、一条都不释放。而此时 Redis 侧 state 已是 running: -// reap 只处理 reserved;对账的 isReleasableWithoutEvidence 刻意排除 running; -// zombie 只扫 Processing,但实验已终态、daemon 不再跳。 +// +// reap 只处理 reserved;对账的 isReleasableWithoutEvidence 刻意排除 running; +// zombie 只扫 Processing,但实验已终态、daemon 不再跳。 +// // 三条兜底全不接 ⇒ 永久泄漏。 // // 这条用例的构造刻意让 **turn 侧完全没有可用信息**(GetIncompleteTurns 若被调用会回空), diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index 73bfa6f70e..1f7a94f6f8 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -944,6 +944,7 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( // 口径就是 `!IsItemRunFinished(status)`,即 Queueing ∪ Processing: // - Queueing:已预占未开跑,或已投递但 consumer 没接管 // - Processing:consumer 接管过(Redis 侧已是 running),但执行可能已经死了 +// // 两者的额度都还挂在账本上,实验既已终态、它们都不会再被执行,必须归还。 // // 终态(Success/Fail/Terminal)刻意排除:它们的额度已由 consumer 侧释放点归还过, From 0d2957eb575cbb5af67b4b6ba1689a81a95b4657 Mon Sep 17 00:00:00 2001 From: xueyizheng <@bytedance.com> Date: Mon, 24 Aug 2026 21:51:42 +0800 Subject: [PATCH 37/58] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E7=9A=84required=EF=BC=8C=E9=99=A4=E9=9D=9E=E7=89=B9?= =?UTF-8?q?=E6=AE=8A=E6=83=85=E5=86=B5=EF=BC=8C=E5=90=A6=E5=88=99=E9=83=BD?= =?UTF-8?q?=E4=BD=BF=E7=94=A8option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- idl/thrift/coze/loop/evaluation/domain/expt.thrift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/idl/thrift/coze/loop/evaluation/domain/expt.thrift b/idl/thrift/coze/loop/evaluation/domain/expt.thrift index 10648ef3be..44afb63e46 100644 --- a/idl/thrift/coze/loop/evaluation/domain/expt.thrift +++ b/idl/thrift/coze/loop/evaluation/domain/expt.thrift @@ -134,9 +134,9 @@ const ExptTriggerType Evalx = "evalx" // 由创建方在 Create/Submit 时申报,服务端冻结进 eval_conf,供中心调度器预占额度使用。 // category/resource_key 语义与额度上限配置一致;amount 的单位由上限配置的 unit 定义,调用方不传 unit。 struct ExpectedResourceConsumption { - 1: required string category // 资源类别:sandbox / agent_account / model / evaluator - 2: required string resource_key // 具体资源:default / doubao_pro / gpt5.5 ...;不允许传 "*"(通配仅用于上限配置) - 3: required i64 amount // 单 item 的预期占用量,必须 > 0 + 1: optional string category // 资源类别:sandbox / agent_account / model / evaluator + 2: optional string resource_key // 具体资源:default / doubao_pro / gpt5.5 ...;不允许传 "*"(通配仅用于上限配置) + 3: optional i64 amount // 单 item 的预期占用量,必须 > 0 // 资源来源/提供方(如 "litellm"、业务方自定义标识)。可选。 // 同一 resource_key 经不同来源可能是不同的池子(同一模型走 LiteLLM 与走业务方 // 自备通道,配额各自独立),带上它才能分开记账。 @@ -145,7 +145,7 @@ struct ExpectedResourceConsumption { } struct ExpectedQuotaConsumption { - 1: required list resources + 1: optional list resources } struct Experiment { From b73ecf74022dc1cbef1a303947f4d5ea5a8c7bc0 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 24 Aug 2026 23:26:05 +0800 Subject: [PATCH 38/58] =?UTF-8?q?fix(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=8F=96=E5=BE=97=E6=89=A7=E8=A1=8C=E6=9D=83?= =?UTF-8?q?=E6=97=B6=E8=A1=A5=E8=AE=B0=20expt=5Fstats=EF=BC=8C=E6=AD=A2?= =?UTF-8?q?=E4=BD=8F=E8=AE=A1=E6=95=B0=E5=8D=95=E5=90=91=E4=B8=8B=E6=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPE 长跑压测实测:enforce 实验的 processing_turn_count **走负数** (一个 14 题实验 fail 累到 4 时 processing = -4),pending_turn_count 恒不下降。 legacy 对照实验计数正常,所以只影响 enforce。 ## 根因 完成侧(expt_result_impl.go:175-177)做的是「从 item 原状态减 1、往新状态加 1」, 减的是 Processing 桶。两条链路谁往那个桶里加过: legacy handleToSubmits 派发时 ArithOperateCount{Processing:+n, Queueing:-n} ← 加了 enforce 派发只 ClaimQuotaReserved(Queueing/none → Queueing/**reserved**) ← 没加 enforce 的 status 全程是 Queueing,真正翻成 Processing 的是 consumer 侧的 StartReservedItem,而那里只写了 run log 与主表,没记 stats。 于是完成时**减一个从未被加过的计数** —— 单向下溢,而不是"少加了一次"。 ## 修在哪 补在 StartReservedItem 成功之后,与已有的主表推进并列 —— 那里才是 「item 真正进入 Processing」的时刻。派发侧不是修复点:那时 status 还是 Queueing, 在派发侧加会让"已预占未消费"的 item 被计成 Processing,与投影口径不一致。 ★ 必须绑定 started(CAS 真的翻了状态)。started=false 是重复投递或已被 repair 修正, 此时 item 早已计入 Processing,再加一次就从"少计"变成"多计" —— CAS 结果是这条路径上唯一的"恰好一次"信号。 失败只告警不阻断,与相邻的主表推进同策:stats 是展示投影, 返回错误会让已取得执行权的 item 被 MQ 重投一遍。 ## 测试 两个用例互为反面,缺一个就有漏网的改法: - AdvancesStatsToProcessing —— 断言 op 的**内容**(Processing:+1 且 Queueing:-1)。 只断言"调过"的话,只加不减也能通过,而那样 pending 依旧不降。 - SkipsStatsOnDuplicateDelivery —— started=false 时 Times(0)。 没有它,把记账写在 started 判断之外也能全绿。 反向变异 3 个全部被检出: ① 去掉 `started &&` → 重复投递用例 FAIL ② 去掉 `Queueing: -1` → 内容断言 FAIL(expected -1, actual 0) ③ 方向写反 → 内容断言 FAIL(两项都反) `go test ./modules/evaluation/domain/service/` 全绿(226s)。 ## 备注 与团队记忆 mem-20260820-new-dispatch-path-missing-enum-field 同族: 新增派发路径时漏掉旧路径的副作用。那次漏 ExptRunMode 字段,这次漏 stats 记账, 同一文件里还有人修过同族的"漏写主表"。**平行实现漏副作用**已出现三次, 共性是老路径把多件事做在一处,新路径只搬了其中一件。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_central_quota_release_test.go | 110 ++++++++++++++++++ .../service/expt_run_item_event_impl.go | 26 +++++ 2 files changed, 136 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go index 65b16f6c58..66f7dd98bf 100644 --- a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go @@ -543,3 +543,113 @@ func TestHandleCentralReservation_AdvancesMainTableForDisplay(t *testing.T) { assert.Equal(t, int32(entity.ItemRunState_Processing), gotFields["status"], "主表状态必须是 Processing,与 run log 一致") } + +// TestHandleCentralReservation_AdvancesStatsToProcessing 中心调度取得执行权后, +// **expt_stats 也必须记 Queueing→Processing**,否则计数会单向下溢。 +// +// ★ 这条守的是一个真实缺陷(2026-08-24 PPE 长跑压测实测): +// 完成侧(expt_result_impl.go 的 statsCntOp)做的是「从 item 原状态减 1、往新状态加 1」, +// 减的是 Processing 桶。legacy 在 handleToSubmits 派发时就把 item 计入 Processing,两边配对; +// 中心调度的派发只把 run log CAS 成 Queueing/reserved(**status 仍是 Queueing**), +// 从未有人往 Processing 桶加过 —— 完成时减一个从未加过的计数。 +// 实测一个 14 题 enforce 实验:fail 累到 4 时 processing = -4,pending 恒 14 从不下降。 +// +// 断言 op 的内容而非"调过就行":只加 Processing 不减 Queueing,pending 依旧不降。 +func TestHandleCentralReservation_AdvancesStatsToProcessing(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + guard := &fakeGuard{confirmResult: true} + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Success)}, + }, nil) + + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).AnyTimes() + + var gotOp *entity.StatsCntArithOp + statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) + statsRepo.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, op *entity.StatsCntArithOp) error { + gotOp = op + return nil + }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: guard, + dispatchRepo: dispatchRepo, + exptItemResultRepo: itemResultRepo, + exptStatsRepo: statsRepo, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + + handler := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return nil + }) + assert.NoError(t, handler(ctx, event)) + + assert.NotNil(t, gotOp, "必须记 stats —— 不记会让完成侧减一个从未加过的计数") + assert.Equal(t, 1, gotOp.OpStatusCnt[entity.ItemRunState_Processing], + "Processing 必须 +1,与完成侧的 -1 配对") + assert.Equal(t, -1, gotOp.OpStatusCnt[entity.ItemRunState_Queueing], + "Queueing 必须 -1,否则 pending_turn_count 永不下降") +} + +// TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery 重复投递时**不得**再记 stats。 +// +// started=false 表示 CAS 未命中 —— item 早已是 Processing(重复投递)或已被 repair 修正。 +// 此时再加一次就从"少计"变成"多计",方向相反但同样是错的。 +// CAS 结果是这条路径上唯一的"恰好一次"信号,所以记账必须绑定它。 +// +// ★ 这条是上一个用例的反面:只断言"会记",把记账写在 started 判断之外也能通过。 +func TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + guard := &fakeGuard{confirmResult: true} + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + // CAS 未命中:重复投递或已被 repair 修正 + dispatchRepo.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Success)}, + }, nil) + + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).AnyTimes() + + statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) + // Times(0):一次都不能调 —— 这是本用例的全部意义 + statsRepo.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).Times(0) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: guard, + dispatchRepo: dispatchRepo, + exptItemResultRepo: itemResultRepo, + exptStatsRepo: statsRepo, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + + handler := svc.HandleCentralReservation(func(ctx context.Context, event *entity.ExptItemEvalEvent) error { + return nil + }) + assert.NoError(t, handler(ctx, event)) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 2b1ba5afa0..83a9270dde 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -340,6 +340,32 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE event.ExptID, event.EvalSetItemID, err) } } + + // expt_stats 的 Queueing→Processing 也要在此记账,否则计数单向下溢。 + // + // 完成侧(expt_result_impl.go 的 statsCntOp)做的是「从 item 原状态减 1、往新状态加 1」, + // 它减的是 Processing 桶。legacy 在 handleToSubmits 里派发时就把 item 计入了 Processing, + // 两边配对;中心调度的派发只把 run log CAS 成 Queueing/reserved(**status 仍是 Queueing**), + // 从未有人往 Processing 桶加过 —— 于是完成时减一个从未加过的计数, + // 表现为 processing_turn_count 走负、pending_turn_count 永不下降。 + // 实测:一个 14 题 enforce 实验 fail 累到 4 时 processing = -4,pending 恒 14。 + // + // ★ 必须绑定 started(CAS 真的把 Queueing/reserved 翻成了 Processing/none)。 + // started=false 是重复投递或已被 repair 修正,此时 item 早已计入 Processing, + // 再加一次就从"少计"变成"多计"——CAS 是这里唯一的恰好一次信号。 + // + // 与主表同为展示投影,失败只告警不阻断:返回错误会让已取得执行权的 item 被 MQ 重投。 + if started && e.exptStatsRepo != nil { + if err := e.exptStatsRepo.ArithOperateCount(ctx, event.ExptID, event.SpaceID, &entity.StatsCntArithOp{ + OpStatusCnt: map[entity.ItemRunState]int{ + entity.ItemRunState_Processing: 1, + entity.ItemRunState_Queueing: -1, + }, + }); err != nil { + logs.CtxWarn(ctx, "[CentralReservation] advance expt stats to Processing failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, err) + } + } } // 执行链返回后释放额度:这是 consumer 侧的主释放点。 From 8d6f08f53a64cf2a08833c9d6cd49506980dd34a Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 25 Aug 2026 15:23:26 +0800 Subject: [PATCH 39/58] =?UTF-8?q?fix(evaluation):=20=E4=B8=A4=E6=9D=A1=20d?= =?UTF-8?q?aemon=20=E7=BB=88=E6=80=81=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=8A=A2=E5=85=88=E5=86=99=E4=B8=BB=E8=A1=A8=20status=EF=BC=8C?= =?UTF-8?q?=E6=AD=A2=E4=BD=8F=20expt=5Fstats=20=E5=87=80=E9=9B=B6=E8=92=B8?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleZombies / sweepTerminatedSandboxItems 判定 item 终态后,除了写 run log 还抢先把主表 expt_item_result.status 改成 Fail。而随后 RecordItemRunLogs 算 expt_stats 增量用的是「主表旧值 -1 / run log 新值 +1」的差分 —— 两边都读到 Fail 就算成净零,该 item 从计数上凭空蒸发(各桶总和 < 总行数)。 去掉这两处的 status 键,err_msg 保留原地写入(#602 要的用户可见超时原因不受影响)。 主表 status 由 RecordItemRunLogs 统一落,run log 已是 Fail,最终态不变, 只是晚一个 tick 内的间隔(约 2.5s)可见 —— 这个代价已确认可接受。 回归来源:a2f11f2a(#602) 为带 err_msg 新增主表写入时顺手带上了 status; fc6c6ff5(#606) 照抄了这个形状,把同一个 bug 复制到 sandbox sweep 路径。 顺带修一处失真注释:原注释称顺序是为「避免额度已放但 item 仍显示 Processing 被下一拍读到而重复授予」,但调度侧 LoadDispatchRuntime 读的是 run log 且候选 必须 status=Queueing,Processing 的 item 永远进不了候选 —— 该机制不可达, 方向也相反(少派而非超发)。改为真实理由:先落 run log 的 Fail 再放额度, 才不会留下「额度已归还、run log 仍算占用」的窗口。 测试侧两处 UpdateItemsResult 的 gomock.Any() 换成捕获实参、断言 status 键不存在 —— 原来用 Any() 意味着删/加这个键测试都不会红,正是 #602 能悄悄溜进来的原因。 两个反向变异(分别把 status 加回两条路径)均被检出。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/service/expt_run_scheduler_event_impl.go | 7 ++++--- .../service/expt_run_scheduler_event_impl_test.go | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go index 5f01db9a3b..9822f53bd3 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go @@ -772,8 +772,9 @@ func (e *ExptSchedulerImpl) handleZombies(ctx context.Context, event *entity.Exp return nil, nil, err } - // item 已落终态 → 释放其额度预占。放在状态写库之后:先确保终态可见, - // 再释放额度,避免"额度已放但 item 仍显示 Processing"这一瞬间被下一拍读到而重复授予。 + // item 已落终态 → 释放其额度预占。放在 run log 写库之后:调度侧判占用读的是 + // run log(LoadDispatchRuntime 按 status IN (Queueing, Processing) 扫), + // 先落 Fail 再放额度,才不会留下"额度已归还、run log 仍算占用"的窗口。 e.releaseCentralQuotaForItems(ctx, expt, event.ExptRunID, zombieItemIDs, "item zombie timeout") // 不清 run_log 的 target_result_id / evaluator_result_ids: @@ -977,7 +978,7 @@ func (e *ExptSchedulerImpl) sweepTerminatedSandboxItems(ctx context.Context, eve false, ) - // 与 handleZombies 一致的写库形状,保证 UI (MGetExperimentResult) 拿到一致的 err_msg。 + // 与 handleZombies 一致的写库形状(含只写 err_msg 不写 status 的理由,见那边注释)。 errBytes := []byte(errno.SerializeErr(errno.NewSandboxTerminatedBeforeReportErr(firstStatus))) if err := e.ExptItemResultRepo.UpdateItemRunLog(ctx, event.ExptID, event.ExptRunID, terminatedItemIDs, map[string]any{ diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go index 69c0d9730e..b60f3d4610 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go @@ -2483,7 +2483,15 @@ func TestExptSchedulerImpl_sweepTerminatedSandboxItems_CrossSpace(t *testing.T) ).Times(1) // 消费方侧写库仍然用 event.SpaceID=3 mockItemRepo.EXPECT().UpdateItemRunLog(gomock.Any(), int64(1), int64(2), []int64{10}, gomock.Any(), int64(3)).Return(nil) - mockItemRepo.EXPECT().UpdateItemsResult(gomock.Any(), int64(3), int64(1), []int64{10}, gomock.Any()).Return(nil) + // ★ 与 handleZombies 同一条不变量:主表只写 err_msg,status 归 RecordItemRunLogs。 + // 本处曾照抄 handleZombies 的写库形状(连 status 一起抄),把同一个净零 bug 复制了一份。 + mockItemRepo.EXPECT().UpdateItemsResult(gomock.Any(), int64(3), int64(1), []int64{10}, gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, _ []int64, ufields map[string]any) error { + _, hasStatus := ufields["status"] + assert.False(t, hasStatus, "sandbox sweep 路径不得抢先写主表 status") + assert.NotNil(t, ufields["err_msg"]) + return nil + }) mockTurnRepo.EXPECT().CreateOrUpdateItemsTurnRunLogStatus(gomock.Any(), int64(3), int64(1), int64(2), []int64{10}, entity.TurnRunState_Fail).Return(nil) items := []*entity.ExptEvalItem{{ItemID: 10, State: entity.ItemRunState_Processing}} From e3a7de93c159e66d664016ef13a63a478c1be8be Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 25 Aug 2026 16:17:21 +0800 Subject: [PATCH 40/58] =?UTF-8?q?feat(evaluation):=20=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E4=B8=80=E5=BE=8B=E4=B8=8D=E8=BF=9B=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E8=B0=83=E5=BA=A6=20=E2=80=94=E2=80=94=20=E5=AE=83?= =?UTF-8?q?=E7=BC=BA=E5=AE=9E=E9=AA=8C=E7=BA=A7=2036h=20=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中心调度里 item 的最后一道兜底是「实验级 zombie 超时 → 实验判失败 → 归还未跑完 item 的额度」,判据 now - event.CreatedAt >= ZombieIntervalSecond(默认 36h)。 而在线实验的 daemon(ExptAppendExec.NextTick)每转一圈就把 event.CreatedAt 刷成当前 时间 —— 那是在线实验该有的行为(长期存活、不断追加 item),但副作用是这个绝对时钟 永不到期。离线四种 mode 都不刷,所以只有在线这一支没有兜底。 后果不止额度多占:投递结果不明的 item 投影停在 Queueing/reserved,而那个形态被调度 候选(只取 Queueing/none)、item zombie(只扫 Processing)、reap(只处理 reserved) 同时漏掉。对账器的退回队列是它唯一出路,若连实验级兜底也没有,任何未被对账覆盖的 窗口都会变成永久泄漏 + item 丢失。 用 ExptType 而非 ExptRunMode:run mode 是 Run 时才定的,而这道闸必须在创建期生效 (dispatch_mode 一次性冻结进 DB 列,之后 Run/Retry 一律回查该列)。ExptType 在 CreateExptParam 里就有,是创建期唯一可用且权威的判据。 本期在线实验不走 evalx trigger,所以这道闸当前不改变任何行为 —— 它挡的是"哪天在线 实验开始发 evalx"那个未来。在此之前那条约定只存在于口头,代码里没有任何拦点。 刻意不动另两个 ShouldEnforceByTrigger 调用点(特权字段读取、飞书通知抑制): 它们回答的是"是不是 evalx 来的",与"要不要进 enforce"无关。 零值 ExptType 不视为 Online:否则没填 expt_type 的调用方会静默失去中心调度。 变异验证:去掉在线判定 → 3 格用例 FAIL。 --- .../domain/entity/expt_dispatch_mode.go | 36 +++++++++++++++++- .../entity/expt_dispatch_mode_trigger_test.go | 37 +++++++++++++++++++ .../domain/service/expt_manage_impl.go | 15 +++++--- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go index 68039978fa..3a6f012922 100644 --- a/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode.go @@ -16,11 +16,15 @@ import "strings" type ExptDispatchMode = string const ( - // ExptDispatchModeLegacy 旧链路:每实验一条 MQ 自循环,按配置并发自主补 item,受空间运行实验数闸约束。 + // ExptDispatchModeLegacy 旧链路:每实验一条 MQ 自循环,按配置并发自主补 item。 // 历史数据与非灰度空间的新实验都是该值(DB 列默认值)。 ExptDispatchModeLegacy ExptDispatchMode = "legacy" - // ExptDispatchModeEnforce 中心调度:由中心调度器按全局优先级 + 资源额度决定派发,跳过空间运行实验数闸。 + // ExptDispatchModeEnforce 中心调度:由中心调度器按全局优先级 + 资源额度决定派发。 // 旧 per-experiment tick 对该模式实验只做初始化与生命周期维护,不得启动新 item。 + // + // 跳过的**只是自主补 item 这一段**。实验级的闸不受影响:`AllowExptRun` + // (空间同时运行实验数,默认 200)在 `Run` / `RetryItems` 里无条件调用, + // 两种模式都要过。 ExptDispatchModeEnforce ExptDispatchMode = "enforce" ) @@ -118,3 +122,31 @@ const ExptTriggerTypeEvalx = "evalx" func ShouldEnforceByTrigger(triggerType string) bool { return strings.EqualFold(strings.TrimSpace(triggerType), ExptTriggerTypeEvalx) } + +// ShouldEnforceByTriggerAndType 在 trigger 判定之外再排除在线实验。 +// +// ★ 为什么在线实验必须挡在中心调度之外(这不是保守,是它缺一道兜底): +// +// 中心调度里 item 的最后一道兜底是「实验级 36h zombie 超时 → 实验判失败 → 归还未跑完 +// item 的额度」,判据是 `now - event.CreatedAt >= ZombieIntervalSecond`。 +// 而在线实验的 daemon(ExptAppendExec.NextTick)每转一圈就把 event.CreatedAt 刷成当前 +// 时间 —— 那是它该有的行为(在线实验本就长期存活、不断追加 item),但副作用是 +// **这个绝对时钟永远不会到期**。离线四种 mode 都不刷,所以只有在线这一支没有兜底。 +// +// 后果不只是额度多占:投递结果不明的 item 投影停在 Queueing/reserved, +// 而那个形态被调度候选(只取 Queueing/none)、item zombie(只扫 Processing)、 +// reap(只处理 reserved)同时漏掉。对账器的退回队列机制是它唯一的出路, +// 而在线实验一旦连实验级兜底也没有,任何一个未被对账覆盖的窗口都会变成永久泄漏。 +// +// 为什么用 ExptType 而不是 ExptRunMode:run mode 是 Run 时才确定的,而这道闸必须在 +// **创建期**生效(dispatch_mode 一次性冻结进 DB 列,之后 Run/Retry 一律回查该列)。 +// ExptType 在 CreateExptParam 里就有,是创建期唯一可用且权威的判据。 +// +// 本期在线实验不走 evalx trigger,所以这道闸目前不改变任何行为 —— 它挡的是 +// "哪天在线实验开始发 evalx" 那个未来。在此之前那条约定只存在于口头,代码里没有拦点。 +func ShouldEnforceByTriggerAndType(triggerType string, exptType ExptType) bool { + if exptType == ExptType_Online { + return false + } + return ShouldEnforceByTrigger(triggerType) +} diff --git a/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go index ed062b832c..cc889e8f57 100644 --- a/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go +++ b/backend/modules/evaluation/domain/entity/expt_dispatch_mode_trigger_test.go @@ -41,6 +41,43 @@ func TestShouldEnforceByTrigger(t *testing.T) { } } +// TestShouldEnforceByTriggerAndType 在线实验必须被挡在 enforce 之外。 +// +// 为什么这条不能只靠"在线实验不发 evalx trigger"这个约定:约定不在代码里, +// 而一旦破了,在线实验会进 enforce 并且**丢掉实验级 36h 超时兜底** +// (daemon 每拍刷 event.CreatedAt,那个绝对时钟永不到期)。 +// 那意味着任何未被对账覆盖的窗口都会变成永久的额度泄漏 + item 丢失。 +func TestShouldEnforceByTriggerAndType(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + trigger string + exptType ExptType + want bool + }{ + // 离线 + evalx 是唯一进 enforce 的组合。 + "offline evalx": {trigger: "evalx", exptType: ExptType_Offline, want: true}, + + // ★ 本次新增的闸:在线实验即使带 evalx 也不进(含大小写/空白变体, + // 防止有人以为绕过 trim 就能进来)。 + "online evalx": {trigger: "evalx", exptType: ExptType_Online, want: false}, + "online evalx spaces": {trigger: " evalx ", exptType: ExptType_Online, want: false}, + "online evalx mixed": {trigger: "EvalX", exptType: ExptType_Online, want: false}, + "online non-evalx": {trigger: "manual", exptType: ExptType_Online, want: false}, + "offline non-evalx": {trigger: "manual", exptType: ExptType_Offline, want: false}, + // 零值 ExptType(未显式设置)不等于 Online,不该被这道闸挡掉 —— + // 否则一批没填 expt_type 的调用方会静默失去中心调度。 + "zero type evalx": {trigger: "evalx", exptType: 0, want: true}, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, c.want, ShouldEnforceByTriggerAndType(c.trigger, c.exptType)) + }) + } +} + func TestExptTriggerTypeEvalxValue(t *testing.T) { t.Parallel() diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index 13f64513c7..127f1a9a14 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -1341,25 +1341,28 @@ func (e *ExptMangerImpl) CreateExpt(ctx context.Context, req *entity.CreateExptP } // ★ 中心化调度模式在创建时一次性冻结。 // - // 准入是两道闸的 AND: + // 准入是三道闸的 AND: // ① trigger 判据 —— 只有 EvalX 发起的实验有资格,因为它是内部平台,会按约定申报 // priority 与 expected_quota_consumption。其它入口(控制台手动、OpenAPI、定时) // 保持 legacy,行为与引入中心调度前完全一致。 - // ② admission policy —— 在①的基础上按空间 / 评测对象类型 / 评测对象 ID 收窄灰度范围。 + // ② 实验类型判据 —— 在线实验一律排除,它缺"实验级 36h 超时"这道兜底 + // (daemon 每拍刷 event.CreatedAt,绝对时钟永不到期)。详见 + // entity.ShouldEnforceByTriggerAndType 的注释。 + // ③ admission policy —— 在①②的基础上按空间 / 评测对象类型 / 评测对象 ID 收窄灰度范围。 // - // 两道闸是 AND 而非 OR:policy 只能收窄、不能扩大。若 policy 能把非 EvalX 入口的实验 + // 三道闸是 AND 而非 OR:policy 只能收窄、不能扩大。若 policy 能把非 EvalX 入口的实验 // 也拽进 enforce,那些实验没有申报向量的字段,结果要么在下面的向量校验处报错、 // 要么(若放宽校验)被调度器永远跳过 —— 后者表现为"实验建好了但一个 item 都不跑"。 // - // 模式由 trigger 派生而非取请求字段:请求里的 scheduler_mode 不可信(任何内部调用方 - // 都能声明 enforce),而 trigger_type 是上游身份的既有表达,已被其它逻辑依赖。 + // 模式由 trigger + 类型派生而非取请求字段:请求里的 scheduler_mode 不可信(任何内部 + // 调用方都能声明 enforce),而 trigger_type 是上游身份的既有表达,已被其它逻辑依赖。 dispatchMode := entity.ExptDispatchModeLegacy schedulerScope := "" expectedQuota := req.ExpectedQuotaConsumption // defaultPriority 未申报优先级时的缺省值。0 表示"没有意见",由 // NormalizeExptPriorityLevelWithDefault 回落到 entity.DefaultExptPriorityLevel。 var defaultPriority int32 - if entity.ShouldEnforceByTrigger(triggerType) { + if entity.ShouldEnforceByTriggerAndType(triggerType, req.ExptType) { // 灰度收窄闸。先判 policy 再校验向量:policy 不放行时该实验走 legacy, // 此时缺向量是正常的(EvalX 对未纳管空间也可能不传),不该报错。 decision, err := e.allowCentralScheduling(ctx, req.WorkspaceID, tuple, expectedQuota) From 97a59c503c062f4ec0534341f509d8d7c101082f Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 25 Aug 2026 16:17:40 +0800 Subject: [PATCH 41/58] =?UTF-8?q?docs(evaluation):=20=E6=94=B9=E6=8E=89=20?= =?UTF-8?q?WithConcurrencyDimension=20=E7=9A=84=E4=B8=A4=E5=A4=84=E5=A4=B1?= =?UTF-8?q?=E7=9C=9F=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nil 分支:两个生产调用点(商业版 toRequirements / frozenConstraintsOf)都在它之前挡了 nil,所以生产不可达。但**不能删** —— 实测 nil receiver 读 c.Resources 直接 panic, 这个分支是本方法唯一的 nil 安全保障,删掉等于把"nil 安全"变成"nil panic"。 补注释说明为何不可达、为何仍要留。 幂等分支:原注释写"例如未来支持重型 item 占 2 份并发",把它说成未来能力。实际 concurrency 已在商业版 category 白名单内、Validate 也不拦,**现在申报即生效**。 TestWithConcurrencyDimension_NilReceiver 的注释断言「若返回 nil,调度器会把该实验当 无向量跳过,enforce 实验永远不跑」—— 后半句不成立:调用点在此之前就已经跳过了 (scheduler.go 的 len(requirements)==0 → skipReasonNoRequirements),这个函数返不返回 非 nil 都改变不了结果。改成如实描述它守的是方法自身的 nil 安全契约,并记上变异实测: 把该分支改成 return nil,全 evaluation 模块只有本用例 FAIL、商业版 51 包零失败。 --- backend/modules/evaluation/domain/entity/expt.go | 8 ++++++-- .../domain/entity/expt_quota_concurrency_test.go | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/modules/evaluation/domain/entity/expt.go b/backend/modules/evaluation/domain/entity/expt.go index caf44e0a54..1db3f29d0a 100644 --- a/backend/modules/evaluation/domain/entity/expt.go +++ b/backend/modules/evaluation/domain/entity/expt.go @@ -424,12 +424,16 @@ const ( // WithConcurrencyDimension 返回在原向量基础上补齐并发维度的**新**向量。 // -// 幂等:若调用方已显式申报 concurrency|item(例如未来支持"重型 item 占 2 份并发"), -// 保留其申报值不覆盖 —— 这正是把额度下沉到 item 粒度后要留的扩展口。 +// 幂等:若调用方已显式申报 concurrency|item,保留其申报值不覆盖。 +// 这条路径**当前就能走通**(concurrency 在商业版 category 白名单内、Validate 也不拦), +// 所以"重型 item 占 2 份并发"这类用法不需要改代码,申报即生效。 // // 不原地改 receiver:ExpectedQuotaConsumption 是创建期冻结进 eval_conf 的快照, // 原地修改会让"冻结"语义失效(同一份快照在不同调用后变形)。 func (c *ExpectedQuotaConsumption) WithConcurrencyDimension() *ExpectedQuotaConsumption { + // nil receiver 不会被生产触达(两个调用点都先挡了 nil:商业版 toRequirements 与 + // frozenConstraintsOf),但这个分支必须留 —— 它是本方法唯一的 nil 安全保障, + // 删掉会让方法从"nil 安全"变成"nil 直接 panic"。 if c == nil { return &ExpectedQuotaConsumption{ Resources: []*ExpectedResourceConsumption{{ diff --git a/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go b/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go index 47a2244c9b..fb62a94a6c 100644 --- a/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go +++ b/backend/modules/evaluation/domain/entity/expt_quota_concurrency_test.go @@ -71,8 +71,13 @@ func TestWithConcurrencyDimension_IdempotentOnExplicitDeclaration(t *testing.T) assert.Equal(t, int64(2), findResource(again, QuotaCategoryConcurrency, QuotaResourceKeyItem).Amount) } -// TestWithConcurrencyDimension_NilReceiver nil 向量也要产出并发维度。 -// 若返回 nil,调度器会把该实验当"无向量"跳过,enforce 实验永远不跑。 +// TestWithConcurrencyDimension_NilReceiver 守的是**本方法自身的 nil 安全契约**, +// 不是调度器的不变量:两个生产调用点(商业版 toRequirements / frozenConstraintsOf) +// 都先挡了 nil,所以这条路径不会被生产触达。 +// +// 之所以仍要钉:nil 分支是本方法唯一的 nil 安全保障,删掉它方法就变成 nil 直接 panic。 +// 本用例是唯一能抓住那次删除的防线(实测:把该分支改成 return nil,全 evaluation 模块 +// 只有本用例 FAIL,商业版 51 个包零失败 —— 即它护的确实只是契约本身)。 func TestWithConcurrencyDimension_NilReceiver(t *testing.T) { var in *ExpectedQuotaConsumption From 5e5ed8a3643051996df3d42fceb7c6245c462c2e Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 26 Aug 2026 16:55:49 +0800 Subject: [PATCH 42/58] =?UTF-8?q?fix(evaluation):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E9=A2=9D=E5=BA=A6=E6=B3=84=E6=BC=8F=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=20=E2=80=94=E2=80=94=20=E7=BB=88=E6=80=81=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=86=99=20run=20log=E3=80=81=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E6=97=A7=20run=20=E9=A2=84=E5=8D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两条都是 2026-08-26 PPE 长跑实测出来的,共同点是**没有任何兜底会兜住**。 ① 实验终态时 item run log 漏写终态 kill 只写了主表 expt_item_result(terminateItemTurns),run log 留在 Processing。 而调度侧三条判据全读 run log:释放判据 IsItemRunFinished 永远 false、对账见 Processing 判「有执行证据、绝不可释放」、zombie 只扫 Processing 但实验已终态 daemon 不再跳 —— 三条路都不接 ⇒ 飞行中的 item 预占永久泄漏。 实测 6 条 48h+ 僵尸吃掉 sandbox|default 8 里的 6,把 prio 99 实验有效并发压到 2, 造成结果层优先级倒挂。不是对账有 bug,是我们喂了它一个假事实。 修法:CompleteExpt 释放额度之后,把 run log 里 Queueing/Processing 的行一并置 Terminal。 置终态后对账会按 terminal_leftover 正常归还,于是它也成了那次 best-effort 释放 万一失败时唯一的兜底。顺序不可调换(先置终态会让释放侧一条都查不到)。 只对中心调度实验生效:run log 的 status 不是用户可见字段,legacy 不走那三条链路。 ② 重试不释放被顶替的旧 run 预占 FailRetry / RetryAll / RetryItems 三处 reset 都是 UpdateItemsResult{expt_run_id: 新值} → BatchCreateNXRunLogs,中间零 Release。改完 DB 里不再存在携带旧 runID 的主表行, 而增量对账只按 LatestRunID 建投影 ⇒ obs == nil ⇒ ActionReportOnly,只有全量 recovery 才可能碰到 —— 增量对账永远够不着。 修法:落在 LogRun / LogRetryItemsRun,即新 run 号写进 LatestRunID **之前**(账本 key 是 (run_id,item_id),旧 run 号一丢 field 就再也拼不出来)。retried 分支刻意不释放: 那说明锁被活着的 run 持有,释放等于把正在跑的额度还回去(真超发)。 测试:8 个变异逐一验证能被抓住(删调用行 / 挪顺序 / 写成 Fail / 用新 run 号释放 / 去掉模式闸 / 去掉 legacy 闸 / 去掉活跑闸 / 去掉同号短路),不靠「测试绿就算过」。 Co-Authored-By: Claude Opus 5 (1M context) --- .../service/expt_manage_execution_impl.go | 159 +++++++- ..._terminal_runlog_and_retry_release_test.go | 386 ++++++++++++++++++ 2 files changed, 536 insertions(+), 9 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_terminal_runlog_and_retry_release_test.go diff --git a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go index 1f7a94f6f8..b091bf6734 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_execution_impl.go @@ -638,6 +638,11 @@ func (e *ExptMangerImpl) CompleteExpt(ctx context.Context, exptID int64, exptRun // 那正是沙箱执行进程死亡的典型形态(2026-08-24 PPE 实测 45 条卡住)。 e.releaseCentralQuotaForIncompleteItems(ctx, got, exptRunID, status) + // ★ 紧跟在释放之后:把 item run log 里仍未终态的行一并置终态。 + // 顺序不可调换 —— 上面那行靠 run log 的 Queueing/Processing 反查待释放集合, + // 先置终态它就一条都查不到(静默不释放)。理由见 terminateIncompleteItemRunLogs。 + e.terminateIncompleteItemRunLogs(ctx, got, exptRunID) + if !opt.NoCompleteItemTurn { incompleteTurnIDs, err := e.exptResultService.GetIncompleteTurns(ctx, exptID, spaceID, session) if err != nil { @@ -873,6 +878,25 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( expt *entity.Experiment, exptRunID *int64, status entity.ExptStatus, +) { + e.releaseCentralQuotaForRun(ctx, expt, exptRunID, + fmt.Sprintf("experiment reached terminal status=%d with incomplete items", int32(status)), "expt complete") +} + +// releaseCentralQuotaForRun 释放 (runID, item) 这批仍挂在账本上的预占。 +// +// 从 releaseCentralQuotaForIncompleteItems 抽出来,是因为**重试路径也要用同一套判据**: +// 它要释放的是"被新 run 顶替掉的那个旧 run"的残留(见 releaseSupersededRunQuota), +// 与"实验终态"只差一个 reason。判据(按 item run log 反查)必须两边完全一致 —— +// 各写一份迟早会分叉。 +// +// occasion 只进日志,用来区分调用场合(expt complete / retry)。 +func (e *ExptMangerImpl) releaseCentralQuotaForRun( + ctx context.Context, + expt *entity.Experiment, + exptRunID *int64, + reason string, + occasion string, ) { if e.centralGuard == nil || expt == nil { return @@ -883,7 +907,7 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( if expt.SchedulerScope == "" { // enforce 却无 Scope:数据异常,无法确定去哪本账释放。宁可不释放也不猜一本 —— // 猜错会归还别人的额度,那比不归还更糟(会导致超发)。 - logs.CtxError(ctx, "[CentralReservation] enforce experiment without scheduler_scope on complete, skip release, expt_id: %v", expt.ID) + logs.CtxError(ctx, "[CentralReservation] enforce experiment without scheduler_scope on %s, skip release, expt_id: %v", occasion, expt.ID) return } @@ -894,7 +918,7 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( runID = expt.LatestRunID } if runID == 0 { - logs.CtxError(ctx, "[CentralReservation] cannot resolve expt_run_id on complete, skip release, expt_id: %v", expt.ID) + logs.CtxError(ctx, "[CentralReservation] cannot resolve expt_run_id on %s, skip release, expt_id: %v", occasion, expt.ID) return } @@ -918,25 +942,69 @@ func (e *ExptMangerImpl) releaseCentralQuotaForIncompleteItems( itemIDs, err := e.incompleteItemIDsForRelease(ctx, expt.ID, runID, expt.SpaceID) if err != nil { // 查不到就不猜:宁可这次不释放(留给对账/人工),也不能凭空构造 item 列表。 - logs.CtxError(ctx, "[CentralReservation] scan incomplete items fail on expt complete, skip release, expt_id: %v, expt_run_id: %v, err: %v", - expt.ID, runID, err) + logs.CtxError(ctx, "[CentralReservation] scan incomplete items fail on %s, skip release, expt_id: %v, expt_run_id: %v, err: %v", + occasion, expt.ID, runID, err) return } if len(itemIDs) == 0 { return } - reason := fmt.Sprintf("experiment reached terminal status=%d with incomplete items", int32(status)) failed := 0 for _, itemID := range itemIDs { if err := e.centralGuard.Release(ctx, expt.SchedulerScope, runID, itemID, reason); err != nil { failed++ - logs.CtxWarn(ctx, "[CentralReservation] release quota fail on expt complete, scope: %v, expt_run_id: %v, item_id: %v, err: %v", - expt.SchedulerScope, runID, itemID, err) + logs.CtxWarn(ctx, "[CentralReservation] release quota fail on %s, scope: %v, expt_run_id: %v, item_id: %v, err: %v", + occasion, expt.SchedulerScope, runID, itemID, err) } } - logs.CtxInfo(ctx, "[CentralReservation] quota released for incomplete items on expt complete, scope: %v, expt_id: %v, expt_run_id: %v, items: %v, failed: %v, status: %v", - expt.SchedulerScope, expt.ID, runID, len(itemIDs), failed, status) + logs.CtxInfo(ctx, "[CentralReservation] quota released for incomplete items on %s, scope: %v, expt_id: %v, expt_run_id: %v, items: %v, failed: %v, reason: %v", + occasion, expt.SchedulerScope, expt.ID, runID, len(itemIDs), failed, reason) +} + +// releaseSupersededRunQuota 在重试新建 run 之前,释放**被顶替的上一个 run** 仍挂在账本上的预占。 +// +// ★ 为什么对账兜不住、非得在这里显式释放:重试会把 expt_item_result 那批行的 expt_run_id +// 直接改写成新 run(expt_run_scheduler_mode_impl.go 里 FailRetry / RetryAll / RetryItems 三处 +// reset 都是 UpdateItemsResult{expt_run_id: 新值} → BatchCreateNXRunLogs,中间零 Release)。 +// 改完之后 **DB 里不再存在携带旧 runID 的主表行**,而增量对账只按 LatestRunID 建投影 ⇒ +// res:<旧runID>: 观测不到 ⇒ obs == nil ⇒ ActionReportOnly, +// 只有全量 recovery 才可能碰到它 —— 增量对账永远够不着。 +// +// ★ 必须排在 LatestRunID 被改写之前:账本 key 是 (run_id, item_id), +// 旧 run 号一旦丢失,那些 field 就再也拼不出来了。 +// +// 只对重试模式生效:Submit / TrialRun 没有"上一个 run",Append 则可能在旧 run 仍有 item +// 在飞时追加,按旧 run 释放会把正在跑的额度还回去(真超发)。 +// +// 调用点已在 mutex 保护内:拿到 expt 级锁才说明没有活着的 run 在跑, +// 此时旧 run 的 Queueing/Processing 残留一定是不会再被执行的。 +func (e *ExptMangerImpl) releaseSupersededRunQuota(ctx context.Context, exptID, spaceID, newRunID int64, mode entity.ExptRunMode) { + if e.centralGuard == nil || !isRetryRunMode(mode) { + return + } + expt, err := e.exptRepo.GetByID(ctx, exptID, spaceID) + if err != nil || expt == nil { + // 查不到就不猜:宁可这次不释放(留给全量 recovery / 人工),也不能凭空构造 scope 或 run 号。 + logs.CtxWarn(ctx, "[CentralReservation] load expt fail before retry, skip superseded release, expt_id: %v, err: %v", exptID, err) + return + } + oldRunID := expt.LatestRunID + if oldRunID == 0 || oldRunID == newRunID { + return + } + e.releaseCentralQuotaForRun(ctx, expt, gptr.Of(oldRunID), + fmt.Sprintf("superseded by retry run=%d", newRunID), "retry") +} + +// isRetryRunMode 判断该模式是否"顶替上一个 run"。 +func isRetryRunMode(mode entity.ExptRunMode) bool { + switch mode { + case entity.EvaluationModeFailRetry, entity.EvaluationModeRetryAll, entity.EvaluationModeRetryItems: + return true + default: + return false + } } // incompleteItemIDsForRelease 取该 run 下**仍未走到终态**的 item id(去重)。 @@ -969,6 +1037,72 @@ func (e *ExptMangerImpl) incompleteItemIDsForRelease(ctx context.Context, exptID return maps.ToSlice(itemIDSet, func(k int64, v bool) int64 { return k }), nil } +// terminateIncompleteItemRunLogs 实验终态时,把 expt_item_result_run_log 里仍未终态的行 +// 一并置 Terminal。 +// +// ★ 为什么必须写这张表:它和主表 expt_item_result 是两张表,而**调度侧的判据全读 run log**。 +// kill 只写了主表(terminateItemTurns),run log 留在 Processing,于是那些正在飞行的 item: +// +// 释放判据 IsItemRunFinished(run log status) 永远 false ⇒ 永不释放; +// 对账 见 Processing 判「有执行证据、绝不可释放」⇒ 每拍主动判 none(不是它有 bug, +// 是我们喂了它一个假事实); +// zombie 兜底 只扫 Processing,但实验已终态、daemon 不再跳 ⇒ 永不再扫。 +// +// 三条路都不接 ⇒ 额度永久泄漏。2026-08-26 PPE 实测 6 条 48h+ 僵尸就是这么来的 +// (主表 status=5 Terminal、run log status=1 Processing,持有者是 status=13 已终止的实验), +// 吃掉 sandbox|default 8 里的 6,把 prio 99 实验的有效并发压到 2、造成结果层优先级倒挂。 +// 对照:同一实验里**未派发**的行 run log 停在 Queueing,不占额度所以不漏 —— 漏的只有飞行中的。 +// +// 置终态之后对账会按 terminal_leftover 正常归还(实测该路径本身是好的), +// 所以这行不只是修数据,也是上面那次 best-effort 释放万一失败时唯一的兜底。 +// +// 只改 Queueing/Processing 两态(经 incompleteItemIDsForRelease 反查),不碰已终态的行: +// 覆盖一条已 Success 的 run log 会让它与主表/统计对不上。 +// +// 只对中心调度实验生效:run log 的 status **不是**用户可见字段(结果页只从 run log 取 log_id, +// 见 expt_result_impl.go MGetItemRunLog 那段),实验终态后读它的只有额度释放判据、对账、 +// zombie 三条中心调度链路。legacy 实验一条都不走 ⇒ 那次全量 scan 对它是纯开销、零收益, +// 而 CompleteExpt 是每个实验必经的路径。 +// 全程 best-effort:失败只告警,不让实验收敛失败。 +func (e *ExptMangerImpl) terminateIncompleteItemRunLogs(ctx context.Context, expt *entity.Experiment, exptRunID *int64) { + if expt == nil || !entity.IsCentralDispatch(expt.ExptDispatchMode) { + return + } + runID := gptr.Indirect(exptRunID) + if runID == 0 { + runID = expt.LatestRunID + } + if runID == 0 { + logs.CtxWarn(ctx, "[ExptEval] cannot resolve expt_run_id on complete, skip run log terminate, expt_id: %v", expt.ID) + return + } + + itemIDs, err := e.incompleteItemIDsForRelease(ctx, expt.ID, runID, expt.SpaceID) + if err != nil { + logs.CtxWarn(ctx, "[ExptEval] scan incomplete item run logs fail on complete, expt_id: %v, expt_run_id: %v, err: %v", + expt.ID, runID, err) + return + } + if len(itemIDs) == 0 { + return + } + + // 分批:待终态集合可以是整份评测集(万级),一次 IN 会把 SQL 撑到几百 KB。 + // 单批失败不放弃其余批 —— 少写一批就是少还一批额度。 + failed := 0 + for _, chunk := range gslice.Chunk(itemIDs, 100) { + if err := e.itemResultRepo.UpdateItemRunLog(ctx, expt.ID, runID, chunk, map[string]any{ + "status": int32(entity.ItemRunState_Terminal), + }, expt.SpaceID); err != nil { + failed += len(chunk) + logs.CtxWarn(ctx, "[ExptEval] terminate incomplete item run logs fail, expt_id: %v, expt_run_id: %v, items: %v, err: %v", + expt.ID, runID, len(chunk), err) + } + } + logs.CtxInfo(ctx, "[ExptEval] incomplete item run logs terminated on expt complete, expt_id: %v, expt_run_id: %v, items: %v, failed: %v", + expt.ID, runID, len(itemIDs), failed) +} + func (e *ExptMangerImpl) terminateItemTurns(ctx context.Context, exptID int64, itemTurnIDs []*entity.ItemTurnID, spaceID int64, session *entity.Session) error { itemIDs := make([]int64, 0, len(itemTurnIDs)) for _, itemTurnID := range itemTurnIDs { @@ -1358,6 +1492,9 @@ func (e *ExptMangerImpl) LogRun(ctx context.Context, exptID, exptRunID int64, mo return err } + // 重试顶替旧 run:必须赶在下面改写 LatestRunID 之前释放旧 run 的残留预占。 + e.releaseSupersededRunQuota(ctx, exptID, spaceID, exptRunID, mode) + if err := e.exptRepo.Update(ctx, &entity.Experiment{ ID: exptID, LatestRunID: exptRunID, @@ -1430,7 +1567,11 @@ func (e *ExptMangerImpl) LogRetryItemsRun(ctx context.Context, exptID int64, mod return 0, false, err } + // retried 分支刻意不释放:那说明锁被一个**活着的 run** 持有,runID 是它的号、 + // LatestRunID 也还是它 —— 此时释放等于把正在跑的 item 的额度还回去。 if !retried { + e.releaseSupersededRunQuota(ctx, exptID, spaceID, runID, mode) + if err := e.exptRepo.Update(ctx, &entity.Experiment{ID: exptID, LatestRunID: runID}); err != nil { return 0, false, err } diff --git a/backend/modules/evaluation/domain/service/expt_terminal_runlog_and_retry_release_test.go b/backend/modules/evaluation/domain/service/expt_terminal_runlog_and_retry_release_test.go new file mode 100644 index 0000000000..3073d9bc70 --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_terminal_runlog_and_retry_release_test.go @@ -0,0 +1,386 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + idgenMocks "github.com/coze-dev/coze-loop/backend/infra/idgen/mocks" + lockMocks "github.com/coze-dev/coze-loop/backend/infra/lock/mocks" + idemMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/idem/mocks" + metricsMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/metrics/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + eventsMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/events/mocks" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + svcMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service/mocks" +) + +// 本文件守住两条独立的额度泄漏路径,两者都是 2026-08-26 PPE 长跑实测出来的: +// +// ① 实验终态时 item run log 漏写终态 ⇒ 飞行中的 item 预占永久泄漏(6 条 48h+ 僵尸) +// ② 重试新建 run 时不释放旧 run 的预占 ⇒ 增量对账永远够不着(主表 run 号已被改写) +// +// 两条的共同点是**没有任何兜底会兜住**,所以调用点本身就是唯一防线 —— +// 因此每条都必须有一个"经真实入口"的用例,而不只是直接调那个私有函数。 + +// ============================================================================ +// ① 实验终态 ⇒ item run log 一并置终态 +// ============================================================================ + +// TestTerminateIncompleteItemRunLogs_WritesTerminal 直接守住写入内容。 +// +// 断言的是 ufields 里 status == Terminal 且 item 集合等于扫描结果 —— +// 不是"UpdateItemRunLog 被调过"。写错状态值(例如写成 Fail)与不写一样有害: +// 调度侧判据只看 IsItemRunFinished,但结果页会把用户主动取消的 item 显示成失败。 +func TestTerminateIncompleteItemRunLogs_WritesTerminal(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + itemRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemRepo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 11}, {ItemID: 12}}, int64(2), nil) + itemRepo.EXPECT(). + UpdateItemRunLog(gomock.Any(), int64(100), int64(200), gomock.Any(), gomock.Any(), int64(7)). + DoAndReturn(func(_ context.Context, _, _ int64, itemIDs []int64, ufields map[string]any, _ int64) error { + assert.ElementsMatch(t, []int64{11, 12}, itemIDs) + assert.Equal(t, int32(entity.ItemRunState_Terminal), ufields["status"], + "run log 必须落 Terminal —— 停在 Processing 会让释放判据、对账、zombie 三条路全部失效") + return nil + }).Times(1) + + svc := &ExptMangerImpl{itemResultRepo: itemRepo} + svc.terminateIncompleteItemRunLogs(context.Background(), enforceExpt(), gptr.Of(int64(200))) +} + +// TestTerminateIncompleteItemRunLogs_SkipsWhenAllTerminal 已全部终态时不该发写请求。 +func TestTerminateIncompleteItemRunLogs_SkipsWhenAllTerminal(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + itemRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemRepo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, int64(0), nil) + // 刻意不声明 UpdateItemRunLog:真发了就是 unexpected call。 + + svc := &ExptMangerImpl{itemResultRepo: itemRepo} + svc.terminateIncompleteItemRunLogs(context.Background(), enforceExpt(), gptr.Of(int64(200))) +} + +// TestTerminateIncompleteItemRunLogs_SkipsLegacy legacy 实验一次查询都不该发。 +// +// run log 的 status 不是用户可见字段(结果页只从它取 log_id),实验终态后读它的只有 +// 额度释放判据 / 对账 / zombie 三条中心调度链路,legacy 一条都不走。 +// CompleteExpt 是每个实验必经路径,多一次全量 scan 对存量用户是纯开销。 +func TestTerminateIncompleteItemRunLogs_SkipsLegacy(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + // 刻意不声明任何期望:legacy 走到 repo 就是 unexpected call。 + svc := &ExptMangerImpl{itemResultRepo: repoMocks.NewMockIExptItemResultRepo(ctrl)} + expt := enforceExpt() + expt.ExptDispatchMode = entity.ExptDispatchModeLegacy + svc.terminateIncompleteItemRunLogs(context.Background(), expt, gptr.Of(int64(200))) +} + +// TestTerminateIncompleteItemRunLogs_ScanFailDoesNotWrite 扫不到就别猜。 +func TestTerminateIncompleteItemRunLogs_ScanFailDoesNotWrite(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + itemRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemRepo.EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, int64(0), assert.AnError) + + svc := &ExptMangerImpl{itemResultRepo: itemRepo} + assert.NotPanics(t, func() { + svc.terminateIncompleteItemRunLogs(context.Background(), enforceExpt(), gptr.Of(int64(200))) + }) +} + +// TestCompleteExpt_TerminatesItemRunLogsAfterRelease ★ 守住调用点**和顺序**。 +// +// 两件事必须一起断言,因为它们的失败模式相反: +// - 调用行被删 ⇒ run log 永远停在 Processing(原 bug 复现); +// - 调用行被挪到释放之前 ⇒ 释放侧按 Queueing/Processing 反查待释放集合时一条都查不到, +// 变成**静默不释放** —— 比原 bug 更难发现(日志显示"已收口",账本却没动)。 +// +// 所以这里用 releaseSeenAtRunLogWrite 记录"写 run log 那一刻已经释放了几条", +// 顺序颠倒时它会是 0,用例变红。 +func TestCompleteExpt_TerminatesItemRunLogsAfterRelease(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + + guard := &fakeGuard{} + mgr.centralGuard = guard + + ctx := context.Background() + session := &entity.Session{UserID: "test_user"} + const exptID, spaceID, runID = int64(123), int64(789), int64(456) + + mgr.idem.(*idemMocks.MockIdempotentService).EXPECT().Exist(ctx, gomock.Any()).AnyTimes().Return(false, nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().GetByID(ctx, exptID, spaceID).Return(&entity.Experiment{ + ID: exptID, + SpaceID: spaceID, + ExptType: entity.ExptType_Offline, + StartAt: gptr.Of(time.Now()), + LatestRunID: runID, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + }, nil) + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + CalculateStats(ctx, exptID, spaceID, session). + Return(&entity.ExptCalculateStats{ProcessingItemCnt: 2}, nil) + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + GetIncompleteTurns(ctx, exptID, spaceID, session).Return(nil, nil).AnyTimes() + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResultRunLog{{ItemID: 11}, {ItemID: 12}}, int64(2), nil).AnyTimes() + + releaseSeenAtRunLogWrite := -1 + runLogTerminated := 0 + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + UpdateItemRunLog(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, itemIDs []int64, ufields map[string]any, _ int64) error { + releaseSeenAtRunLogWrite = len(guard.releases()) + if ufields["status"] == int32(entity.ItemRunState_Terminal) { + runLogTerminated += len(itemIDs) + } + return nil + }).AnyTimes() + + mgr.statsRepo.(*repoMocks.MockIExptStatsRepo).EXPECT(). + UpdateByExptID(ctx, exptID, spaceID, gomock.Any()).Return(nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.quotaRepo.(*repoMocks.MockQuotaRepo).EXPECT(). + CreateOrUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.exptAggrResultService.(*svcMocks.MockExptAggrResultService).EXPECT(). + PublishExptAggrResultEvent(ctx, gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.mtr.(*metricsMocks.MockExptMetric).EXPECT(). + EmitExptExecResult(spaceID, gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mgr.publisher.(*eventsMocks.MockExptEventPublisher).EXPECT(). + PublishExptLifecycleEvent(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + SaveTurnRunLogs(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + UpdateTurnResults(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.turnResultRepo.(*repoMocks.MockIExptTurnResultRepo).EXPECT(). + MGetItemTurnRunLogs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mgr.exptResultService.(*svcMocks.MockExptResultService).EXPECT(). + UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + require.NoError(t, mgr.CompleteExpt(ctx, exptID, gptr.Of(runID), spaceID, session, + entity.WithStatus(entity.ExptStatus_Terminated))) + + assert.Equal(t, 2, runLogTerminated, + "CompleteExpt 必须把未终态 item 的 run log 置 Terminal —— 漏写就是永久泄漏(实测 6 条 48h+ 僵尸)") + assert.Equal(t, 2, releaseSeenAtRunLogWrite, + "必须先释放再置终态:反过来会让释放侧一条都查不到,退化成静默不释放") +} + +// ============================================================================ +// ② 重试新建 run ⇒ 释放被顶替的旧 run 预占 +// ============================================================================ + +// stubRetryLogRunMocks 备好 LogRun 走通所需的最小 mock 集合。 +func stubRetryLogRunMocks(t *testing.T, mgr *ExptMangerImpl, spaceID int64, mode entity.ExptRunMode) { + t.Helper() + mgr.mutex.(*lockMocks.MockILocker).EXPECT(). + LockBackoff(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mgr.mtr.(*metricsMocks.MockExptMetric).EXPECT().EmitExptExecRun(spaceID, int64(mode)).AnyTimes() + mgr.runLogRepo.(*repoMocks.MockIExptRunLogRepo).EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) +} + +// TestLogRun_ReleasesSupersededRunQuotaOnRetry ★ 核心回归:重试必须归还旧 run 的预占。 +// +// 断言 RunID 是**旧** run 号:账本 key 是 (run_id, item_id),拿新 run 号去释放是静默 no-op —— +// 日志照样打"已释放",账本一动不动。这正是最容易写错、又最难从日志发现的一种失败。 +func TestLogRun_ReleasesSupersededRunQuotaOnRetry(t *testing.T) { + for _, mode := range []entity.ExptRunMode{ + entity.EvaluationModeFailRetry, + entity.EvaluationModeRetryAll, + entity.EvaluationModeRetryItems, + } { + t.Run(fmt.Sprintf("mode=%d", int64(mode)), func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + guard := &fakeGuard{} + mgr.centralGuard = guard + + const exptID, spaceID, oldRunID, newRunID = int64(100), int64(7), int64(200), int64(201) + stubRetryLogRunMocks(t, mgr, spaceID, mode) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + GetByID(gomock.Any(), exptID, spaceID).Return(&entity.Experiment{ + ID: exptID, + SpaceID: spaceID, + LatestRunID: oldRunID, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + }, nil) + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), exptID, oldRunID, gomock.Any(), gomock.Any(), gomock.Any(), spaceID). + Return([]*entity.ExptItemResultRunLog{{ItemID: 11}}, int64(1), nil) + + require.NoError(t, mgr.LogRun(context.Background(), exptID, newRunID, mode, spaceID, nil, + &entity.Session{UserID: "u"})) + + releases := guard.releases() + require.Len(t, releases, 1, "重试必须释放旧 run 的残留预占 —— 增量对账够不着它") + assert.Equal(t, oldRunID, releases[0].RunID, + "必须按**旧** run 号释放:账本 key 是 (run_id, item_id),用新 run 号是静默 no-op") + assert.Equal(t, int64(11), releases[0].ItemID) + assert.Equal(t, testScope, releases[0].Scope) + }) + } +} + +// TestLogRun_SkipsReleaseForNonRetryModes 首跑 / 试运行不得释放。 +// +// Submit 的 LatestRunID 若非 0(例如重跑一个跑完的实验),按旧 run 释放没有依据; +// 更危险的是 Append —— 它可能在旧 run 仍有 item 在飞时追加,误释放等于真超发。 +// 这里靠"不声明 GetByID 期望"来断言:真去查了就是 unexpected call。 +func TestLogRun_SkipsReleaseForNonRetryModes(t *testing.T) { + for _, mode := range []entity.ExptRunMode{ + entity.EvaluationModeSubmit, + entity.EvaluationModeTrialRun, + entity.EvaluationModeAppend, + } { + t.Run(fmt.Sprintf("mode=%d", int64(mode)), func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + guard := &fakeGuard{} + mgr.centralGuard = guard + + const exptID, spaceID, newRunID = int64(100), int64(7), int64(201) + stubRetryLogRunMocks(t, mgr, spaceID, mode) + + require.NoError(t, mgr.LogRun(context.Background(), exptID, newRunID, mode, spaceID, nil, + &entity.Session{UserID: "u"})) + assert.Empty(t, guard.releases(), "非重试模式没有被顶替的旧 run,不该释放任何额度") + }) + } +} + +// TestLogRetryItemsRun_ReleasesSupersededRunQuota RetryItems 走的是另一个入口,必须单独守。 +func TestLogRetryItemsRun_ReleasesSupersededRunQuota(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + guard := &fakeGuard{} + mgr.centralGuard = guard + + const exptID, spaceID, oldRunID, newRunID = int64(100), int64(7), int64(200), int64(201) + + mgr.idgenerator.(*idgenMocks.MockIIDGenerator).EXPECT().GenID(gomock.Any()).Return(newRunID, nil) + mgr.mutex.(*lockMocks.MockILocker).EXPECT(). + BackoffLockWithValue(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, "", nil) + mgr.runLogRepo.(*repoMocks.MockIExptRunLogRepo).EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT(). + GetByID(gomock.Any(), exptID, spaceID).Return(&entity.Experiment{ + ID: exptID, + SpaceID: spaceID, + LatestRunID: oldRunID, + SchedulerScope: testScope, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + }, nil) + mgr.itemResultRepo.(*repoMocks.MockIExptItemResultRepo).EXPECT(). + ScanItemRunLogs(gomock.Any(), exptID, oldRunID, gomock.Any(), gomock.Any(), gomock.Any(), spaceID). + Return([]*entity.ExptItemResultRunLog{{ItemID: 11}, {ItemID: 12}}, int64(2), nil) + mgr.exptRepo.(*repoMocks.MockIExperimentRepo).EXPECT().Update(gomock.Any(), gomock.Any()).Return(nil) + mgr.mtr.(*metricsMocks.MockExptMetric).EXPECT().EmitExptExecRun(spaceID, gomock.Any()).AnyTimes() + + runID, retried, err := mgr.LogRetryItemsRun(context.Background(), exptID, + entity.EvaluationModeRetryItems, spaceID, []int64{11, 12}, &entity.Session{UserID: "u"}) + require.NoError(t, err) + assert.Equal(t, newRunID, runID) + assert.False(t, retried) + require.Len(t, guard.releases(), 2) + for _, r := range guard.releases() { + assert.Equal(t, oldRunID, r.RunID) + } +} + +// TestLogRetryItemsRun_SkipsReleaseWhenRunAlive ★ 反向防线:锁被活着的 run 持有时绝不能释放。 +// +// retried == true 说明 runID 是**正在跑的那个 run**,LatestRunID 也还是它。 +// 此时释放等于把正在执行的 item 的额度还回账本 —— 那不是泄漏,是**真超发**, +// 比泄漏严重得多(会让别的实验拿到不存在的额度)。 +func TestLogRetryItemsRun_SkipsReleaseWhenRunAlive(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mgr := newTestExptManager(ctrl) + guard := &fakeGuard{} + mgr.centralGuard = guard + + const exptID, spaceID, aliveRunID = int64(100), int64(7), int64(200) + + mgr.idgenerator.(*idgenMocks.MockIIDGenerator).EXPECT().GenID(gomock.Any()).Return(int64(201), nil) + mgr.mutex.(*lockMocks.MockILocker).EXPECT(). + BackoffLockWithValue(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, "200", nil) + mgr.mutex.(*lockMocks.MockILocker).EXPECT().Exists(gomock.Any(), gomock.Any()).Return(false, nil) + mgr.runLogRepo.(*repoMocks.MockIExptRunLogRepo).EXPECT(). + Get(gomock.Any(), exptID, aliveRunID).Return(&entity.ExptRunLog{ExptID: exptID, ExptRunID: aliveRunID}, nil) + mgr.runLogRepo.(*repoMocks.MockIExptRunLogRepo).EXPECT().Save(gomock.Any(), gomock.Any()).Return(nil) + // 刻意不声明 GetByID / ScanItemRunLogs:真去查就是 unexpected call。 + + runID, retried, err := mgr.LogRetryItemsRun(context.Background(), exptID, + entity.EvaluationModeRetryItems, spaceID, []int64{11}, &entity.Session{UserID: "u"}) + require.NoError(t, err) + assert.Equal(t, aliveRunID, runID) + assert.True(t, retried) + assert.Empty(t, guard.releases(), "旧 run 还活着,释放它的额度就是真超发") +} + +// TestReleaseSupersededRunQuota_Guards 边界:无 guard / 查不到实验 / 新旧同号都不得动手。 +func TestReleaseSupersededRunQuota_Guards(t *testing.T) { + t.Run("guard 为 nil", func(t *testing.T) { + svc := &ExptMangerImpl{} + assert.NotPanics(t, func() { + svc.releaseSupersededRunQuota(context.Background(), 100, 7, 201, entity.EvaluationModeFailRetry) + }) + }) + + t.Run("查不到实验就不猜", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + exptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + exptRepo.EXPECT().GetByID(gomock.Any(), int64(100), int64(7)).Return(nil, assert.AnError) + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard, exptRepo: exptRepo} + svc.releaseSupersededRunQuota(context.Background(), 100, 7, 201, entity.EvaluationModeFailRetry) + assert.Empty(t, guard.releases()) + }) + + t.Run("新旧 run 同号(首跑或幂等重入)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + exptRepo := repoMocks.NewMockIExperimentRepo(ctrl) + exptRepo.EXPECT().GetByID(gomock.Any(), int64(100), int64(7)).Return(&entity.Experiment{ + ID: 100, SpaceID: 7, LatestRunID: 201, + SchedulerScope: testScope, ExptDispatchMode: entity.ExptDispatchModeEnforce, + }, nil) + guard := &fakeGuard{} + svc := &ExptMangerImpl{centralGuard: guard, exptRepo: exptRepo} + svc.releaseSupersededRunQuota(context.Background(), 100, 7, 201, entity.EvaluationModeFailRetry) + assert.Empty(t, guard.releases(), "同号说明没有被顶替的旧 run,不该释放") + }) +} From fa956423368dfc29b7cb90c3634fb21377b229a4 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Fri, 28 Aug 2026 12:35:58 +0800 Subject: [PATCH 43/58] =?UTF-8?q?feat(evaluation):=20=E6=96=B0=E5=A2=9E"?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E4=B8=8D=E5=8F=AF=E6=BB=A1=E8=B6=B3"?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=20=E2=80=94=E2=80=94=20=E7=94=B3?= =?UTF-8?q?=E6=8A=A5=E8=B6=85=E8=B0=83=E5=BA=A6=E5=9F=9F=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E6=97=B6=E7=BD=AE=20item=20=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 供中心调度每拍授予点使用:某维度 AmountPerItem > Limit 即结构性不可能(等多久 都白等),调度器据此把该实验待跑 item 置 Fail 并写入此 err_msg,而非继续静默排队 ——此前它与"暂时排队"在日志和页面上完全无法区分(实测第三方实验静默 38h+)。 结果层同步识别并暴露到 ItemSystemInfo.Error,用户在前端能看到具体维度与数值。 NoAffectStability=true:这是用户申报配置错误,非系统稳定性问题。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/service/expt_result_impl.go | 10 ++++++-- backend/modules/evaluation/pkg/errno/biz.go | 23 +++++++++++++++++++ .../evaluation/pkg/errno/evaluation.go | 10 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/backend/modules/evaluation/domain/service/expt_result_impl.go b/backend/modules/evaluation/domain/service/expt_result_impl.go index 2e2fdb4856..98f11a36b0 100644 --- a/backend/modules/evaluation/domain/service/expt_result_impl.go +++ b/backend/modules/evaluation/domain/service/expt_result_impl.go @@ -1491,13 +1491,19 @@ func NewPayloadBuilder(ctx context.Context, param *entity.MGetExperimentResultPa RetryRecords: itemID2RetryRecords[itemID], } } - // 从 err_msg 反解出用户可见的 item 级错误(当前仅识别 item 僵尸超时;其他类型未来可扩展) + // 从 err_msg 反解出用户可见的 item 级错误(当前识别 item 僵尸超时与额度不可满足;其他类型未来可扩展) if len(itemResultPO.ErrMsg) > 0 { - if ok, msg := errno.ParseItemZombieTimeoutErr(errno.DeserializeErr([]byte(itemResultPO.ErrMsg))); ok { + deserialized := errno.DeserializeErr([]byte(itemResultPO.ErrMsg)) + if ok, msg := errno.ParseItemZombieTimeoutErr(deserialized); ok { itemResult.SystemInfo.Error = &entity.RunError{ Code: int64(errno.ItemZombieTimeoutCode), Detail: gptr.Of(msg), } + } else if ok, msg := errno.ParseItemQuotaImpossibleErr(deserialized); ok { + itemResult.SystemInfo.Error = &entity.RunError{ + Code: int64(errno.ItemQuotaImpossibleCode), + Detail: gptr.Of(msg), + } } } for _, turnID := range itemID2TurnIDs[itemID] { diff --git a/backend/modules/evaluation/pkg/errno/biz.go b/backend/modules/evaluation/pkg/errno/biz.go index af8053840e..e3acd2f511 100644 --- a/backend/modules/evaluation/pkg/errno/biz.go +++ b/backend/modules/evaluation/pkg/errno/biz.go @@ -116,6 +116,29 @@ func ParseItemZombieTimeoutErr(err error) (bool, string) { return false, "" } +// NewItemQuotaImpossibleErr 构造"申报量超过调度域上限、任何配置下都无法调度"错误。 +// +// 与僵尸超时的本质区别:僵尸是"卡了太久"(时间维度),它是"结构上就放不下"(配置维度)—— +// 等多久都不会变化,所以调度器在授予点直接置失败而不是继续排队。key/amount/limit 一并写进 +// err_msg,让用户据此改小申报或调大上限后重建实验。 +func NewItemQuotaImpossibleErr(constraintKey string, amount, limit int64) error { + msg := fmt.Sprintf("实验行申报的 %s 资源量(%d)超过调度域上限(%d),任何配置下都无法调度,请改小申报或调大上限后重建实验", + constraintKey, amount, limit) + return &ErrImpl{ + Code: ItemQuotaImpossibleCode, + Msg: msg, + } +} + +// ParseItemQuotaImpossibleErr 反解额度不可满足错误,返回 (是否命中, 用户可见的详细描述)。 +func ParseItemQuotaImpossibleErr(err error) (bool, string) { + ei, ok := ParseErrImpl(err) + if ok && ei.Code == ItemQuotaImpossibleCode { + return true, ei.ErrMsg() + } + return false, "" +} + // NewSandboxTerminatedBeforeReportErr 构造 "沙箱提前终态导致行失败" 错误。sandboxStatus 传沙箱返回的状态字面量(如 "Failed"/"Canceled"), // 用于把根因附在 err_msg 上供 API 展示。触发场景见 ExptSchedulerImpl.sweepTerminatedSandboxItems。 func NewSandboxTerminatedBeforeReportErr(sandboxStatus string) error { diff --git a/backend/modules/evaluation/pkg/errno/evaluation.go b/backend/modules/evaluation/pkg/errno/evaluation.go index 3c6835829c..2f717e236e 100644 --- a/backend/modules/evaluation/pkg/errno/evaluation.go +++ b/backend/modules/evaluation/pkg/errno/evaluation.go @@ -459,6 +459,10 @@ const ( sandboxTerminatedBeforeReportMessage = "沙箱在结果上报前已提前进入终态,该实验行已置为失败" sandboxTerminatedBeforeReportNoAffectStability = false + ItemQuotaImpossibleCode = 601205087 // 实验行申报的资源量超过调度域登记上限,任何配置下都无法调度(central scheduler 每拍授予点触发) + itemQuotaImpossibleMessage = "实验行申报的资源量超过调度域上限,任何配置下都无法调度,已被置为失败" + itemQuotaImpossibleNoAffectStability = true // 结构性配置错误(申报>上限),非系统稳定性问题 + // SandboxAgent 评测对象阶段性错误码 (601206xxx):按沙箱内执行阶段划分,便于按阶段做 metrics 分类与用户前端展示。 SandboxAgentSetupErrorCode = 601206001 // sandbox agent target setup phase error: agent 初始化 / 环境依赖装载失败 sandboxAgentSetupErrorMessage = "sandbox agent: agent setup failed" @@ -1163,6 +1167,12 @@ func init() { code.WithAffectStability(!sandboxTerminatedBeforeReportNoAffectStability), ) + code.Register( + ItemQuotaImpossibleCode, + itemQuotaImpossibleMessage, + code.WithAffectStability(!itemQuotaImpossibleNoAffectStability), + ) + code.Register( SandboxAgentSetupErrorCode, sandboxAgentSetupErrorMessage, From b4e4c7f4e5258ffe6a5c6a1083253f988bd4bd1e Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Fri, 28 Aug 2026 22:25:42 +0800 Subject: [PATCH 44/58] =?UTF-8?q?fix(evaluation):=20=E4=BF=AE=20reservatio?= =?UTF-8?q?n=20=E7=BC=BA=E5=A4=B1=E6=97=B6=20item=20=E5=8D=A1=E6=AD=BB=20P?= =?UTF-8?q?rocessing=20=E2=80=94=E2=80=94=20=E9=80=80=E5=9B=9E=E9=98=9F?= =?UTF-8?q?=E5=88=97=E8=80=8C=E9=9D=9E=E9=9D=99=E9=BB=98=E4=B8=A2=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfirmRunning 判定 reservation 不存在时,原实现只打一条 Info 就 return,不动 run log 投影。而此时 consumer 往往已用 StartReservedItem 把投影兑现成 Processing/none,于是 item 成了「既无额度、也无执行者」的孤儿 —— 却仍以 Processing 计入 item_concur_num: ScanEvalItems 与 LoadDispatchRuntime 都把它当"在跑",既不派新 item 也等不到它完成, 只能靠异步僵尸阈值(默认 3h)兜底判 Fail。 2026-08-28 线上实测:两个实验各 20 个 item 占满 20 的并发槽位 77 分钟, turn 表零记录、账本零 reservation,实验整体停摆;日志里连一条 Warn 都没有。 对账(reconcile)覆盖不到这一类:它遍历的是账本里的 reservation(groupForObservation 的入参就是 reservation map),而这类漂移的特征恰恰是账本侧无记录 —— 压根进不了输入集。 所以只能在 consumer 侧收口。 改动: - 该分支改为先读投影再分三路处理,日志从 Info 升为 Warn 并带上判定结果 · 终态 —— 迟到消息,一个字段都不动(退回会让已跑完的 item 重跑) · Processing —— 孤儿态,退回 Queueing 让它重新被授予,连带回滚 stats 与主表投影 · Queueing —— reserved 的走 ResetQuotaReserved 清回 none;none 的本就在候选里 - 新增 IExptItemDispatchRepo.RequeueProcessingItem:Processing/none → Queueing/none 的 CAS, 条件同时钉住 status 与 quota_reservation_state,只作用于孤儿这一种形状 - 全程只告警不返回 error:这条消息注定不执行,返回 error 只会让 MQ 无休止重投 选择退回 Queueing 而不是落 Fail:这类 item 是在创建 turn 之前被丢弃的,从未真正执行, 判失败会凭空吃掉一道题;退回队列它能被重新授予并真正跑完。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/repo/expt_item_dispatch.go | 15 ++ .../domain/repo/mocks/expt_item_dispatch.go | 15 ++ .../expt_orphaned_item_requeue_test.go | 165 ++++++++++++++++++ .../service/expt_run_item_event_impl.go | 99 ++++++++++- .../expt_item_dispatch_repo_impl.go | 4 + .../experiment/mysql/expt_item_dispatch.go | 20 +++ 6 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go diff --git a/backend/modules/evaluation/domain/repo/expt_item_dispatch.go b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go index 7b25574f31..60bc83ee30 100644 --- a/backend/modules/evaluation/domain/repo/expt_item_dispatch.go +++ b/backend/modules/evaluation/domain/repo/expt_item_dispatch.go @@ -56,6 +56,21 @@ type IExptItemDispatchRepo interface { // 调用方据此决定是否继续执行 item。 StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (started bool, err error) + // RequeueProcessingItem 把 Processing/none 的 item 退回 Queueing/none,让它重新进入授予候选。 + // + // 唯一调用场景:consumer 取执行权时发现 reservation 已不存在(ConfirmRunning=false), + // 而 run log 投影却已经是 Processing —— 该 item 既没有额度、也没有执行者,属于孤儿态。 + // 不退回的后果不是"少跑一个 item",而是**整个实验停摆**:Processing 计入 item_concur_num, + // ScanEvalItems 与 LoadDispatchRuntime 都把它当"在跑",于是既不派新 item 也等不到它完成, + // 直到异步僵尸阈值(默认 3h)才被判 Fail。实测踩过:两个实验各 20 个 item 卡满槽位 77 分钟。 + // + // 为什么退回 Queueing 而不是落 Fail:这类 item 是在创建 turn 之前被丢弃的(turn 表无记录), + // 从未真正执行,判失败会凭空吃掉一道题;退回队列它能被重新授予并真正跑完。 + // + // 安全性依赖调用点在 item 锁内:锁保证同一 item 没有并发执行者, + // 加上「非终态 + 账本无 reservation」,可断定没有在途执行会被这次退回打断。 + RequeueProcessingItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (requeued bool, err error) + // MGetDispatchObservations 供分钟级对账使用:批量读取指定 item 的 (status, 预占态)。 // 对账据此与 Redis reservation 比对,识别四类漂移(Terminal 遗留、Processing 缺 reservation、 // Queueing/reserved 超时未消费、Queueing/none 却有 reservation)。 diff --git a/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go b/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go index ae6a79bb75..cb772ebd89 100644 --- a/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go +++ b/backend/modules/evaluation/domain/repo/mocks/expt_item_dispatch.go @@ -95,6 +95,21 @@ func (mr *MockIExptItemDispatchRepoMockRecorder) ResetQuotaReserved(arg0, arg1, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetQuotaReserved", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).ResetQuotaReserved), arg0, arg1, arg2, arg3, arg4) } +// RequeueProcessingItem mocks base method. +func (m *MockIExptItemDispatchRepo) RequeueProcessingItem(arg0 context.Context, arg1, arg2, arg3, arg4 int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequeueProcessingItem", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequeueProcessingItem indicates an expected call of RequeueProcessingItem. +func (mr *MockIExptItemDispatchRepoMockRecorder) RequeueProcessingItem(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequeueProcessingItem", reflect.TypeOf((*MockIExptItemDispatchRepo)(nil).RequeueProcessingItem), arg0, arg1, arg2, arg3, arg4) +} + // StartReservedItem mocks base method. func (m *MockIExptItemDispatchRepo) StartReservedItem(arg0 context.Context, arg1, arg2, arg3, arg4 int64) (bool, error) { m.ctrl.T.Helper() diff --git a/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go new file mode 100644 index 0000000000..b59f3362be --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + "github.com/coze-dev/coze-loop/backend/pkg/ctxcache" +) + +// 本文件守的是一个能让整个实验停摆的静默故障: +// consumer 已把 run log 兑现成 Processing/none,之后额度消失,重投消息在 ConfirmRunning +// 被丢弃 —— item 成了「既无额度也无执行者」的孤儿,却仍以 Processing 占着 item_concur_num +// 的槽位。ScanEvalItems 和 LoadDispatchRuntime 都把它当"在跑",于是既不派新 item 也 +// 等不到它完成,只能靠 3h 异步僵尸阈值兜底。 +// +// 2026-08-28 线上实测:两个实验各 20 个 item 占满槽位 77 分钟,turn 表零记录、账本零 reservation。 +// 所以这里断言的不是"调过某个方法",而是**投影确实被退回、且 stats 与主表跟着退回**。 + +func admittedEnforceCtx(event *entity.ExptItemEvalEvent) context.Context { + ctx := ctxcache.Init(context.Background()) + event.WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: event.ExptID, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + return ctx +} + +// TestReservationAbsent_RequeuesOrphanedProcessingItem 主场景:Processing 且账本无 reservation +// ⇒ 必须退回 Queueing,并把 stats 的 Processing 计数与主表状态一起退回。 +func TestReservationAbsent_RequeuesOrphanedProcessingItem(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), int64(3), int64(1), int64(2), []int64{4}). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Processing), QuotaReservationState: entity.QuotaReservationStateNone}, + }, nil) + // ★ 核心断言:退回动作真的发生在这个 item 上。 + dispatchRepo.EXPECT().RequeueProcessingItem(gomock.Any(), int64(3), int64(1), int64(2), int64(4)). + Return(true, nil) + + var gotStatsOp *entity.StatsCntArithOp + statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) + statsRepo.EXPECT().ArithOperateCount(gomock.Any(), int64(1), int64(3), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, op *entity.StatsCntArithOp) error { + gotStatsOp = op + return nil + }) + + var gotFields map[string]any + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), int64(3), int64(1), []int64{4}, gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, _ []int64, ufields map[string]any) error { + gotFields = ufields + return nil + }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + exptStatsRepo: statsRepo, + exptItemResultRepo: itemResultRepo, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + ctx := admittedEnforceCtx(event) + + nextCalled := false + err := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + nextCalled = true + return nil + })(ctx, event) + + assert.NoError(t, err, "这条消息注定不执行,返回 error 只会让 MQ 无休止重投") + assert.False(t, nextCalled, "reservation 不存在时不得执行 item") + + assert.Equal(t, -1, gotStatsOp.OpStatusCnt[entity.ItemRunState_Processing], + "Processing 桶必须减 1,否则 processing_turn_count 只增不减") + assert.Equal(t, 1, gotStatsOp.OpStatusCnt[entity.ItemRunState_Queueing], + "item 回到队列,Queueing 桶要加回来") + assert.Equal(t, int32(entity.ItemRunState_Queueing), gotFields["status"], + "主表是详情页的数据源,不退回会一直显示成执行中") +} + +// TestReservationAbsent_LeavesTerminalItemUntouched 迟到消息:item 已终态, +// 额度早已正常释放。这是唯一的预期路径,任何"修复"动作都会让已跑完的 item 重跑。 +func TestReservationAbsent_LeavesTerminalItemUntouched(t *testing.T) { + for _, st := range []entity.ItemRunState{ + entity.ItemRunState_Success, entity.ItemRunState_Fail, entity.ItemRunState_Terminal, + } { + ctrl := gomock.NewController(t) + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{{ItemID: 4, Status: int32(st)}}, nil) + // 不 EXPECT RequeueProcessingItem / ResetQuotaReserved —— gomock 会让意外调用直接失败, + // 这正是本用例要守的:终态 item 一个字段都不能动。 + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + } + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + t.Fatalf("终态 item 不得执行, state: %v", st) + return nil + })(admittedEnforceCtx(event), event)) + ctrl.Finish() + } +} + +// TestReservationAbsent_ResetsStaleQueueingReserved 还没兑现执行的那一半: +// Queueing/reserved 但账本已无 reservation ⇒ 清回 none 让它重新可授予。 +// 若不清,LoadDispatchRuntime 会一直把它算进占用(Queueing/reserved 计入占用)。 +func TestReservationAbsent_ResetsStaleQueueingReserved(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Queueing), QuotaReservationState: entity.QuotaReservationStateReserved}, + }, nil) + dispatchRepo.EXPECT().ResetQuotaReserved(gomock.Any(), int64(3), int64(1), int64(2), []int64{4}). + Return([]int64{4}, nil) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + } + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + t.Fatal("Queueing/reserved 的 item 本轮不得执行") + return nil + })(admittedEnforceCtx(event), event)) +} + +// TestReservationAbsent_KeepsProjectionWhenLoadFails 读不到投影时必须什么都不动。 +// 盲目退回会让「其实已终态」的 item 重跑 —— 宁可退化成原来的行为(等僵尸阈值)。 +func TestReservationAbsent_KeepsProjectionWhenLoadFails(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, errors.New("db down")) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + } + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + t.Fatal("读投影失败时不得执行 item") + return nil + })(admittedEnforceCtx(event), event)) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 83a9270dde..0b268c37b7 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -291,9 +291,9 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE return err } if !ok { - // reservation 不存在:迟到消息、账本已重建、或已被释放。丢弃不执行。 - logs.CtxInfo(ctx, "[CentralReservation] reservation absent, discard event, expt_run_id: %v, item_id: %v", - event.ExptRunID, event.EvalSetItemID) + // reservation 不存在:迟到消息、账本已重建、或已被释放。本条消息一律不执行, + // 但**必须先修正 run log 投影**,否则 item 会停在 Processing 占着并发槽位。 + e.requeueOrphanedItemOnReservationAbsent(ctx, event) return nil } @@ -394,6 +394,99 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE } } +// requeueOrphanedItemOnReservationAbsent 处理「ConfirmRunning 判定 reservation 不存在」时的投影修正。 +// +// 这个分支以前只打一条 Info 就 return,是一个能让整个实验停摆的静默故障: +// consumer 已经用 StartReservedItem 把 run log 兑现成 Processing/none,之后额度因故消失 +// (执行出错 → MQ 重投、自愈误判陈旧预占而释放、账本重建等),重投的消息在此被丢弃, +// item 就成了「既无额度、也无执行者」的孤儿。而 Processing 会同时被 +// ScanEvalItems(算 item_concur_num 槽位)和 LoadDispatchRuntime(算并发占用)当成"在跑", +// 于是既不派新 item、也永远等不到它完成,只能靠异步僵尸阈值(默认 3h)兜底判 Fail。 +// 2026-08-28 线上实测:两个实验各 20 个 item 占满槽位 77 分钟,turn 表零记录、账本零 reservation。 +// +// 三种形状分开处理,因为它们的正确动作完全不同: +// - 终态 —— 迟到消息,item 早已跑完,什么都不能动(退回会重复执行) +// - Processing —— 孤儿态,退回 Queueing 让它重新被授予(连带回滚 stats 的 Processing 计数) +// - Queueing —— 还没兑现执行;reserved 的交给 ResetQuotaReserved 清回 none,none 的本就在队列里 +// +// 全程只告警不返回错误:这条消息注定不执行,返回 error 只会让 MQ 无休止重投 +// (reservation 不会因为重投而回来)。修正失败最坏退化成原来的行为 —— 等僵尸阈值。 +func (e *ExptItemEventEvalServiceImpl) requeueOrphanedItemOnReservationAbsent(ctx context.Context, event *entity.ExptItemEvalEvent) { + if e.dispatchRepo == nil { + logs.CtxWarn(ctx, "[CentralReservation] reservation absent, discard event; dispatch repo absent, cannot repair projection, expt_id: %v, expt_run_id: %v, item_id: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID) + return + } + + obs, err := e.dispatchRepo.MGetDispatchObservations(ctx, event.SpaceID, event.ExptID, event.ExptRunID, []int64{event.EvalSetItemID}) + if err != nil || len(obs) == 0 || obs[0] == nil { + // 读不到投影就无法判断该不该退回,宁可不动 —— 盲目退回可能让已终态的 item 重跑。 + logs.CtxWarn(ctx, "[CentralReservation] reservation absent, discard event; load projection failed, projection left as-is, expt_id: %v, expt_run_id: %v, item_id: %v, err: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID, err) + return + } + + state := entity.ItemRunState(obs[0].Status) + if entity.IsItemRunFinished(state) { + // 唯一的预期路径:item 已终态,额度早已正常释放,这就是一条迟到消息。 + logs.CtxInfo(ctx, "[CentralReservation] reservation absent on terminal item, discard event (late delivery), expt_run_id: %v, item_id: %v, state: %v", + event.ExptRunID, event.EvalSetItemID, state) + return + } + + if state == entity.ItemRunState_Processing { + requeued, rerr := e.dispatchRepo.RequeueProcessingItem(ctx, event.SpaceID, event.ExptID, event.ExptRunID, event.EvalSetItemID) + if rerr != nil { + logs.CtxError(ctx, "[CentralReservation] requeue orphaned processing item failed, item will occupy a concurrency slot until zombie timeout, expt_id: %v, expt_run_id: %v, item_id: %v, err: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID, rerr) + return + } + logs.CtxWarn(ctx, "[CentralReservation] orphaned item (Processing without reservation) requeued to Queueing, expt_id: %v, expt_run_id: %v, item_id: %v, requeued: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID, requeued) + + // stats 的 Processing 桶必须同步退回,否则 processing_turn_count 只增不减。 + // 只在 CAS 真的命中时记账 —— 与 StartReservedItem 处 started 的用法同理, + // CAS 是这里唯一的"恰好一次"信号。 + if requeued && e.exptStatsRepo != nil { + if serr := e.exptStatsRepo.ArithOperateCount(ctx, event.ExptID, event.SpaceID, &entity.StatsCntArithOp{ + OpStatusCnt: map[entity.ItemRunState]int{ + entity.ItemRunState_Processing: -1, + entity.ItemRunState_Queueing: 1, + }, + }); serr != nil { + logs.CtxWarn(ctx, "[CentralReservation] rollback expt stats to Queueing failed (display only), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, serr) + } + } + + // 主表同为展示投影,跟着退回,避免详情页把已回队列的 item 一直显示成执行中。 + if e.exptItemResultRepo != nil { + if uerr := e.exptItemResultRepo.UpdateItemsResult(ctx, event.SpaceID, event.ExptID, + []int64{event.EvalSetItemID}, map[string]any{"status": int32(entity.ItemRunState_Queueing)}); uerr != nil { + logs.CtxWarn(ctx, "[CentralReservation] rollback main table to Queueing failed (display only), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, uerr) + } + } + return + } + + // 剩下只有 Queueing:reserved 的清回 none 让它重新可授予;none 的本就在候选里,无需动作。 + if obs[0].QuotaReservationState.IsQuotaReserved() { + reset, rerr := e.dispatchRepo.ResetQuotaReserved(ctx, event.SpaceID, event.ExptID, event.ExptRunID, []int64{event.EvalSetItemID}) + if rerr != nil { + logs.CtxError(ctx, "[CentralReservation] reset stale reserved projection failed, expt_id: %v, expt_run_id: %v, item_id: %v, err: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID, rerr) + return + } + logs.CtxWarn(ctx, "[CentralReservation] stale Queueing/reserved projection reset to none, expt_id: %v, expt_run_id: %v, item_id: %v, reset: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID, len(reset)) + return + } + + logs.CtxWarn(ctx, "[CentralReservation] reservation absent while item still Queueing/none, nothing to repair, expt_id: %v, expt_run_id: %v, item_id: %v", + event.ExptID, event.ExptRunID, event.EvalSetItemID) +} + // releaseQuotaIfItemTerminal 在 item 确已进入终态时释放其额度预占。 // // 判定依据是 run log 的实际状态,而不是 execErr 是否为 nil: diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go index 202a0970a8..e074b56328 100644 --- a/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go +++ b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl.go @@ -36,6 +36,10 @@ func (e *ExptItemDispatchRepoImpl) StartReservedItem(ctx context.Context, spaceI return e.dispatchDAO.StartReservedItem(ctx, spaceID, exptID, exptRunID, itemID) } +func (e *ExptItemDispatchRepoImpl) RequeueProcessingItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + return e.dispatchDAO.RequeueProcessingItem(ctx, spaceID, exptID, exptRunID, itemID) +} + func (e *ExptItemDispatchRepoImpl) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) { return e.dispatchDAO.MGetDispatchObservations(ctx, spaceID, exptID, exptRunID, itemIDs) } diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go index a372e6b9b9..091cb33111 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go @@ -21,6 +21,7 @@ type IExptItemDispatchDAO interface { ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*repo.ExptDispatchRuntime, error) StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) + RequeueProcessingItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) } @@ -178,6 +179,25 @@ func (d *exptItemDispatchDAOImpl) StartReservedItem(ctx context.Context, spaceID return res.RowsAffected > 0, nil } +func (d *exptItemDispatchDAOImpl) RequeueProcessingItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + // Processing/none → Queueing/none:孤儿 item 退回授予候选。 + // + // 条件里同时钉住 status=Processing 与 quota_reservation_state=none,是为了让这次退回 + // 只作用于「已兑现执行但额度已消失」这一种形状: + // - status 若已是终态,说明消息只是迟到,item 早就跑完了,不能退回(会重复执行); + // - qrs 若是 reserved,说明它还没被 StartReservedItem 兑现,该走 ResetQuotaReserved。 + res := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). + Where("space_id = ? AND expt_id = ? AND expt_run_id = ? AND item_id = ?", + spaceID, exptID, exptRunID, itemID). + Where("status = ?", int32(entity.ItemRunState_Processing)). + Where("quota_reservation_state = ?", int32(entity.QuotaReservationStateNone)). + Update("status", int32(entity.ItemRunState_Queueing)) + if res.Error != nil { + return false, errorx.Wrapf(res.Error, "requeue processing item fail, expt_run_id: %v, item_id: %v", exptRunID, itemID) + } + return res.RowsAffected > 0, nil +} + func (d *exptItemDispatchDAOImpl) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) { if len(itemIDs) == 0 { return nil, nil From aa204ea7f51fb58f9ae26f2dfceda6cd36704409 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Sat, 29 Aug 2026 00:03:29 +0800 Subject: [PATCH 45/58] =?UTF-8?q?fix(evaluation):=20=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E5=8A=A0=E5=9B=BA=E4=B8=A4=E5=A4=84=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E4=B8=BB=E8=A1=A8=E5=86=99=E7=BB=91=20CAS?= =?UTF-8?q?=20=E7=BB=93=E6=9E=9C=E3=80=81requeue=20=E4=B8=8D=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=83=B5=E5=B0=B8=E6=97=B6=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 44f52a92 的两处漏洞,均由独立 review 指出: 1. 主表 UpdateItemsResult 没绑 requeued,而 stats 绑了。 主表 status 不是纯展示字段,是 stats 的锚点 —— 完成侧 statsCntOp 读 items_result.Status 做「-1」。CAS 未命中时(并发 handleZombies / sweepTerminatedSandboxItems 跑在实验锁下、不持 item 锁,可在读投影与 CAS 之间 抢先落终态)stats 仍挂着 Processing +1 而主表已被改成 Queueing, 完成侧就去减 Queueing 桶 ⇒ processing_turn_count 永远归不了零。 同仓 expt_run_scheduler_event_impl.go 的 zombie 路径已就此写过警示, c4a6a953 也已就此修过一次。 2. RequeueProcessingItem 用 Update 会刷新 updated_at,而僵尸判定正是 `Processing 且 time.Since(updated_at) > zombieSecond`。若「reservation 消失」 是持续性成因(账本损坏、reap 竞态反复触发),item 会在 Queueing ↔ Processing 之间无限往返、每次把僵尸时钟拨回零,实验永不收敛 —— 等于把「3 小时后必定 收敛」的有界故障换成无界故障。改用 UpdateColumn 保留原始 updated_at: 一次性成因下 item 正常重跑,持续性成因下仍在原定 3 小时被判 Fail, 最坏情况不劣于修复前。 补一条 CAS 未命中的用例 —— 原有四个用例挡不住第 1 条。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_orphaned_item_requeue_test.go | 49 +++++++++++++++++++ .../service/expt_run_item_event_impl.go | 9 +++- .../experiment/mysql/expt_item_dispatch.go | 10 +++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go index b59f3362be..ae06291b6b 100644 --- a/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go +++ b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go @@ -163,3 +163,52 @@ func TestReservationAbsent_KeepsProjectionWhenLoadFails(t *testing.T) { return nil })(admittedEnforceCtx(event), event)) } + +// TestReservationAbsent_CASMissLeavesStatsAndMainTableUntouched CAS 未命中时, +// stats 与主表都不能动。 +// +// 触发场景:读投影与 CAS 之间有一个无锁窗口,`handleZombies` / `sweepTerminatedSandboxItems` +// 跑在**实验锁**下、不持 item 锁,可以在这个窗口把 run log 抢先改成终态 —— 于是 CAS 落空。 +// +// 为什么这条必须单独守:主表 status 不是纯展示字段,而是 stats 的锚点(完成侧 statsCntOp +// 读 items_result.Status 做「-1」)。CAS 落空却把主表改成 Queueing,会让 stats 上那笔 +// Processing 永远减不掉,processing_turn_count 归不了零 —— 同仓已就此修过一次(c4a6a953)。 +func TestReservationAbsent_CASMissLeavesStatsAndMainTableUntouched(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Processing), QuotaReservationState: entity.QuotaReservationStateNone}, + }, nil) + // CAS 未命中:并发路径已经把这条 run log 改走了。 + dispatchRepo.EXPECT().RequeueProcessingItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil) + + // 两个 mock 都不 EXPECT 任何调用 —— gomock 会让意外调用直接失败,这正是本用例的断言。 + statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + exptStatsRepo: statsRepo, + exptItemResultRepo: itemResultRepo, + } + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + t.Fatal("CAS 未命中时不得执行 item") + return nil + })(admittedEnforceCtx(event), event)) +} + +// TestReservationAbsent_NoDispatchRepoIsSafe dispatchRepo 缺失时不得执行 item,也不得 panic。 +func TestReservationAbsent_NoDispatchRepoIsSafe(t *testing.T) { + svc := &ExptItemEventEvalServiceImpl{centralGuard: &fakeGuard{confirmResult: false}} + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + t.Fatal("无法修正投影时不得执行 item") + return nil + })(admittedEnforceCtx(event), event)) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 0b268c37b7..2a55ae377d 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -460,7 +460,14 @@ func (e *ExptItemEventEvalServiceImpl) requeueOrphanedItemOnReservationAbsent(ct } // 主表同为展示投影,跟着退回,避免详情页把已回队列的 item 一直显示成执行中。 - if e.exptItemResultRepo != nil { + // + // ★ 必须与 stats 一样绑定 requeued:主表 status 不是纯展示字段,而是 stats 的锚点 —— + // 完成侧 statsCntOp 读 items_result.Status 做「-1」(expt_result_impl.go), + // 若 CAS 未命中(并发 zombie / sandbox sweep 抢先落终态)却把主表改成 Queueing, + // stats 上那笔 Processing 就再也减不掉,processing_turn_count 永远归不了零。 + // 同仓 expt_run_scheduler_event_impl.go 的 zombie 路径已就此写过警示、 + // c4a6a953 也已就此修过一次,这里不能再踩。 + if requeued && e.exptItemResultRepo != nil { if uerr := e.exptItemResultRepo.UpdateItemsResult(ctx, event.SpaceID, event.ExptID, []int64{event.EvalSetItemID}, map[string]any{"status": int32(entity.ItemRunState_Queueing)}); uerr != nil { logs.CtxWarn(ctx, "[CentralReservation] rollback main table to Queueing failed (display only), expt_id: %v, item_id: %v: %v", diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go index 091cb33111..63ee556206 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_dispatch.go @@ -186,12 +186,20 @@ func (d *exptItemDispatchDAOImpl) RequeueProcessingItem(ctx context.Context, spa // 只作用于「已兑现执行但额度已消失」这一种形状: // - status 若已是终态,说明消息只是迟到,item 早就跑完了,不能退回(会重复执行); // - qrs 若是 reserved,说明它还没被 StartReservedItem 兑现,该走 ResetQuotaReserved。 + // ★ 用 UpdateColumn 而非 Update:**刻意不刷新 `updated_at`**。 + // + // 僵尸兜底的判据是 `Processing 且 time.Since(updated_at) > zombieSecond`。若这里刷新时间戳, + // 而"reservation 消失"是个持续性成因(账本损坏、reap 竞态反复触发),item 就会在 + // Queueing ↔ Processing 之间无限往返、每次都把僵尸时钟拨回零,实验永不收敛 —— + // 那是把「3 小时后必定收敛」的有界故障换成了无界故障。 + // 保留原始 updated_at 让僵尸时钟继续走:一次性成因下 item 正常重跑, + // 持续性成因下它仍会在原定的 3 小时被判 Fail,最坏情况不劣于修复前。 res := d.db.NewSession(ctx).Model(&model.ExptItemResultRunLog{}). Where("space_id = ? AND expt_id = ? AND expt_run_id = ? AND item_id = ?", spaceID, exptID, exptRunID, itemID). Where("status = ?", int32(entity.ItemRunState_Processing)). Where("quota_reservation_state = ?", int32(entity.QuotaReservationStateNone)). - Update("status", int32(entity.ItemRunState_Queueing)) + UpdateColumn("status", int32(entity.ItemRunState_Queueing)) if res.Error != nil { return false, errorx.Wrapf(res.Error, "requeue processing item fail, expt_run_id: %v, item_id: %v", exptRunID, itemID) } From 37096aa0034a00b7de2f3c610e133d470b0711f2 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 11:28:05 +0800 Subject: [PATCH 46/58] =?UTF-8?q?test(evaluation):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E3=80=8C=E9=A2=9D=E5=BA=A6=E4=B8=8D=E5=8F=AF=E6=BB=A1=E8=B6=B3?= =?UTF-8?q?=E3=80=8D=E9=94=99=E8=AF=AF=E7=A0=81=E4=B8=8E=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=B1=82=E5=8F=8D=E8=A7=A3=E7=9A=84=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前这条错误码零测试。补两层,分别钉住两类不同的失效: errno 层 —— 序列化 round-trip 与「不与僵尸超时串味」。round-trip 是这条码的真实 契约(调度侧写进 err_msg 落库、结果层反解展示),任一侧编解码不一致,用户看到的 就是"失败但无原因"。互不命中同样关键:两者共用 err_msg 字段,标错会给出相反的 处置建议(僵尸该等/重跑,额度不可满足该改配置)。 结果层 —— 错误码有测试 ≠ 有人真去读它。分支漏接的表现不是报错,而是 item 显示 失败却没有原因;另配一条反向用例,确认僵尸超时不会被新分支抢走。 四条反向变异逐条实跑确认检出:文案漏掉 amount/limit、Parse 不校验 code、 结果层漏接分支、僵尸分支错标成额度码。modules/evaluation 55 包全绿。 Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/service/expt_result_impl_test.go | 52 ++++++++++++++ .../modules/evaluation/pkg/errno/biz_test.go | 69 +++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_result_impl_test.go b/backend/modules/evaluation/domain/service/expt_result_impl_test.go index 328de7cec5..2baa1ae689 100644 --- a/backend/modules/evaluation/domain/service/expt_result_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_result_impl_test.go @@ -8880,3 +8880,55 @@ func TestProvideExptItemRefRepos(t *testing.T) { require.Len(t, got, 1) assert.Equal(t, r, got[0]) } +// TestNewPayloadBuilder_ItemQuotaImpossibleErrParsing 覆盖「额度不可满足」的反解分支。 +// +// 为什么这层必须单测:错误码本身有单测(errno 包),但"结果层有没有真去读它"是另一回事 —— +// 分支写错/漏接的表现不是报错,而是 item 显示成失败却没有原因,用户无从判断该改什么。 +func TestNewPayloadBuilder_ItemQuotaImpossibleErrParsing(t *testing.T) { + newBuilderWithErrMsg := func(t *testing.T, errMsg string) *PayloadBuilder { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + return NewPayloadBuilder( + context.Background(), + &entity.MGetExperimentResultParam{SpaceID: 100, ExptIDs: []int64{1}}, + 1, + []*entity.ExptTurnResult{{ID: 1, ItemID: 1, TurnID: 0, TurnIdx: 0}}, + []*entity.ExptItemResult{{ItemID: 1, ItemIdx: 0, Status: entity.ItemRunState_Fail, ErrMsg: errMsg}}, + repoMocks.NewMockIExperimentRepo(ctrl), + repoMocks.NewMockIExptTurnResultRepo(ctrl), + repoMocks.NewMockIExptAnnotateRepo(ctrl), + svcMocks.NewMockIEvalTargetService(ctrl), + svcMocks.NewMockEvaluatorRecordService(ctrl), + svcMocks.NewMockEvaluationSetItemService(ctrl), + nil, nil, nil, + nil, nil, + map[int64]entity.ItemRunState{}, + nil, nil, + ) + } + + t.Run("err_msg 命中额度不可满足时挂到 SystemInfo.Error", func(t *testing.T) { + builder := newBuilderWithErrMsg(t, errno.SerializeErr( + errno.NewItemQuotaImpossibleErr("sandbox|default", 5000, 8))) + + require.Len(t, builder.ItemResults, 1) + require.NotNil(t, builder.ItemResults[0].SystemInfo) + require.NotNil(t, builder.ItemResults[0].SystemInfo.Error) + assert.Equal(t, int64(errno.ItemQuotaImpossibleCode), builder.ItemResults[0].SystemInfo.Error.Code) + require.NotNil(t, builder.ItemResults[0].SystemInfo.Error.Detail) + // 具体维度与数值都要透到前端:只给一句"额度不足"用户不知道改哪一维、改到多少 + assert.Contains(t, *builder.ItemResults[0].SystemInfo.Error.Detail, "sandbox|default") + assert.Contains(t, *builder.ItemResults[0].SystemInfo.Error.Detail, "5000") + assert.Contains(t, *builder.ItemResults[0].SystemInfo.Error.Detail, "8") + }) + + // ★ 两种失败原因共用 err_msg 字段、由结果层依次尝试反解,必须各归各位。 + // 标错的后果是给出相反的处置建议:僵尸超时该等/重跑,额度不可满足该改配置。 + t.Run("僵尸超时仍归僵尸码,不被额度分支抢走", func(t *testing.T) { + builder := newBuilderWithErrMsg(t, errno.SerializeErr(errno.NewItemZombieTimeoutErr(120, false))) + + require.NotNil(t, builder.ItemResults[0].SystemInfo.Error) + assert.Equal(t, int64(errno.ItemZombieTimeoutCode), builder.ItemResults[0].SystemInfo.Error.Code) + }) +} diff --git a/backend/modules/evaluation/pkg/errno/biz_test.go b/backend/modules/evaluation/pkg/errno/biz_test.go index 549f2dab0f..7cb53fa35e 100644 --- a/backend/modules/evaluation/pkg/errno/biz_test.go +++ b/backend/modules/evaluation/pkg/errno/biz_test.go @@ -99,3 +99,72 @@ func TestNewSandboxTerminatedBeforeReportErr(t *testing.T) { assert.Equal(t, SandboxTerminatedBeforeReportCode, rei.Code) assert.Equal(t, ei.Msg, rei.Msg) } + +func TestNewItemQuotaImpossibleErr(t *testing.T) { + err := NewItemQuotaImpossibleErr("sandbox|default", 5000, 8) + ei, ok := ParseErrImpl(err) + assert.True(t, ok) + assert.Equal(t, ItemQuotaImpossibleCode, ei.Code) + // 三个数值都必须在文案里:用户要靠它判断是改申报量还是调上限。 + // 少了任何一个,错误信息就退化成"配额不够"这种无法据此行动的提示。 + assert.Contains(t, ei.Msg, "sandbox|default") + assert.Contains(t, ei.Msg, "5000") + assert.Contains(t, ei.Msg, "8") + + // ★ Round-trip 是这条错误码的真正契约:调度器把它序列化进 err_msg 落库, + // 结果层再反解出来展示。任一侧编解码不一致,用户看到的就是"失败但无原因"。 + round := DeserializeErr(conv.UnsafeStringToBytes(SerializeErr(err))) + rei, ok := ParseErrImpl(round) + assert.True(t, ok) + assert.Equal(t, ItemQuotaImpossibleCode, rei.Code) + assert.Equal(t, ei.Msg, rei.Msg) +} + +func TestParseItemQuotaImpossibleErr(t *testing.T) { + t.Run("命中 ItemQuotaImpossible 错误码", func(t *testing.T) { + ok, msg := ParseItemQuotaImpossibleErr(NewItemQuotaImpossibleErr("model|gpt5.5", 1000, 100)) + assert.True(t, ok) + assert.Contains(t, msg, "model|gpt5.5") + }) + + t.Run("Round-trip 后仍可反解", func(t *testing.T) { + err := NewItemQuotaImpossibleErr("sandbox|aio", 20, 2) + round := DeserializeErr(conv.UnsafeStringToBytes(SerializeErr(err))) + ok, msg := ParseItemQuotaImpossibleErr(round) + assert.True(t, ok) + assert.Contains(t, msg, "sandbox|aio") + }) + + // ★ 必须与僵尸超时互不串味:两者都写在同一个 err_msg 字段里,由结果层依次尝试反解。 + // 若彼此都能命中,item 的失败原因就会被标成另一种 —— "卡了太久"与"配置放不下" + // 对用户的处置建议完全相反(等一等 vs 改配置)。 + t.Run("僵尸超时错误不命中", func(t *testing.T) { + ok, msg := ParseItemQuotaImpossibleErr(NewItemZombieTimeoutErr(120, false)) + assert.False(t, ok) + assert.Empty(t, msg) + }) + + t.Run("额度不可满足错误不被僵尸解析器命中", func(t *testing.T) { + ok, msg := ParseItemZombieTimeoutErr(NewItemQuotaImpossibleErr("sandbox|default", 5000, 8)) + assert.False(t, ok) + assert.Empty(t, msg) + }) + + t.Run("其他 ErrImpl 类型不命中", func(t *testing.T) { + ok, msg := ParseItemQuotaImpossibleErr(NewTargetResultErr("x")) + assert.False(t, ok) + assert.Empty(t, msg) + }) + + t.Run("非 ErrImpl 类型不命中", func(t *testing.T) { + ok, msg := ParseItemQuotaImpossibleErr(errors.New("plain")) + assert.False(t, ok) + assert.Empty(t, msg) + }) + + t.Run("nil error 不命中", func(t *testing.T) { + ok, msg := ParseItemQuotaImpossibleErr(nil) + assert.False(t, ok) + assert.Empty(t, msg) + }) +} From 5601f145759da02f61c94438dfd7962190141aa7 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 12:00:30 +0800 Subject: [PATCH 47/58] =?UTF-8?q?fix(evaluation):=20=E4=BF=AE=20CI=20lint?= =?UTF-8?q?=20QF1008=20=E2=80=94=E2=80=94=20=E5=8E=BB=E6=8E=89=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E7=9A=84=20Dialector=20=E9=80=89=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit staticcheck 报 `could remove embedded field "Dialector" from selector`: gorm.DB 内嵌了 Dialector,Explain 直接从 gormDB 上就能调。行为完全等价。 Co-Authored-By: Claude Opus 5 (1M context) --- .../repo/experiment/mysql/expt_update_frozen_columns_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go index 501d8db8d1..3a881c1eec 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_update_frozen_columns_test.go @@ -34,7 +34,7 @@ func renderExptUpdate(t *testing.T, expt *model.Experiment) string { Omit(schedulingFrozenColumns...). Updates(expt) // DryRun 下 Statement.SQL 可能为空,用 Explain 把 SQL + 参数一起还原成可断言的文本。 - return gormDB.Dialector.Explain(tx.Statement.SQL.String(), tx.Statement.Vars...) + return gormDB.Explain(tx.Statement.SQL.String(), tx.Statement.Vars...) } // TestExptDAO_Update_NeverClobbersFrozenSchedulingColumns 守住「调度列创建时冻结」这条不变量。 From cfb0debb3f03ed094fab2f205448ea717b1f253c Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 13:26:58 +0800 Subject: [PATCH 48/58] =?UTF-8?q?test(evaluation):=20=E8=A1=A5=E6=B4=BE?= =?UTF-8?q?=E5=8F=91=E6=8A=95=E5=BD=B1=20repo=20=E4=B8=8E=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E9=98=9F=E5=88=97=E6=89=AB=E6=8F=8F=E7=9A=84=E5=8D=95?= =?UTF-8?q?=E6=B5=8B=EF=BC=8Cpatch=20=E8=A6=86=E7=9B=96=E7=8E=87=2079.18%?= =?UTF-8?q?=E2=86=9283.19%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codecov 的 patch 门槛是 80%(threshold 0%、informational:false,会 gate),本分支 实测只有 79.18% —— 差 0.82 个点。补的两个文件此前都是 0 覆盖。 派发投影 repo 是纯透传层,所以用例只钉两件事:入参**按位置**原样转发、返回值与 错误原样上抛。每个方法用精确实参匹配而不是 gomock.Any()——透传层唯一真实的失效 模式就是把 spaceID/exptID/exptRunID 这几个同类型 int64 传错位置,用 Any() 就恰好 放过了唯一要防的那类 bug。CAS 未命中必须回 (false, nil) 也单独钉住:改写成 true 会让重复投递的 item 执行两次。 ScanSchedulerQueue 三条分支各对应一个真实后果:入参原样下推(Scope 丢了会扫出别的 Scope 的实验)、单条 eval_conf 损坏跳过而非整批失败(否则一条脏数据永久阻塞该 Scope 下所有实验的调度)、DAO 出错必须上抛(静默回空会被调度器读成"队列里没候选", 表现是集体不动)。 顺带生成缺失的 IExptItemDispatchDAO mock(按文件里既有的 go:generate 指令)。 四条反向变异逐条实跑确认检出:坏 payload 改为整批失败、DAO 出错静默回空、 exptID/exptRunID 传反、CAS 未命中改写成 true。 55 包全绿;CI 同款 golangci-lint v2.2.1 三档(新增/模块全量/全 backend)均 0 issues。 Co-Authored-By: Claude Opus 5 (1M context) --- .../expt_item_dispatch_repo_impl_test.go | 231 ++++++++++++++++++ .../repo/experiment/expt_repo_impl_test.go | 95 +++++++ .../mysql/mocks/expt_item_dispatch.go | 132 ++++++++++ 3 files changed, 458 insertions(+) create mode 100644 backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl_test.go create mode 100644 backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_dispatch.go diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl_test.go b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl_test.go new file mode 100644 index 0000000000..7611d4b7c9 --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/expt_item_dispatch_repo_impl_test.go @@ -0,0 +1,231 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package experiment + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql/mocks" +) + +// 本文件覆盖派发投影 repo。它是纯透传层,所以用例只钉两件事: +// +// 1. **入参按位置原样转发** —— 一个透传层唯一真实的失效模式就是把 +// spaceID / exptID / exptRunID 这几个同类型的 int64 传错位置。传错既不报错也不 +// 编译失败,表现是查了另一个实验的数据。所以每个方法都用精确实参匹配, +// 不用 gomock.Any():用 Any() 就恰好放过了唯一要防的那类 bug。 +// 2. **返回值与错误原样上抛** —— 不吞、不改写。 + +const ( + testSpaceID int64 = 100 + testExptID int64 = 200 + testExptRunID int64 = 300 + testItemID int64 = 400 +) + +func newDispatchRepoFixture(t *testing.T) (*ExptItemDispatchRepoImpl, *mocks.MockIExptItemDispatchDAO) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + dao := mocks.NewMockIExptItemDispatchDAO(ctrl) + return &ExptItemDispatchRepoImpl{dispatchDAO: dao}, dao +} + +func TestNewExptItemDispatchRepo(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + r := NewExptItemDispatchRepo(mocks.NewMockIExptItemDispatchDAO(ctrl)) + require.NotNil(t, r) + // 构造函数必须真的把 DAO 装进去:装了 nil 的话所有方法都在第一次调用时 panic + impl, ok := r.(*ExptItemDispatchRepoImpl) + require.True(t, ok) + assert.NotNil(t, impl.dispatchDAO) +} + +func TestExptItemDispatchRepo_ClaimQuotaReserved(t *testing.T) { + itemIDs := []int64{1, 2, 3} + + t.Run("成功:实参按位置转发,返回值原样上抛", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT(). + ClaimQuotaReserved(gomock.Any(), testSpaceID, testExptID, testExptRunID, itemIDs). + Return([]int64{1, 3}, nil) + + got, err := r.ClaimQuotaReserved(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.NoError(t, err) + assert.Equal(t, []int64{1, 3}, got) + }) + + t.Run("失败:错误不被吞", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("dao down") + dao.EXPECT().ClaimQuotaReserved(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, wantErr) + + got, err := r.ClaimQuotaReserved(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.ErrorIs(t, err, wantErr) + assert.Nil(t, got) + }) +} + +func TestExptItemDispatchRepo_ResetQuotaReserved(t *testing.T) { + itemIDs := []int64{7, 8} + + t.Run("成功", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT(). + ResetQuotaReserved(gomock.Any(), testSpaceID, testExptID, testExptRunID, itemIDs). + Return(itemIDs, nil) + + got, err := r.ResetQuotaReserved(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.NoError(t, err) + assert.Equal(t, itemIDs, got) + }) + + t.Run("失败", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("reset fail") + dao.EXPECT().ResetQuotaReserved(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, wantErr) + + _, err := r.ResetQuotaReserved(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.ErrorIs(t, err, wantErr) + }) +} + +func TestExptItemDispatchRepo_LoadDispatchRuntime(t *testing.T) { + t.Run("成功:candidateLimit 一并转发", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + want := &repo.ExptDispatchRuntime{ + OccupiedItemIDs: []int64{1, 2}, + CandidateItemIDs: []int64{3, 4, 5}, + } + // candidateLimit 传错(比如恒传 0)会让调度器每拍都拿不到候选, + // 表现是"实验在跑但一个 item 都不派",所以它也要精确匹配。 + dao.EXPECT(). + LoadDispatchRuntime(gomock.Any(), testSpaceID, testExptID, testExptRunID, 2000). + Return(want, nil) + + got, err := r.LoadDispatchRuntime(context.Background(), testSpaceID, testExptID, testExptRunID, 2000) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("失败", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("load fail") + dao.EXPECT().LoadDispatchRuntime(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, wantErr) + + got, err := r.LoadDispatchRuntime(context.Background(), testSpaceID, testExptID, testExptRunID, 10) + require.ErrorIs(t, err, wantErr) + assert.Nil(t, got) + }) +} + +func TestExptItemDispatchRepo_StartReservedItem(t *testing.T) { + // CAS 未命中返回 (false, nil) 而不是错误 —— 调用方据此决定是否继续执行 item, + // 若这里把 false 改写成 true,重复投递的 item 会被执行两次。 + t.Run("CAS 命中返回 true", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT(). + StartReservedItem(gomock.Any(), testSpaceID, testExptID, testExptRunID, testItemID). + Return(true, nil) + + started, err := r.StartReservedItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.NoError(t, err) + assert.True(t, started) + }) + + t.Run("CAS 未命中返回 false 且不报错", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil) + + started, err := r.StartReservedItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.NoError(t, err) + assert.False(t, started, "CAS 未命中必须回 false —— 改写成 true 会让重复投递的 item 执行两次") + }) + + t.Run("失败", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("start fail") + dao.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, wantErr) + + _, err := r.StartReservedItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.ErrorIs(t, err, wantErr) + }) +} + +func TestExptItemDispatchRepo_RequeueProcessingItem(t *testing.T) { + t.Run("退回成功", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT(). + RequeueProcessingItem(gomock.Any(), testSpaceID, testExptID, testExptRunID, testItemID). + Return(true, nil) + + requeued, err := r.RequeueProcessingItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.NoError(t, err) + assert.True(t, requeued) + }) + + t.Run("CAS 未命中返回 false 且不报错", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + dao.EXPECT().RequeueProcessingItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil) + + requeued, err := r.RequeueProcessingItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.NoError(t, err) + assert.False(t, requeued) + }) + + t.Run("失败", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("requeue fail") + dao.EXPECT().RequeueProcessingItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, wantErr) + + _, err := r.RequeueProcessingItem(context.Background(), testSpaceID, testExptID, testExptRunID, testItemID) + require.ErrorIs(t, err, wantErr) + }) +} + +func TestExptItemDispatchRepo_MGetDispatchObservations(t *testing.T) { + itemIDs := []int64{11, 12} + + t.Run("成功:观测结果原样上抛", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + want := []*repo.ExptDispatchObservation{ + {ItemID: 11, Status: 1, QuotaReservationState: entity.QuotaReservationStateReserved}, + {ItemID: 12, Status: 0, QuotaReservationState: entity.QuotaReservationStateNone}, + } + dao.EXPECT(). + MGetDispatchObservations(gomock.Any(), testSpaceID, testExptID, testExptRunID, itemIDs). + Return(want, nil) + + got, err := r.MGetDispatchObservations(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("失败", func(t *testing.T) { + r, dao := newDispatchRepoFixture(t) + wantErr := errors.New("mget fail") + dao.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, wantErr) + + got, err := r.MGetDispatchObservations(context.Background(), testSpaceID, testExptID, testExptRunID, itemIDs) + require.ErrorIs(t, err, wantErr) + assert.Nil(t, got) + }) +} diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl_test.go b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl_test.go index 632ab02131..9518765c8d 100644 --- a/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl_test.go +++ b/backend/modules/evaluation/infra/repo/experiment/expt_repo_impl_test.go @@ -710,3 +710,98 @@ func TestExptRepoImpl_GetIDsByGroupKey(t *testing.T) { assert.Zero(t, total) }) } + +// TestExptRepo_ScanSchedulerQueue 覆盖跨空间调度队列扫描。 +// +// 三条分支各对应一个真实后果: +// - 正常转换:PO→DO 少带字段,调度器会拿不到 priority/mode 而按缺省处理; +// - **单条 payload 损坏要跳过而非整批失败**:一条脏 eval_conf 若能让整次扫描报错, +// 它会永久阻塞该 Scope 下所有实验的调度(不是这一条跑不了,是全都跑不了); +// - DAO 出错必须上抛:静默回空会被调度器读成"队列里没有候选",表现是集体不动。 +func TestExptRepo_ScanSchedulerQueue(t *testing.T) { + param := &entity.SchedulerQueueScanParam{ + DispatchMode: "enforce", + SchedulerScope: "scope-a", + Statuses: []int32{2, 3}, + Limit: 50, + } + + t.Run("正常转换:入参原样下推,PO 转成 DO", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDAO := mysqlMocks.NewMockIExptDAO(ctrl) + r := &exptRepoImpl{exptDAO: mockDAO} + + // param 必须原样下推:Scope 若在这一层被丢掉,DAO 就会扫出别的 Scope 的实验 + mockDAO.EXPECT().ScanSchedulerQueue(gomock.Any(), param).Return([]*model.Experiment{ + {ID: 11, SpaceID: 100, Name: "a"}, + {ID: 12, SpaceID: 100, Name: "b"}, + }, nil) + + got, err := r.ScanSchedulerQueue(context.Background(), param) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, int64(11), got[0].ID) + assert.Equal(t, int64(12), got[1].ID) + assert.Equal(t, int64(100), got[0].SpaceID) + }) + + t.Run("单条 eval_conf 损坏时跳过该条,其余照常返回", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDAO := mysqlMocks.NewMockIExptDAO(ctrl) + r := &exptRepoImpl{exptDAO: mockDAO} + + broken := []byte("{not-json") + mockDAO.EXPECT().ScanSchedulerQueue(gomock.Any(), gomock.Any()).Return([]*model.Experiment{ + {ID: 21, SpaceID: 100}, + {ID: 22, SpaceID: 100, EvalConf: &broken}, // PO2DO 在此报错 + {ID: 23, SpaceID: 100}, + }, nil) + + got, err := r.ScanSchedulerQueue(context.Background(), param) + require.NoError(t, err, "★ 一条脏数据不能让整次扫描失败 —— 那会永久阻塞整个 Scope 的调度") + require.Len(t, got, 2) + assert.Equal(t, []int64{21, 23}, []int64{got[0].ID, got[1].ID}, "跳过的应恰好是损坏那条") + }) + + t.Run("DAO 出错必须上抛,不能静默回空", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDAO := mysqlMocks.NewMockIExptDAO(ctrl) + r := &exptRepoImpl{exptDAO: mockDAO} + + wantErr := errors.New("scan fail") + mockDAO.EXPECT().ScanSchedulerQueue(gomock.Any(), gomock.Any()).Return(nil, wantErr) + + got, err := r.ScanSchedulerQueue(context.Background(), param) + require.ErrorIs(t, err, wantErr, "静默回空会被调度器读成「队列里没候选」,表现是所有实验集体不动") + assert.Nil(t, got) + }) + + t.Run("空队列返回空切片且不报错", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockDAO := mysqlMocks.NewMockIExptDAO(ctrl) + r := &exptRepoImpl{exptDAO: mockDAO} + + mockDAO.EXPECT().ScanSchedulerQueue(gomock.Any(), gomock.Any()).Return(nil, nil) + + got, err := r.ScanSchedulerQueue(context.Background(), param) + require.NoError(t, err) + assert.Empty(t, got) + }) +} + +func TestNewExptSchedulerQueueRepo(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + r := NewExptSchedulerQueueRepo(mysqlMocks.NewMockIExptDAO(ctrl), mysqlMocks.NewMockIExptEvaluatorRefDAO(ctrl), nil) + require.NotNil(t, r) + // 窄接口的实现体仍是 exptRepoImpl:装配错了(比如漏传 exptDAO)会在首次扫描时 panic + impl, ok := r.(*exptRepoImpl) + require.True(t, ok) + assert.NotNil(t, impl.exptDAO) + assert.NotNil(t, impl.exptEvaluatorRefDAO) +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_dispatch.go b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_dispatch.go new file mode 100644 index 0000000000..1a2c9e7cbb --- /dev/null +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_dispatch.go @@ -0,0 +1,132 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql (interfaces: IExptItemDispatchDAO) +// +// Generated by this command: +// +// mockgen -destination=mocks/expt_item_dispatch.go -package=mocks . IExptItemDispatchDAO +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + repo "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" + gomock "go.uber.org/mock/gomock" +) + +// MockIExptItemDispatchDAO is a mock of IExptItemDispatchDAO interface. +type MockIExptItemDispatchDAO struct { + ctrl *gomock.Controller + recorder *MockIExptItemDispatchDAOMockRecorder + isgomock struct{} +} + +// MockIExptItemDispatchDAOMockRecorder is the mock recorder for MockIExptItemDispatchDAO. +type MockIExptItemDispatchDAOMockRecorder struct { + mock *MockIExptItemDispatchDAO +} + +// NewMockIExptItemDispatchDAO creates a new mock instance. +func NewMockIExptItemDispatchDAO(ctrl *gomock.Controller) *MockIExptItemDispatchDAO { + mock := &MockIExptItemDispatchDAO{ctrl: ctrl} + mock.recorder = &MockIExptItemDispatchDAOMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockIExptItemDispatchDAO) EXPECT() *MockIExptItemDispatchDAOMockRecorder { + return m.recorder +} + +// ClaimQuotaReserved mocks base method. +func (m *MockIExptItemDispatchDAO) ClaimQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClaimQuotaReserved", ctx, spaceID, exptID, exptRunID, itemIDs) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClaimQuotaReserved indicates an expected call of ClaimQuotaReserved. +func (mr *MockIExptItemDispatchDAOMockRecorder) ClaimQuotaReserved(ctx, spaceID, exptID, exptRunID, itemIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClaimQuotaReserved", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).ClaimQuotaReserved), ctx, spaceID, exptID, exptRunID, itemIDs) +} + +// LoadDispatchRuntime mocks base method. +func (m *MockIExptItemDispatchDAO) LoadDispatchRuntime(ctx context.Context, spaceID, exptID, exptRunID int64, candidateLimit int) (*repo.ExptDispatchRuntime, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadDispatchRuntime", ctx, spaceID, exptID, exptRunID, candidateLimit) + ret0, _ := ret[0].(*repo.ExptDispatchRuntime) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadDispatchRuntime indicates an expected call of LoadDispatchRuntime. +func (mr *MockIExptItemDispatchDAOMockRecorder) LoadDispatchRuntime(ctx, spaceID, exptID, exptRunID, candidateLimit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadDispatchRuntime", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).LoadDispatchRuntime), ctx, spaceID, exptID, exptRunID, candidateLimit) +} + +// MGetDispatchObservations mocks base method. +func (m *MockIExptItemDispatchDAO) MGetDispatchObservations(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]*repo.ExptDispatchObservation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MGetDispatchObservations", ctx, spaceID, exptID, exptRunID, itemIDs) + ret0, _ := ret[0].([]*repo.ExptDispatchObservation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MGetDispatchObservations indicates an expected call of MGetDispatchObservations. +func (mr *MockIExptItemDispatchDAOMockRecorder) MGetDispatchObservations(ctx, spaceID, exptID, exptRunID, itemIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetDispatchObservations", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).MGetDispatchObservations), ctx, spaceID, exptID, exptRunID, itemIDs) +} + +// RequeueProcessingItem mocks base method. +func (m *MockIExptItemDispatchDAO) RequeueProcessingItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequeueProcessingItem", ctx, spaceID, exptID, exptRunID, itemID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequeueProcessingItem indicates an expected call of RequeueProcessingItem. +func (mr *MockIExptItemDispatchDAOMockRecorder) RequeueProcessingItem(ctx, spaceID, exptID, exptRunID, itemID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequeueProcessingItem", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).RequeueProcessingItem), ctx, spaceID, exptID, exptRunID, itemID) +} + +// ResetQuotaReserved mocks base method. +func (m *MockIExptItemDispatchDAO) ResetQuotaReserved(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64) ([]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResetQuotaReserved", ctx, spaceID, exptID, exptRunID, itemIDs) + ret0, _ := ret[0].([]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResetQuotaReserved indicates an expected call of ResetQuotaReserved. +func (mr *MockIExptItemDispatchDAOMockRecorder) ResetQuotaReserved(ctx, spaceID, exptID, exptRunID, itemIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResetQuotaReserved", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).ResetQuotaReserved), ctx, spaceID, exptID, exptRunID, itemIDs) +} + +// StartReservedItem mocks base method. +func (m *MockIExptItemDispatchDAO) StartReservedItem(ctx context.Context, spaceID, exptID, exptRunID, itemID int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "StartReservedItem", ctx, spaceID, exptID, exptRunID, itemID) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// StartReservedItem indicates an expected call of StartReservedItem. +func (mr *MockIExptItemDispatchDAOMockRecorder) StartReservedItem(ctx, spaceID, exptID, exptRunID, itemID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartReservedItem", reflect.TypeOf((*MockIExptItemDispatchDAO)(nil).StartReservedItem), ctx, spaceID, exptID, exptRunID, itemID) +} From d2dee4bd3aa39f1632d96ed77863e1849f3a48b3 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 17:34:25 +0800 Subject: [PATCH 49/58] =?UTF-8?q?feat(evaluation):=20UpdateExptRunConf=20?= =?UTF-8?q?=E4=B8=A4=E9=9D=A2=E5=BC=80=E6=94=BE=20priority=5Flevel=20?= =?UTF-8?q?=E4=B8=8E=20expected=5Fquota=5Fconsumption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给已发起实验开出运行中调整调度优先级与单 item 预期资源消耗的入口,普通面与 OpenAPI 面同时开放。字段号与各自的创建接口对齐(92/93、60/61),便于两处对照。 只改 IDL 与生成物;授权闸门与写入逻辑随后提交。 --- .../expt/coze.loop.evaluation.expt.go | 156 ++++++++++++++++- .../coze.loop.evaluation.expt_validator.go | 5 + .../expt/k-coze.loop.evaluation.expt.go | 108 ++++++++++++ .../openapi/coze.loop.evaluation.openapi.go | 158 +++++++++++++++++- .../coze.loop.evaluation.openapi_validator.go | 5 + .../openapi/k-coze.loop.evaluation.openapi.go | 108 ++++++++++++ .../coze.loop.evaluation.expt.thrift | 9 +- .../coze.loop.evaluation.openapi.thrift | 9 +- 8 files changed, 549 insertions(+), 9 deletions(-) diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go index 4c0218b96f..26e8545c1c 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt.go @@ -10601,7 +10601,7 @@ func (p *UpdateExperimentResponse) Field255DeepEqual(src *base.BaseResp) bool { return true } -// UpdateExptRunConfRequest 修改进行中实验的运行配置(并发度 / Item 重试次数)。 +// UpdateExptRunConfRequest 修改进行中实验的运行配置(并发度 / Item 重试次数 / 调度参数)。 // 仅对处于 Pending / Processing 状态的实验生效。 type UpdateExptRunConfRequest struct { WorkspaceID int64 `thrift:"workspace_id,1,required" frugal:"1,required,i64" json:"workspace_id" form:"workspace_id,required" ` @@ -10609,8 +10609,14 @@ type UpdateExptRunConfRequest struct { // 评测项并发度:不传或 0 表示不修改;范围 (0, MaxItemConcurNum] ItemConcurNum *int32 `thrift:"item_concur_num,3,optional" frugal:"3,optional,i32" form:"item_concur_num" json:"item_concur_num,omitempty"` // 数据行 Item 最大重试次数:不传表示不修改;0 表示显式设为不重试;范围 [0, 10] - ItemRetryNum *int32 `thrift:"item_retry_num,4,optional" frugal:"4,optional,i32" form:"item_retry_num" json:"item_retry_num,omitempty"` - Base *base.Base `thrift:"Base,255,optional" frugal:"255,optional,base.Base" form:"Base" json:"Base,omitempty" query:"Base"` + ItemRetryNum *int32 `thrift:"item_retry_num,4,optional" frugal:"4,optional,i32" form:"item_retry_num" json:"item_retry_num,omitempty"` + // 以下两个是中心调度特权参数,字段号与 CreateExperimentRequest 对齐(92/93), + // 便于两处对照。未获授权的调用方传了会被丢弃并打 WARN,不报错。 + // 调度优先级:不传表示不修改。改完下一拍生效(调度器每拍从库重扫队列)。 + PriorityLevel *int32 `thrift:"priority_level,92,optional" frugal:"92,optional,i32" form:"priority_level" json:"priority_level,omitempty"` + // 单 item 预期资源消耗:不传表示不修改。改动会同步给该 run 在飞的预占重新计价。 + ExpectedQuotaConsumption *expt.ExpectedQuotaConsumption `thrift:"expected_quota_consumption,93,optional" frugal:"93,optional,expt.ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty"` + Base *base.Base `thrift:"Base,255,optional" frugal:"255,optional,base.Base" form:"Base" json:"Base,omitempty" query:"Base"` } func NewUpdateExptRunConfRequest() *UpdateExptRunConfRequest { @@ -10658,6 +10664,30 @@ func (p *UpdateExptRunConfRequest) GetItemRetryNum() (v int32) { return *p.ItemRetryNum } +var UpdateExptRunConfRequest_PriorityLevel_DEFAULT int32 + +func (p *UpdateExptRunConfRequest) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return UpdateExptRunConfRequest_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var UpdateExptRunConfRequest_ExpectedQuotaConsumption_DEFAULT *expt.ExpectedQuotaConsumption + +func (p *UpdateExptRunConfRequest) GetExpectedQuotaConsumption() (v *expt.ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return UpdateExptRunConfRequest_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + var UpdateExptRunConfRequest_Base_DEFAULT *base.Base func (p *UpdateExptRunConfRequest) GetBase() (v *base.Base) { @@ -10681,6 +10711,12 @@ func (p *UpdateExptRunConfRequest) SetItemConcurNum(val *int32) { func (p *UpdateExptRunConfRequest) SetItemRetryNum(val *int32) { p.ItemRetryNum = val } +func (p *UpdateExptRunConfRequest) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *UpdateExptRunConfRequest) SetExpectedQuotaConsumption(val *expt.ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} func (p *UpdateExptRunConfRequest) SetBase(val *base.Base) { p.Base = val } @@ -10690,6 +10726,8 @@ var fieldIDToName_UpdateExptRunConfRequest = map[int16]string{ 2: "expt_id", 3: "item_concur_num", 4: "item_retry_num", + 92: "priority_level", + 93: "expected_quota_consumption", 255: "Base", } @@ -10701,6 +10739,14 @@ func (p *UpdateExptRunConfRequest) IsSetItemRetryNum() bool { return p.ItemRetryNum != nil } +func (p *UpdateExptRunConfRequest) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *UpdateExptRunConfRequest) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + func (p *UpdateExptRunConfRequest) IsSetBase() bool { return p.Base != nil } @@ -10759,6 +10805,22 @@ func (p *UpdateExptRunConfRequest) Read(iprot thrift.TProtocol) (err error) { } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 92: + if fieldTypeId == thrift.I32 { + if err = p.ReadField92(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 93: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField93(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 255: if fieldTypeId == thrift.STRUCT { if err = p.ReadField255(iprot); err != nil { @@ -10851,6 +10913,25 @@ func (p *UpdateExptRunConfRequest) ReadField4(iprot thrift.TProtocol) error { p.ItemRetryNum = _field return nil } +func (p *UpdateExptRunConfRequest) ReadField92(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *UpdateExptRunConfRequest) ReadField93(iprot thrift.TProtocol) error { + _field := expt.NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} func (p *UpdateExptRunConfRequest) ReadField255(iprot thrift.TProtocol) error { _field := base.NewBase() if err := _field.Read(iprot); err != nil { @@ -10882,6 +10963,14 @@ func (p *UpdateExptRunConfRequest) Write(oprot thrift.TProtocol) (err error) { fieldId = 4 goto WriteFieldError } + if err = p.writeField92(oprot); err != nil { + fieldId = 92 + goto WriteFieldError + } + if err = p.writeField93(oprot); err != nil { + fieldId = 93 + goto WriteFieldError + } if err = p.writeField255(oprot); err != nil { fieldId = 255 goto WriteFieldError @@ -10972,6 +11061,42 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 4 end error: ", p), err) } +func (p *UpdateExptRunConfRequest) writeField92(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 92); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 92 end error: ", p), err) +} +func (p *UpdateExptRunConfRequest) writeField93(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 93); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 93 end error: ", p), err) +} func (p *UpdateExptRunConfRequest) writeField255(oprot thrift.TProtocol) (err error) { if p.IsSetBase() { if err = oprot.WriteFieldBegin("Base", thrift.STRUCT, 255); err != nil { @@ -11017,6 +11142,12 @@ func (p *UpdateExptRunConfRequest) DeepEqual(ano *UpdateExptRunConfRequest) bool if !p.Field4DeepEqual(ano.ItemRetryNum) { return false } + if !p.Field92DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field93DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } if !p.Field255DeepEqual(ano.Base) { return false } @@ -11061,6 +11192,25 @@ func (p *UpdateExptRunConfRequest) Field4DeepEqual(src *int32) bool { } return true } +func (p *UpdateExptRunConfRequest) Field92DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *UpdateExptRunConfRequest) Field93DeepEqual(src *expt.ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} func (p *UpdateExptRunConfRequest) Field255DeepEqual(src *base.Base) bool { if !p.Base.DeepEqual(src) { diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go index 7ff3b05723..7b77f7d9c9 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/coze.loop.evaluation.expt_validator.go @@ -238,6 +238,11 @@ func (p *UpdateExperimentResponse) IsValid() error { return nil } func (p *UpdateExptRunConfRequest) IsValid() error { + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.Base != nil { if err := p.Base.IsValid(); err != nil { return fmt.Errorf("field Base not valid, %w", err) diff --git a/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go b/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go index 70f8063770..65de1c9e77 100644 --- a/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go +++ b/backend/kitex_gen/coze/loop/evaluation/expt/k-coze.loop.evaluation.expt.go @@ -7913,6 +7913,34 @@ func (p *UpdateExptRunConfRequest) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 92: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField92(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 93: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField93(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 255: if fieldTypeId == thrift.STRUCT { l, err = p.FastReadField255(buf[offset:]) @@ -8012,6 +8040,32 @@ func (p *UpdateExptRunConfRequest) FastReadField4(buf []byte) (int, error) { return offset, nil } +func (p *UpdateExptRunConfRequest) FastReadField92(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *UpdateExptRunConfRequest) FastReadField93(buf []byte) (int, error) { + offset := 0 + _field := expt.NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + func (p *UpdateExptRunConfRequest) FastReadField255(buf []byte) (int, error) { offset := 0 _field := base.NewBase() @@ -8035,6 +8089,8 @@ func (p *UpdateExptRunConfRequest) FastWriteNocopy(buf []byte, w thrift.NocopyWr offset += p.fastWriteField2(buf[offset:], w) offset += p.fastWriteField3(buf[offset:], w) offset += p.fastWriteField4(buf[offset:], w) + offset += p.fastWriteField92(buf[offset:], w) + offset += p.fastWriteField93(buf[offset:], w) offset += p.fastWriteField255(buf[offset:], w) } offset += thrift.Binary.WriteFieldStop(buf[offset:]) @@ -8048,6 +8104,8 @@ func (p *UpdateExptRunConfRequest) BLength() int { l += p.field2Length() l += p.field3Length() l += p.field4Length() + l += p.field92Length() + l += p.field93Length() l += p.field255Length() } l += thrift.Binary.FieldStopLength() @@ -8086,6 +8144,24 @@ func (p *UpdateExptRunConfRequest) fastWriteField4(buf []byte, w thrift.NocopyWr return offset } +func (p *UpdateExptRunConfRequest) fastWriteField92(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 92) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *UpdateExptRunConfRequest) fastWriteField93(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 93) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + func (p *UpdateExptRunConfRequest) fastWriteField255(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetBase() { @@ -8127,6 +8203,24 @@ func (p *UpdateExptRunConfRequest) field4Length() int { return l } +func (p *UpdateExptRunConfRequest) field92Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *UpdateExptRunConfRequest) field93Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + func (p *UpdateExptRunConfRequest) field255Length() int { l := 0 if p.IsSetBase() { @@ -8156,6 +8250,20 @@ func (p *UpdateExptRunConfRequest) DeepCopy(s interface{}) error { p.ItemRetryNum = &tmp } + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + var _expectedQuotaConsumption *expt.ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &expt.ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + var _base *base.Base if src.Base != nil { _base = &base.Base{} diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go index 6ddb3ada5b..a9b6b0bfc1 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi.go @@ -34034,7 +34034,7 @@ func (p *GetExperimentsOApiResponse) Field255DeepEqual(src *base.BaseResp) bool return true } -// UpdateExptRunConfOApiRequest 通过 OpenAPI 修改进行中实验的运行配置(并发度 / Item 重试次数)。 +// UpdateExptRunConfOApiRequest 通过 OpenAPI 修改进行中实验的运行配置(并发度 / Item 重试次数 / 调度参数)。 // 仅对处于 Pending / Processing 状态的实验生效。 type UpdateExptRunConfOApiRequest struct { WorkspaceID *int64 `thrift:"workspace_id,1,optional" frugal:"1,optional,i64" json:"workspace_id" form:"workspace_id" ` @@ -34042,9 +34042,15 @@ type UpdateExptRunConfOApiRequest struct { // 评测项并发度:不传或 0 表示不修改;范围 (0, MaxItemConcurNum] ItemConcurNum *int32 `thrift:"item_concur_num,20,optional" frugal:"20,optional,i32" form:"item_concur_num" json:"item_concur_num,omitempty"` // 数据行 Item 最大重试次数:不传表示不修改;0 表示显式设为不重试;范围 [0, 10] - ItemRetryNum *int32 `thrift:"item_retry_num,45,optional" frugal:"45,optional,i32" form:"item_retry_num" json:"item_retry_num,omitempty"` - Extra *extra.Extra `thrift:"extra,254,optional" frugal:"254,optional,extra.Extra" form:"extra" json:"extra,omitempty" query:"extra"` - Base *base.Base `thrift:"Base,255,optional" frugal:"255,optional,base.Base" form:"Base" json:"Base,omitempty" query:"Base"` + ItemRetryNum *int32 `thrift:"item_retry_num,45,optional" frugal:"45,optional,i32" form:"item_retry_num" json:"item_retry_num,omitempty"` + // 以下两个是中心调度特权参数,字段号与 CreateExperimentOApiRequest 对齐(60/61)。 + // 未获授权的调用方传了会被丢弃并打 WARN,不报错。 + // 调度优先级:不传表示不修改。改完下一拍生效(调度器每拍从库重扫队列)。 + PriorityLevel *int32 `thrift:"priority_level,60,optional" frugal:"60,optional,i32" form:"priority_level" json:"priority_level,omitempty"` + // 单 item 预期资源消耗:不传表示不修改。改动会同步给该 run 在飞的预占重新计价。 + ExpectedQuotaConsumption *experiment.ExpectedQuotaConsumption `thrift:"expected_quota_consumption,61,optional" frugal:"61,optional,experiment.ExpectedQuotaConsumption" form:"expected_quota_consumption" json:"expected_quota_consumption,omitempty"` + Extra *extra.Extra `thrift:"extra,254,optional" frugal:"254,optional,extra.Extra" form:"extra" json:"extra,omitempty" query:"extra"` + Base *base.Base `thrift:"Base,255,optional" frugal:"255,optional,base.Base" form:"Base" json:"Base,omitempty" query:"Base"` } func NewUpdateExptRunConfOApiRequest() *UpdateExptRunConfOApiRequest { @@ -34102,6 +34108,30 @@ func (p *UpdateExptRunConfOApiRequest) GetItemRetryNum() (v int32) { return *p.ItemRetryNum } +var UpdateExptRunConfOApiRequest_PriorityLevel_DEFAULT int32 + +func (p *UpdateExptRunConfOApiRequest) GetPriorityLevel() (v int32) { + if p == nil { + return + } + if !p.IsSetPriorityLevel() { + return UpdateExptRunConfOApiRequest_PriorityLevel_DEFAULT + } + return *p.PriorityLevel +} + +var UpdateExptRunConfOApiRequest_ExpectedQuotaConsumption_DEFAULT *experiment.ExpectedQuotaConsumption + +func (p *UpdateExptRunConfOApiRequest) GetExpectedQuotaConsumption() (v *experiment.ExpectedQuotaConsumption) { + if p == nil { + return + } + if !p.IsSetExpectedQuotaConsumption() { + return UpdateExptRunConfOApiRequest_ExpectedQuotaConsumption_DEFAULT + } + return p.ExpectedQuotaConsumption +} + var UpdateExptRunConfOApiRequest_Extra_DEFAULT *extra.Extra func (p *UpdateExptRunConfOApiRequest) GetExtra() (v *extra.Extra) { @@ -34137,6 +34167,12 @@ func (p *UpdateExptRunConfOApiRequest) SetItemConcurNum(val *int32) { func (p *UpdateExptRunConfOApiRequest) SetItemRetryNum(val *int32) { p.ItemRetryNum = val } +func (p *UpdateExptRunConfOApiRequest) SetPriorityLevel(val *int32) { + p.PriorityLevel = val +} +func (p *UpdateExptRunConfOApiRequest) SetExpectedQuotaConsumption(val *experiment.ExpectedQuotaConsumption) { + p.ExpectedQuotaConsumption = val +} func (p *UpdateExptRunConfOApiRequest) SetExtra(val *extra.Extra) { p.Extra = val } @@ -34149,6 +34185,8 @@ var fieldIDToName_UpdateExptRunConfOApiRequest = map[int16]string{ 2: "experiment_id", 20: "item_concur_num", 45: "item_retry_num", + 60: "priority_level", + 61: "expected_quota_consumption", 254: "extra", 255: "Base", } @@ -34169,6 +34207,14 @@ func (p *UpdateExptRunConfOApiRequest) IsSetItemRetryNum() bool { return p.ItemRetryNum != nil } +func (p *UpdateExptRunConfOApiRequest) IsSetPriorityLevel() bool { + return p.PriorityLevel != nil +} + +func (p *UpdateExptRunConfOApiRequest) IsSetExpectedQuotaConsumption() bool { + return p.ExpectedQuotaConsumption != nil +} + func (p *UpdateExptRunConfOApiRequest) IsSetExtra() bool { return p.Extra != nil } @@ -34227,6 +34273,22 @@ func (p *UpdateExptRunConfOApiRequest) Read(iprot thrift.TProtocol) (err error) } else if err = iprot.Skip(fieldTypeId); err != nil { goto SkipFieldError } + case 60: + if fieldTypeId == thrift.I32 { + if err = p.ReadField60(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } + case 61: + if fieldTypeId == thrift.STRUCT { + if err = p.ReadField61(iprot); err != nil { + goto ReadFieldError + } + } else if err = iprot.Skip(fieldTypeId); err != nil { + goto SkipFieldError + } case 254: if fieldTypeId == thrift.STRUCT { if err = p.ReadField254(iprot); err != nil { @@ -34316,6 +34378,25 @@ func (p *UpdateExptRunConfOApiRequest) ReadField45(iprot thrift.TProtocol) error p.ItemRetryNum = _field return nil } +func (p *UpdateExptRunConfOApiRequest) ReadField60(iprot thrift.TProtocol) error { + + var _field *int32 + if v, err := iprot.ReadI32(); err != nil { + return err + } else { + _field = &v + } + p.PriorityLevel = _field + return nil +} +func (p *UpdateExptRunConfOApiRequest) ReadField61(iprot thrift.TProtocol) error { + _field := experiment.NewExpectedQuotaConsumption() + if err := _field.Read(iprot); err != nil { + return err + } + p.ExpectedQuotaConsumption = _field + return nil +} func (p *UpdateExptRunConfOApiRequest) ReadField254(iprot thrift.TProtocol) error { _field := extra.NewExtra() if err := _field.Read(iprot); err != nil { @@ -34355,6 +34436,14 @@ func (p *UpdateExptRunConfOApiRequest) Write(oprot thrift.TProtocol) (err error) fieldId = 45 goto WriteFieldError } + if err = p.writeField60(oprot); err != nil { + fieldId = 60 + goto WriteFieldError + } + if err = p.writeField61(oprot); err != nil { + fieldId = 61 + goto WriteFieldError + } if err = p.writeField254(oprot); err != nil { fieldId = 254 goto WriteFieldError @@ -34453,6 +34542,42 @@ WriteFieldBeginError: WriteFieldEndError: return thrift.PrependError(fmt.Sprintf("%T write field 45 end error: ", p), err) } +func (p *UpdateExptRunConfOApiRequest) writeField60(oprot thrift.TProtocol) (err error) { + if p.IsSetPriorityLevel() { + if err = oprot.WriteFieldBegin("priority_level", thrift.I32, 60); err != nil { + goto WriteFieldBeginError + } + if err := oprot.WriteI32(*p.PriorityLevel); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 60 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 60 end error: ", p), err) +} +func (p *UpdateExptRunConfOApiRequest) writeField61(oprot thrift.TProtocol) (err error) { + if p.IsSetExpectedQuotaConsumption() { + if err = oprot.WriteFieldBegin("expected_quota_consumption", thrift.STRUCT, 61); err != nil { + goto WriteFieldBeginError + } + if err := p.ExpectedQuotaConsumption.Write(oprot); err != nil { + return err + } + if err = oprot.WriteFieldEnd(); err != nil { + goto WriteFieldEndError + } + } + return nil +WriteFieldBeginError: + return thrift.PrependError(fmt.Sprintf("%T write field 61 begin error: ", p), err) +WriteFieldEndError: + return thrift.PrependError(fmt.Sprintf("%T write field 61 end error: ", p), err) +} func (p *UpdateExptRunConfOApiRequest) writeField254(oprot thrift.TProtocol) (err error) { if p.IsSetExtra() { if err = oprot.WriteFieldBegin("extra", thrift.STRUCT, 254); err != nil { @@ -34516,6 +34641,12 @@ func (p *UpdateExptRunConfOApiRequest) DeepEqual(ano *UpdateExptRunConfOApiReque if !p.Field45DeepEqual(ano.ItemRetryNum) { return false } + if !p.Field60DeepEqual(ano.PriorityLevel) { + return false + } + if !p.Field61DeepEqual(ano.ExpectedQuotaConsumption) { + return false + } if !p.Field254DeepEqual(ano.Extra) { return false } @@ -34573,6 +34704,25 @@ func (p *UpdateExptRunConfOApiRequest) Field45DeepEqual(src *int32) bool { } return true } +func (p *UpdateExptRunConfOApiRequest) Field60DeepEqual(src *int32) bool { + + if p.PriorityLevel == src { + return true + } else if p.PriorityLevel == nil || src == nil { + return false + } + if *p.PriorityLevel != *src { + return false + } + return true +} +func (p *UpdateExptRunConfOApiRequest) Field61DeepEqual(src *experiment.ExpectedQuotaConsumption) bool { + + if !p.ExpectedQuotaConsumption.DeepEqual(src) { + return false + } + return true +} func (p *UpdateExptRunConfOApiRequest) Field254DeepEqual(src *extra.Extra) bool { if !p.Extra.DeepEqual(src) { diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go index 5052786299..84c75384d2 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/coze.loop.evaluation.openapi_validator.go @@ -1042,6 +1042,11 @@ func (p *GetExperimentsOApiResponse) IsValid() error { return nil } func (p *UpdateExptRunConfOApiRequest) IsValid() error { + if p.ExpectedQuotaConsumption != nil { + if err := p.ExpectedQuotaConsumption.IsValid(); err != nil { + return fmt.Errorf("field ExpectedQuotaConsumption not valid, %w", err) + } + } if p.Extra != nil { if err := p.Extra.IsValid(); err != nil { return fmt.Errorf("field Extra not valid, %w", err) diff --git a/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go b/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go index ac1dd98640..b0475c5d02 100644 --- a/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go +++ b/backend/kitex_gen/coze/loop/evaluation/openapi/k-coze.loop.evaluation.openapi.go @@ -24487,6 +24487,34 @@ func (p *UpdateExptRunConfOApiRequest) FastRead(buf []byte) (int, error) { goto SkipFieldError } } + case 60: + if fieldTypeId == thrift.I32 { + l, err = p.FastReadField60(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } + case 61: + if fieldTypeId == thrift.STRUCT { + l, err = p.FastReadField61(buf[offset:]) + offset += l + if err != nil { + goto ReadFieldError + } + } else { + l, err = thrift.Binary.Skip(buf[offset:], fieldTypeId) + offset += l + if err != nil { + goto SkipFieldError + } + } case 254: if fieldTypeId == thrift.STRUCT { l, err = p.FastReadField254(buf[offset:]) @@ -24589,6 +24617,32 @@ func (p *UpdateExptRunConfOApiRequest) FastReadField45(buf []byte) (int, error) return offset, nil } +func (p *UpdateExptRunConfOApiRequest) FastReadField60(buf []byte) (int, error) { + offset := 0 + + var _field *int32 + if v, l, err := thrift.Binary.ReadI32(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + _field = &v + } + p.PriorityLevel = _field + return offset, nil +} + +func (p *UpdateExptRunConfOApiRequest) FastReadField61(buf []byte) (int, error) { + offset := 0 + _field := experiment.NewExpectedQuotaConsumption() + if l, err := _field.FastRead(buf[offset:]); err != nil { + return offset, err + } else { + offset += l + } + p.ExpectedQuotaConsumption = _field + return offset, nil +} + func (p *UpdateExptRunConfOApiRequest) FastReadField254(buf []byte) (int, error) { offset := 0 _field := extra.NewExtra() @@ -24624,6 +24678,8 @@ func (p *UpdateExptRunConfOApiRequest) FastWriteNocopy(buf []byte, w thrift.Noco offset += p.fastWriteField2(buf[offset:], w) offset += p.fastWriteField20(buf[offset:], w) offset += p.fastWriteField45(buf[offset:], w) + offset += p.fastWriteField60(buf[offset:], w) + offset += p.fastWriteField61(buf[offset:], w) offset += p.fastWriteField254(buf[offset:], w) offset += p.fastWriteField255(buf[offset:], w) } @@ -24638,6 +24694,8 @@ func (p *UpdateExptRunConfOApiRequest) BLength() int { l += p.field2Length() l += p.field20Length() l += p.field45Length() + l += p.field60Length() + l += p.field61Length() l += p.field254Length() l += p.field255Length() } @@ -24681,6 +24739,24 @@ func (p *UpdateExptRunConfOApiRequest) fastWriteField45(buf []byte, w thrift.Noc return offset } +func (p *UpdateExptRunConfOApiRequest) fastWriteField60(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetPriorityLevel() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.I32, 60) + offset += thrift.Binary.WriteI32(buf[offset:], *p.PriorityLevel) + } + return offset +} + +func (p *UpdateExptRunConfOApiRequest) fastWriteField61(buf []byte, w thrift.NocopyWriter) int { + offset := 0 + if p.IsSetExpectedQuotaConsumption() { + offset += thrift.Binary.WriteFieldBegin(buf[offset:], thrift.STRUCT, 61) + offset += p.ExpectedQuotaConsumption.FastWriteNocopy(buf[offset:], w) + } + return offset +} + func (p *UpdateExptRunConfOApiRequest) fastWriteField254(buf []byte, w thrift.NocopyWriter) int { offset := 0 if p.IsSetExtra() { @@ -24735,6 +24811,24 @@ func (p *UpdateExptRunConfOApiRequest) field45Length() int { return l } +func (p *UpdateExptRunConfOApiRequest) field60Length() int { + l := 0 + if p.IsSetPriorityLevel() { + l += thrift.Binary.FieldBeginLength() + l += thrift.Binary.I32Length() + } + return l +} + +func (p *UpdateExptRunConfOApiRequest) field61Length() int { + l := 0 + if p.IsSetExpectedQuotaConsumption() { + l += thrift.Binary.FieldBeginLength() + l += p.ExpectedQuotaConsumption.BLength() + } + return l +} + func (p *UpdateExptRunConfOApiRequest) field254Length() int { l := 0 if p.IsSetExtra() { @@ -24779,6 +24873,20 @@ func (p *UpdateExptRunConfOApiRequest) DeepCopy(s interface{}) error { p.ItemRetryNum = &tmp } + if src.PriorityLevel != nil { + tmp := *src.PriorityLevel + p.PriorityLevel = &tmp + } + + var _expectedQuotaConsumption *experiment.ExpectedQuotaConsumption + if src.ExpectedQuotaConsumption != nil { + _expectedQuotaConsumption = &experiment.ExpectedQuotaConsumption{} + if err := _expectedQuotaConsumption.DeepCopy(src.ExpectedQuotaConsumption); err != nil { + return err + } + } + p.ExpectedQuotaConsumption = _expectedQuotaConsumption + var _extra *extra.Extra if src.Extra != nil { _extra = &extra.Extra{} diff --git a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift index bfb2c036db..a668da865d 100644 --- a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift +++ b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.expt.thrift @@ -242,7 +242,7 @@ struct UpdateExperimentResponse { 255: base.BaseResp BaseResp } -// UpdateExptRunConfRequest 修改进行中实验的运行配置(并发度 / Item 重试次数)。 +// UpdateExptRunConfRequest 修改进行中实验的运行配置(并发度 / Item 重试次数 / 调度参数)。 // 仅对处于 Pending / Processing 状态的实验生效。 struct UpdateExptRunConfRequest { 1: required i64 workspace_id (api.body='workspace_id',api.js_conv='true', go.tag='json:"workspace_id"') @@ -253,6 +253,13 @@ struct UpdateExptRunConfRequest { // 数据行 Item 最大重试次数:不传表示不修改;0 表示显式设为不重试;范围 [0, 10] 4: optional i32 item_retry_num (api.body='item_retry_num') + // 以下两个是中心调度特权参数,字段号与 CreateExperimentRequest 对齐(92/93), + // 便于两处对照。未获授权的调用方传了会被丢弃并打 WARN,不报错。 + // 调度优先级:不传表示不修改。改完下一拍生效(调度器每拍从库重扫队列)。 + 92: optional i32 priority_level (api.body = 'priority_level') + // 单 item 预期资源消耗:不传表示不修改。改动会同步给该 run 在飞的预占重新计价。 + 93: optional expt.ExpectedQuotaConsumption expected_quota_consumption (api.body = 'expected_quota_consumption') + 255: optional base.Base Base } diff --git a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift index 732fede6f6..0424f9f7aa 100644 --- a/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift +++ b/idl/thrift/coze/loop/evaluation/coze.loop.evaluation.openapi.thrift @@ -734,7 +734,7 @@ struct GetExperimentsOApiResponse { 255: base.BaseResp BaseResp } -// UpdateExptRunConfOApiRequest 通过 OpenAPI 修改进行中实验的运行配置(并发度 / Item 重试次数)。 +// UpdateExptRunConfOApiRequest 通过 OpenAPI 修改进行中实验的运行配置(并发度 / Item 重试次数 / 调度参数)。 // 仅对处于 Pending / Processing 状态的实验生效。 struct UpdateExptRunConfOApiRequest { 1: optional i64 workspace_id (api.body = 'workspace_id', api.js_conv = 'true', go.tag = 'json:"workspace_id"') @@ -745,6 +745,13 @@ struct UpdateExptRunConfOApiRequest { // 数据行 Item 最大重试次数:不传表示不修改;0 表示显式设为不重试;范围 [0, 10] 45: optional i32 item_retry_num (api.body = 'item_retry_num') + // 以下两个是中心调度特权参数,字段号与 CreateExperimentOApiRequest 对齐(60/61)。 + // 未获授权的调用方传了会被丢弃并打 WARN,不报错。 + // 调度优先级:不传表示不修改。改完下一拍生效(调度器每拍从库重扫队列)。 + 60: optional i32 priority_level (api.body = 'priority_level') + // 单 item 预期资源消耗:不传表示不修改。改动会同步给该 run 在飞的预占重新计价。 + 61: optional experiment.ExpectedQuotaConsumption expected_quota_consumption (api.body = 'expected_quota_consumption') + 254: optional extra.Extra extra (agw.source = "not_body_struct") 255: optional base.Base Base } From 7a3d233b89ee508eebb12cc73d043ef30dfa12cc Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 19:50:00 +0800 Subject: [PATCH 50/58] =?UTF-8?q?feat(evaluation):=20UpdateExptRunConf=20?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=20priority=20=E4=B8=8E=20quota=20=E5=90=91?= =?UTF-8?q?=E9=87=8F=E7=9A=84=E5=86=99=E5=85=A5=E4=B8=8E=E6=94=B9=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两面(普通 / OpenAPI)共用一份白名单闸与校验:命中才生效,未命中丢弃 + WARN, 与创建期 enforceSchedulingPrivilege 同口径。判据抽成包级函数,两处各写一遍 迟早漂移成"某一面能绕过白名单"。 几处刻意的取舍: - 校验放在闸门之后。与创建期一致,也避免用"报不报错"把「你不在白名单里」 泄漏给未授权调用方。 - priority 不接受 0 当"不修改",nil 才是。0 落到下游 Normalize 会被收敛成缺省 优先级,于是"我传了 0"和"我想设成缺省"无法区分,静默改掉一个本不该动的值。 - 只允许改 enforce 实验。legacy 既不参与优先级排序也没有额度账本,照写只会得到 一个没人读的值,而调用方收到成功。 - priority 走 UpdateFields 的显式列名 map,不走 exptRepo.Update。后者是 struct Updates + Omit(schedulingFrozenColumns),正是为防部分更新把 enforce 打回 legacy 而存在的;显式列名只可能改到写进 map 的那一列。 - 向量改动后必须给该 run 在飞的预占重新计价,失败上抛。全量恢复刻意只读 MySQL 重建 used,隐含"每条活预占按当前向量计价"这条前提;只改库会让释放按旧价、 恢复按新价,雷埋在修账本的唯一手段里。 新增 ICentralReservationGuard.RepriceRunConsumption 承载改价,开源部署 noop。 --- .../application/convertor/experiment/expt.go | 6 +- .../experiment/openapi_new_fields_test.go | 2 +- .../application/eval_openapi_app.go | 21 +- .../evaluation/application/experiment_app.go | 116 +++++++++-- .../experiment_run_conf_privilege_test.go | 134 ++++++++++++ .../component/central_reservation_guard.go | 32 ++- .../mocks/central_reservation_guard.go | 17 +- .../modules/evaluation/domain/entity/param.go | 11 +- .../expt_central_quota_release_test.go | 31 +++ .../domain/service/expt_manage_impl.go | 47 ++++- .../service/expt_run_conf_scheduling_test.go | 190 ++++++++++++++++++ .../infra/repo/experiment/mysql/expt.go | 4 +- 12 files changed, 573 insertions(+), 38 deletions(-) create mode 100644 backend/modules/evaluation/application/experiment_run_conf_privilege_test.go create mode 100644 backend/modules/evaluation/domain/service/expt_run_conf_scheduling_test.go diff --git a/backend/modules/evaluation/application/convertor/experiment/expt.go b/backend/modules/evaluation/application/convertor/experiment/expt.go index 7afaf8a789..37b8aecbec 100644 --- a/backend/modules/evaluation/application/convertor/experiment/expt.go +++ b/backend/modules/evaluation/application/convertor/experiment/expt.go @@ -930,7 +930,7 @@ func ConvertCreateReq(cer *expt.CreateExperimentRequest, evaluatorVersionRunConf // 都能自己声明 enforce 并伪造一个 scope,绕过额度管控、甚至去动别的环境的账本。 // IDL 里这两个字段不带 api.body 已挡住公网,但内部调用仍需这一层。 param.PriorityLevel = entity.NormalizeExptPriorityLevel(cer.GetPriorityLevel()) - param.ExpectedQuotaConsumption = expectedQuotaConsumptionDTO2DO(cer.GetExpectedQuotaConsumption()) + param.ExpectedQuotaConsumption = ExpectedQuotaConsumptionDTO2DO(cer.GetExpectedQuotaConsumption()) if cer.NotificationConf != nil { notifConf, err := NotificationConfDTO2DO(cer.NotificationConf) if err != nil { @@ -1556,12 +1556,12 @@ func suaModeDO2DTO(m entity.SuaMode) domain_expt.SuaMode { } } -// expectedQuotaConsumptionDTO2DO 把申报的资源消耗向量转成领域对象。 +// ExpectedQuotaConsumptionDTO2DO 把申报的资源消耗向量转成领域对象。 // // 不在此处做合法性校验(非空 / amount>0 / 键唯一 / 禁通配):校验属于领域规则, // 收口在 entity.ExpectedQuotaConsumption.Validate(),由 CreateExpt 在冻结前调用。 // 转换层只负责搬运,否则同一套规则会散落在转换与领域两处、各自演化。 -func expectedQuotaConsumptionDTO2DO(dto *domain_expt.ExpectedQuotaConsumption) *entity.ExpectedQuotaConsumption { +func ExpectedQuotaConsumptionDTO2DO(dto *domain_expt.ExpectedQuotaConsumption) *entity.ExpectedQuotaConsumption { if dto == nil || len(dto.GetResources()) == 0 { return nil } diff --git a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go index 04c8715fac..ae27e9f0c4 100644 --- a/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go +++ b/backend/modules/evaluation/application/convertor/experiment/openapi_new_fields_test.go @@ -812,7 +812,7 @@ func TestExpectedQuotaConsumption_SourceSurvivesRoundTrip(t *testing.T) { }, } - do := expectedQuotaConsumptionDTO2DO(dto) + do := ExpectedQuotaConsumptionDTO2DO(dto) if assert.NotNil(t, do) && assert.Len(t, do.Resources, 2) { assert.Equal(t, "litellm", do.Resources[0].Source, "source 必须搬进 DO") assert.Empty(t, do.Resources[1].Source, "没申报来源时 DO 侧应为空串,不得凭空造值") diff --git a/backend/modules/evaluation/application/eval_openapi_app.go b/backend/modules/evaluation/application/eval_openapi_app.go index 9f4891a444..5809276c31 100644 --- a/backend/modules/evaluation/application/eval_openapi_app.go +++ b/backend/modules/evaluation/application/eval_openapi_app.go @@ -4097,12 +4097,23 @@ func (e *EvalOpenAPIApplication) UpdateExptRunConfOApi(ctx context.Context, req itemRetryNum = gptr.Of(v) } + // 中心调度特权参数:与前端接口共用同一份白名单判据与校验(两处各写一遍必然漂移)。 + // OpenAPI 的向量是另一套 IDL 类型,先转成 domain 形态再交给共用解析。 + priorityLevel, expectedQuota, err := resolveRunConfSchedulingParams( + ctx, e.configer, req.GetWorkspaceID(), req.GetExperimentID(), req.PriorityLevel, + experiment_convertor.ExpectedQuotaConsumptionOpenAPI2Domain(req.ExpectedQuotaConsumption)) + if err != nil { + return nil, err + } + if err = e.manager.UpdateRunConf(ctx, &entity.UpdateRunConfParam{ - ExptID: req.GetExperimentID(), - SpaceID: req.GetWorkspaceID(), - ItemConcurNum: itemConcurNum, - ItemRetryNum: itemRetryNum, - Session: session, + ExptID: req.GetExperimentID(), + SpaceID: req.GetWorkspaceID(), + ItemConcurNum: itemConcurNum, + ItemRetryNum: itemRetryNum, + PriorityLevel: priorityLevel, + ExpectedQuotaConsumption: expectedQuota, + Session: session, }); err != nil { return nil, err } diff --git a/backend/modules/evaluation/application/experiment_app.go b/backend/modules/evaluation/application/experiment_app.go index 2972336c37..f56540dbf8 100644 --- a/backend/modules/evaluation/application/experiment_app.go +++ b/backend/modules/evaluation/application/experiment_app.go @@ -292,12 +292,35 @@ func (e *experimentApplication) enforceSchedulingPrivilege(ctx context.Context, return } - // caller PSM 取自 RPC 框架填充的 caller 字段,调用方无法在业务参数里伪造。 - // 这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段,任何人都能自称 "evalx", - // 因此绝不能反过来拿 trigger_type 当授权判据。 + allowed, subject := allowExptSchedulingPrivilege(ctx, e.configer, req.GetWorkspaceID()) + if allowed { + return + } + + logs.CtxWarn(ctx, "[ExptSchedulingPrivilege] caller not allowed to declare scheduling params, dropping them; "+ + "priority: %v, has_quota: %v, trigger: %v, user_email: %v, space_id: %v, caller_psm: %v", + req.GetPriorityLevel(), declaresQuota, req.GetTriggerType(), subject.UserEmail, subject.SpaceID, subject.CallerPSM) + + req.PriorityLevel = nil + req.ExpectedQuotaConsumption = nil + if declaresEvalxTrigger { + // 降级为 manual 而非清空:TriggerType 为空时下游会按 Manual 兜底, + // 但显式写入让落库值与日志一致、排查时不必再推导一层。 + req.TriggerType = gptr.Of(domain_expt.Manual) + } +} + +// allowExptSchedulingPrivilege 判定当前调用方能否申报中心调度特权参数,并回传用于日志的 subject。 +// +// 做成包级函数供两个 application 共用(普通面在 experimentApplication、OpenAPI 面在 +// EvalOpenAPIApplication):判据只能有一份,两处各写一遍迟早漂移成"某一面能绕过白名单"。 +// +// 两个取值来源都不可由调用方在业务参数里伪造:caller PSM 由 RPC 框架填充, +// 邮箱取自已验证的 ByteTIM ticket claim(商业版 CtxUser 中间件写入)。 +// 这与 trigger_type 有本质区别 —— 后者是请求体里的普通字段,任何人都能自称 "evalx"。 +func allowExptSchedulingPrivilege(ctx context.Context, configer component.IConfiger, spaceID int64) (bool, entity.ExptSchedulingPrivilegeSubject) { callerPSM, _ := kitexutil.GetCaller(ctx) - // 邮箱取自已验证的 ByteTIM ticket claim(商业版 CtxUser 中间件写入),不是请求体字段。 userEmail := "" if u, ok := session.UserInCtx(ctx); ok && u != nil { userEmail = u.Email @@ -305,24 +328,66 @@ func (e *experimentApplication) enforceSchedulingPrivilege(ctx context.Context, subject := entity.ExptSchedulingPrivilegeSubject{ UserEmail: userEmail, - SpaceID: req.GetWorkspaceID(), + SpaceID: spaceID, CallerPSM: callerPSM, } - if e.configer.GetExptSchedulingPrivilegeWhiteList(ctx).AllowSchedulingPrivilege(subject) { - return + if configer == nil { + // 配置缺席时不放行:这条路径决定"能不能申报特权",缺省必须是最保守的那一侧。 + return false, subject } + return configer.GetExptSchedulingPrivilegeWhiteList(ctx).AllowSchedulingPrivilege(subject), subject +} - logs.CtxWarn(ctx, "[ExptSchedulingPrivilege] caller not allowed to declare scheduling params, dropping them; "+ - "priority: %v, has_quota: %v, trigger: %v, user_email: %v, space_id: %v, caller_psm: %v", - req.GetPriorityLevel(), declaresQuota, req.GetTriggerType(), userEmail, subject.SpaceID, callerPSM) +// resolveRunConfSchedulingParams 解析 UpdateExptRunConf 两面共用的中心调度特权参数。 +// +// 命中白名单才生效;未命中一律丢弃 + WARN 而不报错 —— 与创建期 enforceSchedulingPrivilege +// 同一口径,理由见那里(这两个字段已在 IDL 里,突然改成报错会打挂已经在传的调用方)。 +// +// 校验刻意放在闸门之后:一来与创建期一致(那边先清字段、校验在下游),二来避免用 +// "报不报错"这件事把"你不在白名单里"泄漏给未授权调用方。 +func resolveRunConfSchedulingParams( + ctx context.Context, + configer component.IConfiger, + spaceID, exptID int64, + priorityLevel *int32, + quota *domain_expt.ExpectedQuotaConsumption, +) (*int32, *entity.ExpectedQuotaConsumption, error) { + if priorityLevel == nil && quota == nil { + // 什么都没申报:省掉一次配置读取,也避免给未使用这些字段的调用方刷无关日志。 + return nil, nil, nil + } - req.PriorityLevel = nil - req.ExpectedQuotaConsumption = nil - if declaresEvalxTrigger { - // 降级为 manual 而非清空:TriggerType 为空时下游会按 Manual 兜底, - // 但显式写入让落库值与日志一致、排查时不必再推导一层。 - req.TriggerType = gptr.Of(domain_expt.Manual) + allowed, subject := allowExptSchedulingPrivilege(ctx, configer, spaceID) + if !allowed { + logs.CtxWarn(ctx, "[ExptSchedulingPrivilege] caller not allowed to update scheduling params, dropping them; "+ + "expt_id: %v, priority: %v, has_quota: %v, user_email: %v, space_id: %v, caller_psm: %v", + exptID, gptr.Indirect(priorityLevel), quota != nil, subject.UserEmail, subject.SpaceID, subject.CallerPSM) + return nil, nil, nil + } + + // 这里不接受 0 当"不修改":nil 才是"不修改"。0 落到 Normalize 会被收敛成缺省优先级, + // 于是"我传了 0"和"我想设成缺省"变得无法区分,静默改掉一个本不该动的值。 + if priorityLevel != nil && (*priorityLevel < entity.MinExptPriorityLevel || *priorityLevel > entity.MaxExptPriorityLevel) { + return nil, nil, errorx.NewByCode(errno.CommonInvalidParamCode, errorx.WithExtraMsg( + fmt.Sprintf("priority_level must be in range [%d, %d], got %d", entity.MinExptPriorityLevel, entity.MaxExptPriorityLevel, *priorityLevel))) + } + + var consumption *entity.ExpectedQuotaConsumption + if quota != nil { + // 转换层遇到空 resources 会回 nil。放到这里报错而不是当"不修改":调用方明明传了这个 + // 字段,静默忽略等于让"我清空了向量"这个(不被支持的)意图看起来成功了。 + consumption = experiment.ExpectedQuotaConsumptionDTO2DO(quota) + if consumption == nil { + return nil, nil, errorx.NewByCode(errno.CommonInvalidParamCode, + errorx.WithExtraMsg("expected_quota_consumption must not be empty")) + } + consumption = consumption.Normalize() + if err := consumption.Validate(); err != nil { + return nil, nil, errorx.NewByCode(errno.CommonInvalidParamCode, errorx.WithExtraMsg(err.Error())) + } } + + return priorityLevel, consumption, nil } func (e *experimentApplication) CreateExperimentTemplate(ctx context.Context, req *expt.CreateExperimentTemplateRequest) (r *expt.CreateExperimentTemplateResponse, err error) { @@ -1473,12 +1538,21 @@ func (e *experimentApplication) UpdateExptRunConf(ctx context.Context, req *expt itemRetryNum = gptr.Of(v) } + // 中心调度特权参数:命中白名单才生效,未命中丢弃 + WARN(与创建期同口径)。 + priorityLevel, expectedQuota, err := resolveRunConfSchedulingParams( + ctx, e.configer, req.GetWorkspaceID(), req.GetExptID(), req.PriorityLevel, req.ExpectedQuotaConsumption) + if err != nil { + return nil, err + } + if err = e.manager.UpdateRunConf(ctx, &entity.UpdateRunConfParam{ - ExptID: req.GetExptID(), - SpaceID: req.GetWorkspaceID(), - ItemConcurNum: itemConcurNum, - ItemRetryNum: itemRetryNum, - Session: session, + ExptID: req.GetExptID(), + SpaceID: req.GetWorkspaceID(), + ItemConcurNum: itemConcurNum, + ItemRetryNum: itemRetryNum, + PriorityLevel: priorityLevel, + ExpectedQuotaConsumption: expectedQuota, + Session: session, }); err != nil { return nil, err } diff --git a/backend/modules/evaluation/application/experiment_run_conf_privilege_test.go b/backend/modules/evaluation/application/experiment_run_conf_privilege_test.go new file mode 100644 index 0000000000..610fdfcb20 --- /dev/null +++ b/backend/modules/evaluation/application/experiment_run_conf_privilege_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package application + +import ( + "testing" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + domain_expt "github.com/coze-dev/coze-loop/backend/kitex_gen/coze/loop/evaluation/domain/expt" + componentMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/mocks" + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) + +// resolveRunConfSchedulingParams 是 UpdateExptRunConf 两面共用的闸门 + 校验。 +// 它与创建期同口径:未获授权**静默丢弃**(不报错),所以用例断言的是"返回值有没有被清掉"。 + +func quotaDTO(amount int64) *domain_expt.ExpectedQuotaConsumption { + return &domain_expt.ExpectedQuotaConsumption{ + Resources: []*domain_expt.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: amount}, + }, + } +} + +func TestResolveRunConfSchedulingParams_DropsBothWhenNotAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{}) + + priority, quota, err := resolveRunConfSchedulingParams( + ctxWithEmail("stranger@bytedance.com"), mockConfiger, 456, 789, gptr.Of(int32(99)), quotaDTO(1)) + + // 丢弃而不是报错:这两个字段已在 IDL 里,突然报错会打挂已经在传的调用方。 + require.NoError(t, err) + assert.Nil(t, priority, "未授权的 priority 必须丢弃,否则任何人都能把自己的实验设成 99 插队") + assert.Nil(t, quota, "未授权的向量必须丢弃,否则可虚报消耗") +} + +func TestResolveRunConfSchedulingParams_KeepsBothWhenAllowed(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + + priority, quota, err := resolveRunConfSchedulingParams( + ctxWithEmail("admin@bytedance.com"), mockConfiger, 456, 789, gptr.Of(int32(80)), quotaDTO(25)) + + require.NoError(t, err) + require.NotNil(t, priority) + assert.Equal(t, int32(80), *priority) + require.NotNil(t, quota) + require.Len(t, quota.Resources, 1) + assert.Equal(t, int64(25), quota.Resources[0].Amount) +} + +func TestResolveRunConfSchedulingParams_SkipsConfigReadWhenNothingDeclared(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + // 一次配置读取都不该发生:绝大多数调用只改并发度/重试, + // 每次都读白名单既浪费又会给没用这两个字段的调用方刷无关日志。 + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()).Times(0) + + priority, quota, err := resolveRunConfSchedulingParams( + ctxWithEmail("stranger@bytedance.com"), mockConfiger, 456, 789, nil, nil) + + require.NoError(t, err) + assert.Nil(t, priority) + assert.Nil(t, quota) +} + +func TestResolveRunConfSchedulingParams_RejectsOutOfRangePriority(t *testing.T) { + // 0 也要拒绝:nil 才是"不修改"。0 落到下游 Normalize 会被收敛成缺省优先级, + // 于是"我传了 0"和"我想设成缺省"无法区分,静默改掉一个本不该动的值。 + for _, bad := range []int32{0, -1, 100} { + ctrl := gomock.NewController(t) + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + + _, _, err := resolveRunConfSchedulingParams( + ctxWithEmail("admin@bytedance.com"), mockConfiger, 456, 789, gptr.Of(bad), nil) + assert.Error(t, err, "priority_level=%d 应被拒绝", bad) + ctrl.Finish() + } +} + +func TestResolveRunConfSchedulingParams_RejectsInvalidVector(t *testing.T) { + cases := map[string]*domain_expt.ExpectedQuotaConsumption{ + "空 resources": {Resources: nil}, + "amount 非正": quotaDTO(0), + "申报了通配": {Resources: []*domain_expt.ExpectedResourceConsumption{{Category: "sandbox", ResourceKey: "*", Amount: 1}}}, + } + for name, dto := range cases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{UserEmails: []string{"admin@bytedance.com"}}) + + // 报错而不是当"不修改":调用方明明传了这个字段,静默忽略等于让一个 + // 不被支持的意图看起来成功了。 + _, _, err := resolveRunConfSchedulingParams( + ctxWithEmail("admin@bytedance.com"), mockConfiger, 456, 789, nil, dto) + assert.Error(t, err) + }) + } +} + +func TestResolveRunConfSchedulingParams_ValidationHappensAfterTheGate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockConfiger := componentMocks.NewMockIConfiger(ctrl) + mockConfiger.EXPECT().GetExptSchedulingPrivilegeWhiteList(gomock.Any()). + Return(&entity.ExptSchedulingPrivilegeWhiteList{}) + + // 未授权调用方传一个非法值:必须走"丢弃"而不是"报参数错" —— + // 否则报不报错这件事本身就把"你不在白名单里"泄漏了出去。 + priority, quota, err := resolveRunConfSchedulingParams( + ctxWithEmail("stranger@bytedance.com"), mockConfiger, 456, 789, gptr.Of(int32(500)), quotaDTO(-1)) + + require.NoError(t, err) + assert.Nil(t, priority) + assert.Nil(t, quota) +} diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index 23d64dde32..c723babf78 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -3,7 +3,11 @@ package component -import "context" +import ( + "context" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" +) //go:generate mockgen -destination=mocks/central_reservation_guard.go -package=mocks . ICentralReservationGuard @@ -35,6 +39,24 @@ type ICentralReservationGuard interface { // Release 在 item 进入终态(成功/失败/终止/僵尸清理)时幂等释放额度。 // 重复调用为 no-op。 Release(ctx context.Context, schedulerScope string, exptRunID, itemID int64, reason string) error + + // RepriceRunConsumption 把该 run 在飞的预占按新的单 item 消耗向量重新计价。 + // + // 为什么改向量必须配一次重新计价:全量恢复是"账本不可信时的唯一出路",所以它刻意只读 + // MySQL(扫队列 + 实验冻结向量)重建 used,绝不读账本。于是它隐含一条前提 —— + // **每条活预占都是按实验当前向量计价的**。单改 MySQL 里的向量就打破了这条前提: + // 释放仍按每条 reservation 自存的旧 amount 走,而恢复会按新向量重算,两边对不上账, + // 雷正好埋在修账本的唯一手段里。所以改完向量要把在飞预占的存量金额一起改写, + // 让前提重新成立,恢复逻辑一行都不用动。 + // + // newConsumption 由调用方传入而不是由实现方回查 MySQL:回查会撞上主从延迟, + // 按旧向量"重新计价"一遍且完全静默 —— 这类错误比失败更难发现。 + // + // 只覆盖该 run 在飞的预占。持有预占的只有已派发 item,被并发上限卡着(不是题数), + // 通常几十条,一次原子操作就能全覆盖,不存在"只改一半"的中间态。 + // + // **失败必须当错误上抛**:向量已经落库、账本还是旧价,此时返回成功会让调用方以为改好了。 + RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error } // NewNoopCentralReservationGuard 返回开源部署使用的 noop 实现。 @@ -59,6 +81,14 @@ func (noopCentralReservationGuard) Release(ctx context.Context, schedulerScope s return nil } +// RepriceRunConsumption 无账本可改价,直接成功返回。 +// +// 与 ConfirmRunning 的 fail-closed 相反:这里没有"放行了就绕过额度"的风险 —— +// 开源部署根本没有预占,改价是空操作。反过来若报错,会让开源部署连改向量都做不到。 +func (noopCentralReservationGuard) RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error { + return nil +} + //go:generate mockgen -destination=mocks/central_scope_owner.go -package=mocks . ICentralSchedulerScopeOwner // ICentralSchedulerScopeOwner 判定本进程是否拥有某个调度域。 diff --git a/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go b/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go index 6903356a2b..1d001a7912 100644 --- a/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/mocks/central_reservation_guard.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -destination=mocks/central_reservation_guard.go -package=mocks . ICentralReservationGuard +// mockgen -destination=mocks/central_reservation_guard.go -package=mocks github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component ICentralReservationGuard // // Package mocks is a generated GoMock package. @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + entity "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" gomock "go.uber.org/mock/gomock" ) @@ -68,3 +69,17 @@ func (mr *MockICentralReservationGuardMockRecorder) Release(ctx, schedulerScope, mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Release", reflect.TypeOf((*MockICentralReservationGuard)(nil).Release), ctx, schedulerScope, exptRunID, itemID, reason) } + +// RepriceRunConsumption mocks base method. +func (m *MockICentralReservationGuard) RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RepriceRunConsumption", ctx, schedulerScope, exptID, exptRunID, newConsumption) + ret0, _ := ret[0].(error) + return ret0 +} + +// RepriceRunConsumption indicates an expected call of RepriceRunConsumption. +func (mr *MockICentralReservationGuardMockRecorder) RepriceRunConsumption(ctx, schedulerScope, exptID, exptRunID, newConsumption any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RepriceRunConsumption", reflect.TypeOf((*MockICentralReservationGuard)(nil).RepriceRunConsumption), ctx, schedulerScope, exptID, exptRunID, newConsumption) +} diff --git a/backend/modules/evaluation/domain/entity/param.go b/backend/modules/evaluation/domain/entity/param.go index 4afa4e58d2..c123234e67 100644 --- a/backend/modules/evaluation/domain/entity/param.go +++ b/backend/modules/evaluation/domain/entity/param.go @@ -387,7 +387,16 @@ type UpdateRunConfParam struct { SpaceID int64 ItemConcurNum *int ItemRetryNum *int - Session *Session + + // PriorityLevel / ExpectedQuotaConsumption 中心调度参数,nil = 不修改。 + // 两者只对 enforce 实验有意义,legacy 实验既不参与优先级排序也没有额度账本, + // 所以服务层遇到 legacy 会直接拒绝而不是写一个没人读的值。 + PriorityLevel *int32 + // 整向量替换而非增量:与创建期"冻结进 eval_conf"的语义一致, + // 增量语义会让"删掉一个资源维度"无法表达。 + ExpectedQuotaConsumption *ExpectedQuotaConsumption + + Session *Session } type CreateExptParam struct { diff --git a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go index 66f7dd98bf..2e3725de43 100644 --- a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go @@ -44,6 +44,17 @@ type fakeGuard struct { releaseErr error releaseCalls []releaseCall + + repriceErr error + repriceCalls []repriceCall +} + +// repriceCall 记录一次改价调用。带上向量本身:只数次数会放过"调了但传的是旧向量"。 +type repriceCall struct { + Scope string + ExptID int64 + RunID int64 + Consumption *entity.ExpectedQuotaConsumption } func (f *fakeGuard) ConfirmRunning(ctx context.Context, schedulerScope string, exptRunID, itemID int64) (bool, error) { @@ -60,6 +71,21 @@ func (f *fakeGuard) Release(ctx context.Context, schedulerScope string, exptRunI return f.releaseErr } +func (f *fakeGuard) RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error { + f.mu.Lock() + defer f.mu.Unlock() + f.repriceCalls = append(f.repriceCalls, repriceCall{Scope: schedulerScope, ExptID: exptID, RunID: exptRunID, Consumption: newConsumption}) + return f.repriceErr +} + +func (f *fakeGuard) reprices() []repriceCall { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]repriceCall, len(f.repriceCalls)) + copy(out, f.repriceCalls) + return out +} + func (f *fakeGuard) releases() []releaseCall { f.mu.Lock() defer f.mu.Unlock() @@ -484,6 +510,11 @@ func (r *recordingGuard) Release(ctx context.Context, schedulerScope string, exp return nil } +func (r *recordingGuard) RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error { + *r.seq = append(*r.seq, "reprice") + return nil +} + // TestHandleCentralReservation_AdvancesMainTableForDisplay 中心调度取得执行权后, // **主表 expt_item_result 也必须推进到 Processing**,否则用户看到的状态是错的。 // diff --git a/backend/modules/evaluation/domain/service/expt_manage_impl.go b/backend/modules/evaluation/domain/service/expt_manage_impl.go index 127f1a9a14..359356fe4c 100644 --- a/backend/modules/evaluation/domain/service/expt_manage_impl.go +++ b/backend/modules/evaluation/domain/service/expt_manage_impl.go @@ -1745,8 +1745,8 @@ func (e *ExptMangerImpl) Update(ctx context.Context, expt *entity.Experiment, se return e.exptRepo.Update(ctx, expt) } -// UpdateRunConf 修改进行中实验的运行配置(并发度 / Item 重试次数)。 -// 采用 read-modify-write:读出完整实验 → 内存中仅覆盖指定字段 → 序列化完整 EvalConf → 只写 eval_conf 单列。 +// UpdateRunConf 修改进行中实验的运行配置(并发度 / Item 重试次数 / 中心调度参数)。 +// 采用 read-modify-write:读出完整实验 → 内存中仅覆盖指定字段 → 序列化完整 EvalConf → 只写指定列。 // 严禁用只含两字段的裸 EvalConf 覆盖该列(会清空 ConnectorConf/TimeRange/Ext)。 func (e *ExptMangerImpl) UpdateRunConf(ctx context.Context, param *entity.UpdateRunConfParam) error { got, err := e.exptRepo.GetByID(ctx, param.ExptID, param.SpaceID) @@ -1767,6 +1767,15 @@ func (e *ExptMangerImpl) UpdateRunConf(ctx context.Context, param *entity.Update return errorx.NewByCode(errno.ExperimentValidateFailCode, errorx.WithExtraMsg("EvalConfig is invalid")) } + // 中心调度参数只对 enforce 实验有意义:legacy 实验既不参与优先级排序(调度器的扫描条件是 + // scheduler_mode='enforce')、也没有额度账本可改价。所以这里显式拒绝而不是照写 —— + // 写进去只会得到一个没人读的值,接口返回成功、用户以为改好了,是最难发现的一类问题。 + if (param.PriorityLevel != nil || param.ExpectedQuotaConsumption != nil) && + got.ExptDispatchMode != entity.ExptDispatchModeEnforce { + return errorx.NewByCode(errno.ExperimentValidateFailCode, + errorx.WithExtraMsg("priority_level / expected_quota_consumption can only be modified for centrally-scheduled experiments")) + } + // read-modify-write:在完整 EvalConf 上仅覆盖需要修改的字段。 evalConf := got.EvalConf if param.ItemConcurNum != nil { @@ -1775,14 +1784,44 @@ func (e *ExptMangerImpl) UpdateRunConf(ctx context.Context, param *entity.Update if param.ItemRetryNum != nil { evalConf.ItemRetryNum = param.ItemRetryNum } + if param.ExpectedQuotaConsumption != nil { + evalConf.ExpectedQuotaConsumption = param.ExpectedQuotaConsumption + } bytes, err := json.Marshal(evalConf) if err != nil { return errorx.Wrapf(err, "marshal EvalConf fail, expt_id: %v", param.ExptID) } - // 只写 eval_conf 单列,与调度器的 status 写列级不重叠,爆炸半径最小。 - return e.exptRepo.UpdateFields(ctx, param.ExptID, map[string]any{"eval_conf": &bytes}) + // 写列级最小化,与调度器的 status 写不重叠。 + // + // priority_level 走这条显式列名的路径,**不能**走 exptRepo.Update:那条是 struct Updates + + // Omit(schedulingFrozenColumns),正是为了防止部分更新把 enforce 实验打回 legacy 而存在的 + // (见 mysql/expt.go 的 schedulingFrozenColumns 注释)。这里给的是显式列名 map, + // 只可能改到写进 map 的那一列,改不到 scheduler_mode / scheduler_scope。 + ufields := map[string]any{"eval_conf": &bytes} + if param.PriorityLevel != nil { + ufields["priority_level"] = *param.PriorityLevel + } + if err := e.exptRepo.UpdateFields(ctx, param.ExptID, ufields); err != nil { + return err + } + + // 向量改了就要把该 run 在飞的预占一起改价,否则全量恢复的前提被打破 —— + // 完整论证在 component.ICentralReservationGuard.RepriceRunConsumption 的注释里。 + // + // 顺序刻意是"先 MySQL 后账本":MySQL 是恢复的唯一真值来源,先写它意味着即使这一步崩, + // 下一次全量恢复也会把账本收敛到新向量;反过来先改账本再写库,崩了就留下一本没有依据的账。 + // + // 与沙箱名额同步(失败只告警)不同,这里失败必须上抛:名额没跟上只是暂时吃不满, + // 账本价错了会让额度长期算错,而调用方以为已经改好。 + if param.ExpectedQuotaConsumption != nil && e.centralGuard != nil { + if err := e.centralGuard.RepriceRunConsumption(ctx, got.SchedulerScope, param.ExptID, got.LatestRunID, param.ExpectedQuotaConsumption); err != nil { + return errorx.Wrapf(err, "reprice in-flight reservations fail, expt_id: %v, expt_run_id: %v", param.ExptID, got.LatestRunID) + } + } + + return nil } func (e *ExptMangerImpl) Delete(ctx context.Context, exptID, spaceID int64, session *entity.Session) error { diff --git a/backend/modules/evaluation/domain/service/expt_run_conf_scheduling_test.go b/backend/modules/evaluation/domain/service/expt_run_conf_scheduling_test.go new file mode 100644 index 0000000000..5bf973764d --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_run_conf_scheduling_test.go @@ -0,0 +1,190 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/bytedance/gg/gptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + "github.com/coze-dev/coze-loop/backend/pkg/json" +) + +// 本文件覆盖 UpdateRunConf 里新增的两个中心调度参数。 +// +// 三件事必须钉住,都是"接口返回成功但实际没改对"这一类问题: +// 1. priority_level 走显式列名写入,且**不会**顺带碰到 scheduler_mode / scheduler_scope +// (那两列被冻结的理由见 mysql/expt.go 的 schedulingFrozenColumns) +// 2. 向量走 eval_conf 的 read-modify-write,不能覆盖掉 EvalConf 的其它字段 +// 3. 改了向量就必须给在飞预占改价,且**先写库再改账本**、改价失败要上抛 + +const ( + runConfExptID = int64(7676064108126400001) + runConfSpaceID = int64(7533128632407949313) + runConfRunID = int64(7676064108126404609) +) + +func enforceExptForRunConf() *entity.Experiment { + return &entity.Experiment{ + ID: runConfExptID, + SpaceID: runConfSpaceID, + Status: entity.ExptStatus_Processing, + LatestRunID: runConfRunID, + ExptDispatchMode: entity.ExptDispatchModeEnforce, + SchedulerScope: "fornax_cn", + EvalConf: &entity.EvaluationConfiguration{ + ConnectorConf: entity.Connector{TargetConf: &entity.TargetConf{TargetVersionID: 999}}, + ItemConcurNum: gptr.Of(3), + Ext: map[string]string{"k": "v"}, + ExpectedQuotaConsumption: &entity.ExpectedQuotaConsumption{Resources: []*entity.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 10}, + }}, + }, + } +} + +func newVector() *entity.ExpectedQuotaConsumption { + return &entity.ExpectedQuotaConsumption{Resources: []*entity.ExpectedResourceConsumption{ + {Category: "sandbox", ResourceKey: "default", Amount: 25}, + }} +} + +func TestUpdateRunConf_PriorityGoesToItsOwnColumn(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := repoMocks.NewMockIExperimentRepo(ctrl) + mgr := &ExptMangerImpl{exptRepo: mockRepo} + + mockRepo.EXPECT().GetByID(gomock.Any(), runConfExptID, runConfSpaceID).Return(enforceExptForRunConf(), nil) + mockRepo.EXPECT().UpdateFields(gomock.Any(), runConfExptID, gomock.Any()).DoAndReturn( + func(_ context.Context, _ int64, ufields map[string]any) error { + assert.Equal(t, int32(80), ufields["priority_level"]) + // 冻结列一个都不能出现在 map 里:出现即意味着这条路径也能把 enforce 打回 legacy, + // 那会让旧 daemon 恢复自主派发、两个派发驱动同时绕过账本。 + _, hasMode := ufields["scheduler_mode"] + _, hasScope := ufields["scheduler_scope"] + assert.False(t, hasMode, "scheduler_mode 不得被这条路径写入") + assert.False(t, hasScope, "scheduler_scope 不得被这条路径写入") + return nil + }) + + require.NoError(t, mgr.UpdateRunConf(context.Background(), &entity.UpdateRunConfParam{ + ExptID: runConfExptID, + SpaceID: runConfSpaceID, + PriorityLevel: gptr.Of(int32(80)), + })) +} + +func TestUpdateRunConf_VectorIsRMWAndTriggersReprice(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := repoMocks.NewMockIExperimentRepo(ctrl) + guard := &fakeGuard{} + mgr := &ExptMangerImpl{exptRepo: mockRepo, centralGuard: guard} + + mockRepo.EXPECT().GetByID(gomock.Any(), runConfExptID, runConfSpaceID).Return(enforceExptForRunConf(), nil) + mockRepo.EXPECT().UpdateFields(gomock.Any(), runConfExptID, gomock.Any()).DoAndReturn( + func(_ context.Context, _ int64, ufields map[string]any) error { + raw, ok := ufields["eval_conf"].(*[]byte) + require.True(t, ok) + var got entity.EvaluationConfiguration + require.NoError(t, json.Unmarshal(*raw, &got)) + require.NotNil(t, got.ExpectedQuotaConsumption) + require.Len(t, got.ExpectedQuotaConsumption.Resources, 1) + assert.Equal(t, int64(25), got.ExpectedQuotaConsumption.Resources[0].Amount) + // RMW 红线:向量是 EvalConf 的一个字段,用裸结构覆盖会清空同列的其它配置。 + assert.Equal(t, int64(999), got.ConnectorConf.TargetConf.TargetVersionID) + assert.Equal(t, "v", got.Ext["k"]) + assert.Equal(t, 3, gptr.Indirect(got.ItemConcurNum)) + return nil + }) + + require.NoError(t, mgr.UpdateRunConf(context.Background(), &entity.UpdateRunConfParam{ + ExptID: runConfExptID, + SpaceID: runConfSpaceID, + ExpectedQuotaConsumption: newVector(), + })) + + // 改价必须发生,且带的是**新**向量与该实验的 LatestRunID。 + // 只断言"调过一次"会放过"调了但传的是旧向量"——那种错误完全静默。 + calls := guard.reprices() + require.Len(t, calls, 1) + assert.Equal(t, "fornax_cn", calls[0].Scope) + assert.Equal(t, runConfExptID, calls[0].ExptID) + assert.Equal(t, runConfRunID, calls[0].RunID) + require.NotNil(t, calls[0].Consumption) + assert.Equal(t, int64(25), calls[0].Consumption.Resources[0].Amount) +} + +func TestUpdateRunConf_RepriceFailureIsPropagated(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := repoMocks.NewMockIExperimentRepo(ctrl) + guard := &fakeGuard{repriceErr: errors.New("ledger down")} + mgr := &ExptMangerImpl{exptRepo: mockRepo, centralGuard: guard} + + mockRepo.EXPECT().GetByID(gomock.Any(), runConfExptID, runConfSpaceID).Return(enforceExptForRunConf(), nil) + mockRepo.EXPECT().UpdateFields(gomock.Any(), runConfExptID, gomock.Any()).Return(nil) + + // 向量已落库、账本还是旧价。返回成功会让调用方以为改好了,而额度会长期算错 —— + // 这与沙箱名额同步(失败只告警)不同,那里的后果只是暂时吃不满。 + err := mgr.UpdateRunConf(context.Background(), &entity.UpdateRunConfParam{ + ExptID: runConfExptID, + SpaceID: runConfSpaceID, + ExpectedQuotaConsumption: newVector(), + }) + require.Error(t, err) +} + +func TestUpdateRunConf_NoRepriceWhenVectorUntouched(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := repoMocks.NewMockIExperimentRepo(ctrl) + guard := &fakeGuard{} + mgr := &ExptMangerImpl{exptRepo: mockRepo, centralGuard: guard} + + mockRepo.EXPECT().GetByID(gomock.Any(), runConfExptID, runConfSpaceID).Return(enforceExptForRunConf(), nil) + mockRepo.EXPECT().UpdateFields(gomock.Any(), runConfExptID, gomock.Any()).Return(nil) + + // 只改并发度不该碰账本:无谓的改价会把在飞预占重写一遍,多一次可失败的写。 + require.NoError(t, mgr.UpdateRunConf(context.Background(), &entity.UpdateRunConfParam{ + ExptID: runConfExptID, + SpaceID: runConfSpaceID, + ItemConcurNum: gptr.Of(10), + })) + assert.Empty(t, guard.reprices()) +} + +func TestUpdateRunConf_RejectsSchedulingParamsOnLegacyExpt(t *testing.T) { + for name, param := range map[string]*entity.UpdateRunConfParam{ + "priority": {ExptID: runConfExptID, SpaceID: runConfSpaceID, PriorityLevel: gptr.Of(int32(80))}, + "quota": {ExptID: runConfExptID, SpaceID: runConfSpaceID, ExpectedQuotaConsumption: newVector()}, + } { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRepo := repoMocks.NewMockIExperimentRepo(ctrl) + guard := &fakeGuard{} + mgr := &ExptMangerImpl{exptRepo: mockRepo, centralGuard: guard} + + legacy := enforceExptForRunConf() + legacy.ExptDispatchMode = entity.ExptDispatchModeLegacy + legacy.SchedulerScope = "" + mockRepo.EXPECT().GetByID(gomock.Any(), runConfExptID, runConfSpaceID).Return(legacy, nil) + // 一次写都不该发生:legacy 实验既不参与优先级排序也没有账本, + // 照写只会得到一个没人读的值,而调用方收到成功。 + mockRepo.EXPECT().UpdateFields(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + require.Error(t, mgr.UpdateRunConf(context.Background(), param)) + assert.Empty(t, guard.reprices()) + }) + } +} diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go index 55d5ab709e..01b6b16827 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt.go @@ -103,7 +103,9 @@ func (d *exptDAOImpl) Create(ctx context.Context, expt *model.Experiment) error // 同一个 run 出现两个派发驱动、绕过全局额度账本,正是设计上明令禁止的情形。 // 且 scope 是零值会被跳过,最终留下 mode=legacy + scope 非空 的不可能组合。 // -// 这三列的唯一合法写入点是 Create(见 expt_manage_impl.go 的冻结逻辑)。 +// scheduler_mode / scheduler_scope 的唯一合法写入点是 Create(见 expt_manage_impl.go 的冻结逻辑)。 +// priority_level 多一个:UpdateRunConf 允许运行中改优先级,它走 UpdateFields 的显式列名 map, +// 只能改到写进 map 的那一列 —— 这正是本 Omit 要防的"部分更新顺手重置"在那条路径上不成立的原因。 var schedulingFrozenColumns = []string{"priority_level", "scheduler_mode", "scheduler_scope"} func (d *exptDAOImpl) Update(ctx context.Context, expt *model.Experiment) error { From 477c22b525470b6a763217b423edbc2c0185b6ac Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 21:12:38 +0800 Subject: [PATCH 51/58] =?UTF-8?q?test(evaluation):=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?rebase=20onto=20main=20=E5=90=8E=20NewPayloadBuilder=20?= =?UTF-8?q?=E7=9A=84=E6=96=B0=E5=A2=9E=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 的 item run count 给 NewPayloadBuilder 插了两个参数 (第 8 位 exptItemResultRepo、第 10 位 evalTargetRepo), 而本分支新增的 TestNewPayloadBuilder_ItemQuotaImpossibleErrParsing 是按旧签名写的。 两处改动在不同位置,git 文本合并成功、不报冲突,但测试包编译不过: expt_result_impl_test.go:8444: not enough arguments in call to NewPayloadBuilder 本用例只验"额度不可满足 / 僵尸超时"两种 err_msg 的反解分支,构造期不触碰这两个依赖, 故传 nil 并标注参数名,避免下次再有人数不清位置。 验证:go build ./... 通过;modules/evaluation 55 个包全绿。 --- .../modules/evaluation/domain/service/expt_result_impl_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_result_impl_test.go b/backend/modules/evaluation/domain/service/expt_result_impl_test.go index 2baa1ae689..4bc2028c4c 100644 --- a/backend/modules/evaluation/domain/service/expt_result_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_result_impl_test.go @@ -8897,7 +8897,9 @@ func TestNewPayloadBuilder_ItemQuotaImpossibleErrParsing(t *testing.T) { []*entity.ExptItemResult{{ItemID: 1, ItemIdx: 0, Status: entity.ItemRunState_Fail, ErrMsg: errMsg}}, repoMocks.NewMockIExperimentRepo(ctrl), repoMocks.NewMockIExptTurnResultRepo(ctrl), + nil, // exptItemResultRepo repoMocks.NewMockIExptAnnotateRepo(ctrl), + nil, // evalTargetRepo svcMocks.NewMockIEvalTargetService(ctrl), svcMocks.NewMockEvaluatorRecordService(ctrl), svcMocks.NewMockEvaluationSetItemService(ctrl), From 844deb32159fcc9878f236d55bb088ea0d3ffc94 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Mon, 31 Aug 2026 21:31:36 +0800 Subject: [PATCH 52/58] =?UTF-8?q?docs(evaluation):=20=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E6=94=B9=E4=BB=B7=E4=B8=8E=E8=B0=83=E5=BA=A6=E6=8B=8D=E4=B9=8B?= =?UTF-8?q?=E9=97=B4=E9=82=A3=E4=B8=AA=E5=88=BB=E6=84=8F=E4=B8=8D=E5=8A=A0?= =?UTF-8?q?=E9=94=81=E7=9A=84=E7=AB=9E=E4=BA=89=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 调度拍是「读一页候选(连冻结向量)→ 逐个 Reserve」,改价可能插在某个候选的 已读向量与未 Reserve 之间,那一拍新建的 reservation 会按旧价计价。 写清三件事,因为缺任何一件这个窗口就不可接受:影响面被并发上限封住且下一拍 不再发生;释放按每条自存 amount 归还所以不漏不负;随 item 终态自然消退。 并写明残留影响只在个位数 limit 的维度上不可忽略,以及根治方向是向量修订号 而不是在用户面接口里抢调度租约。 --- .../component/central_reservation_guard.go | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/backend/modules/evaluation/domain/component/central_reservation_guard.go b/backend/modules/evaluation/domain/component/central_reservation_guard.go index c723babf78..555f27acaa 100644 --- a/backend/modules/evaluation/domain/component/central_reservation_guard.go +++ b/backend/modules/evaluation/domain/component/central_reservation_guard.go @@ -56,6 +56,28 @@ type ICentralReservationGuard interface { // 通常几十条,一次原子操作就能全覆盖,不存在"只改一半"的中间态。 // // **失败必须当错误上抛**:向量已经落库、账本还是旧价,此时返回成功会让调用方以为改好了。 + // + // ⚠️ **与调度拍之间有一个已知的竞争窗口,刻意不加锁。** + // + // 调度拍是「先从 MySQL 读一页候选(连冻结向量一起读进内存)→ 再逐个 Reserve」, + // 所以本方法可能正好插在某个候选的「已读向量」与「还没 Reserve」之间:那一拍 + // 新建的 reservation 会按**旧**价计价,与刚落库的新向量不一致。 + // + // 不加锁是因为:能锁住它的只有调度拍自己的租约(per-scope、TTL 90s),而本方法在 + // 用户面接口的同步链路上 —— 去抢那把锁要么长时间阻塞调用方,要么把调度权抢走。 + // + // 之所以可以接受,是因为三件事,缺一不可: + // 1. 影响面被封住:只限「那一拍、那个实验、那一批新建的 reservation」, + // 条数 ≤ 该实验的并发上限,每条偏差恰好 |新值−旧值|,且下一拍重读 MySQL 后不再发生。 + // 2. 最要紧的不变量没破:释放按每条 reservation 自存的 amount 归还, + // 按旧价扣的就按旧价还 —— 不漏、不负、不悬挂。 + // 3. 会自然消退:这些 reservation 在对应 item 终态时被删除,账本随之回到正确值。 + // (全量恢复也会把两边一起 re-base,但它不是定期任务,不能当作修复手段。) + // + // 残留的唯一影响是窗口内授予判断偏松:账本少算 → 可能比新申报本意多放进几个 item。 + // 在宽上限维度上可忽略;**在个位数 limit 的维度上不可忽略**(少算一个 seat 就是独占 + // 资源被双占)。若将来需要根治,方向是给冻结向量加修订号、Reserve 时一并写进快照, + // 让改价与恢复能按修订号识别旧价条目 —— 不要改成"在这里抢调度租约"。 RepriceRunConsumption(ctx context.Context, schedulerScope string, exptID, exptRunID int64, newConsumption *entity.ExpectedQuotaConsumption) error } From 1e4769cc9eb46f822b56580e996683362f5e1681 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 1 Sep 2026 15:23:01 +0800 Subject: [PATCH 53/58] =?UTF-8?q?feat(evaluation):=20=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E9=93=BE=E9=80=8F=E4=BC=A0=E5=8F=91=E8=B5=B7=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E7=9A=84=E7=A9=BA=E9=97=B4=20(ExptSpaceID)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨空间共享评测对象时,传给 operator 的 spaceID 已被 resolveLoadSpaceID 换成 **评测对象来源空间** —— 那是「去哪个空间读这个对象」的口径。而「按哪个空间取模型凭据 (api_key / base_url)」应当跟着发起实验的空间走:evaluator 侧早已如此 (evaluatorSpaceID = expt.SpaceID),callTarget 的埋点也一直用 Event.SpaceID, 只有 target 的模型凭据还在按来源空间取。 两个口径必须并存,所以另开一个字段,**不改那个 spaceID 形参** —— 它同时决定沙箱 workspace、eval_target_record 的落库/读取空间、空间 AK/SK 与 TCC 空间级配置, 换掉会撕裂销毁链(四条回收链全读 record.SpaceID)与回传链 (ReportInvokeRecords 按 (space, record_id) 定位)。 - ExecuteTargetCtx / ExecuteEvalTargetParam 各加 ExptSpaceID,纯加字段 - expt_run_item_turn_impl.go 从 etec.Event.SpaceID 填入;target_impl.go 透传 - 调试链路 (DebugTarget / AsyncDebugTarget) 不填 → 0,消费侧据此回落原行为 ★ 恒取 Event.SpaceID,不从 ItemConfig 派生:多集执行恒用顶层 target,而 ItemConfig 的来源空间是 per-set 的,按 per-set 派生会重演「拿 B 的空间去加载 A 的 target」那类错配。 测试:新增跨空间守卫用例,专门造「Event.SpaceID=42 / spaceID 形参=99」的 fixture。 同空间 fixture 上写这条断言是没有牙齿的(两值相等,分辨不出实现取了哪个)—— 变异验证过:把实现改成取形参,该用例因断言失败而红。 验证:go build ./... 通过;modules/evaluation 55 个包全绿。 --- .../modules/evaluation/domain/entity/param.go | 7 ++ .../evaluation/domain/entity/target_record.go | 8 ++ .../domain/service/expt_run_item_turn_impl.go | 5 + .../service/expt_run_item_turn_impl_test.go | 103 ++++++++++++++++++ .../evaluation/domain/service/target_impl.go | 2 + 5 files changed, 125 insertions(+) diff --git a/backend/modules/evaluation/domain/entity/param.go b/backend/modules/evaluation/domain/entity/param.go index c123234e67..769abc5ab2 100644 --- a/backend/modules/evaluation/domain/entity/param.go +++ b/backend/modules/evaluation/domain/entity/param.go @@ -244,6 +244,13 @@ type ExecuteEvalTargetParam struct { // ExptGroupKey 实验分组 key (Experiment.ExperimentGroupKey, 默认为实验 id 字符串), // 属实验级属性, 供评测对象 (如 SandboxAgent) 透传给外部执行侧。 ExptGroupKey string + // ExptSpaceID 发起实验所在空间 (消费方空间)。0 = 未知 (调试链路无实验)。 + // + // 与本结构其它字段的关键区别: operator 收到的 spaceID 形参在跨空间共享评测对象时 + // 是**对象来源空间**, 本字段才是发起实验的空间。消费方**只应**用它做"按空间取配置" + // 这类归属判断 (当前唯一消费方: SandboxAgent 的模型凭据替换规则); + // 沙箱 workspace、record 空间、AK/SK 一律继续用 spaceID 形参, 换了会撕裂销毁链与回传链。 + ExptSpaceID int64 } // EvalSetItemMeta 承载评测集与 item 层面的元数据快照, 用于评测对象 (如 SandboxAgent) 透传给外部执行侧。 diff --git a/backend/modules/evaluation/domain/entity/target_record.go b/backend/modules/evaluation/domain/entity/target_record.go index 5b4e366d2d..d4d582c07a 100644 --- a/backend/modules/evaluation/domain/entity/target_record.go +++ b/backend/modules/evaluation/domain/entity/target_record.go @@ -184,6 +184,14 @@ type ExecuteTargetCtx struct { ItemMeta *EvalSetItemMeta // ExptGroupKey 实验分组 key (实验级属性), 供评测对象透传给外部执行侧。 ExptGroupKey string + // ExptSpaceID 发起实验所在空间 (消费方空间)。0 = 未知 (调试链路无实验)。 + // + // 为什么需要它: 执行 target 用的 spaceID 在跨空间共享时被换成**评测对象来源空间** + // (见 resolveLoadSpaceID), 那是"去哪个空间读这个对象"的口径; 而"按哪个空间取模型凭据" + // 应当跟着发起实验的空间走 —— evaluator 侧早已如此 (evaluatorSpaceID = expt.SpaceID)。 + // 两个口径必须并存, 所以另开一个字段, 不要去改那个 spaceID 形参: 它还同时决定 + // 沙箱 workspace、record 落库/读取空间、空间 AK/SK 与 TCC 空间级配置。 + ExptSpaceID int64 } type TargetTrajectoryConf struct { diff --git a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go index ecb64cda61..d901d556e4 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl.go @@ -308,6 +308,11 @@ func (e *DefaultExptTurnEvaluationImpl) callTarget(ctx context.Context, etec *en LogID: logs.GetLogID(ctx), ItemMeta: buildEvalSetItemMeta(etec), ExptGroupKey: etec.Expt.ExperimentGroupKey, + // ★ 恒取事件里的实验空间, **不要**从 ItemConfig 派生。 + // 多集执行恒用顶层 target, 而 ItemConfig 的来源空间是 per-set 的 + // (见 ExptItemEvalCtx.TargetSourceSpaceID 的论证) —— 按 per-set 派生会重演 + // "拿 B 的空间去加载 A 的 target" 那类错配。 + ExptSpaceID: etec.Event.SpaceID, } if etec.Expt.EvalConf != nil { etc.EnableExtractTrajectory = etec.Expt.EvalConf.EnableExtractTrajectory diff --git a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl_test.go index 4c77407c3c..d321f536d2 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_turn_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_turn_impl_test.go @@ -2223,6 +2223,14 @@ func TestDefaultExptTurnEvaluationImpl_callTarget_Async(t *testing.T) { assert.Equal(t, int64(777), gptr.Indirect(param.ExperimentRunID)) assert.Equal(t, int64(888), param.ItemID) assert.Equal(t, int64(999), param.TurnID) + // ★ ExptSpaceID 必须是事件里的实验空间。 + // + // 跨空间共享评测对象时,传给 AsyncExecuteTarget 的 spaceID 已被 resolveLoadSpaceID + // 换成**对象来源空间**,本字段是下游唯一能拿到"发起实验在哪个空间"的入口 + // (SandboxAgent 据此选模型凭据的替换规则)。 + // 若被改成从 ItemConfig 派生,多集实验会拿到 per-set 的来源空间 —— 那是另一个空间。 + assert.Equal(t, etec.Event.SpaceID, param.ExptSpaceID, + "ExptSpaceID 必须恒等于事件里的实验空间,不能取 spaceID 形参、也不能从 ItemConfig 派生") if assert.NotNil(t, input) { assert.Equal(t, "payload", gptr.Indirect(input.InputFields["fieldA"].Text)) assert.Equal(t, "ext-val", input.Ext["ext-key"]) @@ -5505,3 +5513,98 @@ func TestDefaultExptTurnEvaluationImpl_MixedEvaluatorCompletionOrder(t *testing. newCase(t, entity.EvaluatorRunStatusAsyncInvoking, true) }) } + +// TestDefaultExptTurnEvaluationImpl_callTarget_CrossSpaceCarriesExptSpaceID +// ★ 跨空间共享评测对象时,ExecuteTargetCtx.ExptSpaceID 必须是**发起实验的空间**, +// 而传给 AsyncExecuteTarget 的 spaceID 仍是**评测对象来源空间**。两者刻意不同。 +// +// 为什么要专门造一个「两者不等」的 fixture:同空间实验里 Event.SpaceID 与 spaceID 形参 +// 恰好相等,写在那种 fixture 上的断言分辨不出实现到底取了哪一个 —— 而取错正是本字段 +// 要修的那个 bug(下游 SandboxAgent 据 ExptSpaceID 选模型凭据的替换规则)。 +func TestDefaultExptTurnEvaluationImpl_callTarget_CrossSpaceCarriesExptSpaceID(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockMetric := metricsmocks.NewMockExptMetric(ctrl) + mockEvalTargetService := svcmocks.NewMockIEvalTargetService(ctrl) + mockEvalAsyncRepo := repomocks.NewMockIEvalAsyncRepo(ctrl) + service := &DefaultExptTurnEvaluationImpl{ + metric: mockMetric, + evalTargetService: mockEvalTargetService, + evalAsyncRepo: mockEvalAsyncRepo, + } + + const ( + exptSpaceID = int64(42) // 发起实验的空间(消费方) + targetSourceSpaceID = int64(99) // 评测对象来源空间,由 CallTarget 经 resolveLoadSpaceID 算出后传入 + targetID = int64(101) + targetVersionID = int64(202) + ) + + record := &entity.EvalTargetRecord{ + ID: 9999, + EvalTargetOutputData: &entity.EvalTargetOutputData{OutputFields: map[string]*entity.Content{}}, + Status: gptr.Of(entity.EvalTargetRunStatusAsyncInvoking), + TargetID: targetID, + TargetVersionID: targetVersionID, + SpaceID: targetSourceSpaceID, + } + + etec := &entity.ExptTurnEvalCtx{ + ExptItemEvalCtx: &entity.ExptItemEvalCtx{ + Event: &entity.ExptItemEvalEvent{ + ExptID: 555, ExptRunID: 777, SpaceID: exptSpaceID, + Session: &entity.Session{UserID: "user"}, + }, + Expt: &entity.Experiment{ + SpaceID: exptSpaceID, + TargetSpaceID: targetSourceSpaceID, // >0 即跨空间共享 + TargetVersionID: targetVersionID, + Target: &entity.EvalTarget{ + ID: targetID, + EvalTargetType: entity.EvalTargetTypeCustomRPCServer, + EvalTargetVersion: &entity.EvalTargetVersion{ + ID: targetVersionID, + CustomRPCServer: &entity.CustomRPCServer{IsAsync: gptr.Of(true)}, + }, + }, + EvalConf: &entity.EvaluationConfiguration{ + ConnectorConf: entity.Connector{ + TargetConf: &entity.TargetConf{ + TargetVersionID: targetVersionID, + IngressConf: &entity.TargetIngressConf{ + EvalSetAdapter: &entity.FieldAdapter{ + FieldConfs: []*entity.FieldConf{{FieldName: "fieldA", FromField: "fieldA"}}, + }, + }, + }, + }, + }, + }, + EvalSetItem: &entity.EvaluationSetItem{ItemID: 888}, + }, + Turn: &entity.Turn{ + ID: 999, + FieldDataList: []*entity.FieldData{ + {Name: "fieldA", Content: &entity.Content{ContentType: gptr.Of(entity.ContentTypeText), Text: gptr.Of("payload")}}, + }, + }, + } + + // 埋点用的是实验空间(Event.SpaceID),不是形参 —— 本身就与本次改动方向一致。 + mockMetric.EXPECT().EmitTurnExecTargetResult(exptSpaceID, false) + // 第二个实参钉住 targetSourceSpaceID:加载/执行 target 仍走来源空间,本次改动不动它。 + mockEvalTargetService.EXPECT(). + AsyncExecuteTarget(gomock.Any(), targetSourceSpaceID, targetID, targetVersionID, gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _, _ int64, param *entity.ExecuteTargetCtx, _ *entity.EvalTargetInputData) (*entity.EvalTargetRecord, string, error) { + assert.Equal(t, exptSpaceID, param.ExptSpaceID, + "★ ExptSpaceID 必须是发起实验的空间(42),不是评测对象来源空间(99) —— "+ + "取错就是本字段要修的那个 bug:模型凭据会按对象所在空间取") + return record, "callee-service", nil + }) + mockEvalAsyncRepo.EXPECT().SetEvalAsyncCtx(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + got, err := service.callTarget(context.Background(), etec, nil, targetSourceSpaceID) + require.NoError(t, err) + require.NotNil(t, got) +} diff --git a/backend/modules/evaluation/domain/service/target_impl.go b/backend/modules/evaluation/domain/service/target_impl.go index baa62da8ed..5ec12e470b 100644 --- a/backend/modules/evaluation/domain/service/target_impl.go +++ b/backend/modules/evaluation/domain/service/target_impl.go @@ -584,6 +584,8 @@ func (e *EvalTargetServiceImpl) asyncExecuteTarget(ctx context.Context, spaceID LogID: param.LogID, ItemMeta: param.ItemMeta, ExptGroupKey: param.ExptGroupKey, + // 透传发起实验的空间。注意这里**不能**用 spaceID —— 跨空间共享时它已是对象来源空间。 + ExptSpaceID: param.ExptSpaceID, }) if execErr != nil { // If an asynchronous call fails, return immediately without logging the error or propagating the exception. From f837d1327babf8cad8c2a6dd91f10dcc9e7bc0c1 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 1 Sep 2026 16:05:48 +0800 Subject: [PATCH 54/58] =?UTF-8?q?fix(evaluation):=20expt=5Fstats=20?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E8=A1=8C=E6=94=B9=E4=B8=BA=E9=94=9A=E5=9C=A8?= =?UTF-8?q?=E4=B8=BB=E8=A1=A8=E4=B8=8A=EF=BC=8C=E5=B9=B6=E5=8A=A0=E6=AF=8F?= =?UTF-8?q?=E6=8B=8D=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上实测:PPE 一个 900 题 enforce 实验,expt_stats 的 pending 虚高 281、 success 少 249,而 item 表与 turn 表分毫不差;同泳道的 legacy 实验完全一致。 缺口还在持续增长(一小时内 21 个完成只被记了 17 个)。 两处修改: ① 派发侧记账换判据。原先绑在 run log 的 CAS(started)上,而主表推进是无条件的 —— 两个判据一分叉,计数行就与主表对不上。而完成侧 statsCntOp 恰恰是按 items_result.Status 做「-1」的,于是它去减一个计数行里从没加过的桶。 现在以「主表真的发生了状态迁移」为唯一判据,两边同源;减的桶取主表实际所在的 状态而非写死 Queueing(repair/重投会让它停在别的状态)。 ② 调度器每拍对账。终态路径有多条(正常完成 / zombie / 沙箱 sweep),每条都得自己 记得记一笔增量账,漏一笔就永久偏一笔,而那些调用点全是 warn-only 或干脆没有 —— 没有任何机制会发现。运行期此前也没有重算:CalculateStats 只在 CompleteExpt 与在线实验 daemon 里调。现在每拍用一条 GROUP BY 拿主表真实分布,不一致才写回, 并打 Warn —— 那条日志是「还在漏」的唯一信号。 以主表而非 turn 表为准:完成侧的「-1」读的就是主表,要能修正它必须同源; 拿 turn 表对账会在多轮实验上得出另一套数字(所以没复用 CalculateStats)。 派发侧仍保持失败只告警:返回错误会让已取得执行权的 item 被 MQ 重投, 重投一次 Agent 执行的代价远大于一次计数偏差 —— 残留偏差由②收敛。 --- .../modules/evaluation/domain/repo/expt.go | 2 + .../evaluation/domain/repo/mocks/expt.go | 1013 +++++++++-------- .../expt_central_quota_release_test.go | 23 +- .../service/expt_run_item_event_impl.go | 37 +- .../service/expt_run_scheduler_event_impl.go | 71 ++ .../expt_run_scheduler_event_impl_test.go | 5 + .../service/expt_stats_consistency_test.go | 246 ++++ .../infra/repo/experiment/expt_item_result.go | 12 + .../repo/experiment/mysql/expt_item_result.go | 33 + .../mysql/mocks/expt_item_result.go | 22 +- 10 files changed, 951 insertions(+), 513 deletions(-) create mode 100644 backend/modules/evaluation/domain/service/expt_stats_consistency_test.go diff --git a/backend/modules/evaluation/domain/repo/expt.go b/backend/modules/evaluation/domain/repo/expt.go index e3cef5a23d..762d36cb80 100644 --- a/backend/modules/evaluation/domain/repo/expt.go +++ b/backend/modules/evaluation/domain/repo/expt.go @@ -69,6 +69,8 @@ type IExptItemResultRepo interface { GetItemTurnResults(ctx context.Context, spaceID, exptID, itemID int64) ([]*entity.ExptTurnResult, error) MGetItemTurnResults(ctx context.Context, spaceID, exptID int64, itemIDs []int64) ([]*entity.ExptTurnResult, error) UpdateItemsResult(ctx context.Context, spaceID, exptID int64, itemID []int64, ufields map[string]any) error + // CountItemsByStatus 返回该实验 item 的状态分布(status -> 行数),供 expt_stats 计数行对账。 + CountItemsByStatus(ctx context.Context, spaceID, exptID int64) (map[entity.ItemRunState]int64, error) GetMaxItemIdxByExptID(ctx context.Context, exptID, spaceID int64) (int32, error) BatchCreateNXRunLogs(ctx context.Context, itemRunLogs []*entity.ExptItemResultRunLog) error diff --git a/backend/modules/evaluation/domain/repo/mocks/expt.go b/backend/modules/evaluation/domain/repo/mocks/expt.go index 9c83b8fa57..6e44ef0585 100644 --- a/backend/modules/evaluation/domain/repo/mocks/expt.go +++ b/backend/modules/evaluation/domain/repo/mocks/expt.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -destination ./mocks/expt.go --package mocks . IExperimentRepo,IExptStatsRepo,IExptItemResultRepo,IExptTurnResultRepo,IExptRunLogRepo,IExptAggrResultRepo,QuotaRepo,IExptTurnResultFilterRepo,IExptAnnotateRepo,IExptResultExportRecordRepo,IEvalAsyncRepo,IExptInsightAnalysisRecordRepo,IExptItemRefRepo +// mockgen -destination ./mocks/expt.go --package mocks github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo IExperimentRepo,IExptStatsRepo,IExptItemResultRepo,IExptTurnResultRepo,IExptRunLogRepo,IExptAggrResultRepo,QuotaRepo,IExptTurnResultFilterRepo,IExptAnnotateRepo,IExptResultExportRecordRepo,IEvalAsyncRepo,IExptInsightAnalysisRecordRepo,IExptItemRefRepo // // Package mocks is a generated GoMock package. @@ -22,6 +22,7 @@ import ( type MockIExperimentRepo struct { ctrl *gomock.Controller recorder *MockIExperimentRepoMockRecorder + isgomock struct{} } // MockIExperimentRepoMockRecorder is the mock recorder for MockIExperimentRepo. @@ -42,67 +43,67 @@ func (m *MockIExperimentRepo) EXPECT() *MockIExperimentRepoMockRecorder { } // Create mocks base method. -func (m *MockIExperimentRepo) Create(arg0 context.Context, arg1 *entity.Experiment, arg2 []*entity.ExptEvaluatorRef) error { +func (m *MockIExperimentRepo) Create(ctx context.Context, expt *entity.Experiment, exptEvaluatorRefs []*entity.ExptEvaluatorRef) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Create", ctx, expt, exptEvaluatorRefs) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockIExperimentRepoMockRecorder) Create(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) Create(ctx, expt, exptEvaluatorRefs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExperimentRepo)(nil).Create), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExperimentRepo)(nil).Create), ctx, expt, exptEvaluatorRefs) } // Delete mocks base method. -func (m *MockIExperimentRepo) Delete(arg0 context.Context, arg1, arg2 int64) error { +func (m *MockIExperimentRepo) Delete(ctx context.Context, id, spaceID int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Delete", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Delete", ctx, id, spaceID) ret0, _ := ret[0].(error) return ret0 } // Delete indicates an expected call of Delete. -func (mr *MockIExperimentRepoMockRecorder) Delete(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) Delete(ctx, id, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockIExperimentRepo)(nil).Delete), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockIExperimentRepo)(nil).Delete), ctx, id, spaceID) } // ExistGroupKey mocks base method. -func (m *MockIExperimentRepo) ExistGroupKey(arg0 context.Context, arg1 string, arg2 int64) (bool, error) { +func (m *MockIExperimentRepo) ExistGroupKey(ctx context.Context, groupKey string, spaceID int64) (bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ExistGroupKey", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "ExistGroupKey", ctx, groupKey, spaceID) ret0, _ := ret[0].(bool) ret1, _ := ret[1].(error) return ret0, ret1 } // ExistGroupKey indicates an expected call of ExistGroupKey. -func (mr *MockIExperimentRepoMockRecorder) ExistGroupKey(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) ExistGroupKey(ctx, groupKey, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistGroupKey", reflect.TypeOf((*MockIExperimentRepo)(nil).ExistGroupKey), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExistGroupKey", reflect.TypeOf((*MockIExperimentRepo)(nil).ExistGroupKey), ctx, groupKey, spaceID) } // GetByID mocks base method. -func (m *MockIExperimentRepo) GetByID(arg0 context.Context, arg1, arg2 int64) (*entity.Experiment, error) { +func (m *MockIExperimentRepo) GetByID(ctx context.Context, id, spaceID int64) (*entity.Experiment, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetByID", ctx, id, spaceID) ret0, _ := ret[0].(*entity.Experiment) ret1, _ := ret[1].(error) return ret0, ret1 } // GetByID indicates an expected call of GetByID. -func (mr *MockIExperimentRepoMockRecorder) GetByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) GetByID(ctx, id, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockIExperimentRepo)(nil).GetByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockIExperimentRepo)(nil).GetByID), ctx, id, spaceID) } // GetByName mocks base method. -func (m *MockIExperimentRepo) GetByName(arg0 context.Context, arg1 string, arg2 int64) (*entity.Experiment, bool, error) { +func (m *MockIExperimentRepo) GetByName(ctx context.Context, name string, spaceID int64) (*entity.Experiment, bool, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByName", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetByName", ctx, name, spaceID) ret0, _ := ret[0].(*entity.Experiment) ret1, _ := ret[1].(bool) ret2, _ := ret[2].(error) @@ -110,45 +111,30 @@ func (m *MockIExperimentRepo) GetByName(arg0 context.Context, arg1 string, arg2 } // GetByName indicates an expected call of GetByName. -func (mr *MockIExperimentRepoMockRecorder) GetByName(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) GetByName(ctx, name, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByName", reflect.TypeOf((*MockIExperimentRepo)(nil).GetByName), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByName", reflect.TypeOf((*MockIExperimentRepo)(nil).GetByName), ctx, name, spaceID) } // GetEvaluatorRefByExptIDs mocks base method. -func (m *MockIExperimentRepo) GetEvaluatorRefByExptIDs(arg0 context.Context, arg1 []int64, arg2 int64) ([]*entity.ExptEvaluatorRef, error) { +func (m *MockIExperimentRepo) GetEvaluatorRefByExptIDs(ctx context.Context, exptID []int64, spaceID int64) ([]*entity.ExptEvaluatorRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEvaluatorRefByExptIDs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetEvaluatorRefByExptIDs", ctx, exptID, spaceID) ret0, _ := ret[0].([]*entity.ExptEvaluatorRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetEvaluatorRefByExptIDs indicates an expected call of GetEvaluatorRefByExptIDs. -func (mr *MockIExperimentRepoMockRecorder) GetEvaluatorRefByExptIDs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) GetEvaluatorRefByExptIDs(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvaluatorRefByExptIDs", reflect.TypeOf((*MockIExperimentRepo)(nil).GetEvaluatorRefByExptIDs), arg0, arg1, arg2) -} - -// ScanSchedulerQueue mocks base method. -func (m *MockIExperimentRepo) ScanSchedulerQueue(arg0 context.Context, arg1 *entity.SchedulerQueueScanParam) ([]*entity.Experiment, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ScanSchedulerQueue", arg0, arg1) - ret0, _ := ret[0].([]*entity.Experiment) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ScanSchedulerQueue indicates an expected call of ScanSchedulerQueue. -func (mr *MockIExperimentRepoMockRecorder) ScanSchedulerQueue(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanSchedulerQueue", reflect.TypeOf((*MockIExperimentRepo)(nil).ScanSchedulerQueue), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvaluatorRefByExptIDs", reflect.TypeOf((*MockIExperimentRepo)(nil).GetEvaluatorRefByExptIDs), ctx, exptID, spaceID) } // GetIDsByGroupKey mocks base method. -func (m *MockIExperimentRepo) GetIDsByGroupKey(arg0 context.Context, arg1 int64, arg2 string, arg3, arg4 int32) ([]int64, int64, error) { +func (m *MockIExperimentRepo) GetIDsByGroupKey(ctx context.Context, spaceID int64, groupKey string, page, pageSize int32) ([]int64, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetIDsByGroupKey", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "GetIDsByGroupKey", ctx, spaceID, groupKey, page, pageSize) ret0, _ := ret[0].([]int64) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -156,15 +142,15 @@ func (m *MockIExperimentRepo) GetIDsByGroupKey(arg0 context.Context, arg1 int64, } // GetIDsByGroupKey indicates an expected call of GetIDsByGroupKey. -func (mr *MockIExperimentRepoMockRecorder) GetIDsByGroupKey(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) GetIDsByGroupKey(ctx, spaceID, groupKey, page, pageSize any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIDsByGroupKey", reflect.TypeOf((*MockIExperimentRepo)(nil).GetIDsByGroupKey), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetIDsByGroupKey", reflect.TypeOf((*MockIExperimentRepo)(nil).GetIDsByGroupKey), ctx, spaceID, groupKey, page, pageSize) } // List mocks base method. -func (m *MockIExperimentRepo) List(arg0 context.Context, arg1, arg2 int32, arg3 *entity.ExptListFilter, arg4 []*entity.OrderBy, arg5 int64) ([]*entity.Experiment, int64, error) { +func (m *MockIExperimentRepo) List(ctx context.Context, page, size int32, filter *entity.ExptListFilter, orders []*entity.OrderBy, spaceID int64) ([]*entity.Experiment, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "List", ctx, page, size, filter, orders, spaceID) ret0, _ := ret[0].([]*entity.Experiment) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -172,87 +158,88 @@ func (m *MockIExperimentRepo) List(arg0 context.Context, arg1, arg2 int32, arg3 } // List indicates an expected call of List. -func (mr *MockIExperimentRepoMockRecorder) List(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) List(ctx, page, size, filter, orders, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExperimentRepo)(nil).List), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExperimentRepo)(nil).List), ctx, page, size, filter, orders, spaceID) } // MDelete mocks base method. -func (m *MockIExperimentRepo) MDelete(arg0 context.Context, arg1 []int64, arg2 int64) error { +func (m *MockIExperimentRepo) MDelete(ctx context.Context, ids []int64, spaceID int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MDelete", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MDelete", ctx, ids, spaceID) ret0, _ := ret[0].(error) return ret0 } // MDelete indicates an expected call of MDelete. -func (mr *MockIExperimentRepoMockRecorder) MDelete(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) MDelete(ctx, ids, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MDelete", reflect.TypeOf((*MockIExperimentRepo)(nil).MDelete), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MDelete", reflect.TypeOf((*MockIExperimentRepo)(nil).MDelete), ctx, ids, spaceID) } // MGetBasicByID mocks base method. -func (m *MockIExperimentRepo) MGetBasicByID(arg0 context.Context, arg1 []int64) ([]*entity.Experiment, error) { +func (m *MockIExperimentRepo) MGetBasicByID(ctx context.Context, ids []int64) ([]*entity.Experiment, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetBasicByID", arg0, arg1) + ret := m.ctrl.Call(m, "MGetBasicByID", ctx, ids) ret0, _ := ret[0].([]*entity.Experiment) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetBasicByID indicates an expected call of MGetBasicByID. -func (mr *MockIExperimentRepoMockRecorder) MGetBasicByID(arg0, arg1 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) MGetBasicByID(ctx, ids any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetBasicByID", reflect.TypeOf((*MockIExperimentRepo)(nil).MGetBasicByID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetBasicByID", reflect.TypeOf((*MockIExperimentRepo)(nil).MGetBasicByID), ctx, ids) } // MGetByID mocks base method. -func (m *MockIExperimentRepo) MGetByID(arg0 context.Context, arg1 []int64, arg2 int64) ([]*entity.Experiment, error) { +func (m *MockIExperimentRepo) MGetByID(ctx context.Context, ids []int64, spaceID int64) ([]*entity.Experiment, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MGetByID", ctx, ids, spaceID) ret0, _ := ret[0].([]*entity.Experiment) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetByID indicates an expected call of MGetByID. -func (mr *MockIExperimentRepoMockRecorder) MGetByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) MGetByID(ctx, ids, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetByID", reflect.TypeOf((*MockIExperimentRepo)(nil).MGetByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetByID", reflect.TypeOf((*MockIExperimentRepo)(nil).MGetByID), ctx, ids, spaceID) } // Update mocks base method. -func (m *MockIExperimentRepo) Update(arg0 context.Context, arg1 *entity.Experiment) error { +func (m *MockIExperimentRepo) Update(ctx context.Context, expt *entity.Experiment) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Update", arg0, arg1) + ret := m.ctrl.Call(m, "Update", ctx, expt) ret0, _ := ret[0].(error) return ret0 } // Update indicates an expected call of Update. -func (mr *MockIExperimentRepoMockRecorder) Update(arg0, arg1 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) Update(ctx, expt any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIExperimentRepo)(nil).Update), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIExperimentRepo)(nil).Update), ctx, expt) } // UpdateFields mocks base method. -func (m *MockIExperimentRepo) UpdateFields(arg0 context.Context, arg1 int64, arg2 map[string]any) error { +func (m *MockIExperimentRepo) UpdateFields(ctx context.Context, exptID int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateFields", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "UpdateFields", ctx, exptID, ufields) ret0, _ := ret[0].(error) return ret0 } // UpdateFields indicates an expected call of UpdateFields. -func (mr *MockIExperimentRepoMockRecorder) UpdateFields(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExperimentRepoMockRecorder) UpdateFields(ctx, exptID, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFields", reflect.TypeOf((*MockIExperimentRepo)(nil).UpdateFields), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFields", reflect.TypeOf((*MockIExperimentRepo)(nil).UpdateFields), ctx, exptID, ufields) } // MockIExptStatsRepo is a mock of IExptStatsRepo interface. type MockIExptStatsRepo struct { ctrl *gomock.Controller recorder *MockIExptStatsRepoMockRecorder + isgomock struct{} } // MockIExptStatsRepoMockRecorder is the mock recorder for MockIExptStatsRepo. @@ -273,95 +260,96 @@ func (m *MockIExptStatsRepo) EXPECT() *MockIExptStatsRepoMockRecorder { } // ArithOperateCount mocks base method. -func (m *MockIExptStatsRepo) ArithOperateCount(arg0 context.Context, arg1, arg2 int64, arg3 *entity.StatsCntArithOp) error { +func (m *MockIExptStatsRepo) ArithOperateCount(ctx context.Context, exptID, spaceID int64, cntArithOp *entity.StatsCntArithOp) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ArithOperateCount", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "ArithOperateCount", ctx, exptID, spaceID, cntArithOp) ret0, _ := ret[0].(error) return ret0 } // ArithOperateCount indicates an expected call of ArithOperateCount. -func (mr *MockIExptStatsRepoMockRecorder) ArithOperateCount(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) ArithOperateCount(ctx, exptID, spaceID, cntArithOp any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ArithOperateCount", reflect.TypeOf((*MockIExptStatsRepo)(nil).ArithOperateCount), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ArithOperateCount", reflect.TypeOf((*MockIExptStatsRepo)(nil).ArithOperateCount), ctx, exptID, spaceID, cntArithOp) } // Create mocks base method. -func (m *MockIExptStatsRepo) Create(arg0 context.Context, arg1 *entity.ExptStats) error { +func (m *MockIExptStatsRepo) Create(ctx context.Context, stats *entity.ExptStats) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret := m.ctrl.Call(m, "Create", ctx, stats) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockIExptStatsRepoMockRecorder) Create(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) Create(ctx, stats any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExptStatsRepo)(nil).Create), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExptStatsRepo)(nil).Create), ctx, stats) } // Get mocks base method. -func (m *MockIExptStatsRepo) Get(arg0 context.Context, arg1, arg2 int64) (*entity.ExptStats, error) { +func (m *MockIExptStatsRepo) Get(ctx context.Context, exptID, spaceID int64) (*entity.ExptStats, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Get", ctx, exptID, spaceID) ret0, _ := ret[0].(*entity.ExptStats) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockIExptStatsRepoMockRecorder) Get(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) Get(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptStatsRepo)(nil).Get), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptStatsRepo)(nil).Get), ctx, exptID, spaceID) } // MGet mocks base method. -func (m *MockIExptStatsRepo) MGet(arg0 context.Context, arg1 []int64, arg2 int64) ([]*entity.ExptStats, error) { +func (m *MockIExptStatsRepo) MGet(ctx context.Context, exptIDs []int64, spaceID int64) ([]*entity.ExptStats, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGet", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MGet", ctx, exptIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptStats) ret1, _ := ret[1].(error) return ret0, ret1 } // MGet indicates an expected call of MGet. -func (mr *MockIExptStatsRepoMockRecorder) MGet(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) MGet(ctx, exptIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGet", reflect.TypeOf((*MockIExptStatsRepo)(nil).MGet), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGet", reflect.TypeOf((*MockIExptStatsRepo)(nil).MGet), ctx, exptIDs, spaceID) } // Save mocks base method. -func (m *MockIExptStatsRepo) Save(arg0 context.Context, arg1 *entity.ExptStats) error { +func (m *MockIExptStatsRepo) Save(ctx context.Context, stats *entity.ExptStats) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Save", arg0, arg1) + ret := m.ctrl.Call(m, "Save", ctx, stats) ret0, _ := ret[0].(error) return ret0 } // Save indicates an expected call of Save. -func (mr *MockIExptStatsRepoMockRecorder) Save(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) Save(ctx, stats any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptStatsRepo)(nil).Save), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptStatsRepo)(nil).Save), ctx, stats) } // UpdateByExptID mocks base method. -func (m *MockIExptStatsRepo) UpdateByExptID(arg0 context.Context, arg1, arg2 int64, arg3 *entity.ExptStats) error { +func (m *MockIExptStatsRepo) UpdateByExptID(ctx context.Context, exptID, spaceID int64, stats *entity.ExptStats) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateByExptID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "UpdateByExptID", ctx, exptID, spaceID, stats) ret0, _ := ret[0].(error) return ret0 } // UpdateByExptID indicates an expected call of UpdateByExptID. -func (mr *MockIExptStatsRepoMockRecorder) UpdateByExptID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptStatsRepoMockRecorder) UpdateByExptID(ctx, exptID, spaceID, stats any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateByExptID", reflect.TypeOf((*MockIExptStatsRepo)(nil).UpdateByExptID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateByExptID", reflect.TypeOf((*MockIExptStatsRepo)(nil).UpdateByExptID), ctx, exptID, spaceID, stats) } // MockIExptItemResultRepo is a mock of IExptItemResultRepo interface. type MockIExptItemResultRepo struct { ctrl *gomock.Controller recorder *MockIExptItemResultRepoMockRecorder + isgomock struct{} } // MockIExptItemResultRepoMockRecorder is the mock recorder for MockIExptItemResultRepo. @@ -382,112 +370,127 @@ func (m *MockIExptItemResultRepo) EXPECT() *MockIExptItemResultRepoMockRecorder } // BatchCreateNX mocks base method. -func (m *MockIExptItemResultRepo) BatchCreateNX(arg0 context.Context, arg1 []*entity.ExptItemResult) error { +func (m *MockIExptItemResultRepo) BatchCreateNX(ctx context.Context, itemResults []*entity.ExptItemResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreateNX", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreateNX", ctx, itemResults) ret0, _ := ret[0].(error) return ret0 } // BatchCreateNX indicates an expected call of BatchCreateNX. -func (mr *MockIExptItemResultRepoMockRecorder) BatchCreateNX(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) BatchCreateNX(ctx, itemResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNX", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchCreateNX), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNX", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchCreateNX), ctx, itemResults) } // BatchCreateNXRunLogs mocks base method. -func (m *MockIExptItemResultRepo) BatchCreateNXRunLogs(arg0 context.Context, arg1 []*entity.ExptItemResultRunLog) error { +func (m *MockIExptItemResultRepo) BatchCreateNXRunLogs(ctx context.Context, itemRunLogs []*entity.ExptItemResultRunLog) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreateNXRunLogs", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreateNXRunLogs", ctx, itemRunLogs) ret0, _ := ret[0].(error) return ret0 } // BatchCreateNXRunLogs indicates an expected call of BatchCreateNXRunLogs. -func (mr *MockIExptItemResultRepoMockRecorder) BatchCreateNXRunLogs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) BatchCreateNXRunLogs(ctx, itemRunLogs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNXRunLogs", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchCreateNXRunLogs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNXRunLogs", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchCreateNXRunLogs), ctx, itemRunLogs) } // BatchGet mocks base method. -func (m *MockIExptItemResultRepo) BatchGet(arg0 context.Context, arg1, arg2 int64, arg3 []int64) ([]*entity.ExptItemResult, error) { +func (m *MockIExptItemResultRepo) BatchGet(ctx context.Context, spaceID, exptID int64, itemIDs []int64) ([]*entity.ExptItemResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGet", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "BatchGet", ctx, spaceID, exptID, itemIDs) ret0, _ := ret[0].([]*entity.ExptItemResult) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGet indicates an expected call of BatchGet. -func (mr *MockIExptItemResultRepoMockRecorder) BatchGet(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) BatchGet(ctx, spaceID, exptID, itemIDs any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGet", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchGet), ctx, spaceID, exptID, itemIDs) +} + +// CountItemsByStatus mocks base method. +func (m *MockIExptItemResultRepo) CountItemsByStatus(ctx context.Context, spaceID, exptID int64) (map[entity.ItemRunState]int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CountItemsByStatus", ctx, spaceID, exptID) + ret0, _ := ret[0].(map[entity.ItemRunState]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountItemsByStatus indicates an expected call of CountItemsByStatus. +func (mr *MockIExptItemResultRepoMockRecorder) CountItemsByStatus(ctx, spaceID, exptID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGet", reflect.TypeOf((*MockIExptItemResultRepo)(nil).BatchGet), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountItemsByStatus", reflect.TypeOf((*MockIExptItemResultRepo)(nil).CountItemsByStatus), ctx, spaceID, exptID) } // GetItemIDListByExptID mocks base method. -func (m *MockIExptItemResultRepo) GetItemIDListByExptID(arg0 context.Context, arg1, arg2 int64) ([]int64, error) { +func (m *MockIExptItemResultRepo) GetItemIDListByExptID(ctx context.Context, exptID, spaceID int64) ([]int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetItemIDListByExptID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetItemIDListByExptID", ctx, exptID, spaceID) ret0, _ := ret[0].([]int64) ret1, _ := ret[1].(error) return ret0, ret1 } // GetItemIDListByExptID indicates an expected call of GetItemIDListByExptID. -func (mr *MockIExptItemResultRepoMockRecorder) GetItemIDListByExptID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) GetItemIDListByExptID(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemIDListByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemIDListByExptID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemIDListByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemIDListByExptID), ctx, exptID, spaceID) } // GetItemRunLog mocks base method. -func (m *MockIExptItemResultRepo) GetItemRunLog(arg0 context.Context, arg1, arg2, arg3, arg4 int64) (*entity.ExptItemResultRunLog, error) { +func (m *MockIExptItemResultRepo) GetItemRunLog(ctx context.Context, exptID, exptRunID, itemID, spaceID int64) (*entity.ExptItemResultRunLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetItemRunLog", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "GetItemRunLog", ctx, exptID, exptRunID, itemID, spaceID) ret0, _ := ret[0].(*entity.ExptItemResultRunLog) ret1, _ := ret[1].(error) return ret0, ret1 } // GetItemRunLog indicates an expected call of GetItemRunLog. -func (mr *MockIExptItemResultRepoMockRecorder) GetItemRunLog(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) GetItemRunLog(ctx, exptID, exptRunID, itemID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemRunLog), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemRunLog), ctx, exptID, exptRunID, itemID, spaceID) } // GetItemTurnResults mocks base method. -func (m *MockIExptItemResultRepo) GetItemTurnResults(arg0 context.Context, arg1, arg2, arg3 int64) ([]*entity.ExptTurnResult, error) { +func (m *MockIExptItemResultRepo) GetItemTurnResults(ctx context.Context, spaceID, exptID, itemID int64) ([]*entity.ExptTurnResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetItemTurnResults", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetItemTurnResults", ctx, spaceID, exptID, itemID) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(error) return ret0, ret1 } // GetItemTurnResults indicates an expected call of GetItemTurnResults. -func (mr *MockIExptItemResultRepoMockRecorder) GetItemTurnResults(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) GetItemTurnResults(ctx, spaceID, exptID, itemID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemTurnResults), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetItemTurnResults), ctx, spaceID, exptID, itemID) } // GetMaxItemIdxByExptID mocks base method. -func (m *MockIExptItemResultRepo) GetMaxItemIdxByExptID(arg0 context.Context, arg1, arg2 int64) (int32, error) { +func (m *MockIExptItemResultRepo) GetMaxItemIdxByExptID(ctx context.Context, exptID, spaceID int64) (int32, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetMaxItemIdxByExptID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetMaxItemIdxByExptID", ctx, exptID, spaceID) ret0, _ := ret[0].(int32) ret1, _ := ret[1].(error) return ret0, ret1 } // GetMaxItemIdxByExptID indicates an expected call of GetMaxItemIdxByExptID. -func (mr *MockIExptItemResultRepoMockRecorder) GetMaxItemIdxByExptID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) GetMaxItemIdxByExptID(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxItemIdxByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetMaxItemIdxByExptID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxItemIdxByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).GetMaxItemIdxByExptID), ctx, exptID, spaceID) } // ListItemResultsByExptID mocks base method. -func (m *MockIExptItemResultRepo) ListItemResultsByExptID(arg0 context.Context, arg1, arg2 int64, arg3 entity.Page, arg4 bool) ([]*entity.ExptItemResult, int64, error) { +func (m *MockIExptItemResultRepo) ListItemResultsByExptID(ctx context.Context, exptID, spaceID int64, page entity.Page, desc bool) ([]*entity.ExptItemResult, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListItemResultsByExptID", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "ListItemResultsByExptID", ctx, exptID, spaceID, page, desc) ret0, _ := ret[0].([]*entity.ExptItemResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -495,74 +498,74 @@ func (m *MockIExptItemResultRepo) ListItemResultsByExptID(arg0 context.Context, } // ListItemResultsByExptID indicates an expected call of ListItemResultsByExptID. -func (mr *MockIExptItemResultRepoMockRecorder) ListItemResultsByExptID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) ListItemResultsByExptID(ctx, exptID, spaceID, page, desc any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListItemResultsByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ListItemResultsByExptID), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListItemResultsByExptID", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ListItemResultsByExptID), ctx, exptID, spaceID, page, desc) } // MGetItemResults mocks base method. -func (m *MockIExptItemResultRepo) MGetItemResults(arg0 context.Context, arg1 int64, arg2 []int64, arg3 int64) ([]*entity.ExptItemResult, error) { +func (m *MockIExptItemResultRepo) MGetItemResults(ctx context.Context, exptID int64, itemIDs []int64, spaceID int64) ([]*entity.ExptItemResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetItemResults", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MGetItemResults", ctx, exptID, itemIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptItemResult) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetItemResults indicates an expected call of MGetItemResults. -func (mr *MockIExptItemResultRepoMockRecorder) MGetItemResults(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) MGetItemResults(ctx, exptID, itemIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemResults), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemResults), ctx, exptID, itemIDs, spaceID) } // MGetItemRunLog mocks base method. -func (m *MockIExptItemResultRepo) MGetItemRunLog(arg0 context.Context, arg1, arg2 int64, arg3 []int64, arg4 int64) ([]*entity.ExptItemResultRunLog, error) { +func (m *MockIExptItemResultRepo) MGetItemRunLog(ctx context.Context, exptID, exptRunID int64, itemIDs []int64, spaceID int64) ([]*entity.ExptItemResultRunLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetItemRunLog", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "MGetItemRunLog", ctx, exptID, exptRunID, itemIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptItemResultRunLog) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetItemRunLog indicates an expected call of MGetItemRunLog. -func (mr *MockIExptItemResultRepoMockRecorder) MGetItemRunLog(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) MGetItemRunLog(ctx, exptID, exptRunID, itemIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemRunLog), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemRunLog), ctx, exptID, exptRunID, itemIDs, spaceID) } // MGetItemTurnResults mocks base method. -func (m *MockIExptItemResultRepo) MGetItemTurnResults(arg0 context.Context, arg1, arg2 int64, arg3 []int64) ([]*entity.ExptTurnResult, error) { +func (m *MockIExptItemResultRepo) MGetItemTurnResults(ctx context.Context, spaceID, exptID int64, itemIDs []int64) ([]*entity.ExptTurnResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetItemTurnResults", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MGetItemTurnResults", ctx, spaceID, exptID, itemIDs) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetItemTurnResults indicates an expected call of MGetItemTurnResults. -func (mr *MockIExptItemResultRepoMockRecorder) MGetItemTurnResults(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) MGetItemTurnResults(ctx, spaceID, exptID, itemIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemTurnResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemTurnResults), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemTurnResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).MGetItemTurnResults), ctx, spaceID, exptID, itemIDs) } // SaveItemResults mocks base method. -func (m *MockIExptItemResultRepo) SaveItemResults(arg0 context.Context, arg1 []*entity.ExptItemResult) error { +func (m *MockIExptItemResultRepo) SaveItemResults(ctx context.Context, itemResults []*entity.ExptItemResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveItemResults", arg0, arg1) + ret := m.ctrl.Call(m, "SaveItemResults", ctx, itemResults) ret0, _ := ret[0].(error) return ret0 } // SaveItemResults indicates an expected call of SaveItemResults. -func (mr *MockIExptItemResultRepoMockRecorder) SaveItemResults(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) SaveItemResults(ctx, itemResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).SaveItemResults), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).SaveItemResults), ctx, itemResults) } // ScanItemResults mocks base method. -func (m *MockIExptItemResultRepo) ScanItemResults(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int32, arg5 int64) ([]*entity.ExptItemResult, int64, error) { +func (m *MockIExptItemResultRepo) ScanItemResults(ctx context.Context, exptID, cursor, limit int64, status []int32, spaceID int64) ([]*entity.ExptItemResult, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ScanItemResults", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "ScanItemResults", ctx, exptID, cursor, limit, status, spaceID) ret0, _ := ret[0].([]*entity.ExptItemResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -570,15 +573,15 @@ func (m *MockIExptItemResultRepo) ScanItemResults(arg0 context.Context, arg1, ar } // ScanItemResults indicates an expected call of ScanItemResults. -func (mr *MockIExptItemResultRepoMockRecorder) ScanItemResults(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) ScanItemResults(ctx, exptID, cursor, limit, status, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ScanItemResults), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanItemResults", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ScanItemResults), ctx, exptID, cursor, limit, status, spaceID) } // ScanItemRunLogs mocks base method. -func (m *MockIExptItemResultRepo) ScanItemRunLogs(arg0 context.Context, arg1, arg2 int64, arg3 *entity.ExptItemRunLogFilter, arg4, arg5, arg6 int64) ([]*entity.ExptItemResultRunLog, int64, error) { +func (m *MockIExptItemResultRepo) ScanItemRunLogs(ctx context.Context, exptID, exptRunID int64, filter *entity.ExptItemRunLogFilter, cursor, limit, spaceID int64) ([]*entity.ExptItemResultRunLog, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ScanItemRunLogs", arg0, arg1, arg2, arg3, arg4, arg5, arg6) + ret := m.ctrl.Call(m, "ScanItemRunLogs", ctx, exptID, exptRunID, filter, cursor, limit, spaceID) ret0, _ := ret[0].([]*entity.ExptItemResultRunLog) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -586,43 +589,44 @@ func (m *MockIExptItemResultRepo) ScanItemRunLogs(arg0 context.Context, arg1, ar } // ScanItemRunLogs indicates an expected call of ScanItemRunLogs. -func (mr *MockIExptItemResultRepoMockRecorder) ScanItemRunLogs(arg0, arg1, arg2, arg3, arg4, arg5, arg6 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) ScanItemRunLogs(ctx, exptID, exptRunID, filter, cursor, limit, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanItemRunLogs", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ScanItemRunLogs), arg0, arg1, arg2, arg3, arg4, arg5, arg6) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanItemRunLogs", reflect.TypeOf((*MockIExptItemResultRepo)(nil).ScanItemRunLogs), ctx, exptID, exptRunID, filter, cursor, limit, spaceID) } // UpdateItemRunLog mocks base method. -func (m *MockIExptItemResultRepo) UpdateItemRunLog(arg0 context.Context, arg1, arg2 int64, arg3 []int64, arg4 map[string]any, arg5 int64) error { +func (m *MockIExptItemResultRepo) UpdateItemRunLog(ctx context.Context, exptID, exptRunID int64, itemID []int64, ufields map[string]any, spaceID int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateItemRunLog", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "UpdateItemRunLog", ctx, exptID, exptRunID, itemID, ufields, spaceID) ret0, _ := ret[0].(error) return ret0 } // UpdateItemRunLog indicates an expected call of UpdateItemRunLog. -func (mr *MockIExptItemResultRepoMockRecorder) UpdateItemRunLog(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) UpdateItemRunLog(ctx, exptID, exptRunID, itemID, ufields, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).UpdateItemRunLog), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateItemRunLog", reflect.TypeOf((*MockIExptItemResultRepo)(nil).UpdateItemRunLog), ctx, exptID, exptRunID, itemID, ufields, spaceID) } // UpdateItemsResult mocks base method. -func (m *MockIExptItemResultRepo) UpdateItemsResult(arg0 context.Context, arg1, arg2 int64, arg3 []int64, arg4 map[string]any) error { +func (m *MockIExptItemResultRepo) UpdateItemsResult(ctx context.Context, spaceID, exptID int64, itemID []int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateItemsResult", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "UpdateItemsResult", ctx, spaceID, exptID, itemID, ufields) ret0, _ := ret[0].(error) return ret0 } // UpdateItemsResult indicates an expected call of UpdateItemsResult. -func (mr *MockIExptItemResultRepoMockRecorder) UpdateItemsResult(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptItemResultRepoMockRecorder) UpdateItemsResult(ctx, spaceID, exptID, itemID, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateItemsResult", reflect.TypeOf((*MockIExptItemResultRepo)(nil).UpdateItemsResult), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateItemsResult", reflect.TypeOf((*MockIExptItemResultRepo)(nil).UpdateItemsResult), ctx, spaceID, exptID, itemID, ufields) } // MockIExptTurnResultRepo is a mock of IExptTurnResultRepo interface. type MockIExptTurnResultRepo struct { ctrl *gomock.Controller recorder *MockIExptTurnResultRepoMockRecorder + isgomock struct{} } // MockIExptTurnResultRepoMockRecorder is the mock recorder for MockIExptTurnResultRepo. @@ -643,170 +647,170 @@ func (m *MockIExptTurnResultRepo) EXPECT() *MockIExptTurnResultRepoMockRecorder } // BatchCreateNX mocks base method. -func (m *MockIExptTurnResultRepo) BatchCreateNX(arg0 context.Context, arg1 []*entity.ExptTurnResult) error { +func (m *MockIExptTurnResultRepo) BatchCreateNX(ctx context.Context, turnResults []*entity.ExptTurnResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreateNX", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreateNX", ctx, turnResults) ret0, _ := ret[0].(error) return ret0 } // BatchCreateNX indicates an expected call of BatchCreateNX. -func (mr *MockIExptTurnResultRepoMockRecorder) BatchCreateNX(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) BatchCreateNX(ctx, turnResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNX", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchCreateNX), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNX", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchCreateNX), ctx, turnResults) } // BatchCreateNXRunLog mocks base method. -func (m *MockIExptTurnResultRepo) BatchCreateNXRunLog(arg0 context.Context, arg1 []*entity.ExptTurnResultRunLog) error { +func (m *MockIExptTurnResultRepo) BatchCreateNXRunLog(ctx context.Context, turnResults []*entity.ExptTurnResultRunLog) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreateNXRunLog", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreateNXRunLog", ctx, turnResults) ret0, _ := ret[0].(error) return ret0 } // BatchCreateNXRunLog indicates an expected call of BatchCreateNXRunLog. -func (mr *MockIExptTurnResultRepoMockRecorder) BatchCreateNXRunLog(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) BatchCreateNXRunLog(ctx, turnResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNXRunLog", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchCreateNXRunLog), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateNXRunLog", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchCreateNXRunLog), ctx, turnResults) } // BatchGet mocks base method. -func (m *MockIExptTurnResultRepo) BatchGet(arg0 context.Context, arg1, arg2 int64, arg3 []int64) ([]*entity.ExptTurnResult, error) { +func (m *MockIExptTurnResultRepo) BatchGet(ctx context.Context, spaceID, exptID int64, itemIDs []int64) ([]*entity.ExptTurnResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGet", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "BatchGet", ctx, spaceID, exptID, itemIDs) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGet indicates an expected call of BatchGet. -func (mr *MockIExptTurnResultRepoMockRecorder) BatchGet(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) BatchGet(ctx, spaceID, exptID, itemIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGet", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchGet), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGet", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchGet), ctx, spaceID, exptID, itemIDs) } // BatchGetTurnEvaluatorResultRef mocks base method. -func (m *MockIExptTurnResultRepo) BatchGetTurnEvaluatorResultRef(arg0 context.Context, arg1 int64, arg2 []int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { +func (m *MockIExptTurnResultRepo) BatchGetTurnEvaluatorResultRef(ctx context.Context, spaceID int64, exptTurnResultIDs []int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGetTurnEvaluatorResultRef", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "BatchGetTurnEvaluatorResultRef", ctx, spaceID, exptTurnResultIDs) ret0, _ := ret[0].([]*entity.ExptTurnEvaluatorResultRef) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGetTurnEvaluatorResultRef indicates an expected call of BatchGetTurnEvaluatorResultRef. -func (mr *MockIExptTurnResultRepoMockRecorder) BatchGetTurnEvaluatorResultRef(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) BatchGetTurnEvaluatorResultRef(ctx, spaceID, exptTurnResultIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetTurnEvaluatorResultRef", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchGetTurnEvaluatorResultRef), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetTurnEvaluatorResultRef", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).BatchGetTurnEvaluatorResultRef), ctx, spaceID, exptTurnResultIDs) } // CreateOrUpdateItemsTurnRunLogStatus mocks base method. -func (m *MockIExptTurnResultRepo) CreateOrUpdateItemsTurnRunLogStatus(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int64, arg5 entity.TurnRunState) error { +func (m *MockIExptTurnResultRepo) CreateOrUpdateItemsTurnRunLogStatus(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64, status entity.TurnRunState) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateOrUpdateItemsTurnRunLogStatus", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "CreateOrUpdateItemsTurnRunLogStatus", ctx, spaceID, exptID, exptRunID, itemIDs, status) ret0, _ := ret[0].(error) return ret0 } // CreateOrUpdateItemsTurnRunLogStatus indicates an expected call of CreateOrUpdateItemsTurnRunLogStatus. -func (mr *MockIExptTurnResultRepoMockRecorder) CreateOrUpdateItemsTurnRunLogStatus(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) CreateOrUpdateItemsTurnRunLogStatus(ctx, spaceID, exptID, exptRunID, itemIDs, status any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdateItemsTurnRunLogStatus", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).CreateOrUpdateItemsTurnRunLogStatus), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdateItemsTurnRunLogStatus", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).CreateOrUpdateItemsTurnRunLogStatus), ctx, spaceID, exptID, exptRunID, itemIDs, status) } // CreateTurnEvaluatorRefs mocks base method. -func (m *MockIExptTurnResultRepo) CreateTurnEvaluatorRefs(arg0 context.Context, arg1 []*entity.ExptTurnEvaluatorResultRef) error { +func (m *MockIExptTurnResultRepo) CreateTurnEvaluatorRefs(ctx context.Context, turnResults []*entity.ExptTurnEvaluatorResultRef) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateTurnEvaluatorRefs", arg0, arg1) + ret := m.ctrl.Call(m, "CreateTurnEvaluatorRefs", ctx, turnResults) ret0, _ := ret[0].(error) return ret0 } // CreateTurnEvaluatorRefs indicates an expected call of CreateTurnEvaluatorRefs. -func (mr *MockIExptTurnResultRepoMockRecorder) CreateTurnEvaluatorRefs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) CreateTurnEvaluatorRefs(ctx, turnResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTurnEvaluatorRefs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).CreateTurnEvaluatorRefs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTurnEvaluatorRefs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).CreateTurnEvaluatorRefs), ctx, turnResults) } // Get mocks base method. -func (m *MockIExptTurnResultRepo) Get(arg0 context.Context, arg1, arg2, arg3, arg4 int64) (*entity.ExptTurnResult, error) { +func (m *MockIExptTurnResultRepo) Get(ctx context.Context, spaceID, exptID, itemID, turnID int64) (*entity.ExptTurnResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "Get", ctx, spaceID, exptID, itemID, turnID) ret0, _ := ret[0].(*entity.ExptTurnResult) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockIExptTurnResultRepoMockRecorder) Get(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) Get(ctx, spaceID, exptID, itemID, turnID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).Get), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).Get), ctx, spaceID, exptID, itemID, turnID) } // GetItemTurnResults mocks base method. -func (m *MockIExptTurnResultRepo) GetItemTurnResults(arg0 context.Context, arg1, arg2, arg3 int64) ([]*entity.ExptTurnResult, error) { +func (m *MockIExptTurnResultRepo) GetItemTurnResults(ctx context.Context, exptID, itemID, spaceID int64) ([]*entity.ExptTurnResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetItemTurnResults", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetItemTurnResults", ctx, exptID, itemID, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(error) return ret0, ret1 } // GetItemTurnResults indicates an expected call of GetItemTurnResults. -func (mr *MockIExptTurnResultRepoMockRecorder) GetItemTurnResults(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) GetItemTurnResults(ctx, exptID, itemID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetItemTurnResults), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetItemTurnResults), ctx, exptID, itemID, spaceID) } // GetItemTurnRunLogs mocks base method. -func (m *MockIExptTurnResultRepo) GetItemTurnRunLogs(arg0 context.Context, arg1, arg2, arg3, arg4 int64) ([]*entity.ExptTurnResultRunLog, error) { +func (m *MockIExptTurnResultRepo) GetItemTurnRunLogs(ctx context.Context, exptID, exptRunID, itemID, spaceID int64) ([]*entity.ExptTurnResultRunLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetItemTurnRunLogs", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "GetItemTurnRunLogs", ctx, exptID, exptRunID, itemID, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResultRunLog) ret1, _ := ret[1].(error) return ret0, ret1 } // GetItemTurnRunLogs indicates an expected call of GetItemTurnRunLogs. -func (mr *MockIExptTurnResultRepoMockRecorder) GetItemTurnRunLogs(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) GetItemTurnRunLogs(ctx, exptID, exptRunID, itemID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetItemTurnRunLogs), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetItemTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetItemTurnRunLogs), ctx, exptID, exptRunID, itemID, spaceID) } // GetTurnEvaluatorResultRefByEvaluatorVersionID mocks base method. -func (m *MockIExptTurnResultRepo) GetTurnEvaluatorResultRefByEvaluatorVersionID(arg0 context.Context, arg1, arg2, arg3 int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { +func (m *MockIExptTurnResultRepo) GetTurnEvaluatorResultRefByEvaluatorVersionID(ctx context.Context, spaceID, exptID, evaluatorVersionID int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTurnEvaluatorResultRefByEvaluatorVersionID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetTurnEvaluatorResultRefByEvaluatorVersionID", ctx, spaceID, exptID, evaluatorVersionID) ret0, _ := ret[0].([]*entity.ExptTurnEvaluatorResultRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTurnEvaluatorResultRefByEvaluatorVersionID indicates an expected call of GetTurnEvaluatorResultRefByEvaluatorVersionID. -func (mr *MockIExptTurnResultRepoMockRecorder) GetTurnEvaluatorResultRefByEvaluatorVersionID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) GetTurnEvaluatorResultRefByEvaluatorVersionID(ctx, spaceID, exptID, evaluatorVersionID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTurnEvaluatorResultRefByEvaluatorVersionID", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetTurnEvaluatorResultRefByEvaluatorVersionID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTurnEvaluatorResultRefByEvaluatorVersionID", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetTurnEvaluatorResultRefByEvaluatorVersionID), ctx, spaceID, exptID, evaluatorVersionID) } // GetTurnEvaluatorResultRefByExptID mocks base method. -func (m *MockIExptTurnResultRepo) GetTurnEvaluatorResultRefByExptID(arg0 context.Context, arg1, arg2 int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { +func (m *MockIExptTurnResultRepo) GetTurnEvaluatorResultRefByExptID(ctx context.Context, spaceID, exptID int64) ([]*entity.ExptTurnEvaluatorResultRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTurnEvaluatorResultRefByExptID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetTurnEvaluatorResultRefByExptID", ctx, spaceID, exptID) ret0, _ := ret[0].([]*entity.ExptTurnEvaluatorResultRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTurnEvaluatorResultRefByExptID indicates an expected call of GetTurnEvaluatorResultRefByExptID. -func (mr *MockIExptTurnResultRepoMockRecorder) GetTurnEvaluatorResultRefByExptID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) GetTurnEvaluatorResultRefByExptID(ctx, spaceID, exptID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTurnEvaluatorResultRefByExptID", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetTurnEvaluatorResultRefByExptID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTurnEvaluatorResultRefByExptID", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).GetTurnEvaluatorResultRefByExptID), ctx, spaceID, exptID) } // ListTurnResult mocks base method. -func (m *MockIExptTurnResultRepo) ListTurnResult(arg0 context.Context, arg1, arg2 int64, arg3 *entity.ExptTurnResultFilter, arg4 entity.Page, arg5 bool) ([]*entity.ExptTurnResult, int64, error) { +func (m *MockIExptTurnResultRepo) ListTurnResult(ctx context.Context, spaceID, exptID int64, filter *entity.ExptTurnResultFilter, page entity.Page, desc bool) ([]*entity.ExptTurnResult, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListTurnResult", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "ListTurnResult", ctx, spaceID, exptID, filter, page, desc) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -814,15 +818,15 @@ func (m *MockIExptTurnResultRepo) ListTurnResult(arg0 context.Context, arg1, arg } // ListTurnResult indicates an expected call of ListTurnResult. -func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResult(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResult(ctx, spaceID, exptID, filter, page, desc any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResult", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResult), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResult", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResult), ctx, spaceID, exptID, filter, page, desc) } // ListTurnResultByItemIDs mocks base method. -func (m *MockIExptTurnResultRepo) ListTurnResultByItemIDs(arg0 context.Context, arg1, arg2 int64, arg3 []int64, arg4 entity.Page, arg5 bool) ([]*entity.ExptTurnResult, int64, error) { +func (m *MockIExptTurnResultRepo) ListTurnResultByItemIDs(ctx context.Context, spaceID, exptID int64, itemIDs []int64, page entity.Page, desc bool) ([]*entity.ExptTurnResult, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListTurnResultByItemIDs", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "ListTurnResultByItemIDs", ctx, spaceID, exptID, itemIDs, page, desc) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -830,15 +834,15 @@ func (m *MockIExptTurnResultRepo) ListTurnResultByItemIDs(arg0 context.Context, } // ListTurnResultByItemIDs indicates an expected call of ListTurnResultByItemIDs. -func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResultByItemIDs(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResultByItemIDs(ctx, spaceID, exptID, itemIDs, page, desc any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResultByItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResultByItemIDs), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResultByItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResultByItemIDs), ctx, spaceID, exptID, itemIDs, page, desc) } // ListTurnResultWithCursor mocks base method. -func (m *MockIExptTurnResultRepo) ListTurnResultWithCursor(arg0 context.Context, arg1, arg2 int64, arg3 *entity.ExptTurnResultFilter, arg4 *entity.ExptTurnResultListCursor, arg5 int, arg6 bool) ([]*entity.ExptTurnResult, int64, *entity.ExptTurnResultListCursor, error) { +func (m *MockIExptTurnResultRepo) ListTurnResultWithCursor(ctx context.Context, spaceID, exptID int64, filter *entity.ExptTurnResultFilter, cursor *entity.ExptTurnResultListCursor, limit int, desc bool) ([]*entity.ExptTurnResult, int64, *entity.ExptTurnResultListCursor, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListTurnResultWithCursor", arg0, arg1, arg2, arg3, arg4, arg5, arg6) + ret := m.ctrl.Call(m, "ListTurnResultWithCursor", ctx, spaceID, exptID, filter, cursor, limit, desc) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(*entity.ExptTurnResultListCursor) @@ -847,58 +851,58 @@ func (m *MockIExptTurnResultRepo) ListTurnResultWithCursor(arg0 context.Context, } // ListTurnResultWithCursor indicates an expected call of ListTurnResultWithCursor. -func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResultWithCursor(arg0, arg1, arg2, arg3, arg4, arg5, arg6 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) ListTurnResultWithCursor(ctx, spaceID, exptID, filter, cursor, limit, desc any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResultWithCursor", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResultWithCursor), arg0, arg1, arg2, arg3, arg4, arg5, arg6) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTurnResultWithCursor", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ListTurnResultWithCursor), ctx, spaceID, exptID, filter, cursor, limit, desc) } // MGetItemTurnRunLogs mocks base method. -func (m *MockIExptTurnResultRepo) MGetItemTurnRunLogs(arg0 context.Context, arg1, arg2 int64, arg3 []int64, arg4 int64) ([]*entity.ExptTurnResultRunLog, error) { +func (m *MockIExptTurnResultRepo) MGetItemTurnRunLogs(ctx context.Context, exptID, exptRunID int64, itemIDs []int64, spaceID int64) ([]*entity.ExptTurnResultRunLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetItemTurnRunLogs", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "MGetItemTurnRunLogs", ctx, exptID, exptRunID, itemIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResultRunLog) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetItemTurnRunLogs indicates an expected call of MGetItemTurnRunLogs. -func (mr *MockIExptTurnResultRepoMockRecorder) MGetItemTurnRunLogs(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) MGetItemTurnRunLogs(ctx, exptID, exptRunID, itemIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).MGetItemTurnRunLogs), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetItemTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).MGetItemTurnRunLogs), ctx, exptID, exptRunID, itemIDs, spaceID) } // SaveTurnResults mocks base method. -func (m *MockIExptTurnResultRepo) SaveTurnResults(arg0 context.Context, arg1 []*entity.ExptTurnResult) error { +func (m *MockIExptTurnResultRepo) SaveTurnResults(ctx context.Context, turnResults []*entity.ExptTurnResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveTurnResults", arg0, arg1) + ret := m.ctrl.Call(m, "SaveTurnResults", ctx, turnResults) ret0, _ := ret[0].(error) return ret0 } // SaveTurnResults indicates an expected call of SaveTurnResults. -func (mr *MockIExptTurnResultRepoMockRecorder) SaveTurnResults(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) SaveTurnResults(ctx, turnResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).SaveTurnResults), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).SaveTurnResults), ctx, turnResults) } // SaveTurnRunLogs mocks base method. -func (m *MockIExptTurnResultRepo) SaveTurnRunLogs(arg0 context.Context, arg1 []*entity.ExptTurnResultRunLog) error { +func (m *MockIExptTurnResultRepo) SaveTurnRunLogs(ctx context.Context, turnResults []*entity.ExptTurnResultRunLog) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SaveTurnRunLogs", arg0, arg1) + ret := m.ctrl.Call(m, "SaveTurnRunLogs", ctx, turnResults) ret0, _ := ret[0].(error) return ret0 } // SaveTurnRunLogs indicates an expected call of SaveTurnRunLogs. -func (mr *MockIExptTurnResultRepoMockRecorder) SaveTurnRunLogs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) SaveTurnRunLogs(ctx, turnResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).SaveTurnRunLogs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).SaveTurnRunLogs), ctx, turnResults) } // ScanTurnResults mocks base method. -func (m *MockIExptTurnResultRepo) ScanTurnResults(arg0 context.Context, arg1 int64, arg2 []int32, arg3, arg4, arg5 int64) ([]*entity.ExptTurnResult, int64, error) { +func (m *MockIExptTurnResultRepo) ScanTurnResults(ctx context.Context, exptID int64, status []int32, cursor, limit, spaceID int64) ([]*entity.ExptTurnResult, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ScanTurnResults", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "ScanTurnResults", ctx, exptID, status, cursor, limit, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResult) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -906,15 +910,15 @@ func (m *MockIExptTurnResultRepo) ScanTurnResults(arg0 context.Context, arg1 int } // ScanTurnResults indicates an expected call of ScanTurnResults. -func (mr *MockIExptTurnResultRepoMockRecorder) ScanTurnResults(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) ScanTurnResults(ctx, exptID, status, cursor, limit, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ScanTurnResults), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ScanTurnResults), ctx, exptID, status, cursor, limit, spaceID) } // ScanTurnRunLogs mocks base method. -func (m *MockIExptTurnResultRepo) ScanTurnRunLogs(arg0 context.Context, arg1, arg2, arg3, arg4 int64) ([]*entity.ExptTurnResultRunLog, int64, error) { +func (m *MockIExptTurnResultRepo) ScanTurnRunLogs(ctx context.Context, exptID, cursor, limit, spaceID int64) ([]*entity.ExptTurnResultRunLog, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ScanTurnRunLogs", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "ScanTurnRunLogs", ctx, exptID, cursor, limit, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResultRunLog) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -922,57 +926,58 @@ func (m *MockIExptTurnResultRepo) ScanTurnRunLogs(arg0 context.Context, arg1, ar } // ScanTurnRunLogs indicates an expected call of ScanTurnRunLogs. -func (mr *MockIExptTurnResultRepoMockRecorder) ScanTurnRunLogs(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) ScanTurnRunLogs(ctx, exptID, cursor, limit, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ScanTurnRunLogs), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScanTurnRunLogs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).ScanTurnRunLogs), ctx, exptID, cursor, limit, spaceID) } // UpdateTurnResults mocks base method. -func (m *MockIExptTurnResultRepo) UpdateTurnResults(arg0 context.Context, arg1 int64, arg2 []*entity.ItemTurnID, arg3 int64, arg4 map[string]any) error { +func (m *MockIExptTurnResultRepo) UpdateTurnResults(ctx context.Context, exptID int64, itemTurnIDs []*entity.ItemTurnID, spaceID int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTurnResults", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "UpdateTurnResults", ctx, exptID, itemTurnIDs, spaceID, ufields) ret0, _ := ret[0].(error) return ret0 } // UpdateTurnResults indicates an expected call of UpdateTurnResults. -func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnResults(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnResults(ctx, exptID, itemTurnIDs, spaceID, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnResults), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnResults", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnResults), ctx, exptID, itemTurnIDs, spaceID, ufields) } // UpdateTurnResultsWithItemIDs mocks base method. -func (m *MockIExptTurnResultRepo) UpdateTurnResultsWithItemIDs(arg0 context.Context, arg1 int64, arg2 []int64, arg3 int64, arg4 map[string]any) error { +func (m *MockIExptTurnResultRepo) UpdateTurnResultsWithItemIDs(ctx context.Context, exptID int64, itemIDs []int64, spaceID int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTurnResultsWithItemIDs", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "UpdateTurnResultsWithItemIDs", ctx, exptID, itemIDs, spaceID, ufields) ret0, _ := ret[0].(error) return ret0 } // UpdateTurnResultsWithItemIDs indicates an expected call of UpdateTurnResultsWithItemIDs. -func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnResultsWithItemIDs(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnResultsWithItemIDs(ctx, exptID, itemIDs, spaceID, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnResultsWithItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnResultsWithItemIDs), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnResultsWithItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnResultsWithItemIDs), ctx, exptID, itemIDs, spaceID, ufields) } // UpdateTurnRunLogWithItemIDs mocks base method. -func (m *MockIExptTurnResultRepo) UpdateTurnRunLogWithItemIDs(arg0 context.Context, arg1, arg2, arg3 int64, arg4 []int64, arg5 map[string]any) error { +func (m *MockIExptTurnResultRepo) UpdateTurnRunLogWithItemIDs(ctx context.Context, spaceID, exptID, exptRunID int64, itemIDs []int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTurnRunLogWithItemIDs", arg0, arg1, arg2, arg3, arg4, arg5) + ret := m.ctrl.Call(m, "UpdateTurnRunLogWithItemIDs", ctx, spaceID, exptID, exptRunID, itemIDs, ufields) ret0, _ := ret[0].(error) return ret0 } // UpdateTurnRunLogWithItemIDs indicates an expected call of UpdateTurnRunLogWithItemIDs. -func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnRunLogWithItemIDs(arg0, arg1, arg2, arg3, arg4, arg5 any) *gomock.Call { +func (mr *MockIExptTurnResultRepoMockRecorder) UpdateTurnRunLogWithItemIDs(ctx, spaceID, exptID, exptRunID, itemIDs, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnRunLogWithItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnRunLogWithItemIDs), arg0, arg1, arg2, arg3, arg4, arg5) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTurnRunLogWithItemIDs", reflect.TypeOf((*MockIExptTurnResultRepo)(nil).UpdateTurnRunLogWithItemIDs), ctx, spaceID, exptID, exptRunID, itemIDs, ufields) } // MockIExptRunLogRepo is a mock of IExptRunLogRepo interface. type MockIExptRunLogRepo struct { ctrl *gomock.Controller recorder *MockIExptRunLogRepoMockRecorder + isgomock struct{} } // MockIExptRunLogRepoMockRecorder is the mock recorder for MockIExptRunLogRepo. @@ -993,66 +998,67 @@ func (m *MockIExptRunLogRepo) EXPECT() *MockIExptRunLogRepoMockRecorder { } // Create mocks base method. -func (m *MockIExptRunLogRepo) Create(arg0 context.Context, arg1 *entity.ExptRunLog) error { +func (m *MockIExptRunLogRepo) Create(ctx context.Context, exptRunLog *entity.ExptRunLog) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret := m.ctrl.Call(m, "Create", ctx, exptRunLog) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create. -func (mr *MockIExptRunLogRepoMockRecorder) Create(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptRunLogRepoMockRecorder) Create(ctx, exptRunLog any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Create), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Create), ctx, exptRunLog) } // Get mocks base method. -func (m *MockIExptRunLogRepo) Get(arg0 context.Context, arg1, arg2 int64) (*entity.ExptRunLog, error) { +func (m *MockIExptRunLogRepo) Get(ctx context.Context, exptID, exptRunID int64) (*entity.ExptRunLog, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Get", ctx, exptID, exptRunID) ret0, _ := ret[0].(*entity.ExptRunLog) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockIExptRunLogRepoMockRecorder) Get(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptRunLogRepoMockRecorder) Get(ctx, exptID, exptRunID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Get), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Get), ctx, exptID, exptRunID) } // Save mocks base method. -func (m *MockIExptRunLogRepo) Save(arg0 context.Context, arg1 *entity.ExptRunLog) error { +func (m *MockIExptRunLogRepo) Save(ctx context.Context, exptRunLog *entity.ExptRunLog) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Save", arg0, arg1) + ret := m.ctrl.Call(m, "Save", ctx, exptRunLog) ret0, _ := ret[0].(error) return ret0 } // Save indicates an expected call of Save. -func (mr *MockIExptRunLogRepoMockRecorder) Save(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptRunLogRepoMockRecorder) Save(ctx, exptRunLog any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Save), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Save), ctx, exptRunLog) } // Update mocks base method. -func (m *MockIExptRunLogRepo) Update(arg0 context.Context, arg1, arg2 int64, arg3 map[string]any) error { +func (m *MockIExptRunLogRepo) Update(ctx context.Context, exptID, exptRunID int64, ufields map[string]any) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Update", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "Update", ctx, exptID, exptRunID, ufields) ret0, _ := ret[0].(error) return ret0 } // Update indicates an expected call of Update. -func (mr *MockIExptRunLogRepoMockRecorder) Update(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptRunLogRepoMockRecorder) Update(ctx, exptID, exptRunID, ufields any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Update), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIExptRunLogRepo)(nil).Update), ctx, exptID, exptRunID, ufields) } // MockIExptAggrResultRepo is a mock of IExptAggrResultRepo interface. type MockIExptAggrResultRepo struct { ctrl *gomock.Controller recorder *MockIExptAggrResultRepoMockRecorder + isgomock struct{} } // MockIExptAggrResultRepoMockRecorder is the mock recorder for MockIExptAggrResultRepo. @@ -1073,53 +1079,53 @@ func (m *MockIExptAggrResultRepo) EXPECT() *MockIExptAggrResultRepoMockRecorder } // BatchCreateExptAggrResult mocks base method. -func (m *MockIExptAggrResultRepo) BatchCreateExptAggrResult(arg0 context.Context, arg1 []*entity.ExptAggrResult) error { +func (m *MockIExptAggrResultRepo) BatchCreateExptAggrResult(ctx context.Context, exptAggrResults []*entity.ExptAggrResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreateExptAggrResult", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreateExptAggrResult", ctx, exptAggrResults) ret0, _ := ret[0].(error) return ret0 } // BatchCreateExptAggrResult indicates an expected call of BatchCreateExptAggrResult. -func (mr *MockIExptAggrResultRepoMockRecorder) BatchCreateExptAggrResult(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) BatchCreateExptAggrResult(ctx, exptAggrResults any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).BatchCreateExptAggrResult), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreateExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).BatchCreateExptAggrResult), ctx, exptAggrResults) } // BatchGetExptAggrResultByExperimentIDs mocks base method. -func (m *MockIExptAggrResultRepo) BatchGetExptAggrResultByExperimentIDs(arg0 context.Context, arg1 []int64) ([]*entity.ExptAggrResult, error) { +func (m *MockIExptAggrResultRepo) BatchGetExptAggrResultByExperimentIDs(ctx context.Context, experimentIDs []int64) ([]*entity.ExptAggrResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGetExptAggrResultByExperimentIDs", arg0, arg1) + ret := m.ctrl.Call(m, "BatchGetExptAggrResultByExperimentIDs", ctx, experimentIDs) ret0, _ := ret[0].([]*entity.ExptAggrResult) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGetExptAggrResultByExperimentIDs indicates an expected call of BatchGetExptAggrResultByExperimentIDs. -func (mr *MockIExptAggrResultRepoMockRecorder) BatchGetExptAggrResultByExperimentIDs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) BatchGetExptAggrResultByExperimentIDs(ctx, experimentIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptAggrResultByExperimentIDs", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).BatchGetExptAggrResultByExperimentIDs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptAggrResultByExperimentIDs", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).BatchGetExptAggrResultByExperimentIDs), ctx, experimentIDs) } // CreateExptAggrResult mocks base method. -func (m *MockIExptAggrResultRepo) CreateExptAggrResult(arg0 context.Context, arg1 *entity.ExptAggrResult) error { +func (m *MockIExptAggrResultRepo) CreateExptAggrResult(ctx context.Context, exptAggrResult *entity.ExptAggrResult) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateExptAggrResult", arg0, arg1) + ret := m.ctrl.Call(m, "CreateExptAggrResult", ctx, exptAggrResult) ret0, _ := ret[0].(error) return ret0 } // CreateExptAggrResult indicates an expected call of CreateExptAggrResult. -func (mr *MockIExptAggrResultRepoMockRecorder) CreateExptAggrResult(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) CreateExptAggrResult(ctx, exptAggrResult any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).CreateExptAggrResult), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).CreateExptAggrResult), ctx, exptAggrResult) } // DeleteExptAggrResult mocks base method. -func (m *MockIExptAggrResultRepo) DeleteExptAggrResult(arg0 context.Context, arg1 *entity.ExptAggrResult, arg2 ...db.Option) error { +func (m *MockIExptAggrResultRepo) DeleteExptAggrResult(ctx context.Context, exptAggrResult *entity.ExptAggrResult, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, exptAggrResult} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "DeleteExptAggrResult", varargs...) @@ -1128,75 +1134,76 @@ func (m *MockIExptAggrResultRepo) DeleteExptAggrResult(arg0 context.Context, arg } // DeleteExptAggrResult indicates an expected call of DeleteExptAggrResult. -func (mr *MockIExptAggrResultRepoMockRecorder) DeleteExptAggrResult(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) DeleteExptAggrResult(ctx, exptAggrResult any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, exptAggrResult}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).DeleteExptAggrResult), varargs...) } // GetExptAggrResult mocks base method. -func (m *MockIExptAggrResultRepo) GetExptAggrResult(arg0 context.Context, arg1 int64, arg2 int32, arg3 string) (*entity.ExptAggrResult, error) { +func (m *MockIExptAggrResultRepo) GetExptAggrResult(ctx context.Context, experimentID int64, fieldType int32, fieldKey string) (*entity.ExptAggrResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptAggrResult", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetExptAggrResult", ctx, experimentID, fieldType, fieldKey) ret0, _ := ret[0].(*entity.ExptAggrResult) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptAggrResult indicates an expected call of GetExptAggrResult. -func (mr *MockIExptAggrResultRepoMockRecorder) GetExptAggrResult(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) GetExptAggrResult(ctx, experimentID, fieldType, fieldKey any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).GetExptAggrResult), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptAggrResult", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).GetExptAggrResult), ctx, experimentID, fieldType, fieldKey) } // GetExptAggrResultByExperimentID mocks base method. -func (m *MockIExptAggrResultRepo) GetExptAggrResultByExperimentID(arg0 context.Context, arg1 int64) ([]*entity.ExptAggrResult, error) { +func (m *MockIExptAggrResultRepo) GetExptAggrResultByExperimentID(ctx context.Context, experimentID int64) ([]*entity.ExptAggrResult, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptAggrResultByExperimentID", arg0, arg1) + ret := m.ctrl.Call(m, "GetExptAggrResultByExperimentID", ctx, experimentID) ret0, _ := ret[0].([]*entity.ExptAggrResult) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptAggrResultByExperimentID indicates an expected call of GetExptAggrResultByExperimentID. -func (mr *MockIExptAggrResultRepoMockRecorder) GetExptAggrResultByExperimentID(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) GetExptAggrResultByExperimentID(ctx, experimentID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptAggrResultByExperimentID", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).GetExptAggrResultByExperimentID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptAggrResultByExperimentID", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).GetExptAggrResultByExperimentID), ctx, experimentID) } // UpdateAndGetLatestVersion mocks base method. -func (m *MockIExptAggrResultRepo) UpdateAndGetLatestVersion(arg0 context.Context, arg1 int64, arg2 int32, arg3 string) (int64, error) { +func (m *MockIExptAggrResultRepo) UpdateAndGetLatestVersion(ctx context.Context, experimentID int64, fieldType int32, fieldKey string) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAndGetLatestVersion", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "UpdateAndGetLatestVersion", ctx, experimentID, fieldType, fieldKey) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateAndGetLatestVersion indicates an expected call of UpdateAndGetLatestVersion. -func (mr *MockIExptAggrResultRepoMockRecorder) UpdateAndGetLatestVersion(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) UpdateAndGetLatestVersion(ctx, experimentID, fieldType, fieldKey any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAndGetLatestVersion", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).UpdateAndGetLatestVersion), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAndGetLatestVersion", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).UpdateAndGetLatestVersion), ctx, experimentID, fieldType, fieldKey) } // UpdateExptAggrResultByVersion mocks base method. -func (m *MockIExptAggrResultRepo) UpdateExptAggrResultByVersion(arg0 context.Context, arg1 *entity.ExptAggrResult, arg2 int64) error { +func (m *MockIExptAggrResultRepo) UpdateExptAggrResultByVersion(ctx context.Context, exptAggrResult *entity.ExptAggrResult, taskVersion int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateExptAggrResultByVersion", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "UpdateExptAggrResultByVersion", ctx, exptAggrResult, taskVersion) ret0, _ := ret[0].(error) return ret0 } // UpdateExptAggrResultByVersion indicates an expected call of UpdateExptAggrResultByVersion. -func (mr *MockIExptAggrResultRepoMockRecorder) UpdateExptAggrResultByVersion(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAggrResultRepoMockRecorder) UpdateExptAggrResultByVersion(ctx, exptAggrResult, taskVersion any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExptAggrResultByVersion", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).UpdateExptAggrResultByVersion), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateExptAggrResultByVersion", reflect.TypeOf((*MockIExptAggrResultRepo)(nil).UpdateExptAggrResultByVersion), ctx, exptAggrResult, taskVersion) } // MockQuotaRepo is a mock of QuotaRepo interface. type MockQuotaRepo struct { ctrl *gomock.Controller recorder *MockQuotaRepoMockRecorder + isgomock struct{} } // MockQuotaRepoMockRecorder is the mock recorder for MockQuotaRepo. @@ -1217,23 +1224,24 @@ func (m *MockQuotaRepo) EXPECT() *MockQuotaRepoMockRecorder { } // CreateOrUpdate mocks base method. -func (m *MockQuotaRepo) CreateOrUpdate(arg0 context.Context, arg1 int64, arg2 func(*entity.QuotaSpaceExpt) (*entity.QuotaSpaceExpt, bool, error), arg3 *entity.Session) error { +func (m *MockQuotaRepo) CreateOrUpdate(ctx context.Context, spaceID int64, updater func(*entity.QuotaSpaceExpt) (*entity.QuotaSpaceExpt, bool, error), session *entity.Session) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateOrUpdate", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "CreateOrUpdate", ctx, spaceID, updater, session) ret0, _ := ret[0].(error) return ret0 } // CreateOrUpdate indicates an expected call of CreateOrUpdate. -func (mr *MockQuotaRepoMockRecorder) CreateOrUpdate(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQuotaRepoMockRecorder) CreateOrUpdate(ctx, spaceID, updater, session any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdate", reflect.TypeOf((*MockQuotaRepo)(nil).CreateOrUpdate), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdate", reflect.TypeOf((*MockQuotaRepo)(nil).CreateOrUpdate), ctx, spaceID, updater, session) } // MockIExptTurnResultFilterRepo is a mock of IExptTurnResultFilterRepo interface. type MockIExptTurnResultFilterRepo struct { ctrl *gomock.Controller recorder *MockIExptTurnResultFilterRepoMockRecorder + isgomock struct{} } // MockIExptTurnResultFilterRepoMockRecorder is the mock recorder for MockIExptTurnResultFilterRepo. @@ -1254,10 +1262,10 @@ func (m *MockIExptTurnResultFilterRepo) EXPECT() *MockIExptTurnResultFilterRepoM } // DeleteExptTurnResultFilterKeyMapping mocks base method. -func (m *MockIExptTurnResultFilterRepo) DeleteExptTurnResultFilterKeyMapping(arg0 context.Context, arg1 *entity.ExptTurnResultFilterKeyMapping, arg2 ...db.Option) error { +func (m *MockIExptTurnResultFilterRepo) DeleteExptTurnResultFilterKeyMapping(ctx context.Context, mapping *entity.ExptTurnResultFilterKeyMapping, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, mapping} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "DeleteExptTurnResultFilterKeyMapping", varargs...) @@ -1266,60 +1274,60 @@ func (m *MockIExptTurnResultFilterRepo) DeleteExptTurnResultFilterKeyMapping(arg } // DeleteExptTurnResultFilterKeyMapping indicates an expected call of DeleteExptTurnResultFilterKeyMapping. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) DeleteExptTurnResultFilterKeyMapping(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) DeleteExptTurnResultFilterKeyMapping(ctx, mapping any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, mapping}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExptTurnResultFilterKeyMapping", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).DeleteExptTurnResultFilterKeyMapping), varargs...) } // GetByExptIDItemIDs mocks base method. -func (m *MockIExptTurnResultFilterRepo) GetByExptIDItemIDs(arg0 context.Context, arg1, arg2, arg3 string, arg4 []string) ([]*entity.ExptTurnResultFilterEntity, error) { +func (m *MockIExptTurnResultFilterRepo) GetByExptIDItemIDs(ctx context.Context, spaceID, exptID, createdDate string, itemIDs []string) ([]*entity.ExptTurnResultFilterEntity, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByExptIDItemIDs", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "GetByExptIDItemIDs", ctx, spaceID, exptID, createdDate, itemIDs) ret0, _ := ret[0].([]*entity.ExptTurnResultFilterEntity) ret1, _ := ret[1].(error) return ret0, ret1 } // GetByExptIDItemIDs indicates an expected call of GetByExptIDItemIDs. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) GetByExptIDItemIDs(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) GetByExptIDItemIDs(ctx, spaceID, exptID, createdDate, itemIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByExptIDItemIDs", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).GetByExptIDItemIDs), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByExptIDItemIDs", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).GetByExptIDItemIDs), ctx, spaceID, exptID, createdDate, itemIDs) } // GetExptTurnResultFilterKeyMappings mocks base method. -func (m *MockIExptTurnResultFilterRepo) GetExptTurnResultFilterKeyMappings(arg0 context.Context, arg1, arg2 int64) ([]*entity.ExptTurnResultFilterKeyMapping, error) { +func (m *MockIExptTurnResultFilterRepo) GetExptTurnResultFilterKeyMappings(ctx context.Context, spaceID, exptID int64) ([]*entity.ExptTurnResultFilterKeyMapping, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTurnResultFilterKeyMappings", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetExptTurnResultFilterKeyMappings", ctx, spaceID, exptID) ret0, _ := ret[0].([]*entity.ExptTurnResultFilterKeyMapping) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptTurnResultFilterKeyMappings indicates an expected call of GetExptTurnResultFilterKeyMappings. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) GetExptTurnResultFilterKeyMappings(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) GetExptTurnResultFilterKeyMappings(ctx, spaceID, exptID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnResultFilterKeyMappings", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).GetExptTurnResultFilterKeyMappings), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnResultFilterKeyMappings", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).GetExptTurnResultFilterKeyMappings), ctx, spaceID, exptID) } // InsertExptTurnResultFilterKeyMappings mocks base method. -func (m *MockIExptTurnResultFilterRepo) InsertExptTurnResultFilterKeyMappings(arg0 context.Context, arg1 []*entity.ExptTurnResultFilterKeyMapping) error { +func (m *MockIExptTurnResultFilterRepo) InsertExptTurnResultFilterKeyMappings(ctx context.Context, mappings []*entity.ExptTurnResultFilterKeyMapping) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InsertExptTurnResultFilterKeyMappings", arg0, arg1) + ret := m.ctrl.Call(m, "InsertExptTurnResultFilterKeyMappings", ctx, mappings) ret0, _ := ret[0].(error) return ret0 } // InsertExptTurnResultFilterKeyMappings indicates an expected call of InsertExptTurnResultFilterKeyMappings. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) InsertExptTurnResultFilterKeyMappings(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) InsertExptTurnResultFilterKeyMappings(ctx, mappings any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertExptTurnResultFilterKeyMappings", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).InsertExptTurnResultFilterKeyMappings), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertExptTurnResultFilterKeyMappings", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).InsertExptTurnResultFilterKeyMappings), ctx, mappings) } // QueryItemIDStates mocks base method. -func (m *MockIExptTurnResultFilterRepo) QueryItemIDStates(arg0 context.Context, arg1 *entity.ExptTurnResultFilterAccelerator) (map[int64]entity.ItemRunState, int64, error) { +func (m *MockIExptTurnResultFilterRepo) QueryItemIDStates(ctx context.Context, filter *entity.ExptTurnResultFilterAccelerator) (map[int64]entity.ItemRunState, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "QueryItemIDStates", arg0, arg1) + ret := m.ctrl.Call(m, "QueryItemIDStates", ctx, filter) ret0, _ := ret[0].(map[int64]entity.ItemRunState) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -1327,29 +1335,30 @@ func (m *MockIExptTurnResultFilterRepo) QueryItemIDStates(arg0 context.Context, } // QueryItemIDStates indicates an expected call of QueryItemIDStates. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) QueryItemIDStates(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) QueryItemIDStates(ctx, filter any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryItemIDStates", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).QueryItemIDStates), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryItemIDStates", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).QueryItemIDStates), ctx, filter) } // Save mocks base method. -func (m *MockIExptTurnResultFilterRepo) Save(arg0 context.Context, arg1 []*entity.ExptTurnResultFilterEntity) error { +func (m *MockIExptTurnResultFilterRepo) Save(ctx context.Context, filters []*entity.ExptTurnResultFilterEntity) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Save", arg0, arg1) + ret := m.ctrl.Call(m, "Save", ctx, filters) ret0, _ := ret[0].(error) return ret0 } // Save indicates an expected call of Save. -func (mr *MockIExptTurnResultFilterRepoMockRecorder) Save(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptTurnResultFilterRepoMockRecorder) Save(ctx, filters any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).Save), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockIExptTurnResultFilterRepo)(nil).Save), ctx, filters) } // MockIExptAnnotateRepo is a mock of IExptAnnotateRepo interface. type MockIExptAnnotateRepo struct { ctrl *gomock.Controller recorder *MockIExptAnnotateRepoMockRecorder + isgomock struct{} } // MockIExptAnnotateRepoMockRecorder is the mock recorder for MockIExptAnnotateRepo. @@ -1370,68 +1379,68 @@ func (m *MockIExptAnnotateRepo) EXPECT() *MockIExptAnnotateRepoMockRecorder { } // BatchGetExptTurnAnnotateRecordRefs mocks base method. -func (m *MockIExptAnnotateRepo) BatchGetExptTurnAnnotateRecordRefs(arg0 context.Context, arg1 []int64, arg2 int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { +func (m *MockIExptAnnotateRepo) BatchGetExptTurnAnnotateRecordRefs(ctx context.Context, exptIDs []int64, spaceID int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGetExptTurnAnnotateRecordRefs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "BatchGetExptTurnAnnotateRecordRefs", ctx, exptIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnAnnotateRecordRef) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGetExptTurnAnnotateRecordRefs indicates an expected call of BatchGetExptTurnAnnotateRecordRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) BatchGetExptTurnAnnotateRecordRefs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) BatchGetExptTurnAnnotateRecordRefs(ctx, exptIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).BatchGetExptTurnAnnotateRecordRefs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).BatchGetExptTurnAnnotateRecordRefs), ctx, exptIDs, spaceID) } // BatchGetExptTurnResultTagRefs mocks base method. -func (m *MockIExptAnnotateRepo) BatchGetExptTurnResultTagRefs(arg0 context.Context, arg1 []int64, arg2 int64) ([]*entity.ExptTurnResultTagRef, error) { +func (m *MockIExptAnnotateRepo) BatchGetExptTurnResultTagRefs(ctx context.Context, exptIDs []int64, spaceID int64) ([]*entity.ExptTurnResultTagRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchGetExptTurnResultTagRefs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "BatchGetExptTurnResultTagRefs", ctx, exptIDs, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResultTagRef) ret1, _ := ret[1].(error) return ret0, ret1 } // BatchGetExptTurnResultTagRefs indicates an expected call of BatchGetExptTurnResultTagRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) BatchGetExptTurnResultTagRefs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) BatchGetExptTurnResultTagRefs(ctx, exptIDs, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).BatchGetExptTurnResultTagRefs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGetExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).BatchGetExptTurnResultTagRefs), ctx, exptIDs, spaceID) } // CreateExptTurnAnnotateRecordRefs mocks base method. -func (m *MockIExptAnnotateRepo) CreateExptTurnAnnotateRecordRefs(arg0 context.Context, arg1 *entity.ExptTurnAnnotateRecordRef) error { +func (m *MockIExptAnnotateRepo) CreateExptTurnAnnotateRecordRefs(ctx context.Context, refs *entity.ExptTurnAnnotateRecordRef) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateExptTurnAnnotateRecordRefs", arg0, arg1) + ret := m.ctrl.Call(m, "CreateExptTurnAnnotateRecordRefs", ctx, refs) ret0, _ := ret[0].(error) return ret0 } // CreateExptTurnAnnotateRecordRefs indicates an expected call of CreateExptTurnAnnotateRecordRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) CreateExptTurnAnnotateRecordRefs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) CreateExptTurnAnnotateRecordRefs(ctx, refs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).CreateExptTurnAnnotateRecordRefs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).CreateExptTurnAnnotateRecordRefs), ctx, refs) } // CreateExptTurnResultTagRefs mocks base method. -func (m *MockIExptAnnotateRepo) CreateExptTurnResultTagRefs(arg0 context.Context, arg1 []*entity.ExptTurnResultTagRef) error { +func (m *MockIExptAnnotateRepo) CreateExptTurnResultTagRefs(ctx context.Context, refs []*entity.ExptTurnResultTagRef) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateExptTurnResultTagRefs", arg0, arg1) + ret := m.ctrl.Call(m, "CreateExptTurnResultTagRefs", ctx, refs) ret0, _ := ret[0].(error) return ret0 } // CreateExptTurnResultTagRefs indicates an expected call of CreateExptTurnResultTagRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) CreateExptTurnResultTagRefs(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) CreateExptTurnResultTagRefs(ctx, refs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).CreateExptTurnResultTagRefs), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).CreateExptTurnResultTagRefs), ctx, refs) } // DeleteExptTurnResultTagRef mocks base method. -func (m *MockIExptAnnotateRepo) DeleteExptTurnResultTagRef(arg0 context.Context, arg1, arg2, arg3 int64, arg4 ...db.Option) error { +func (m *MockIExptAnnotateRepo) DeleteExptTurnResultTagRef(ctx context.Context, exptID, spaceID, tagKeyID int64, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2, arg3} - for _, a := range arg4 { + varargs := []any{ctx, exptID, spaceID, tagKeyID} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "DeleteExptTurnResultTagRef", varargs...) @@ -1440,17 +1449,17 @@ func (m *MockIExptAnnotateRepo) DeleteExptTurnResultTagRef(arg0 context.Context, } // DeleteExptTurnResultTagRef indicates an expected call of DeleteExptTurnResultTagRef. -func (mr *MockIExptAnnotateRepoMockRecorder) DeleteExptTurnResultTagRef(arg0, arg1, arg2, arg3 any, arg4 ...any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) DeleteExptTurnResultTagRef(ctx, exptID, spaceID, tagKeyID any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2, arg3}, arg4...) + varargs := append([]any{ctx, exptID, spaceID, tagKeyID}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExptTurnResultTagRef", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).DeleteExptTurnResultTagRef), varargs...) } // DeleteTurnAnnotateRecordRef mocks base method. -func (m *MockIExptAnnotateRepo) DeleteTurnAnnotateRecordRef(arg0 context.Context, arg1, arg2, arg3 int64, arg4 ...db.Option) error { +func (m *MockIExptAnnotateRepo) DeleteTurnAnnotateRecordRef(ctx context.Context, exptID, spaceID, tagKeyID int64, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2, arg3} - for _, a := range arg4 { + varargs := []any{ctx, exptID, spaceID, tagKeyID} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "DeleteTurnAnnotateRecordRef", varargs...) @@ -1459,122 +1468,122 @@ func (m *MockIExptAnnotateRepo) DeleteTurnAnnotateRecordRef(arg0 context.Context } // DeleteTurnAnnotateRecordRef indicates an expected call of DeleteTurnAnnotateRecordRef. -func (mr *MockIExptAnnotateRepoMockRecorder) DeleteTurnAnnotateRecordRef(arg0, arg1, arg2, arg3 any, arg4 ...any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) DeleteTurnAnnotateRecordRef(ctx, exptID, spaceID, tagKeyID any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2, arg3}, arg4...) + varargs := append([]any{ctx, exptID, spaceID, tagKeyID}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTurnAnnotateRecordRef", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).DeleteTurnAnnotateRecordRef), varargs...) } // GetAnnotateRecordByID mocks base method. -func (m *MockIExptAnnotateRepo) GetAnnotateRecordByID(arg0 context.Context, arg1, arg2 int64) (*entity.AnnotateRecord, error) { +func (m *MockIExptAnnotateRepo) GetAnnotateRecordByID(ctx context.Context, spaceID, recordID int64) (*entity.AnnotateRecord, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAnnotateRecordByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetAnnotateRecordByID", ctx, spaceID, recordID) ret0, _ := ret[0].(*entity.AnnotateRecord) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAnnotateRecordByID indicates an expected call of GetAnnotateRecordByID. -func (mr *MockIExptAnnotateRepoMockRecorder) GetAnnotateRecordByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetAnnotateRecordByID(ctx, spaceID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnnotateRecordByID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetAnnotateRecordByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnnotateRecordByID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetAnnotateRecordByID), ctx, spaceID, recordID) } // GetAnnotateRecordsByIDs mocks base method. -func (m *MockIExptAnnotateRepo) GetAnnotateRecordsByIDs(arg0 context.Context, arg1 int64, arg2 []int64) ([]*entity.AnnotateRecord, error) { +func (m *MockIExptAnnotateRepo) GetAnnotateRecordsByIDs(ctx context.Context, spaceID int64, recordIDs []int64) ([]*entity.AnnotateRecord, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAnnotateRecordsByIDs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetAnnotateRecordsByIDs", ctx, spaceID, recordIDs) ret0, _ := ret[0].([]*entity.AnnotateRecord) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAnnotateRecordsByIDs indicates an expected call of GetAnnotateRecordsByIDs. -func (mr *MockIExptAnnotateRepoMockRecorder) GetAnnotateRecordsByIDs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetAnnotateRecordsByIDs(ctx, spaceID, recordIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnnotateRecordsByIDs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetAnnotateRecordsByIDs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnnotateRecordsByIDs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetAnnotateRecordsByIDs), ctx, spaceID, recordIDs) } // GetExptTurnAnnotateRecordRefs mocks base method. -func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefs(arg0 context.Context, arg1, arg2 int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { +func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefs(ctx context.Context, exptID, spaceID int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefs", ctx, exptID, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnAnnotateRecordRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptTurnAnnotateRecordRefs indicates an expected call of GetExptTurnAnnotateRecordRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefs(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefs), ctx, exptID, spaceID) } // GetExptTurnAnnotateRecordRefsByTagKeyID mocks base method. -func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefsByTagKeyID(arg0 context.Context, arg1, arg2, arg3 int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { +func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefsByTagKeyID(ctx context.Context, exptID, spaceID, tagKeyID int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefsByTagKeyID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefsByTagKeyID", ctx, exptID, spaceID, tagKeyID) ret0, _ := ret[0].([]*entity.ExptTurnAnnotateRecordRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptTurnAnnotateRecordRefsByTagKeyID indicates an expected call of GetExptTurnAnnotateRecordRefsByTagKeyID. -func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefsByTagKeyID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefsByTagKeyID(ctx, exptID, spaceID, tagKeyID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefsByTagKeyID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefsByTagKeyID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefsByTagKeyID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefsByTagKeyID), ctx, exptID, spaceID, tagKeyID) } // GetExptTurnAnnotateRecordRefsByTurnResultIDs mocks base method. -func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefsByTurnResultIDs(arg0 context.Context, arg1 int64, arg2 []int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { +func (m *MockIExptAnnotateRepo) GetExptTurnAnnotateRecordRefsByTurnResultIDs(ctx context.Context, exptID int64, turnResultIDs []int64) ([]*entity.ExptTurnAnnotateRecordRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefsByTurnResultIDs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetExptTurnAnnotateRecordRefsByTurnResultIDs", ctx, exptID, turnResultIDs) ret0, _ := ret[0].([]*entity.ExptTurnAnnotateRecordRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptTurnAnnotateRecordRefsByTurnResultIDs indicates an expected call of GetExptTurnAnnotateRecordRefsByTurnResultIDs. -func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefsByTurnResultIDs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnAnnotateRecordRefsByTurnResultIDs(ctx, exptID, turnResultIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefsByTurnResultIDs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefsByTurnResultIDs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnAnnotateRecordRefsByTurnResultIDs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnAnnotateRecordRefsByTurnResultIDs), ctx, exptID, turnResultIDs) } // GetExptTurnResultTagRefs mocks base method. -func (m *MockIExptAnnotateRepo) GetExptTurnResultTagRefs(arg0 context.Context, arg1, arg2 int64) ([]*entity.ExptTurnResultTagRef, error) { +func (m *MockIExptAnnotateRepo) GetExptTurnResultTagRefs(ctx context.Context, exptID, spaceID int64) ([]*entity.ExptTurnResultTagRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetExptTurnResultTagRefs", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetExptTurnResultTagRefs", ctx, exptID, spaceID) ret0, _ := ret[0].([]*entity.ExptTurnResultTagRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetExptTurnResultTagRefs indicates an expected call of GetExptTurnResultTagRefs. -func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnResultTagRefs(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetExptTurnResultTagRefs(ctx, exptID, spaceID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnResultTagRefs), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExptTurnResultTagRefs", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetExptTurnResultTagRefs), ctx, exptID, spaceID) } // GetTagRefByTagKeyID mocks base method. -func (m *MockIExptAnnotateRepo) GetTagRefByTagKeyID(arg0 context.Context, arg1, arg2, arg3 int64) (*entity.ExptTurnResultTagRef, error) { +func (m *MockIExptAnnotateRepo) GetTagRefByTagKeyID(ctx context.Context, exptID, spaceID, tagKeyID int64) (*entity.ExptTurnResultTagRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTagRefByTagKeyID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetTagRefByTagKeyID", ctx, exptID, spaceID, tagKeyID) ret0, _ := ret[0].(*entity.ExptTurnResultTagRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetTagRefByTagKeyID indicates an expected call of GetTagRefByTagKeyID. -func (mr *MockIExptAnnotateRepoMockRecorder) GetTagRefByTagKeyID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) GetTagRefByTagKeyID(ctx, exptID, spaceID, tagKeyID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagRefByTagKeyID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetTagRefByTagKeyID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTagRefByTagKeyID", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).GetTagRefByTagKeyID), ctx, exptID, spaceID, tagKeyID) } // SaveAnnotateRecord mocks base method. -func (m *MockIExptAnnotateRepo) SaveAnnotateRecord(arg0 context.Context, arg1 int64, arg2 *entity.AnnotateRecord, arg3 ...db.Option) error { +func (m *MockIExptAnnotateRepo) SaveAnnotateRecord(ctx context.Context, exptTurnResultID int64, record *entity.AnnotateRecord, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2} - for _, a := range arg3 { + varargs := []any{ctx, exptTurnResultID, record} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "SaveAnnotateRecord", varargs...) @@ -1583,31 +1592,31 @@ func (m *MockIExptAnnotateRepo) SaveAnnotateRecord(arg0 context.Context, arg1 in } // SaveAnnotateRecord indicates an expected call of SaveAnnotateRecord. -func (mr *MockIExptAnnotateRepoMockRecorder) SaveAnnotateRecord(arg0, arg1, arg2 any, arg3 ...any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) SaveAnnotateRecord(ctx, exptTurnResultID, record any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2}, arg3...) + varargs := append([]any{ctx, exptTurnResultID, record}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveAnnotateRecord", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).SaveAnnotateRecord), varargs...) } // UpdateAnnotateRecord mocks base method. -func (m *MockIExptAnnotateRepo) UpdateAnnotateRecord(arg0 context.Context, arg1 *entity.AnnotateRecord) error { +func (m *MockIExptAnnotateRepo) UpdateAnnotateRecord(ctx context.Context, record *entity.AnnotateRecord) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAnnotateRecord", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateAnnotateRecord", ctx, record) ret0, _ := ret[0].(error) return ret0 } // UpdateAnnotateRecord indicates an expected call of UpdateAnnotateRecord. -func (mr *MockIExptAnnotateRepoMockRecorder) UpdateAnnotateRecord(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) UpdateAnnotateRecord(ctx, record any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAnnotateRecord", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).UpdateAnnotateRecord), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAnnotateRecord", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).UpdateAnnotateRecord), ctx, record) } // UpdateCompleteCount mocks base method. -func (m *MockIExptAnnotateRepo) UpdateCompleteCount(arg0 context.Context, arg1, arg2, arg3 int64, arg4 ...db.Option) error { +func (m *MockIExptAnnotateRepo) UpdateCompleteCount(ctx context.Context, exptID, spaceID, tagKeyID int64, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2, arg3} - for _, a := range arg4 { + varargs := []any{ctx, exptID, spaceID, tagKeyID} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "UpdateCompleteCount", varargs...) @@ -1616,9 +1625,9 @@ func (m *MockIExptAnnotateRepo) UpdateCompleteCount(arg0 context.Context, arg1, } // UpdateCompleteCount indicates an expected call of UpdateCompleteCount. -func (mr *MockIExptAnnotateRepoMockRecorder) UpdateCompleteCount(arg0, arg1, arg2, arg3 any, arg4 ...any) *gomock.Call { +func (mr *MockIExptAnnotateRepoMockRecorder) UpdateCompleteCount(ctx, exptID, spaceID, tagKeyID any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2, arg3}, arg4...) + varargs := append([]any{ctx, exptID, spaceID, tagKeyID}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateCompleteCount", reflect.TypeOf((*MockIExptAnnotateRepo)(nil).UpdateCompleteCount), varargs...) } @@ -1626,6 +1635,7 @@ func (mr *MockIExptAnnotateRepoMockRecorder) UpdateCompleteCount(arg0, arg1, arg type MockIExptResultExportRecordRepo struct { ctrl *gomock.Controller recorder *MockIExptResultExportRecordRepoMockRecorder + isgomock struct{} } // MockIExptResultExportRecordRepoMockRecorder is the mock recorder for MockIExptResultExportRecordRepo. @@ -1646,10 +1656,10 @@ func (m *MockIExptResultExportRecordRepo) EXPECT() *MockIExptResultExportRecordR } // Create mocks base method. -func (m *MockIExptResultExportRecordRepo) Create(arg0 context.Context, arg1 *entity.ExptResultExportRecord, arg2 ...db.Option) (int64, error) { +func (m *MockIExptResultExportRecordRepo) Create(ctx context.Context, exportRecord *entity.ExptResultExportRecord, opts ...db.Option) (int64, error) { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, exportRecord} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Create", varargs...) @@ -1659,31 +1669,31 @@ func (m *MockIExptResultExportRecordRepo) Create(arg0 context.Context, arg1 *ent } // Create indicates an expected call of Create. -func (mr *MockIExptResultExportRecordRepoMockRecorder) Create(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptResultExportRecordRepoMockRecorder) Create(ctx, exportRecord any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, exportRecord}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).Create), varargs...) } // Get mocks base method. -func (m *MockIExptResultExportRecordRepo) Get(arg0 context.Context, arg1, arg2 int64) (*entity.ExptResultExportRecord, error) { +func (m *MockIExptResultExportRecordRepo) Get(ctx context.Context, spaceID, exportID int64) (*entity.ExptResultExportRecord, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "Get", ctx, spaceID, exportID) ret0, _ := ret[0].(*entity.ExptResultExportRecord) ret1, _ := ret[1].(error) return ret0, ret1 } // Get indicates an expected call of Get. -func (mr *MockIExptResultExportRecordRepoMockRecorder) Get(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptResultExportRecordRepoMockRecorder) Get(ctx, spaceID, exportID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).Get), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).Get), ctx, spaceID, exportID) } // List mocks base method. -func (m *MockIExptResultExportRecordRepo) List(arg0 context.Context, arg1, arg2 int64, arg3 entity.Page, arg4 *int32) ([]*entity.ExptResultExportRecord, int64, error) { +func (m *MockIExptResultExportRecordRepo) List(ctx context.Context, spaceID, exptID int64, page entity.Page, csvExportStatus *int32) ([]*entity.ExptResultExportRecord, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "List", ctx, spaceID, exptID, page, csvExportStatus) ret0, _ := ret[0].([]*entity.ExptResultExportRecord) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -1691,16 +1701,16 @@ func (m *MockIExptResultExportRecordRepo) List(arg0 context.Context, arg1, arg2 } // List indicates an expected call of List. -func (mr *MockIExptResultExportRecordRepoMockRecorder) List(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptResultExportRecordRepoMockRecorder) List(ctx, spaceID, exptID, page, csvExportStatus any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).List), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).List), ctx, spaceID, exptID, page, csvExportStatus) } // Update mocks base method. -func (m *MockIExptResultExportRecordRepo) Update(arg0 context.Context, arg1 *entity.ExptResultExportRecord, arg2 ...db.Option) error { +func (m *MockIExptResultExportRecordRepo) Update(ctx context.Context, exportRecord *entity.ExptResultExportRecord, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, exportRecord} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Update", varargs...) @@ -1709,9 +1719,9 @@ func (m *MockIExptResultExportRecordRepo) Update(arg0 context.Context, arg1 *ent } // Update indicates an expected call of Update. -func (mr *MockIExptResultExportRecordRepoMockRecorder) Update(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptResultExportRecordRepoMockRecorder) Update(ctx, exportRecord any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, exportRecord}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockIExptResultExportRecordRepo)(nil).Update), varargs...) } @@ -1719,6 +1729,7 @@ func (mr *MockIExptResultExportRecordRepoMockRecorder) Update(arg0, arg1 any, ar type MockIEvalAsyncRepo struct { ctrl *gomock.Controller recorder *MockIEvalAsyncRepoMockRecorder + isgomock struct{} } // MockIEvalAsyncRepoMockRecorder is the mock recorder for MockIEvalAsyncRepo. @@ -1739,18 +1750,18 @@ func (m *MockIEvalAsyncRepo) EXPECT() *MockIEvalAsyncRepoMockRecorder { } // GetEvalAsyncCtx mocks base method. -func (m *MockIEvalAsyncRepo) GetEvalAsyncCtx(arg0 context.Context, arg1 string) (*entity.EvalAsyncCtx, error) { +func (m *MockIEvalAsyncRepo) GetEvalAsyncCtx(ctx context.Context, invokeID string) (*entity.EvalAsyncCtx, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEvalAsyncCtx", arg0, arg1) + ret := m.ctrl.Call(m, "GetEvalAsyncCtx", ctx, invokeID) ret0, _ := ret[0].(*entity.EvalAsyncCtx) ret1, _ := ret[1].(error) return ret0, ret1 } // GetEvalAsyncCtx indicates an expected call of GetEvalAsyncCtx. -func (mr *MockIEvalAsyncRepoMockRecorder) GetEvalAsyncCtx(arg0, arg1 any) *gomock.Call { +func (mr *MockIEvalAsyncRepoMockRecorder) GetEvalAsyncCtx(ctx, invokeID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvalAsyncCtx", reflect.TypeOf((*MockIEvalAsyncRepo)(nil).GetEvalAsyncCtx), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEvalAsyncCtx", reflect.TypeOf((*MockIEvalAsyncRepo)(nil).GetEvalAsyncCtx), ctx, invokeID) } // GetEvalAsyncCtxStrong mocks base method. @@ -1784,23 +1795,24 @@ func (mr *MockIEvalAsyncRepoMockRecorder) MarkEvalAsyncResumeReady(ctx, invokeID } // SetEvalAsyncCtx mocks base method. -func (m *MockIEvalAsyncRepo) SetEvalAsyncCtx(arg0 context.Context, arg1 string, arg2 *entity.EvalAsyncCtx) error { +func (m *MockIEvalAsyncRepo) SetEvalAsyncCtx(ctx context.Context, invokeID string, actx *entity.EvalAsyncCtx) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetEvalAsyncCtx", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SetEvalAsyncCtx", ctx, invokeID, actx) ret0, _ := ret[0].(error) return ret0 } // SetEvalAsyncCtx indicates an expected call of SetEvalAsyncCtx. -func (mr *MockIEvalAsyncRepoMockRecorder) SetEvalAsyncCtx(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIEvalAsyncRepoMockRecorder) SetEvalAsyncCtx(ctx, invokeID, actx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEvalAsyncCtx", reflect.TypeOf((*MockIEvalAsyncRepo)(nil).SetEvalAsyncCtx), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEvalAsyncCtx", reflect.TypeOf((*MockIEvalAsyncRepo)(nil).SetEvalAsyncCtx), ctx, invokeID, actx) } // MockIExptInsightAnalysisRecordRepo is a mock of IExptInsightAnalysisRecordRepo interface. type MockIExptInsightAnalysisRecordRepo struct { ctrl *gomock.Controller recorder *MockIExptInsightAnalysisRecordRepoMockRecorder + isgomock struct{} } // MockIExptInsightAnalysisRecordRepoMockRecorder is the mock recorder for MockIExptInsightAnalysisRecordRepo. @@ -1821,9 +1833,9 @@ func (m *MockIExptInsightAnalysisRecordRepo) EXPECT() *MockIExptInsightAnalysisR } // CountFeedbackVote mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) CountFeedbackVote(arg0 context.Context, arg1, arg2, arg3 int64) (int64, int64, error) { +func (m *MockIExptInsightAnalysisRecordRepo) CountFeedbackVote(ctx context.Context, spaceID, exptID, recordID int64) (int64, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CountFeedbackVote", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "CountFeedbackVote", ctx, spaceID, exptID, recordID) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -1831,16 +1843,16 @@ func (m *MockIExptInsightAnalysisRecordRepo) CountFeedbackVote(arg0 context.Cont } // CountFeedbackVote indicates an expected call of CountFeedbackVote. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CountFeedbackVote(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CountFeedbackVote(ctx, spaceID, exptID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountFeedbackVote", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).CountFeedbackVote), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountFeedbackVote", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).CountFeedbackVote), ctx, spaceID, exptID, recordID) } // CreateAnalysisRecord mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) CreateAnalysisRecord(arg0 context.Context, arg1 *entity.ExptInsightAnalysisRecord, arg2 ...db.Option) (int64, error) { +func (m *MockIExptInsightAnalysisRecordRepo) CreateAnalysisRecord(ctx context.Context, record *entity.ExptInsightAnalysisRecord, opts ...db.Option) (int64, error) { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, record} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "CreateAnalysisRecord", varargs...) @@ -1850,17 +1862,17 @@ func (m *MockIExptInsightAnalysisRecordRepo) CreateAnalysisRecord(arg0 context.C } // CreateAnalysisRecord indicates an expected call of CreateAnalysisRecord. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateAnalysisRecord(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateAnalysisRecord(ctx, record any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, record}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).CreateAnalysisRecord), varargs...) } // CreateFeedbackComment mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackComment(arg0 context.Context, arg1 *entity.ExptInsightAnalysisFeedbackComment, arg2 ...db.Option) error { +func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackComment(ctx context.Context, feedbackComment *entity.ExptInsightAnalysisFeedbackComment, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, feedbackComment} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "CreateFeedbackComment", varargs...) @@ -1869,17 +1881,17 @@ func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackComment(arg0 context. } // CreateFeedbackComment indicates an expected call of CreateFeedbackComment. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateFeedbackComment(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateFeedbackComment(ctx, feedbackComment any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, feedbackComment}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateFeedbackComment", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).CreateFeedbackComment), varargs...) } // CreateFeedbackVote mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackVote(arg0 context.Context, arg1 *entity.ExptInsightAnalysisFeedbackVote, arg2 ...db.Option) error { +func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackVote(ctx context.Context, feedbackVote *entity.ExptInsightAnalysisFeedbackVote, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, feedbackVote} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "CreateFeedbackVote", varargs...) @@ -1888,60 +1900,60 @@ func (m *MockIExptInsightAnalysisRecordRepo) CreateFeedbackVote(arg0 context.Con } // CreateFeedbackVote indicates an expected call of CreateFeedbackVote. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateFeedbackVote(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) CreateFeedbackVote(ctx, feedbackVote any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, feedbackVote}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateFeedbackVote", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).CreateFeedbackVote), varargs...) } // DeleteAnalysisRecord mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) DeleteAnalysisRecord(arg0 context.Context, arg1, arg2, arg3 int64) error { +func (m *MockIExptInsightAnalysisRecordRepo) DeleteAnalysisRecord(ctx context.Context, spaceID, exptID, recordID int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteAnalysisRecord", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "DeleteAnalysisRecord", ctx, spaceID, exptID, recordID) ret0, _ := ret[0].(error) return ret0 } // DeleteAnalysisRecord indicates an expected call of DeleteAnalysisRecord. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) DeleteAnalysisRecord(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) DeleteAnalysisRecord(ctx, spaceID, exptID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).DeleteAnalysisRecord), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).DeleteAnalysisRecord), ctx, spaceID, exptID, recordID) } // DeleteFeedbackComment mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) DeleteFeedbackComment(arg0 context.Context, arg1, arg2, arg3 int64) error { +func (m *MockIExptInsightAnalysisRecordRepo) DeleteFeedbackComment(ctx context.Context, spaceID, exptID, commentID int64) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteFeedbackComment", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "DeleteFeedbackComment", ctx, spaceID, exptID, commentID) ret0, _ := ret[0].(error) return ret0 } // DeleteFeedbackComment indicates an expected call of DeleteFeedbackComment. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) DeleteFeedbackComment(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) DeleteFeedbackComment(ctx, spaceID, exptID, commentID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFeedbackComment", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).DeleteFeedbackComment), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFeedbackComment", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).DeleteFeedbackComment), ctx, spaceID, exptID, commentID) } // GetAnalysisRecordByID mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) GetAnalysisRecordByID(arg0 context.Context, arg1, arg2, arg3 int64) (*entity.ExptInsightAnalysisRecord, error) { +func (m *MockIExptInsightAnalysisRecordRepo) GetAnalysisRecordByID(ctx context.Context, spaceID, exptID, recordID int64) (*entity.ExptInsightAnalysisRecord, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetAnalysisRecordByID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetAnalysisRecordByID", ctx, spaceID, exptID, recordID) ret0, _ := ret[0].(*entity.ExptInsightAnalysisRecord) ret1, _ := ret[1].(error) return ret0, ret1 } // GetAnalysisRecordByID indicates an expected call of GetAnalysisRecordByID. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetAnalysisRecordByID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetAnalysisRecordByID(ctx, spaceID, exptID, recordID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnalysisRecordByID", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).GetAnalysisRecordByID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAnalysisRecordByID", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).GetAnalysisRecordByID), ctx, spaceID, exptID, recordID) } // GetFeedbackCommentByRecordID mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackCommentByRecordID(arg0 context.Context, arg1, arg2, arg3 int64, arg4 ...db.Option) (*entity.ExptInsightAnalysisFeedbackComment, error) { +func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackCommentByRecordID(ctx context.Context, spaceID, exptID, recordID int64, opts ...db.Option) (*entity.ExptInsightAnalysisFeedbackComment, error) { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2, arg3} - for _, a := range arg4 { + varargs := []any{ctx, spaceID, exptID, recordID} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "GetFeedbackCommentByRecordID", varargs...) @@ -1951,17 +1963,17 @@ func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackCommentByRecordID(arg0 c } // GetFeedbackCommentByRecordID indicates an expected call of GetFeedbackCommentByRecordID. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetFeedbackCommentByRecordID(arg0, arg1, arg2, arg3 any, arg4 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetFeedbackCommentByRecordID(ctx, spaceID, exptID, recordID any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2, arg3}, arg4...) + varargs := append([]any{ctx, spaceID, exptID, recordID}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeedbackCommentByRecordID", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).GetFeedbackCommentByRecordID), varargs...) } // GetFeedbackVoteByUser mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackVoteByUser(arg0 context.Context, arg1, arg2, arg3 int64, arg4 string, arg5 ...db.Option) (*entity.ExptInsightAnalysisFeedbackVote, error) { +func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackVoteByUser(ctx context.Context, spaceID, exptID, recordID int64, userID string, opts ...db.Option) (*entity.ExptInsightAnalysisFeedbackVote, error) { m.ctrl.T.Helper() - varargs := []any{arg0, arg1, arg2, arg3, arg4} - for _, a := range arg5 { + varargs := []any{ctx, spaceID, exptID, recordID, userID} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "GetFeedbackVoteByUser", varargs...) @@ -1971,16 +1983,16 @@ func (m *MockIExptInsightAnalysisRecordRepo) GetFeedbackVoteByUser(arg0 context. } // GetFeedbackVoteByUser indicates an expected call of GetFeedbackVoteByUser. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetFeedbackVoteByUser(arg0, arg1, arg2, arg3, arg4 any, arg5 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) GetFeedbackVoteByUser(ctx, spaceID, exptID, recordID, userID any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1, arg2, arg3, arg4}, arg5...) + varargs := append([]any{ctx, spaceID, exptID, recordID, userID}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFeedbackVoteByUser", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).GetFeedbackVoteByUser), varargs...) } // List mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) List(arg0 context.Context, arg1, arg2, arg3 int64, arg4 entity.Page) ([]*entity.ExptInsightAnalysisFeedbackComment, int64, error) { +func (m *MockIExptInsightAnalysisRecordRepo) List(ctx context.Context, spaceID, exptID, recordID int64, page entity.Page) ([]*entity.ExptInsightAnalysisFeedbackComment, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "List", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "List", ctx, spaceID, exptID, recordID, page) ret0, _ := ret[0].([]*entity.ExptInsightAnalysisFeedbackComment) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -1988,15 +2000,15 @@ func (m *MockIExptInsightAnalysisRecordRepo) List(arg0 context.Context, arg1, ar } // List indicates an expected call of List. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) List(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) List(ctx, spaceID, exptID, recordID, page any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).List), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).List), ctx, spaceID, exptID, recordID, page) } // ListAnalysisRecord mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) ListAnalysisRecord(arg0 context.Context, arg1, arg2 int64, arg3 entity.Page) ([]*entity.ExptInsightAnalysisRecord, int64, error) { +func (m *MockIExptInsightAnalysisRecordRepo) ListAnalysisRecord(ctx context.Context, spaceID, exptID int64, page entity.Page) ([]*entity.ExptInsightAnalysisRecord, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAnalysisRecord", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "ListAnalysisRecord", ctx, spaceID, exptID, page) ret0, _ := ret[0].([]*entity.ExptInsightAnalysisRecord) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -2004,16 +2016,16 @@ func (m *MockIExptInsightAnalysisRecordRepo) ListAnalysisRecord(arg0 context.Con } // ListAnalysisRecord indicates an expected call of ListAnalysisRecord. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) ListAnalysisRecord(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) ListAnalysisRecord(ctx, spaceID, exptID, page any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).ListAnalysisRecord), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).ListAnalysisRecord), ctx, spaceID, exptID, page) } // UpdateAnalysisRecord mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) UpdateAnalysisRecord(arg0 context.Context, arg1 *entity.ExptInsightAnalysisRecord, arg2 ...db.Option) error { +func (m *MockIExptInsightAnalysisRecordRepo) UpdateAnalysisRecord(ctx context.Context, record *entity.ExptInsightAnalysisRecord, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, record} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "UpdateAnalysisRecord", varargs...) @@ -2022,17 +2034,17 @@ func (m *MockIExptInsightAnalysisRecordRepo) UpdateAnalysisRecord(arg0 context.C } // UpdateAnalysisRecord indicates an expected call of UpdateAnalysisRecord. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateAnalysisRecord(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateAnalysisRecord(ctx, record any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, record}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAnalysisRecord", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).UpdateAnalysisRecord), varargs...) } // UpdateFeedbackComment mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackComment(arg0 context.Context, arg1 *entity.ExptInsightAnalysisFeedbackComment, arg2 ...db.Option) error { +func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackComment(ctx context.Context, feedbackComment *entity.ExptInsightAnalysisFeedbackComment, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, feedbackComment} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "UpdateFeedbackComment", varargs...) @@ -2041,17 +2053,17 @@ func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackComment(arg0 context. } // UpdateFeedbackComment indicates an expected call of UpdateFeedbackComment. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateFeedbackComment(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateFeedbackComment(ctx, feedbackComment any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, feedbackComment}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFeedbackComment", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).UpdateFeedbackComment), varargs...) } // UpdateFeedbackVote mocks base method. -func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackVote(arg0 context.Context, arg1 *entity.ExptInsightAnalysisFeedbackVote, arg2 ...db.Option) error { +func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackVote(ctx context.Context, feedbackVote *entity.ExptInsightAnalysisFeedbackVote, opts ...db.Option) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, feedbackVote} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "UpdateFeedbackVote", varargs...) @@ -2060,9 +2072,9 @@ func (m *MockIExptInsightAnalysisRecordRepo) UpdateFeedbackVote(arg0 context.Con } // UpdateFeedbackVote indicates an expected call of UpdateFeedbackVote. -func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateFeedbackVote(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateFeedbackVote(ctx, feedbackVote any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, feedbackVote}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateFeedbackVote", reflect.TypeOf((*MockIExptInsightAnalysisRecordRepo)(nil).UpdateFeedbackVote), varargs...) } @@ -2070,6 +2082,7 @@ func (mr *MockIExptInsightAnalysisRecordRepoMockRecorder) UpdateFeedbackVote(arg type MockIExptItemRefRepo struct { ctrl *gomock.Controller recorder *MockIExptItemRefRepoMockRecorder + isgomock struct{} } // MockIExptItemRefRepoMockRecorder is the mock recorder for MockIExptItemRefRepo. @@ -2090,53 +2103,53 @@ func (m *MockIExptItemRefRepo) EXPECT() *MockIExptItemRefRepoMockRecorder { } // BatchCreate mocks base method. -func (m *MockIExptItemRefRepo) BatchCreate(arg0 context.Context, arg1 []*entity.ExptItemRef) error { +func (m *MockIExptItemRefRepo) BatchCreate(ctx context.Context, items []*entity.ExptItemRef) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BatchCreate", arg0, arg1) + ret := m.ctrl.Call(m, "BatchCreate", ctx, items) ret0, _ := ret[0].(error) return ret0 } // BatchCreate indicates an expected call of BatchCreate. -func (mr *MockIExptItemRefRepoMockRecorder) BatchCreate(arg0, arg1 any) *gomock.Call { +func (mr *MockIExptItemRefRepoMockRecorder) BatchCreate(ctx, items any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreate", reflect.TypeOf((*MockIExptItemRefRepo)(nil).BatchCreate), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchCreate", reflect.TypeOf((*MockIExptItemRefRepo)(nil).BatchCreate), ctx, items) } // CountByEvalSetGrouped mocks base method. -func (m *MockIExptItemRefRepo) CountByEvalSetGrouped(arg0 context.Context, arg1 int64, arg2 []int64) (map[int64][]*entity.ExptEvalSetItemCount, error) { +func (m *MockIExptItemRefRepo) CountByEvalSetGrouped(ctx context.Context, spaceID int64, exptIDs []int64) (map[int64][]*entity.ExptEvalSetItemCount, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CountByEvalSetGrouped", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "CountByEvalSetGrouped", ctx, spaceID, exptIDs) ret0, _ := ret[0].(map[int64][]*entity.ExptEvalSetItemCount) ret1, _ := ret[1].(error) return ret0, ret1 } // CountByEvalSetGrouped indicates an expected call of CountByEvalSetGrouped. -func (mr *MockIExptItemRefRepoMockRecorder) CountByEvalSetGrouped(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockIExptItemRefRepoMockRecorder) CountByEvalSetGrouped(ctx, spaceID, exptIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountByEvalSetGrouped", reflect.TypeOf((*MockIExptItemRefRepo)(nil).CountByEvalSetGrouped), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountByEvalSetGrouped", reflect.TypeOf((*MockIExptItemRefRepo)(nil).CountByEvalSetGrouped), ctx, spaceID, exptIDs) } // GetByExptIDAndItemID mocks base method. -func (m *MockIExptItemRefRepo) GetByExptIDAndItemID(arg0 context.Context, arg1, arg2, arg3 int64) (*entity.ExptItemRef, error) { +func (m *MockIExptItemRefRepo) GetByExptIDAndItemID(ctx context.Context, spaceID, exptID, itemID int64) (*entity.ExptItemRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetByExptIDAndItemID", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "GetByExptIDAndItemID", ctx, spaceID, exptID, itemID) ret0, _ := ret[0].(*entity.ExptItemRef) ret1, _ := ret[1].(error) return ret0, ret1 } // GetByExptIDAndItemID indicates an expected call of GetByExptIDAndItemID. -func (mr *MockIExptItemRefRepoMockRecorder) GetByExptIDAndItemID(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemRefRepoMockRecorder) GetByExptIDAndItemID(ctx, spaceID, exptID, itemID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByExptIDAndItemID", reflect.TypeOf((*MockIExptItemRefRepo)(nil).GetByExptIDAndItemID), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByExptIDAndItemID", reflect.TypeOf((*MockIExptItemRefRepo)(nil).GetByExptIDAndItemID), ctx, spaceID, exptID, itemID) } // ListByExptID mocks base method. -func (m *MockIExptItemRefRepo) ListByExptID(arg0 context.Context, arg1, arg2, arg3, arg4 int64) ([]*entity.ExptItemRef, int64, error) { +func (m *MockIExptItemRefRepo) ListByExptID(ctx context.Context, spaceID, exptID, cursor, limit int64) ([]*entity.ExptItemRef, int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListByExptID", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "ListByExptID", ctx, spaceID, exptID, cursor, limit) ret0, _ := ret[0].([]*entity.ExptItemRef) ret1, _ := ret[1].(int64) ret2, _ := ret[2].(error) @@ -2144,22 +2157,22 @@ func (m *MockIExptItemRefRepo) ListByExptID(arg0 context.Context, arg1, arg2, ar } // ListByExptID indicates an expected call of ListByExptID. -func (mr *MockIExptItemRefRepoMockRecorder) ListByExptID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockIExptItemRefRepoMockRecorder) ListByExptID(ctx, spaceID, exptID, cursor, limit any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByExptID", reflect.TypeOf((*MockIExptItemRefRepo)(nil).ListByExptID), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListByExptID", reflect.TypeOf((*MockIExptItemRefRepo)(nil).ListByExptID), ctx, spaceID, exptID, cursor, limit) } // MGetByExptIDAndItemIDs mocks base method. -func (m *MockIExptItemRefRepo) MGetByExptIDAndItemIDs(arg0 context.Context, arg1, arg2 int64, arg3 []int64) ([]*entity.ExptItemRef, error) { +func (m *MockIExptItemRefRepo) MGetByExptIDAndItemIDs(ctx context.Context, spaceID, exptID int64, itemIDs []int64) ([]*entity.ExptItemRef, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MGetByExptIDAndItemIDs", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MGetByExptIDAndItemIDs", ctx, spaceID, exptID, itemIDs) ret0, _ := ret[0].([]*entity.ExptItemRef) ret1, _ := ret[1].(error) return ret0, ret1 } // MGetByExptIDAndItemIDs indicates an expected call of MGetByExptIDAndItemIDs. -func (mr *MockIExptItemRefRepoMockRecorder) MGetByExptIDAndItemIDs(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockIExptItemRefRepoMockRecorder) MGetByExptIDAndItemIDs(ctx, spaceID, exptID, itemIDs any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetByExptIDAndItemIDs", reflect.TypeOf((*MockIExptItemRefRepo)(nil).MGetByExptIDAndItemIDs), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MGetByExptIDAndItemIDs", reflect.TypeOf((*MockIExptItemRefRepo)(nil).MGetByExptIDAndItemIDs), ctx, spaceID, exptID, itemIDs) } diff --git a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go index 2e3725de43..5e09861ccf 100644 --- a/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go +++ b/backend/modules/evaluation/domain/service/expt_central_quota_release_test.go @@ -545,6 +545,9 @@ func TestHandleCentralReservation_AdvancesMainTableForDisplay(t *testing.T) { var gotFields map[string]any var gotItemIDs []int64 itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + // 推进前会先读主表当前状态:判据是"真的发生了状态迁移",见 expt_stats_consistency_test.go。 + itemResultRepo.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Queueing}}, nil) itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _, _ int64, itemIDs []int64, ufields map[string]any) error { gotItemIDs = itemIDs @@ -600,6 +603,8 @@ func TestHandleCentralReservation_AdvancesStatsToProcessing(t *testing.T) { }, nil) itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Queueing}}, nil) itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil).AnyTimes() @@ -636,13 +641,15 @@ func TestHandleCentralReservation_AdvancesStatsToProcessing(t *testing.T) { "Queueing 必须 -1,否则 pending_turn_count 永不下降") } -// TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery 重复投递时**不得**再记 stats。 +// TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery 主表已是 Processing 时**不得**再记 stats。 // -// started=false 表示 CAS 未命中 —— item 早已是 Processing(重复投递)或已被 repair 修正。 -// 此时再加一次就从"少计"变成"多计",方向相反但同样是错的。 -// CAS 结果是这条路径上唯一的"恰好一次"信号,所以记账必须绑定它。 +// ⚠️ **本用例的判据换过一次**:原先绑的是 run log 的 CAS(started=false 就跳过),理由是 +// "CAS 未命中说明 item 早已计入 Processing"。那个假设在**首投就未命中**时不成立 —— +// 于是那条 item 从来没被计过,Queueing 永不减。PPE 实测一个 900 题 enforce 实验 +// pending 虚高 281、success 少 249,同泳道 legacy 分毫不差。 // -// ★ 这条是上一个用例的反面:只断言"会记",把记账写在 started 判断之外也能通过。 +// 现在判据是**主表实际发生了状态迁移**:主表既是完成侧做「-1」的锚点,也就该是记账的判据, +// 两边同源才不可能分叉。"重复投递不得多记"这个原意保留,只是换了更可靠的信号。 func TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -658,8 +665,12 @@ func TestHandleCentralReservation_SkipsStatsOnDuplicateDelivery(t *testing.T) { }, nil) itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + // 主表已是 Processing —— 这才是"已经计过账"的可靠证据。 + itemResultRepo.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Processing}}, nil) + // 已是 Processing 就连主表都不该重写。 itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil).AnyTimes() + Return(nil).Times(0) statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) // Times(0):一次都不能调 —— 这是本用例的全部意义 diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 2a55ae377d..0df26d698c 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -333,11 +333,26 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE // // 失败只告警不阻断:主表是展示投影,写不进去不影响执行与额度正确性, // 而这里返回错误会让已经拿到执行权的 item 被 MQ 重投一遍。 + // + // ★ 记账与主表推进必须**同一个判据**,见下方 expt_stats 那段的论证。 + // 所以这里先读主表当前状态,只有真的发生「非 Processing → Processing」才动两处。 + prevState, prevKnown := entity.ItemRunState_Unknown, false if e.exptItemResultRepo != nil { + if got, gerr := e.exptItemResultRepo.BatchGet(ctx, event.SpaceID, event.ExptID, []int64{event.EvalSetItemID}); gerr != nil { + logs.CtxWarn(ctx, "[CentralReservation] read main table state failed, skip advancing (display only), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, gerr) + } else if len(got) > 0 && got[0] != nil { + prevState, prevKnown = got[0].Status, true + } + } + + advanced := prevKnown && prevState != entity.ItemRunState_Processing + if advanced { if err := e.exptItemResultRepo.UpdateItemsResult(ctx, event.SpaceID, event.ExptID, []int64{event.EvalSetItemID}, map[string]any{"status": int32(entity.ItemRunState_Processing)}); err != nil { logs.CtxWarn(ctx, "[CentralReservation] advance main table to Processing failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", event.ExptID, event.EvalSetItemID, err) + advanced = false } } @@ -350,16 +365,26 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE // 表现为 processing_turn_count 走负、pending_turn_count 永不下降。 // 实测:一个 14 题 enforce 实验 fail 累到 4 时 processing = -4,pending 恒 14。 // - // ★ 必须绑定 started(CAS 真的把 Queueing/reserved 翻成了 Processing/none)。 - // started=false 是重复投递或已被 repair 修正,此时 item 早已计入 Processing, - // 再加一次就从"少计"变成"多计"——CAS 是这里唯一的恰好一次信号。 + // ★ 判据是**主表真的发生了状态迁移**,不是 run log 的 CAS。 + // + // 原先绑在 started(run log CAS)上,而主表推进是无条件的 —— 两个判据一分叉, + // 计数行就与主表对不上。而完成侧 statsCntOp 恰恰是**按主表状态**做「-1」的 + // (`statsCntOp[itemResult.Status] -= 1`,见 expt_result_impl.go), + // 于是它会去减一个计数行里从来没加过的桶。 + // 实测代价:PPE 一个 900 题 enforce 实验,pending 虚高 281、success 少 249, + // 而同泳道的 legacy 实验分毫不差 —— legacy 的派发是四张表无条件一起写的。 + // + // 用 prevState 而不是写死 Queueing:中心调度派发时主表可能停在 Queueing, + // 也可能因为 repair/重投停在别的状态,减错桶同样会让计数行歪掉。 // - // 与主表同为展示投影,失败只告警不阻断:返回错误会让已取得执行权的 item 被 MQ 重投。 - if started && e.exptStatsRepo != nil { + // 与主表同为展示投影,失败只告警不阻断:返回错误会让已取得执行权的 item 被 MQ 重投, + // 而重投一次 Agent 执行的代价远大于一次计数偏差。**残留的偏差由调度器每拍的对账收敛** + // (ExptSchedulerImpl.reconcileExptStats)。 + if advanced && e.exptStatsRepo != nil { if err := e.exptStatsRepo.ArithOperateCount(ctx, event.ExptID, event.SpaceID, &entity.StatsCntArithOp{ OpStatusCnt: map[entity.ItemRunState]int{ entity.ItemRunState_Processing: 1, - entity.ItemRunState_Queueing: -1, + prevState: -1, }, }); err != nil { logs.CtxWarn(ctx, "[CentralReservation] advance expt stats to Processing failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go index 9822f53bd3..56562b05e8 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go @@ -429,6 +429,10 @@ func (e *ExptSchedulerImpl) schedule(ctx context.Context, event *entity.ExptSche return err } + // 每拍对账一次 expt_stats 计数行。放在归档之后:那一步刚把本拍完成的 item 记完账, + // 此刻主表与计数行"本该"一致,不一致就是真漂了。 + e.reconcileExptStats(ctx, event, exptDetail) + // ★ 中心化调度防双驱动:enforce 实验的新 item 派发权归中心调度器独有。 // // 旧 per-experiment tick 在此**丢弃 toSubmit**,但保留其余全部职责 —— @@ -1224,3 +1228,70 @@ func (e *ExptSchedulerImpl) releaseCentralQuotaForItems(ctx context.Context, exp logs.CtxInfo(ctx, "[CentralReservation] quota released for daemon-terminated items, scope: %v, expt_run_id: %v, count: %v, reason: %v", expt.SchedulerScope, exptRunID, len(itemIDs), reason) } + +// reconcileExptStats 把 expt_stats 计数行对齐到主表的真实状态分布。 +// +// 为什么需要它:expt_stats 是**增量计数**(ArithOperateCount 逐笔 ±1),而落终态的路径有多条 +// (正常完成 / zombie / 沙箱提前终态 sweep / 派发侧推进),每条都得自己"记得"记一笔账。 +// 漏一笔就永久偏一笔 —— 那些调用点全是 warn-only 或干脆没有记账,**没有任何机制会发现**。 +// 运行期此前也没有任何重算:CalculateStats 只在 CompleteExpt(终态收口)与在线实验 daemon 里调, +// 于是偏差会一路显示给用户,还会经 webhook 的 progress 播报给下游。 +// 实测:PPE 一个 900 题 enforce 实验 pending 虚高 281、success 少 249,且**仍在增长**。 +// +// 以主表为准而不是 turn 表:完成侧 statsCntOp 是按 items_result.Status 做「-1」的 +// (见 expt_result_impl.go),要能修正它就必须以同一张表为准;拿 turn 表对账会在多轮实验上 +// 得出另一套数字。CalculateStats 走的是 turn 表,所以这里不复用它。 +// +// 代价是一条 GROUP BY(走 (space_id, expt_id) 前缀),每实验每拍一次,可接受。 +// +// 失败只告警:对账是自愈机制,它自己不能成为调度中断的理由。 +func (e *ExptSchedulerImpl) reconcileExptStats(ctx context.Context, event *entity.ExptScheduleEvent, expt *entity.Experiment) { + if e.ExptItemResultRepo == nil || e.ExptStatsRepo == nil { + return + } + + actual, err := e.ExptItemResultRepo.CountItemsByStatus(ctx, event.SpaceID, event.ExptID) + if err != nil { + logs.CtxWarn(ctx, "[ExptStatsReconcile] count items by status failed, skip this tick, expt_id: %v: %v", event.ExptID, err) + return + } + if len(actual) == 0 { + // 一行 item 都没有:可能是刚建、还没落 item。此时覆盖会把 finishExptStart 写下的 + // PendingItemCnt 抹成 0,让实验看起来"没有任何待跑"。 + return + } + + got, err := e.ExptStatsRepo.Get(ctx, event.ExptID, event.SpaceID) + if err != nil { + logs.CtxWarn(ctx, "[ExptStatsReconcile] get expt stats failed, skip this tick, expt_id: %v: %v", event.ExptID, err) + return + } + if got == nil { + return + } + + want := &entity.ExptStats{ + PendingItemCnt: int32(actual[entity.ItemRunState_Queueing]), + SuccessItemCnt: int32(actual[entity.ItemRunState_Success]), + FailItemCnt: int32(actual[entity.ItemRunState_Fail]), + ProcessingItemCnt: int32(actual[entity.ItemRunState_Processing]), + TerminatedItemCnt: int32(actual[entity.ItemRunState_Terminal]), + } + if want.PendingItemCnt == got.PendingItemCnt && want.SuccessItemCnt == got.SuccessItemCnt && + want.FailItemCnt == got.FailItemCnt && want.ProcessingItemCnt == got.ProcessingItemCnt && + want.TerminatedItemCnt == got.TerminatedItemCnt { + return + } + + // 一致时直接返回、不写库;只有真的漂了才写 —— 顺带让这条 Warn 成为"还在漏"的唯一信号。 + // 谁把这条日志删了,漏点就重新变成静默的。 + logs.CtxWarn(ctx, "[ExptStatsReconcile] expt stats drifted from item table, correcting; expt_id: %v, "+ + "stats(pending/success/fail/processing/terminated)=%v/%v/%v/%v/%v, actual=%v/%v/%v/%v/%v", + event.ExptID, + got.PendingItemCnt, got.SuccessItemCnt, got.FailItemCnt, got.ProcessingItemCnt, got.TerminatedItemCnt, + want.PendingItemCnt, want.SuccessItemCnt, want.FailItemCnt, want.ProcessingItemCnt, want.TerminatedItemCnt) + + if err := e.ExptStatsRepo.UpdateByExptID(ctx, event.ExptID, event.SpaceID, want); err != nil { + logs.CtxWarn(ctx, "[ExptStatsReconcile] correct expt stats failed, expt_id: %v: %v", event.ExptID, err) + } +} diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go index b60f3d4610..1482d9efb0 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl_test.go @@ -235,6 +235,11 @@ func TestExptSchedulerImpl_Schedule(t *testing.T) { tt.prepareMock(f, ctrl, tt.args) // Modification point: pass ctrl } + // 每拍会做一次 expt_stats 对账(reconcileExptStats)。它与本组用例要断言的调度流程 + // 无关,且读不到真值时自身就会跳过 —— 这里直接让它读到空分布并静默返回。 + f.exptItemResultRepo.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + svc := &ExptSchedulerImpl{ Manager: f.manager, ExptRepo: f.exptRepo, diff --git a/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go b/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go new file mode 100644 index 0000000000..2772d6b4c2 --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go @@ -0,0 +1,246 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + "github.com/coze-dev/coze-loop/backend/pkg/ctxcache" +) + +// 本文件守一条不变量:**expt_stats 计数行的桶分布必须等于主表 expt_item_result 的状态分布**。 +// +// 它此前被破坏过一次,代价很大:中心调度的派发把 stats 记账绑在 run log 的 CAS 上、 +// 而主表推进是无条件的,两个判据一分叉,完成侧(按主表状态做「-1」)就去减一个计数行里 +// 从没加过的桶。PPE 实测一个 900 题 enforce 实验 pending 虚高 281、success 少 249, +// 而同泳道的 legacy 实验分毫不差 —— legacy 的派发是四张表无条件一起写的。 +// +// 两层防护各有用例: +// - 派发侧:记账与主表推进同一个判据(本文件前半) +// - 调度器每拍对账:把残留偏差收敛掉(本文件后半) + +func centralAdmittedCtx(t *testing.T) context.Context { + t.Helper() + ctx := ctxcache.Init(context.Background()) + (&entity.ExptItemEvalEvent{}).WithCtxCentralAdmittedExpt(ctx, &entity.Experiment{ + ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce, SchedulerScope: testScope, + }) + return ctx +} + +// TestCentralReservation_StatsFollowsMainTableNotCAS 核心回归: +// run log CAS 未命中(started=false),但主表还停在 Queueing 时,**stats 记账仍然必须发生**。 +// +// 旧代码在这里直接跳过,于是那条 item 的 Queueing 永远不减 —— 这正是线上 pending 虚高的来源。 +func TestCentralReservation_StatsFollowsMainTableNotCAS(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatch := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + + // CAS 未命中:重复投递或已被 repair 修正。 + dispatch.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(false, nil) + // 执行链返回后的释放路径会查一次投影,与本用例无关。 + dispatch.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + // 但主表仍是 Queueing —— 说明这条 item 从来没被记进 Processing 桶。 + items.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Queueing}}, nil) + items.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + var gotOp *entity.StatsCntArithOp + stats.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, op *entity.StatsCntArithOp) error { + gotOp = op + return nil + }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: true}, + dispatchRepo: dispatch, + exptItemResultRepo: items, + exptStatsRepo: stats, + } + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) + + require.NotNil(t, gotOp, "CAS 未命中但主表仍在 Queueing 时必须记账 —— 跳过就是线上 pending 虚高的成因") + assert.Equal(t, 1, gotOp.OpStatusCnt[entity.ItemRunState_Processing]) + assert.Equal(t, -1, gotOp.OpStatusCnt[entity.ItemRunState_Queueing]) +} + +// TestCentralReservation_NoStatsOpWhenMainTableAlreadyProcessing 主表已是 Processing 时 +// 不得重复记账 —— 那会从"少计"变成"多计",同样让计数行歪掉。 +func TestCentralReservation_NoStatsOpWhenMainTableAlreadyProcessing(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatch := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + + dispatch.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil) // CAS 命中,但主表已经是 Processing + dispatch.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + items.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Processing}}, nil) + // 主表不该被重写,stats 不该被记账 + items.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: true}, dispatchRepo: dispatch, + exptItemResultRepo: items, exptStatsRepo: stats, + } + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} + +// TestCentralReservation_StatsDecrementsActualBucket 主表停在非 Queueing 的状态(repair / +// 重投留下的)时,减的必须是**那个**桶,不能写死 Queueing —— 减错桶与不减一样会让计数行歪。 +func TestCentralReservation_StatsDecrementsActualBucket(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatch := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + + dispatch.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + dispatch.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + items.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Fail}}, nil) + items.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + var gotOp *entity.StatsCntArithOp + stats.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, op *entity.StatsCntArithOp) error { gotOp = op; return nil }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: true}, dispatchRepo: dispatch, + exptItemResultRepo: items, exptStatsRepo: stats, + } + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) + + require.NotNil(t, gotOp) + assert.Equal(t, -1, gotOp.OpStatusCnt[entity.ItemRunState_Fail], "减的必须是主表实际所在的桶") + assert.Equal(t, 0, gotOp.OpStatusCnt[entity.ItemRunState_Queueing], "不得写死 Queueing") +} + +// TestCentralReservation_NoStatsOpWhenMainTableWriteFails 主表没写进去就不该记账 —— +// 否则计数行跑到主表前面,完成侧的「-1」又会减错。 +func TestCentralReservation_NoStatsOpWhenMainTableWriteFails(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatch := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + + dispatch.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + dispatch.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + items.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: entity.ItemRunState_Queueing}}, nil) + items.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("db down")) + stats.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: true}, dispatchRepo: dispatch, + exptItemResultRepo: items, exptStatsRepo: stats, + } + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + // 主表是展示投影,写失败不阻断执行 —— 但也不能记账。 + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} + +// ---------- 每拍对账 ---------- + +func reconcileFixture(t *testing.T) (*ExptSchedulerImpl, *repoMocks.MockIExptItemResultRepo, *repoMocks.MockIExptStatsRepo) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + return &ExptSchedulerImpl{ExptItemResultRepo: items, ExptStatsRepo: stats}, items, stats +} + +func TestReconcileExptStats_CorrectsDrift(t *testing.T) { + e, items, stats := reconcileFixture(t) + + // 线上实测过的形态:主表 597 success / 253 queueing,计数行却停在 352 / 530。 + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{ + entity.ItemRunState_Queueing: 253, entity.ItemRunState_Processing: 11, + entity.ItemRunState_Success: 597, entity.ItemRunState_Fail: 39, + }, nil) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&entity.ExptStats{ + PendingItemCnt: 530, ProcessingItemCnt: 7, SuccessItemCnt: 352, FailItemCnt: 11, + }, nil) + + var got *entity.ExptStats + stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, s *entity.ExptStats) error { got = s; return nil }) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) + + require.NotNil(t, got, "漂了就必须写回") + assert.Equal(t, int32(253), got.PendingItemCnt) + assert.Equal(t, int32(597), got.SuccessItemCnt) + assert.Equal(t, int32(39), got.FailItemCnt) + assert.Equal(t, int32(11), got.ProcessingItemCnt) +} + +func TestReconcileExptStats_NoWriteWhenConsistent(t *testing.T) { + e, items, stats := reconcileFixture(t) + + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{ + entity.ItemRunState_Queueing: 3, entity.ItemRunState_Processing: 10, entity.ItemRunState_Success: 17, + }, nil) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Return(&entity.ExptStats{ + PendingItemCnt: 3, ProcessingItemCnt: 10, SuccessItemCnt: 17, + }, nil) + // 一致就不能写:每实验每拍一次写库是纯浪费,而且会把 updated_at 刷成"刚改过", + // 让"这行到底动没动"这个排查线索失效。 + stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) +} + +func TestReconcileExptStats_SkipsWhenNoItemRows(t *testing.T) { + e, items, stats := reconcileFixture(t) + + // 实验刚建、item 还没落库。此时覆盖会把 finishExptStart 写下的 PendingItemCnt 抹成 0, + // 详情页看起来"一条待跑都没有"。 + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{}, nil) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) +} + +func TestReconcileExptStats_SkipsOnCountError(t *testing.T) { + e, items, stats := reconcileFixture(t) + + // 对账是自愈机制,读不到真值时**什么都不做**,绝不能拿一个不完整的分布去覆盖。 + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("db down")) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) +} diff --git a/backend/modules/evaluation/infra/repo/experiment/expt_item_result.go b/backend/modules/evaluation/infra/repo/experiment/expt_item_result.go index cb1bbffe01..3f1c723f7f 100644 --- a/backend/modules/evaluation/infra/repo/experiment/expt_item_result.go +++ b/backend/modules/evaluation/infra/repo/experiment/expt_item_result.go @@ -39,6 +39,18 @@ func (e ExptItemResultRepoImpl) BatchGet(ctx context.Context, spaceID, exptID in return results, nil } +func (e ExptItemResultRepoImpl) CountItemsByStatus(ctx context.Context, spaceID, exptID int64) (map[entity.ItemRunState]int64, error) { + got, err := e.exptItemResultDAO.CountItemsByStatus(ctx, spaceID, exptID) + if err != nil { + return nil, err + } + out := make(map[entity.ItemRunState]int64, len(got)) + for status, cnt := range got { + out[entity.ItemRunState(status)] = cnt + } + return out, nil +} + func (e ExptItemResultRepoImpl) UpdateItemsResult(ctx context.Context, spaceID, exptID int64, itemIDs []int64, ufields map[string]any) error { err := e.exptItemResultDAO.UpdateItemsResult(ctx, spaceID, exptID, itemIDs, ufields) if err != nil { diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_result.go b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_result.go index 3b0c22b446..1c8fd76fcc 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_result.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/expt_item_result.go @@ -36,6 +36,9 @@ type IExptItemResultDAO interface { GetItemTurnResults(ctx context.Context, spaceID, exptID, itemID int64, opts ...db.Option) ([]*model.ExptTurnResult, error) MGetItemTurnResults(ctx context.Context, spaceID, exptID int64, itemIDs []int64, opts ...db.Option) ([]*model.ExptTurnResult, error) UpdateItemsResult(ctx context.Context, spaceID, exptID int64, itemID []int64, ufields map[string]any, opts ...db.Option) error + // CountItemsByStatus 按 status 聚合该实验的 item 行数,返回 status -> 行数。 + // 供 expt_stats 计数行的对账使用:主表是完成侧记账的锚点,所以要对账就得以它为准。 + CountItemsByStatus(ctx context.Context, spaceID, exptID int64, opts ...db.Option) (map[int32]int64, error) GetMaxItemIdxByExptID(ctx context.Context, exptID, spaceID int64, opts ...db.Option) (int32, error) BatchCreateNXRunLogs(ctx context.Context, itemRunLogs []*model.ExptItemResultRunLog, opts ...db.Option) error @@ -82,6 +85,36 @@ func (dao *exptItemResultDAOImpl) UpdateItemsResult(ctx context.Context, spaceID return nil } +// CountItemsByStatus 一条 GROUP BY 拿到该实验 item 的状态分布。 +// +// 走 (space_id, expt_id) 前缀,不带 item_id,所以是一次索引扫描而非逐行取。 +// 刻意只查主表不查 turn 表:完成侧的 statsCntOp 是按 items_result.Status 做「-1」的 +// (见 expt_result_impl.go),对账要能修正它就必须以同一张表为准 —— +// 拿 turn 表对账会在多轮实验上得出另一套数字。 +func (dao *exptItemResultDAOImpl) CountItemsByStatus(ctx context.Context, spaceID, exptID int64, opts ...db.Option) (map[int32]int64, error) { + type row struct { + Status int32 `gorm:"column:status"` + Cnt int64 `gorm:"column:cnt"` + } + var rows []*row + session := dao.provider.NewSession(ctx, opts...) + if err := session.Model(&model.ExptItemResult{}). + Select("status, count(*) as cnt"). + Where("space_id = ? AND expt_id = ?", spaceID, exptID). + Group("status"). + Find(&rows).Error; err != nil { + return nil, errorx.Wrapf(err, "CountItemsByStatus fail, expt_id: %v", exptID) + } + out := make(map[int32]int64, len(rows)) + for _, r := range rows { + if r == nil { + continue + } + out[r.Status] = r.Cnt + } + return out, nil +} + func (dao *exptItemResultDAOImpl) GetItemTurnResults(ctx context.Context, spaceID, exptID, itemID int64, opts ...db.Option) ([]*model.ExptTurnResult, error) { db := dao.provider.NewSession(ctx, opts...) q := query.Use(db).ExptTurnResult diff --git a/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_result.go b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_result.go index 0d915174c9..ae76b6fce8 100644 --- a/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_result.go +++ b/backend/modules/evaluation/infra/repo/experiment/mysql/mocks/expt_item_result.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -destination=mocks/expt_item_result.go -package mocks . IExptItemResultDAO +// mockgen -destination=mocks/expt_item_result.go -package=mocks github.com/coze-dev/coze-loop/backend/modules/evaluation/infra/repo/experiment/mysql IExptItemResultDAO // // Package mocks is a generated GoMock package. @@ -101,6 +101,26 @@ func (mr *MockIExptItemResultDAOMockRecorder) BatchGet(ctx, spaceID, exptID, ite return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BatchGet", reflect.TypeOf((*MockIExptItemResultDAO)(nil).BatchGet), varargs...) } +// CountItemsByStatus mocks base method. +func (m *MockIExptItemResultDAO) CountItemsByStatus(ctx context.Context, spaceID, exptID int64, opts ...db.Option) (map[int32]int64, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, spaceID, exptID} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CountItemsByStatus", varargs...) + ret0, _ := ret[0].(map[int32]int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CountItemsByStatus indicates an expected call of CountItemsByStatus. +func (mr *MockIExptItemResultDAOMockRecorder) CountItemsByStatus(ctx, spaceID, exptID any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, spaceID, exptID}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountItemsByStatus", reflect.TypeOf((*MockIExptItemResultDAO)(nil).CountItemsByStatus), varargs...) +} + // GetItemIDListByExptID mocks base method. func (m *MockIExptItemResultDAO) GetItemIDListByExptID(ctx context.Context, exptID, spaceID int64) ([]int64, error) { m.ctrl.T.Helper() From 6ef53948633a46cc548cf3e11f03b2dcc0f0833d Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 1 Sep 2026 16:27:56 +0800 Subject: [PATCH 55/58] =?UTF-8?q?perf(evaluation):=20expt=5Fstats=20?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E6=94=B6=E7=AA=84=E5=88=B0=20enforce=20?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=EF=BC=8C=E5=B9=B6=E6=8C=89=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=205=20=E5=88=86=E9=92=9F=E8=8A=82=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版是「每实验每拍一条 GROUP BY」,一个空间几百个在跑的实验就是每分钟几百条 count。 两道闸把成本降下来,判据都不是拍脑袋: - 只对 enforce 实验。legacy 的派发把 run log、主表、turn 表、stats 四张无条件一起写, 失败 return err 重投整批 —— 计数行天然镜像主表。2026-09-01 PPE 实测两个正在跑的 legacy 实验(30 题 / 3 题)分毫不差,而同泳道 enforce 差 249/281。给 legacy 对账是纯开销。 - 同一实验 5 分钟内只对一次(走 idem SetNX)。偏差只在有人正看时才有意义,且实验进终态 时 CompleteExpt 一定会全量重算兜底 —— 对账只需要"最终收敛",不需要"实时精确"。 SetNX 出错时跳过而不是继续:Redis 抖动本就该少做事,"出错就对账"会让 Redis 一挂 就退化成每拍全量 count,正好是最不该加压的时候。 --- .../service/expt_run_scheduler_event_impl.go | 37 +++++++++- .../service/expt_stats_consistency_test.go | 68 ++++++++++++++++--- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go index 56562b05e8..aa688c4ea8 100644 --- a/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_scheduler_event_impl.go @@ -429,7 +429,7 @@ func (e *ExptSchedulerImpl) schedule(ctx context.Context, event *entity.ExptSche return err } - // 每拍对账一次 expt_stats 计数行。放在归档之后:那一步刚把本拍完成的 item 记完账, + // 对账 expt_stats 计数行。放在归档之后:那一步刚把本拍完成的 item 记完账, // 此刻主表与计数行"本该"一致,不一致就是真漂了。 e.reconcileExptStats(ctx, event, exptDetail) @@ -1229,6 +1229,13 @@ func (e *ExptSchedulerImpl) releaseCentralQuotaForItems(ctx context.Context, exp expt.SchedulerScope, exptRunID, len(itemIDs), reason) } +// exptStatsReconcileInterval 单个实验两次对账之间的最小间隔。 +// +// 取 5 分钟而不是每拍(60s):偏差只在"有人正看着"时才有意义,而实验进终态时 +// CompleteExpt 一定会 CalculateStats 全量重算兜底,所以对账只需要"最终会收敛"、 +// 不需要"实时精确"。拍是 60s,这一步把成本降到 1/5,再叠加下面的 enforce-only 过滤。 +const exptStatsReconcileInterval = 5 * time.Minute + // reconcileExptStats 把 expt_stats 计数行对齐到主表的真实状态分布。 // // 为什么需要它:expt_stats 是**增量计数**(ArithOperateCount 逐笔 ±1),而落终态的路径有多条 @@ -1246,10 +1253,36 @@ func (e *ExptSchedulerImpl) releaseCentralQuotaForItems(ctx context.Context, exp // // 失败只告警:对账是自愈机制,它自己不能成为调度中断的理由。 func (e *ExptSchedulerImpl) reconcileExptStats(ctx context.Context, event *entity.ExptScheduleEvent, expt *entity.Experiment) { - if e.ExptItemResultRepo == nil || e.ExptStatsRepo == nil { + if e.ExptItemResultRepo == nil || e.ExptStatsRepo == nil || expt == nil { + return + } + + // 只对 enforce 实验对账。 + // + // legacy 的派发(handleToSubmits)把 run log、主表、turn 表、stats 四张**无条件一起写**, + // 任何一步失败都 return err 让 MQ 重投整批 —— 计数行天然镜像主表。 + // 2026-09-01 PPE 实测两个正在跑的 legacy 实验(30 题 / 3 题)分毫不差, + // 而同泳道的 enforce 实验差 249/281。给 legacy 对账是纯开销。 + if !entity.IsCentralDispatch(expt.ExptDispatchMode) { return } + // 节流:同一实验 5 分钟内只对一次。没有它就是"每实验每拍一条 GROUP BY", + // 一个空间几百个在跑的实验会变成每分钟几百条 count。 + // + // SetNX 出错时**跳过**而不是继续:Redis 抖动本来就该少做事, + // 反过来"出错就对账"会让 Redis 一挂就退化成每拍全量 count,正好是最不该发生的时候。 + if e.Idem != nil { + ok, err := e.Idem.SetNX(ctx, fmt.Sprintf("expt_stats_reconcile:%d", event.ExptID), exptStatsReconcileInterval) + if err != nil { + logs.CtxWarn(ctx, "[ExptStatsReconcile] throttle key set failed, skip this tick, expt_id: %v: %v", event.ExptID, err) + return + } + if !ok { + return + } + } + actual, err := e.ExptItemResultRepo.CountItemsByStatus(ctx, event.SpaceID, event.ExptID) if err != nil { logs.CtxWarn(ctx, "[ExptStatsReconcile] count items by status failed, skip this tick, expt_id: %v: %v", event.ExptID, err) diff --git a/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go b/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go index 2772d6b4c2..d9df0b0887 100644 --- a/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go +++ b/backend/modules/evaluation/domain/service/expt_stats_consistency_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + idemMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/component/idem/mocks" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" "github.com/coze-dev/coze-loop/backend/pkg/ctxcache" @@ -172,19 +173,26 @@ func TestCentralReservation_NoStatsOpWhenMainTableWriteFails(t *testing.T) { // ---------- 每拍对账 ---------- -func reconcileFixture(t *testing.T) (*ExptSchedulerImpl, *repoMocks.MockIExptItemResultRepo, *repoMocks.MockIExptStatsRepo) { +// enforceExptForReconcile 对账只对 enforce 实验做,本文件的用例统一用它。 +func enforceExptForReconcile() *entity.Experiment { + return &entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeEnforce} +} + +func reconcileFixture(t *testing.T) (*ExptSchedulerImpl, *repoMocks.MockIExptItemResultRepo, *repoMocks.MockIExptStatsRepo, *idemMocks.MockIdempotentService) { t.Helper() ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) items := repoMocks.NewMockIExptItemResultRepo(ctrl) stats := repoMocks.NewMockIExptStatsRepo(ctrl) - return &ExptSchedulerImpl{ExptItemResultRepo: items, ExptStatsRepo: stats}, items, stats + idm := idemMocks.NewMockIdempotentService(ctrl) + return &ExptSchedulerImpl{ExptItemResultRepo: items, ExptStatsRepo: stats, Idem: idm}, items, stats, idm } func TestReconcileExptStats_CorrectsDrift(t *testing.T) { - e, items, stats := reconcileFixture(t) + e, items, stats, idm := reconcileFixture(t) // 线上实测过的形态:主表 597 success / 253 queueing,计数行却停在 352 / 530。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{ entity.ItemRunState_Queueing: 253, entity.ItemRunState_Processing: 11, entity.ItemRunState_Success: 597, entity.ItemRunState_Fail: 39, @@ -197,7 +205,7 @@ func TestReconcileExptStats_CorrectsDrift(t *testing.T) { stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(func(_ context.Context, _, _ int64, s *entity.ExptStats) error { got = s; return nil }) - e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) require.NotNil(t, got, "漂了就必须写回") assert.Equal(t, int32(253), got.PendingItemCnt) @@ -207,8 +215,9 @@ func TestReconcileExptStats_CorrectsDrift(t *testing.T) { } func TestReconcileExptStats_NoWriteWhenConsistent(t *testing.T) { - e, items, stats := reconcileFixture(t) + e, items, stats, idm := reconcileFixture(t) + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{ entity.ItemRunState_Queueing: 3, entity.ItemRunState_Processing: 10, entity.ItemRunState_Success: 17, }, nil) @@ -219,28 +228,67 @@ func TestReconcileExptStats_NoWriteWhenConsistent(t *testing.T) { // 让"这行到底动没动"这个排查线索失效。 stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) } func TestReconcileExptStats_SkipsWhenNoItemRows(t *testing.T) { - e, items, stats := reconcileFixture(t) + e, items, stats, idm := reconcileFixture(t) // 实验刚建、item 还没落库。此时覆盖会把 finishExptStart 写下的 PendingItemCnt 抹成 0, // 详情页看起来"一条待跑都没有"。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(map[entity.ItemRunState]int64{}, nil) stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) } func TestReconcileExptStats_SkipsOnCountError(t *testing.T) { - e, items, stats := reconcileFixture(t) + e, items, stats, idm := reconcileFixture(t) // 对账是自愈机制,读不到真值时**什么都不做**,绝不能拿一个不完整的分布去覆盖。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("db down")) stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) stats.EXPECT().UpdateByExptID(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, nil) + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) +} + +func TestReconcileExptStats_SkipsLegacyExperiment(t *testing.T) { + e, items, stats, idm := reconcileFixture(t) + + // legacy 的派发是四张表无条件一起写、失败重投整批,计数行天然镜像主表。 + // 实测两个正在跑的 legacy 实验分毫不差 —— 给它对账是纯开销,连节流键都不该占。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, + &entity.Experiment{ID: 1, ExptDispatchMode: entity.ExptDispatchModeLegacy}) +} + +func TestReconcileExptStats_ThrottledWithinInterval(t *testing.T) { + e, items, stats, idm := reconcileFixture(t) + + // 节流键还在(本周期已对过)→ 一条 GROUP BY 都不该发。 + // 没有这道闸就是"每实验每拍一条 count",几百个在跑的实验会打成每分钟几百条。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) +} + +func TestReconcileExptStats_SkipsWhenThrottleKeyErrors(t *testing.T) { + e, items, stats, idm := reconcileFixture(t) + + // Redis 抖动时**少做事**:反过来"出错就对账"会让 Redis 一挂就退化成每拍全量 count, + // 正好是最不该加压的时候。 + idm.EXPECT().SetNX(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, errors.New("redis down")) + items.EXPECT().CountItemsByStatus(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + stats.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + e.reconcileExptStats(context.Background(), &entity.ExptScheduleEvent{ExptID: 1, SpaceID: 2}, enforceExptForReconcile()) } From 1067a697ea04b1658f29f86250a843ae46392dae Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Tue, 1 Sep 2026 19:22:49 +0800 Subject: [PATCH 56/58] =?UTF-8?q?chore(evaluation):=20rebase=20onto=20main?= =?UTF-8?q?=20=E6=94=B6=E5=B0=BE=EF=BC=88=E9=87=8D=E6=96=B0=E7=94=9F?= =?UTF-8?q?=E6=88=90=20wire=5Fgen=20+=20=E8=A1=A5=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 引入 ProvideExptItemRefRepos 后 wire 的匿名 slice 变量编号整体后移, 逐 commit 解冲突无法保证生成物一致,改由 wire 重新生成; 并补上解冲突时丢掉的函数间空行(gofumpt)。 --- backend/modules/evaluation/application/wire_gen.go | 8 ++++---- .../evaluation/domain/service/expt_result_impl_test.go | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/modules/evaluation/application/wire_gen.go b/backend/modules/evaluation/application/wire_gen.go index 92db164ad8..45238d462a 100644 --- a/backend/modules/evaluation/application/wire_gen.go +++ b/backend/modules/evaluation/application/wire_gen.go @@ -180,10 +180,10 @@ func InitExperimentApplication(ctx context.Context, idgen2 idgen.IIDGenerator, d iCentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() iCentralReservationGuard := component.NewNoopCentralReservationGuard() iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, componentIConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, serviceEvaluatorService, benefitSvc, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, iCentralAdmissionPolicy, iCentralReservationGuard) - v3 := service.ProvideNoSandboxAgentNotifiers() + v4 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, componentIConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) + exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, componentIConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v4...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() @@ -457,10 +457,10 @@ func InitEvalOpenAPIApplication(ctx context.Context, configFactory conf.IConfigL iCentralAdmissionPolicy := component.NewNoopCentralAdmissionPolicy() iCentralReservationGuard := component.NewNoopCentralReservationGuard() iExptManager := service.NewExptManager(exptResultService, iExperimentRepo, iExptRunLogRepo, iExptStatsRepo, iExptItemResultRepo, iExptItemRefRepo, iExptTurnResultRepo, iConfiger, quotaRepo, iLocker, idempotentService, exptEventPublisher, auditClient, idgen2, exptMetric, iLatestWriteTracker, evaluationSetVersionService, iEvaluationSetService, iEvalTargetService, evaluatorService, benefitService, exptAggrResultService, iExptTemplateRepo, iExptTemplateManager, iNotifyRPCAdapter, iUserProvider, pipelineListAdapter, resourceAccessAuthorizer, sandboxAgentMetrics, iCentralSchedulerScopeProvider, iCentralAdmissionPolicy, iCentralReservationGuard) - v3 := service.ProvideNoSandboxAgentNotifiers() + v4 := service.ProvideNoSandboxAgentNotifiers() schedulerModeFactory := service.NewSchedulerModeFactory(iExptManager, iExptItemResultRepo, iExptStatsRepo, iExptTurnResultRepo, idgen2, evaluationSetItemService, iExperimentRepo, iExptItemRefRepo, idempotentService, iConfiger, exptEventPublisher, evaluatorRecordService, exptResultService, iExptTemplateManager, iExptRunLogRepo, iLocker, v4...) iItemCompletePublisher := service.ProvideNilItemCompletePublisher() - exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v3...) + exptSchedulerEvent := service.NewExptSchedulerSvc(iExptManager, iExperimentRepo, iExptItemResultRepo, iExptTurnResultRepo, iEvaluatorRecordRepo, iExptStatsRepo, iExptRunLogRepo, idempotentService, iConfiger, quotaRepo, iLocker, exptEventPublisher, auditClient, exptMetric, exptResultService, idgen2, evaluationSetItemService, schedulerModeFactory, iEvalTargetService, iItemCompletePublisher, iExptItemRefRepo, sandboxAgentMetrics, iCentralReservationGuard, v4...) iExptItemDispatchDAO := mysql.NewExptItemDispatchDAO(db2) iExptItemDispatchRepo := experiment.NewExptItemDispatchRepo(iExptItemDispatchDAO) iCentralSchedulerScopeOwner := component.NewNoopCentralSchedulerScopeOwner() diff --git a/backend/modules/evaluation/domain/service/expt_result_impl_test.go b/backend/modules/evaluation/domain/service/expt_result_impl_test.go index 4bc2028c4c..90cefd3b30 100644 --- a/backend/modules/evaluation/domain/service/expt_result_impl_test.go +++ b/backend/modules/evaluation/domain/service/expt_result_impl_test.go @@ -8880,6 +8880,7 @@ func TestProvideExptItemRefRepos(t *testing.T) { require.Len(t, got, 1) assert.Equal(t, r, got[0]) } + // TestNewPayloadBuilder_ItemQuotaImpossibleErrParsing 覆盖「额度不可满足」的反解分支。 // // 为什么这层必须单测:错误码本身有单测(errno 包),但"结果层有没有真去读它"是另一回事 —— From 9c5811f73a7fab308f5bbd5bb11e594aee99106e Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 2 Sep 2026 14:29:56 +0800 Subject: [PATCH 57/58] =?UTF-8?q?fix(evaluation):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E6=B4=BE=E5=8F=91=E8=A1=A5=E5=86=99=20turn?= =?UTF-8?q?=20=E4=B8=BB=E8=A1=A8=E4=B8=8E=20CK=20=E5=8A=A0=E9=80=9F?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacy 的 handleToSubmits 派发时五写(run log / 主表 / CK 加速表 / turn 主表 / stats), 中心调度这条平行路径只写了前两项与 stats。 漏 CK 的表现:item_run_state 筛选打的是 etrf.status,开启加速器时结果里的 run_state 也从 CK 读,于是 enforce 实验执行期间按「运行中」筛恒为空、按「排队中」筛反而捞出 正在跑的 item 并显示成排队中;完成时的 upsert 会纠回来,只有 in-flight 窗口失真。 判据沿用主表是否真的发生迁移,两写彼此独立且只告警不阻断。 --- .../expt_central_display_projection_test.go | 144 ++++++++++++++++++ .../service/expt_run_item_event_impl.go | 25 +++ 2 files changed, 169 insertions(+) create mode 100644 backend/modules/evaluation/domain/service/expt_central_display_projection_test.go diff --git a/backend/modules/evaluation/domain/service/expt_central_display_projection_test.go b/backend/modules/evaluation/domain/service/expt_central_display_projection_test.go new file mode 100644 index 0000000000..0c04d53f7b --- /dev/null +++ b/backend/modules/evaluation/domain/service/expt_central_display_projection_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025 coze-dev Authors +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" + repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + svcMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service/mocks" +) + +// 本文件守的不变量:**中心调度派发时,展示投影的四张表要一起前进** +// —— run log、主表 expt_item_result、turn 主表 expt_turn_result、CK 加速表 expt_turn_result_filter。 +// +// legacy 的 handleToSubmits 一直是五写(含 stats),中心调度这条平行路径逐项漏过: +// 先漏主表(item 跑了 14h 仍显示排队中),再漏 stats(计数走负),最后漏这两张。 +// +// 漏 CK 的表现最难察觉:`item_run_state` 筛选打的是 etrf.status,且开启加速器时结果里的 +// run_state 也从 CK 读,于是执行期间按「运行中」筛恒为空、按「排队中」筛反而捞出正在跑的 item。 +// 完成时那次 upsert 会纠回来,所以只有 in-flight 窗口是错的,对着终态数据复盘看不出来。 +// PPE 实测:主表已 status=1,CK 仍为 0。 + +// centralDisplayFixture 组一套「CAS 命中、主表从 Queueing 前进到 Processing」的最小依赖。 +func centralDisplayFixture(t *testing.T, prev entity.ItemRunState, mainTableErr error) ( + *ExptItemEventEvalServiceImpl, *repoMocks.MockIExptTurnResultRepo, *svcMocks.MockExptResultService, +) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + dispatch := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatch.EXPECT().StartReservedItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + dispatch.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil).AnyTimes() + + items := repoMocks.NewMockIExptItemResultRepo(ctrl) + items.EXPECT().BatchGet(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*entity.ExptItemResult{{ItemID: 4, Status: prev}}, nil) + if prev != entity.ItemRunState_Processing { + items.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(mainTableErr) + } + + stats := repoMocks.NewMockIExptStatsRepo(ctrl) + stats.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + turns := repoMocks.NewMockIExptTurnResultRepo(ctrl) + result := svcMocks.NewMockExptResultService(ctrl) + + return &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: true}, + dispatchRepo: dispatch, + exptItemResultRepo: items, + exptStatsRepo: stats, + exptTurnResultRepo: turns, + resultSvc: result, + }, turns, result +} + +// TestCentralReservation_AdvancesTurnTableAndFilter 核心回归:主表真的前进时, +// turn 主表与 CK 加速表都必须跟着写。 +func TestCentralReservation_AdvancesTurnTableAndFilter(t *testing.T) { + svc, turns, result := centralDisplayFixture(t, entity.ItemRunState_Queueing, nil) + + var gotTurnFields map[string]any + turns.EXPECT().UpdateTurnResultsWithItemIDs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ int64, itemIDs []int64, _ int64, ufields map[string]any) error { + assert.Equal(t, []int64{4}, itemIDs) + gotTurnFields = ufields + return nil + }) + + var gotFilterItemIDs []int64 + result.EXPECT().UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, spaceID, exptID int64, itemIDs []int64) error { + assert.Equal(t, int64(3), spaceID) + assert.Equal(t, int64(1), exptID) + gotFilterItemIDs = itemIDs + return nil + }) + + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) + + require.NotNil(t, gotTurnFields, "turn 主表没写 —— turn 层状态会一直停在排队中") + assert.Equal(t, int32(entity.TurnRunState_Processing), gotTurnFields["status"]) + require.NotNil(t, gotFilterItemIDs, "CK 加速表没写 —— 按「运行中」筛会恒为空") + assert.Equal(t, []int64{4}, gotFilterItemIDs) +} + +// TestCentralReservation_SkipsDisplayProjectionWhenAlreadyProcessing 主表已是 Processing 时 +// 不重复写:与 stats 记账同一个判据,避免重复投递把 CK 反复刷一遍。 +func TestCentralReservation_SkipsDisplayProjectionWhenAlreadyProcessing(t *testing.T) { + svc, turns, result := centralDisplayFixture(t, entity.ItemRunState_Processing, nil) + + turns.EXPECT().UpdateTurnResultsWithItemIDs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + result.EXPECT().UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} + +// TestCentralReservation_FilterWrittenEvenIfTurnTableFails 两个写彼此独立: +// turn 主表写失败不能把 CK 那一写连带跳过,否则一次 DB 抖动就让筛选长期失真。 +func TestCentralReservation_FilterWrittenEvenIfTurnTableFails(t *testing.T) { + svc, turns, result := centralDisplayFixture(t, entity.ItemRunState_Queueing, nil) + + turns.EXPECT().UpdateTurnResultsWithItemIDs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(errors.New("db down")) + result.EXPECT().UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} + +// TestCentralReservation_NoDisplayProjectionWhenMainTableWriteFails 主表没写成功就不是 +// 「真的发生了迁移」,此时写 turn / CK 会让它们领先于主表 —— 反而制造新的不一致。 +func TestCentralReservation_NoDisplayProjectionWhenMainTableWriteFails(t *testing.T) { + svc, turns, result := centralDisplayFixture(t, entity.ItemRunState_Queueing, errors.New("db down")) + + turns.EXPECT().UpdateTurnResultsWithItemIDs(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + result.EXPECT().UpsertExptTurnResultFilter(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} + +// TestCentralReservation_DisplayProjectionToleratesNilDeps 老调用方(未注入这两个依赖的单测/ +// 部署形态)不能因此 panic —— 与相邻两项一致,缺依赖就退化为不写。 +func TestCentralReservation_DisplayProjectionToleratesNilDeps(t *testing.T) { + svc, _, _ := centralDisplayFixture(t, entity.ItemRunState_Queueing, nil) + svc.exptTurnResultRepo = nil + svc.resultSvc = nil + + handler := svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { return nil }) + require.NoError(t, handler(centralAdmittedCtx(t), &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4})) +} diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index 0df26d698c..bdfcf065cb 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -391,6 +391,31 @@ func (e *ExptItemEventEvalServiceImpl) HandleCentralReservation(next RecordEvalE event.ExptID, event.EvalSetItemID, err) } } + + // turn 主表与 CK 加速表同为展示投影,legacy 的 handleToSubmits 派发时是与 + // run log / 主表 / stats 一起五写的,中心调度这条路径此前只写了前三项。 + // + // 漏 CK 的后果最隐蔽:`item_run_state` 筛选打的是 etrf.status,且开启加速器时 + // 结果里的 run_state 也从 CK 读(QueryItemIDStates),于是执行期间按「运行中」筛 + // 恒为空、按「排队中」筛反而捞出正在跑的 item 并显示成排队中。完成时那次 upsert + // 会把它纠回来,所以事后对着终态数据看不出问题。 + // 实测:PPE 一个 enforce 实验,主表已 status=1,CK 仍为 0。 + // + // 必须在主表推进之后调:CK 行的 status 是 BuildTurnResultFilter 按主表状态回填的。 + // 与上面两项同样只告警不阻断(返回错误会让已取得执行权的 item 被 MQ 重投)。 + if advanced && e.exptTurnResultRepo != nil { + if err := e.exptTurnResultRepo.UpdateTurnResultsWithItemIDs(ctx, event.ExptID, []int64{event.EvalSetItemID}, event.SpaceID, + map[string]any{"status": int32(entity.TurnRunState_Processing)}); err != nil { + logs.CtxWarn(ctx, "[CentralReservation] advance turn results to Processing failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, err) + } + } + if advanced && e.resultSvc != nil { + if err := e.resultSvc.UpsertExptTurnResultFilter(ctx, event.SpaceID, event.ExptID, []int64{event.EvalSetItemID}); err != nil { + logs.CtxWarn(ctx, "[CentralReservation] upsert turn result filter failed (display only, execution unaffected), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, err) + } + } } // 执行链返回后释放额度:这是 consumer 侧的主释放点。 From 4da012c86baef506cd1dd07612fb65b7adfbf1a4 Mon Sep 17 00:00:00 2001 From: xueyizheng Date: Wed, 2 Sep 2026 15:03:52 +0800 Subject: [PATCH 58/58] =?UTF-8?q?fix(evaluation):=20=E5=AD=A4=E5=84=BF=20i?= =?UTF-8?q?tem=20=E9=80=80=E5=9B=9E=E9=98=9F=E5=88=97=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=9B=9E=E9=80=80=20turn=20=E4=B8=BB=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=20CK=20=E5=8A=A0=E9=80=9F=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 派发侧推进五项(run log / 主表 / stats / turn 主表 / CK),此前回退只做前三项, 留下「item 已回队列、turn 与 CK 仍显示执行中」的错位。它不自愈:实验若以 Failed 收口,CompleteExpt 的 default 分支只销毁沙箱、不动 turn 状态。 判据与 stats、主表一致(绑定 requeued),失败只告警。 --- .../expt_orphaned_item_requeue_test.go | 58 +++++++++++++++++++ .../service/expt_run_item_event_impl.go | 18 ++++++ 2 files changed, 76 insertions(+) diff --git a/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go index ae06291b6b..b9bae08504 100644 --- a/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go +++ b/backend/modules/evaluation/domain/service/expt_orphaned_item_requeue_test.go @@ -13,6 +13,7 @@ import ( "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/entity" "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo" repoMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/repo/mocks" + svcMocks "github.com/coze-dev/coze-loop/backend/modules/evaluation/domain/service/mocks" "github.com/coze-dev/coze-loop/backend/pkg/ctxcache" ) @@ -91,6 +92,63 @@ func TestReservationAbsent_RequeuesOrphanedProcessingItem(t *testing.T) { "主表是详情页的数据源,不退回会一直显示成执行中") } +// TestReservationAbsent_RollsBackTurnTableAndFilter 派发侧推进五项(run log / 主表 / stats / +// turn 主表 / CK 加速表),回退必须同样是五项。只退前三项会留下「item 已回队列、turn 与 CK +// 仍显示执行中」的错位,而且不自愈 —— 实验若以 Failed 收口,CompleteExpt 的 default 分支 +// 只销毁沙箱、不动 turn 状态。 +func TestReservationAbsent_RollsBackTurnTableAndFilter(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + dispatchRepo := repoMocks.NewMockIExptItemDispatchRepo(ctrl) + dispatchRepo.EXPECT().MGetDispatchObservations(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*repo.ExptDispatchObservation{ + {ItemID: 4, Status: int32(entity.ItemRunState_Processing), QuotaReservationState: entity.QuotaReservationStateNone}, + }, nil) + dispatchRepo.EXPECT().RequeueProcessingItem(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(true, nil) + + statsRepo := repoMocks.NewMockIExptStatsRepo(ctrl) + statsRepo.EXPECT().ArithOperateCount(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + itemResultRepo := repoMocks.NewMockIExptItemResultRepo(ctrl) + itemResultRepo.EXPECT().UpdateItemsResult(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + + var gotTurnFields map[string]any + turnResultRepo := repoMocks.NewMockIExptTurnResultRepo(ctrl) + turnResultRepo.EXPECT().UpdateTurnResultsWithItemIDs(gomock.Any(), int64(1), []int64{4}, int64(3), gomock.Any()). + DoAndReturn(func(_ context.Context, _ int64, _ []int64, _ int64, ufields map[string]any) error { + gotTurnFields = ufields + return nil + }) + + var gotFilterItemIDs []int64 + resultSvc := svcMocks.NewMockExptResultService(ctrl) + resultSvc.EXPECT().UpsertExptTurnResultFilter(gomock.Any(), int64(3), int64(1), gomock.Any()). + DoAndReturn(func(_ context.Context, _, _ int64, itemIDs []int64) error { + gotFilterItemIDs = itemIDs + return nil + }) + + svc := &ExptItemEventEvalServiceImpl{ + centralGuard: &fakeGuard{confirmResult: false}, + dispatchRepo: dispatchRepo, + exptStatsRepo: statsRepo, + exptItemResultRepo: itemResultRepo, + exptTurnResultRepo: turnResultRepo, + resultSvc: resultSvc, + } + + event := &entity.ExptItemEvalEvent{ExptID: 1, ExptRunID: 2, SpaceID: 3, EvalSetItemID: 4} + assert.NoError(t, svc.HandleCentralReservation(func(context.Context, *entity.ExptItemEvalEvent) error { + return nil + })(admittedEnforceCtx(event), event)) + + assert.Equal(t, int32(entity.TurnRunState_Queueing), gotTurnFields["status"], + "turn 主表不退回,详情页 turn 会一直显示运行中而 item 显示排队中") + assert.Equal(t, []int64{4}, gotFilterItemIDs, + "CK 不刷新,开加速器时按「运行中」筛会筛出实际在排队的 item") +} + // TestReservationAbsent_LeavesTerminalItemUntouched 迟到消息:item 已终态, // 额度早已正常释放。这是唯一的预期路径,任何"修复"动作都会让已跑完的 item 重跑。 func TestReservationAbsent_LeavesTerminalItemUntouched(t *testing.T) { diff --git a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go index bdfcf065cb..cfd86ed33e 100644 --- a/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go +++ b/backend/modules/evaluation/domain/service/expt_run_item_event_impl.go @@ -524,6 +524,24 @@ func (e *ExptItemEventEvalServiceImpl) requeueOrphanedItemOnReservationAbsent(ct event.ExptID, event.EvalSetItemID, uerr) } } + + // turn 主表与 CK 加速表也要跟着退回:派发侧推进的是这五项,回退只做前三项就会留下 + // 「item 已回队列、turn 与 CK 仍显示执行中」的错位。它不会自愈 —— 实验若以 Failed + // 收口,CompleteExpt 的 default 分支只销毁沙箱、不动 turn 状态。 + // 同样绑定 requeued(判据与 stats、主表一致),失败只告警。 + if requeued && e.exptTurnResultRepo != nil { + if uerr := e.exptTurnResultRepo.UpdateTurnResultsWithItemIDs(ctx, event.ExptID, []int64{event.EvalSetItemID}, event.SpaceID, + map[string]any{"status": int32(entity.TurnRunState_Queueing)}); uerr != nil { + logs.CtxWarn(ctx, "[CentralReservation] rollback turn results to Queueing failed (display only), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, uerr) + } + } + if requeued && e.resultSvc != nil { + if uerr := e.resultSvc.UpsertExptTurnResultFilter(ctx, event.SpaceID, event.ExptID, []int64{event.EvalSetItemID}); uerr != nil { + logs.CtxWarn(ctx, "[CentralReservation] rollback turn result filter failed (display only), expt_id: %v, item_id: %v: %v", + event.ExptID, event.EvalSetItemID, uerr) + } + } return }