Skip to content

perf traffic control logic - #268

Open
TuBoShu-max wants to merge 2 commits into
masterfrom
feature/perf_traffic_1029
Open

TuBoShu-max wants to merge 2 commits into
masterfrom
feature/perf_traffic_1029

Conversation

@TuBoShu-max

Copy link
Copy Markdown
Collaborator

No description provided.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


吴康亮 seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

return &experiments.TrafficControlTargetTraffic{
ItemOrExpId: key, TrafficControlTaskId: task.TrafficControlTaskId,
TrafficControlTargetId: id, TargetTraffic: detail.Traffic,
TaskTraffic: task.ActualTraffic.TaskTraffics[key].Traffic,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TaskTraffics[key] 缺少存在性检查 → 会在 L743 产生 Inf/NaN

先澄清一个容易误判的点:这里不会 panic。SDK 中 TaskTraffics 是 map[string]TaskTrafficDetail(值类型,model/model_traffic_ctrl_task.go:170),缺失 key 返回零值结构体,.Traffic 取到 0.0。Data 同为值类型 map[string]TargetTrafficDetail;L1081 那个 targetTraffic == nil 检查是因为 TargetTraffics 才是指针切片 []*TargetTraffic——两个字段类型本来就不同,不构成守卫不对称。

真正的问题是 TaskTraffic = 0 会一路流到 computeMacroTargetAlpha L743 那个没有除零保护的除法:

trafficPercentage := targetTraffic / taskTraffic
  • targetTraffic > 0 → +Inf:L233 判定 +Inf >= aimValue/100 为 true → achieved → L249 提前返回,该目标静默停止上提(下次有效测量可恢复)。
  • targetTraffic == 0 → NaN,这一支不可逆:
    • L243 currentError = aimValue/100 - NaN = NaN
    • L291-294 的积分钳位对 NaN 完全失效(NaN 参与的任何比较均为 false)
    • integralSum 全仓库无任何重置点 → 进程生命周期内永久污染
    • L200 iTerm = ki * NaN → alpha = NaN
    • L371-374 if alpha == 0 { continue } —— NaN == 0 为 false,过滤不掉,NaN alpha 会进入上提/打分逻辑污染 item score,影响面溢出到全局排序

两个加重因素:

  1. L703-708 的 defer recover() 拦不住这个——Inf/NaN 在 Go 中是合法 float64 值,不触发 panic。
  2. IntegralThreshold 无代码默认值(recconf.go:308 是裸 float64,pid_controller.go:60 直接透传)。未配置即为 0 → L279 的 integralThreshold > 0 不成立 → 跳过积分分离的意外保护,直接走 L288 integralSum += factor * currentError * dt,NaN 落入积分项。反之若配置了 IntegralThreshold > 0,math.Abs(NaN) <= integralThreshold 为 false 会落入积分分离区,integralSum 反而幸免——缺陷严重性依赖配置项,隐蔽性更高。

这不是本 PR 引入的回归:SDK 自己的 experiments/client_traffic_ctrl_tasks.go:274 做的是同样的无守卫索引(taskTraffic := taskTrafficMap[expOrItemId]),L1089 的注释也写明「与 SDK 的线性查找一致」,两边产生记录的前置条件也一致(都要求 Data[key] 存在)。因此这条不阻塞合并,只是本 PR 把这段逻辑收归仓内后,顺手修掉的成本很低。

另外 L725 / L739 已经有 taskTraffic = 1 的除零兜底,说明除零是作者已知的风险,只是漏了 L1099 这条新路径。

建议

1. 源头修复:lookup 在 TaskTraffics[key] 缺失时返回 nil。三个调用点(L1105 / L1110 / L1114)已经都在判 record != nil,无需改动即可正确降级为「无数据」:

lookup := func(key string) *experiments.TrafficControlTargetTraffic {
	detail, ok := targetTraffic.Data[key]
	if !ok {
		return nil
	}
	// skip the record when the task-level aggregate is absent, otherwise the
	// zero TaskTraffic yields Inf/NaN in the percentage computation
	taskDetail, ok := task.ActualTraffic.TaskTraffics[key]
	if !ok {
		return nil
	}
	return &experiments.TrafficControlTargetTraffic{
		ItemOrExpId: key, TrafficControlTaskId: task.TrafficControlTaskId,
		TrafficControlTargetId: id, TargetTraffic: detail.Traffic,
		TaskTraffic: taskDetail.Traffic,
		RecordTime:  time.Unix(detail.RecordTime, 0),
	}
}

2. 纵深防御:L743 前补除零保护,与 L725 / L739 的既有写法保持一致:

if taskTraffic <= 0 {
	taskTraffic = 1
}

3. 可选:L744 setMeasurement 前加 math.IsNaN / math.IsInf 拦截。sort 包内其他实现已有此惯例(ssd_sort.go:388/424/443/457、embedding_query.go:191),PID 路径是唯一缺口。

⚠️ 注意:单靠给 L1099 加 exists 判断是无效的——值类型缺失 key 本来就返回 0.0,加守卫后数值完全不变,Inf/NaN 依旧存在。必须同时处理除法侧(方案 2),或让整条记录缺失(方案 1)。

@bruceding bruceding left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — 1 处阻塞级问题(sort/traffic_control_sort.go:1321-1327,详见该行 inline comment)。

摘要:itemExprProg == nil 的语义由 master 的 return true(匹配全部物品)反转为 return false(匹配零个物品)。已核实 nil 的生产方 GenerateItemExpress 本 PR 一字未改(master/HEAD 函数体 byte-compare 相同),因此这是消费方的单边语义反转,构成本 PR 引入的回归。

影响:物品条件四个字段全空的存量调控目标(仅靠场景/用户/实验限定范围)会在宏观(L350)与微观(L917)两条路径上同时静默失效;无 error 日志、不 panic、配置与 schema 均无变化,属静默的行为语义兼容性破坏。PR 未提供兼容开关,描述为空。

注:作者收紧「解析/编译失败」两种情形的意图是正确的(master 在该情形下 return true 确有 bug),应当保留;建议按 inline comment 中的三态区分方案修复,而非整体回退。

另有一条非阻塞建议已单独评论在 L1099(TaskTraffics[key] → Inf/NaN,经核实为 SDK 既有行为、非本 PR 回归)。

Comment on lines 1321 to +1327
if controller.itemExprProg == nil {
return true
// 物品表达式未编译成功(未配置条件或解析/编译失败)时拦截所有物品,
// 避免错误配置把全量物品当成调控目标;警告日志每个控制器只打一次。
controller.itemExprNilWarnOnce.Do(func() {
log.Warning(fmt.Sprintf("module=TrafficControlSort\titem expression is not compiled, all items are excluded\t%s", trafficIdentity(controller.task.TrafficControlTaskId, controller.target.TrafficControlTargetId, controller.target.Name)))
})
return false

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 阻塞:itemExprProg == nil 的语义被反转,构成静默的向后兼容性破坏

这里把 nil 的处理从 master 的 return true(匹配全部物品)改成了 return false(匹配零个物品)。这是本 PR 引入的行为回归,不是既有问题。

1. 纵向不一致(版本间)——兼容性问题的来源

已核对:nil 的生产方 GenerateItemExpress(sort/pid_controller.go:425-467)本 PR 一字未改(master 与 HEAD 的函数体做过 byte-compare,完全相同;pid_controller.go 的 59 增 45 删全部落在 member cache 移除、时区 sync.Once、日志三处)。因此不存在"上游编译逻辑变了、下游必须跟着变"的正当性,这是一次纯粹的消费方单边语义反转。

itemExprProg == nil 实际有三个来源,其中第一个是完全合法的:

来源 代码位置 是否打日志 配置是否合法 master HEAD 应有行为
(a) 未配置物品条件 pid_controller.go:448 守卫不成立 → 跳过编译 无任何日志 ✅ 合法 true ❌ false 匹配全部
(b) 解析失败 :430 / :437 early return error 日志 ❌ 错误 ❌ true ✅ false 匹配零个
(c) 编译失败 :455 / :462 early return error 日志 ❌ 错误 ❌ true ✅ false 匹配零个

L1322 的注释「未配置条件或解析/编译失败」表明作者清楚存在两种来源,但选择了一并拦截。

(a) 的成立已逐层验证:ParseExpression("", "")(pid_controller.go:609-657)中 L611 的 conditionArray != "" 为 false 跳过整个循环,L649 的 conditionExpress != "" 也为 false,最终 return "", nil——空字符串且无 error。回到 GenerateItemExpress:440-447,targetExpression 与 taskExpression 均为空,三个分支全不命中,itemExpression 保持 "",于是 L448 守卫不成立,编译被整体跳过,且一条日志都不打。

2. 横向不一致(同版本内)——反驳"这是有意的安全默认"

同一文件内 user 侧保留了相反约定,isControlledByUser(L1235-1238):

if controller.userExprProg == nil {
    controllerNewMap[targetId] = controller   // nil 用户条件 = 匹配全部用户
    continue
}

而 GenerateUserExpress(pid_controller.go:469-484,L475-477 if taskUserExpression == "" { return })产生 nil 的结构与 item 侧完全同构(同样是"未配置 / 解析失败 / 编译失败"三来源)。

两侧结构同构但结论相反,说明 match-all 才是本仓库对"未配置条件"的既定约定,item 侧的翻转是孤例。如果"nil → 拒绝"是正确的安全默认,user 侧早该同样处理。

3. 兼容性定性

兼容维度 是否受影响
API / 编译兼容 ❌ 否(签名未变,isControlledItem 仍保留)
配置 schema 兼容 ❌ 否(recconf 相关字段未动,存量配置照常解析,控制台上的任务无需修改)
行为语义兼容 ✅ 是——同一份配置、同一个调控任务,升级前后行为突变

这是最难发现的一类 breaking change,所有常规防线全部失效:

  1. 配置不报错(schema 未变,运维看不出异常)
  2. 编译 / 启动不报错,运行不 panic
  3. 无 error 日志——来源 (a) 走的是"表达式为空"分支,GenerateItemExpress 完全静默
  4. 唯一信号是 itemExprNilWarnOnce 那条每 controller 仅一次的 warning,措辞 item expression is not compiled, all items are excluded 会被读成"该目标表达式配错了",而该配置完全合法,排查方向从第一步就被带偏;sync.Once 意味着日志轮转后基本无法回溯
  5. 指标不异常、接口正常返回,只是排序结果里该目标的物品不再被上提

最终暴露形式是业务侧的"调控目标达不成量",通常要等业务方看流量占比报表才发现,定位链路很长。

受影响面已核实为全路径:itemExprProg 全仓库只有一个消费者(本函数),而它有两个活跃调用点,正好覆盖宏观与微观两条路径:

  • 宏观 L350(matchMacroTargets 并行 worker 内):恒 false → localScores[targetId] 永远为 0、matchedTargets 永远为空 → 该目标宏观得分恒 0,alpha 计算与上提配额全部不生效
  • 微观 L917:恒 false → 直接 continue → localControlNumberMap 不计数、controller.compute 不被调用、alpha 不施加 → 单品调控完全静止

master 上有 3 个调用点(:269/:358/:684),HEAD 收敛为 2 个,两处共用同一 helper,没有任何路径幸免。

谁会中招(需同时满足,因为 GenerateItemExpress:441-447 对 task/target 取并集,任一非空就会编译):task 级与 target 级的 ItemConditionArray、ItemConditionExpress 四个字段全空。典型形态是不靠物品属性筛选、只靠场景 / 用户 / 实验限定范围的调控目标(例如"某实验桶内全量物品保量")。

4. 本 PR 未提供任何兼容手段

  • recconf/recconf.go 的唯一改动是新增 TargetSampleCount(每请求调控目标抽样数),与此无关,没有兼容开关 / feature flag
  • PR 描述为空(只有标题 perf traffic control logic),无行为变更说明、无 breaking change 声明、无迁移指引

升级方无从得知这一语义变化。

5. 作者的意图是对的,所以不要简单回退

L1323 的「避免错误配置把全量物品当成调控目标」指向的是来源 (b)(c)——master 在这两种真失败下 return true 确实是 bug:一个表达式写错的目标会把全量物品当成调控对象,可能引发大面积错误上提。这个收紧是本 PR 的改进,应当保留。

问题只在手段过宽,把合法的 (a) 一并挡了。正确修法是三态区分:保留对 (b)(c) 的拒绝,恢复对 (a) 的 match-all。

6. 建议修复

推荐加状态位(而非编译恒真表达式)——因为 L350 / L917 都是 per-item × per-controller 的热循环,编译恒真 program 会给每个无条件目标凭空增加一次 VM Run,与本 PR 的 perf 目标相反;状态位方案在热路径上零额外开销(连 VM 都不进):

// PIDController struct
itemExprUnconfigured bool // true when no item condition is configured at all
// GenerateItemExpress, in the branch where itemExpression == ""
} else {
    // no item condition configured: the target intentionally matches all items,
    // which is different from a parse/compile failure and must stay distinguishable
    p.itemExprUnconfigured = true
}
// isControlledItemFeaturesWithVM
if controller.itemExprProg == nil {
    // unconfigured means "match all items" (same convention as isControlledByUser);
    // a genuine parse/compile failure still excludes every item
    if controller.itemExprUnconfigured {
        return true
    }
    controller.itemExprNilWarnOnce.Do(func() {
        log.Warning(fmt.Sprintf("module=TrafficControlSort\titem expression failed to compile, all items are excluded\t%s", trafficIdentity(controller.task.TrafficControlTaskId, controller.target.TrafficControlTargetId, controller.target.Name)))
    })
    return false
}

顺带建议:GenerateUserExpress(pid_controller.go:469-484)同步处理,让两侧约定统一为"nil == 真失败",否则文件内仍是两套约定、只是方向反了过来,后续维护者还会踩同一个坑。同时把 warning 措辞改成明确的"failed to compile",避免继续误导运维。

7. 必须补的回归测试

四类,缺一不可——第 1 与第 4 条期望相反,正是当前实现无法同时满足的约束,也是证明修复正确的最小充分集:

# 场景 构造 期望
1 未配置任何物品条件 task 与 target 四个字段全空 匹配全部物品(当前实现会失败 ← 本条即回归守卫)
2 仅任务级配置条件 task 有条件、target 为空 按 task 条件匹配
3 仅目标级配置条件 target 有条件、task 为空 按 target 条件匹配
4 编译失败 非法表达式(如 ItemConditionExpress: "&&&") 匹配零个物品 + 打出 warning

isControlledItemFeaturesWithVM 是纯函数(入参 controller + itemId + properties + machine,无 ctx / goroutine / 时钟依赖),这四条都是廉价的确定性单测,不存在"难以测试"的豁免理由。


结论:请在合并前修复,或由维护者明确确认"翻转空物品条件语义"是有意为之、并已评估存量部署影响面(线上有多少 target 的四个物品条件字段全空)。 若确认为有意变更,则至少需要:PR 描述中写明 breaking change、提供兼容开关、并把 warning 措辞改为不再误导。

@bruceding bruceding left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes(第 2 处阻塞) — sort/traffic_control_sort.go:681-686,详见该行 inline comment。

摘要:12ms 超时预算是既有的(master :427 与本 PR :620 完全一致),但超时命中后的行为被本 PR 改坏了——master 是 break Loop 立即返回、不等在飞 goroutine;本 PR 改成 for result := range results 阻塞等待 channel 关闭,而关闭依赖 L661-664 的 wg.Wait(),因此主协程在超时后要等所有 worker 跑完手头 job 才能返回。worker pool 的串行化叠加阻塞等 close,使实际等待时间 ≈ 最慢一个在飞 computeMacroTargetAlpha,12ms 预算失效。

这与本 PR perf traffic control logic 的目标直接冲突,且恰好在超时真正被触发的高负载场景下最严重。

已核实:本 PR 用 L661-664「后台 goroutine 先 wg.Wait 再 close」正确修掉了 master 的 send-on-closed-channel panic(master :425 的 defer close(retCh) 与 :503 的在飞发送存在竞争,靠 :431 的 recover 兜住)。该改动应当保留,修复方案见 inline comment —— 只改超时分支为非阻塞 drain,不动 close 所有权。

两处校正(避免高估):等待幅度有界(非无限);本 PR 给 ctx.LogDebug 加 if ctx.Debug 守卫实际上减少了日志开销,是正面改动。残留的 pid_controller.go:302-310 log.Debug 采样点是 master 既有、非本 PR 引入。

—— 至此本 PR 已记录 2 处阻塞(L1321-1327 语义兼容性、L681-686 超时失效)+ 1 处非阻塞建议(L1099 Inf/NaN)。

Comment on lines 681 to +686
case <-gCtx.Done():
if errors.Is(gCtx.Err(), gocontext.DeadlineExceeded) {
ctx.LogWarning(fmt.Sprintf("module=TrafficControlSort\ttraffic controller timeout: %v", gCtx.Err()))
timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded)
for result := range results {
collectResult(result)
}
break Loop
break Collect

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 阻塞:超时分支从「立即返回」变成「阻塞等待所有 worker」,12ms 预算失效

这是本 PR 引入的回归,不是既有行为。12ms 超时预算本身是既有的(master :427 与本 PR :620 的 gocontext.WithTimeout(..., time.Millisecond*12) 一字不差),但超时命中后的行为被改坏了。

1. master 与 HEAD 的直接对比

master(:510-523)——超时即刻跳出,不等任何在飞 goroutine:

Loop:
	for range controllerMap {
		select {
		case pair := <-retCh:
			...
		case <-gCtx.Done():
			if errors.Is(gCtx.Err(), gocontext.DeadlineExceeded) { ctx.LogWarning(...) }
			break Loop          // ← 立即返回
		}
	}

HEAD(L681-686)——超时后阻塞直到 results 被 close:

case <-gCtx.Done():
	timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded)
	for result := range results {   // ← 裸 range,无取消出口
		collectResult(result)
	}
	break Collect

而 results 只在 L661-664 的 wg.Wait() 返回后才 close:

go func() { wg.Wait(); close(results) }()

即 主协程在超时后要等所有 worker 退出才能返回。

2. 并发模型的改变放大了这个回归

master HEAD
并发结构 一个 target 一个 goroutine(:429),全并发 固定 workerCount 的 worker pool + jobs channel
取消粒度 每个 goroutine 在自己的发送 select 上独立取消(:498-507) worker 必须跑完手头那一个 job(L648)才能回到 L638 检查取消
结果缓冲 MinInt(len(controllerMap), 64)(:424) 仅 workerCount(L619)
超时后主协程 break Loop 直接走 阻塞在 for range results 等 wg.Wait()

worker pool 的串行化叠加阻塞等 close,使超时后的等待时间 ≈ 最慢那个在飞 computeMacroTargetAlpha 的耗时,而不再是 12ms。

需要说明的是,worker 侧的取消检查点其实设计得比较完备——L638 循环顶部 gCtx.Err()、L642 select 的 <-gCtx.Done()、L654 发送侧的 <-gCtx.Done() 都在。所以 worker 只在正在执行 L648 那一个 job 期间无法被打断;而 computeMacroTargetAlpha 内部的 setMeasurement / compute / 日志都不接受 gCtx,无法中断。

3. 成因:这个回归是为了修 master 的一个真 bug

master 有一个实打实的 send-on-closed-channel 缺陷::425 的 defer close(retCh) 在主协程执行,而 :429 起的 goroutine 在主协程 break Loop 返回后可能仍在 :503 往 retCh 发送 → 向已关闭 channel 发送 → panic。master 靠 :431-436 的 recover 兜住,那条 "traffic control timeout in background" 日志就是这个场景的产物。

HEAD 用 L661-664「专属 goroutine 先 wg.Wait() 再 close(results)」正确修掉了这个 bug——close 的所有权移到了确认无发送者之后。这个改动应当保留。

代价就是主协程为了不去 close 一个还有发送者的 channel,变成了要等 wg.Wait(),把「立即返回」这个特性弄丢了。所以修复必须两者兼得,不能简单回退到 master 写法(那会把 send-on-closed panic 带回来)。

4. 幅度校正:不是无界,且日志部分本 PR 其实是改善

为避免高估,两点校正:

  • 等待幅度有界:上限是「最慢一个在飞 computeMacroTargetAlpha」,不是无限等待。正常情况下该函数是纯 CPU 的 PID 计算,微秒级,代价很小。
  • 本 PR 实际上减少了日志开销:master 的 compute 里 ctx.LogDebug(:16/:30/:35/:45/:52/:71/:106)全是无条件调用,本 PR 逐个包进了 if ctx.Debug 守卫;computeMacroTargetAlpha 里的日志同样被包进 L746-748 / L758-760 的 Debug 守卫。这是正面改动。

但风险在高负载下依然真实:pid_controller.go:302-310 的 logProb/log.Debug 采样块(master :302-306 就有,非本 PR 引入)在 logProb = 1.0(非单品粒度)时对宏观目标每次 compute 都打一条 log.Debug,走全局 logger。若部署镜像带 --alsologtostderr(每行日志持全局锁双写终端+文件,是已知的秒级尾延迟来源),这个点恰好落在 worker 无法被取消的那段代码里,使「等待最慢 worker」从理论风险变成实际风险。

关键在于:12ms 超时真正被触发的场景就是高负载场景,而这个回归恰好在高负载下最严重——超时机制在最需要它的时候失效。 这与本 PR perf traffic control logic 的目标直接冲突,且落在在线排序关键路径的尾延迟上,因此定为阻塞。

5. 建议修复

只改超时分支,L661-664 的 close 所有权机制原样保留:

case <-gCtx.Done():
	timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded)
	// drain only what is already buffered and return immediately; the background
	// goroutine still owns closing results after wg.Wait(), so there is no risk
	// of closing a channel with active senders
Drain:
	for {
		select {
		case result, ok := <-results:
			if !ok {
				break Drain
			}
			collectResult(result)
		default:
			break Drain
		}
	}
	break Collect

要点:

  1. default 分支使其非阻塞——只收已经躺在缓冲区里的结果(缓冲为 workerCount,最多这么多条),随即返回。
  2. 不动 L661-664:仍由后台 goroutine 在 wg.Wait() 后 close,主协程永不 close,因此不会重现 master 的 send-on-closed panic。
  3. worker 侧发送 L652-656 已有 <-gCtx.Done() 出口,不会因主协程提前离开而永久阻塞泄漏。
  4. 语义变化:超时后放弃尚未算完的 target 的 alpha(它们本来也赶不上 12ms 预算)。这与 master 的 break Loop 行为一致,属于恢复原语义,不是引入新行为。

6. 必须补的测试

构造一个慢 worker(job 内 sleep 超过 12ms),断言 computeMacroControlAlpha 的返回耗时 ≈ 12ms 而非 sleep 时长。当前实现下这个测试会失败,正好作为回归守卫;修复后应当通过。

@bruceding bruceding left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

第 3 条评论:sort/traffic_control_sort.go:391-413(selectMacroQuantityUpliftItems),详见 inline comment。

按量宏观目标的上提名额限制是本 PR 新增的(master 宏观循环无 item 级名额,配置注释也由「单品调控时」改写为「单品调控和按量全局调控分别适用」)。当前按原始排名贪心分配,名额可能全部落在已在曝光区内的物品上,导致该目标无法增加曝光、PID 反馈环失去作用能力并引发积分饱和。

考虑到「按排名先到先得」与既有微观路径(L967,继承自 master :734)一致、且作者专门改写了配置注释,这可能是有意的对称设计,因此本条用 COMMENT 而非 request changes,请作者判断。

inline comment 中附了一个净增约 6 行的最小改动修复方案(两趟扫描:优先曝光区外候选,剩余名额回落到区内,保证不减少任何原本会发生的上提)+ 3 条确定性单测 + 明确标注的超范围事项。

若确认是设计意图,建议在 PR 描述中补充说明——当前 PR body 为空,这个从「无限」到「最多 5 个」的能力收窄对使用方不可见。

—— 本 PR 目前累计:2 处阻塞(L1321-1327 语义兼容性、L681-686 超时失效)、1 处待作者决策(本条)、1 处非阻塞建议(L1099 Inf/NaN,经核实为 SDK 既有行为)。

Comment on lines +391 to +413
func selectMacroQuantityUpliftItems(matchedTargets [][]string, activeControllers map[string]*PIDController, targetAlphaMap map[string]float64, limit int) []bool {
selected := make([]bool, len(matchedTargets))
if limit <= 0 {
return selected
}

selectedCount := 0
for itemIndex, targetIds := range matchedTargets {
for _, targetId := range targetIds {
controller, ok := activeControllers[targetId]
if !ok || controller.task.ControlType != constants.TrafficControlTaskControlTypeQuantity || targetAlphaMap[targetId] <= 0 {
continue
}
selected[itemIndex] = true
selectedCount++
break
}
if selectedCount >= limit {
break
}
}
return selected
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按量宏观目标的上提名额可能全部消耗在已曝光物品上 —— 附最小改动修复方案

先说明定性:这是本 PR 新增的行为限制(已确证),但可能是有意的对称设计,因此这条不作为强制阻塞项,而是把问题与一个最小改动的修复方案一并列出,请作者判断是设计意图还是需要调整。

一、这个名额限制是本 PR 新增的(三重证据)

  1. 配置注释被本 PR 改写:
    • master :1094:// 单品调控时,限制单品调控数量,默认为5
    • 本 PR :1468:// 单品调控**和按量全局调控**分别适用的单次上提物品数上限,默认为5
  2. upliftItemLimit / selectMacroQuantityUpliftItems / shouldApplyMacroTarget 三个函数均为新增。
  3. master 的宏观上提循环(:352-376)完全没有 item 级名额:每个匹配到非零 alpha 的物品都会被上提(finalDeltaRank += deltaRank // 形成合力),且不区分 Quantity / Percent。master 的 limitCount 只出现在 microControl(:734)。

即:按量宏观目标的上提能力从无限收窄为最多 5 个物品(默认值)。

二、具体问题:选择标准与调控目标不一致

matchedTargets 的下标就是原始排名(:138 设 _ORIGIN_POSITION_ = i+1,:352 用同一下标填充),而 L398 按下标升序贪心取前 limit 个。selectMacroQuantityUpliftItems 的签名与函数体内都没有 ctx / Size / pageNo / 曝光边界,结构上无法感知曝光窗口。

举例:ctx.Size = 20、limit = 5,某未达标的按量目标同时匹配原始排名 1-5 与 21-25 的物品:

  • 名额全被 1-5 拿走;它们本来就在曝光区内,上提与否不改变曝光数
  • 21-25 被 L561 的 shouldApplyMacroTarget 跳过,拿不到任何上提
  • 结果:该目标进入曝光区的物品数增量恒为 0,即使 alpha 已经很大

两个放大因素:

  • 名额是所有按量目标共享的单一预算(L390 注释明写「共享」,L397 只有一个 selectedCount,L406 的 break 使一个物品即使命中多个按量目标也只占一个名额)。多个按量目标按物品排名先到先得地瓜分 5 个名额,排名靠后的目标可能一个都拿不到,与它欠量多严重无关。
  • alpha 的数值大小不参与分配(L401 只用 targetAlphaMap[targetId] <= 0 做布尔门控)。PID 的输出无法影响名额归属,于是:名额被已曝光物品占满 → 曝光数无法增加 → error 恒为正 → pid_controller.go:282/288 积分持续累积 → :291-292 钳到 integralMax → alpha 饱和但完全无效(典型积分饱和)。

三、缓解因素(为什么这条不作为强制阻塞)

  • 「按排名先到先得」与既有微观路径一致(L967 upliftCount < limitCount,继承自 master :734),所以宏观沿用同一标准可能是有意的对称设计,而非疏漏。
  • 作者显然清楚这个语义扩展(专门改写了配置注释),不是无意引入。
  • limit 可通过 PidSingleControlLimitCount 调整。

四、最小改动修复方案(净增约 6 行)

改动点

# 位置 改动
1 L391 函数签名 增加一个参数 exposureBoundary int
2 L397-411 循环体 外层包一趟 for pass := 0; pass < 2,加 3 行区内外过滤;内层目标匹配逻辑一字不改
3 L509 调用点 末尾多传一个实参 ctx.Size
附 L389-390 注释 更新为英文并说明新语义

无结构体变更、无新增依赖、无新增 import、无架构调整。

边界判据直接复用同文件既有约定:微观路径 L972-975 已在用 pos > ctx.Size 判定「在曝光区外」,而 _ORIGIN_POSITION_ = index + 1,故 pos > ctx.Size ⟺ index >= ctx.Size。方案中的 itemIndex >= exposureBoundary(传 ctx.Size)与之完全等价,不需要把 items 传进来读属性,也不引入第二套窗口定义。

核心思路是两趟扫描,而不是跳过已曝光物品:

// selectMacroQuantityUpliftItems allocates the shared per-request uplift quota for
// all quantity-based macro targets. Candidates are scanned in original rank order,
// but items still outside the exposure window are served first: uplifting an item
// that is already exposed cannot add any new exposure for the target, so spending
// the quota on it starves the targets that still have to grow. The second pass
// spends any leftover quota inside the window, so no budget is wasted and no uplift
// that used to happen is dropped. An item matching several positive quantity
// targets still consumes a single slot.
//
// exposureBoundary reuses the convention of the micro control path (an item is
// outside the exposure window when its position is greater than ctx.Size); a
// non-positive value disables the preference and keeps the legacy rank-only order.
func selectMacroQuantityUpliftItems(matchedTargets [][]string, activeControllers map[string]*PIDController, targetAlphaMap map[string]float64, limit, exposureBoundary int) []bool {
	selected := make([]bool, len(matchedTargets))
	if limit <= 0 {
		return selected
	}

	selectedCount := 0
	for pass := 0; pass < 2 && selectedCount < limit; pass++ {
		for itemIndex, targetIds := range matchedTargets {
			outside := exposureBoundary <= 0 || itemIndex >= exposureBoundary
			if (pass == 0) != outside {
				continue
			}
			for _, targetId := range targetIds {
				controller, ok := activeControllers[targetId]
				if !ok || controller.task.ControlType != constants.TrafficControlTaskControlTypeQuantity || targetAlphaMap[targetId] <= 0 {
					continue
				}
				selected[itemIndex] = true
				selectedCount++
				break
			}
			if selectedCount >= limit {
				break
			}
		}
	}
	return selected
}

调用点:

quantityUpliftSelected := selectMacroQuantityUpliftItems(matchedTargets, activeControllers, targetAlphaMap, upliftItemLimit(experimentParams), ctx.Size)

为什么是两趟而不是单趟跳过——这是刻意的风险最小化:

方案 语义 风险
两趟(推荐) 只重排分配顺序 任何输入下选中集合的大小与改动前相同(min(limit, 候选数)),是可证明的最小行为变更
单趟跳过区内 区内物品永不获名额 若某目标匹配物品全在区内,它从「拿 5 个名额」变成「拿 0 个」——相对 master 的第二次能力收窄,可能把问题修得更糟

退化情形已核对:

  • exposureBoundary <= 0(ctx.Size 未设置)→ outside 恒真 → pass 0 覆盖全部、pass 1 空转 → 与当前实现逐物品等价
  • limit <= 0 → 提前返回全 false,与原逻辑一致
  • 两趟处理的物品集合互斥(outside 真假二分)→ selected 不会重复置位、selectedCount 不会重复计数

五、配套测试

sort/ 已有 8 个 *_test.go,但没有 traffic_control_sort_test.go。建议新建,三条确定性单测(无 goroutine / 无时钟 / 无 ctx,PIDController 只需填 task.ControlType):

  1. 区外优先:25 个物品、曝光窗口 20、目标匹配 0-4 与 20-24、limit = 5 → 断言名额全给 20-24。此条在当前实现下必然失败,正好是回归守卫。
  2. 剩余名额回落:候选全在区内 → 断言仍获得名额(证明修复没有减少上提)。
  3. 边界为 0 时保持旧序:断言退化为原始排名先到先得。

go test ./sort/ -run TestSelectMacroQuantityUpliftItems -v

六、明确不在本次最小改动范围内

  • 积分抗饱和:本方案恢复反馈环后,第二节的积分饱和会随之消失(曝光数能涨 → error 收敛)。但若某目标匹配物品全部在曝光区内,则确实无物可提,积分仍会累积到 integralMax——这属于「目标已占满窗口」的另一情形,需独立的抗饱和逻辑,建议另开 issue。
  • 按 alpha 大小排序分配(让欠量最严重的目标优先):会改变分配的确定性顺序并增加排序开销,超出最小改动边界。
  • 名额是否应按目标独立而非共享:属产品语义决策,需业务方确认。

综上:若第二节的行为是设计意图,请在 PR 描述中补充说明(当前 PR body 为空,这个从「无限」到「最多 5 个」的能力收窄对使用方是不可见的);若不是,第四节的方案可以用约 6 行改动修掉,且不减少任何原本会发生的上提。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants