fix(notify): 汇总模式按消息计量限流,批次大小不再被每轮请求预算掐死

令牌桶的额度此前被同时当成两个量纲用:对实时模式它是「发几条消息」,
对汇总模式却被直接当成「一批装几条漏洞」传给 ClaimDigestBatch。

notifyMaxSendsPerChannelPerTick(=5)是由租约时长倒推的 HTTP 请求预算,
而一个汇总批次只发一次请求,两者本不该相乘。稳态下更糟:rate_per_min=20
的渠道在 3 秒的 tick 里只补得到 1 个令牌,于是每条汇总消息只装 1 个漏洞,
头部还写着「近 30 分钟新增 1 个漏洞」——digest 退化成带汇总文案的实时推送,
MaxDigestBatchSize(500) 永不可达。

改为统一按**消息条数**计量:一批漏洞合成一条消息、消耗一个令牌,
rate_per_min 对 digest 依然生效(每分钟最多这么多条汇总消息),
而批次大小只受 MaxDigestBatchSize 这个内存上界约束。

ClaimDigestBatch 原先的注释声称「单批同时受限流额度与内存上界约束」,
那正是本问题的来源——限流额度本不该充当批次大小。注释一并改掉,
免得下次又按「rate_per_min 对 digest 不生效」把它改回去。

顺带让 takeTokens 接受 want 上限:原先整桶抽空后才由调用方截断,
多出来的令牌(飞书默认 100/min 时是 95 个)直接丢弃,渠道这一轮
没有待发投递时也照扣,注释声称的突发容量在任何情况下都做不到。

决策收在 digestTickPlan 里由新增用例直接钉住。现有端到端用例都手动给
stepDigest 传一个够大的 limit,绕过了 step 里的额度计算,所以覆盖不到这里。
This commit is contained in:
Autumn-27 committed 2026-10-01 06:43:29 -04:00
1 parent f2f35ebc2e
commit 93c1d39df3
3 files changed
+123 -26

No files matched your search

+62 -6
View File
@@ -856,28 +856,84 @@ func TestNotifyRateLimitDoesNotConsumeRetryBudget(t *testing.T) {
n := &Notifier{buckets: map[int64]*notifyBucket{}}
now := time.Now()
// 每分钟 1 条:满桶时最多 1 条。
if got := n.takeTokens(1, 1, now); got != 1 {
if got := n.takeTokens(1, 1, notifyMaxSendsPerChannelPerTick, now); got != 1 {
t.Fatalf("满桶时每分钟 1 条应取 1 个令牌,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
if got := n.takeTokens(1, 1, notifyMaxSendsPerChannelPerTick, now.Add(time.Millisecond)); got != 0 {
t.Fatalf("令牌耗尽后应立即返回 0,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(30*time.Second)); got != 0 {
if got := n.takeTokens(1, 1, notifyMaxSendsPerChannelPerTick, now.Add(30*time.Second)); got != 0 {
t.Fatalf("半程不应补满一个令牌,得到 %d", got)
}
if got := n.takeTokens(1, 1, now.Add(time.Minute)); got != 1 {
if got := n.takeTokens(1, 1, notifyMaxSendsPerChannelPerTick, now.Add(time.Minute)); got != 1 {
t.Fatalf("满一个周期应补回 1 个令牌,得到 %d", got)
}
// 不限流渠道走有限上限,避免单轮被无限积压拖住。
if got := n.takeTokens(2, 0, now); got != notifyUnlimitedBurstPerTick {
if got := n.takeTokens(2, 0, notifyUnlimitedBurstPerTick+10, now); got != notifyUnlimitedBurstPerTick {
t.Fatalf("不限流应返回每轮上限 %d,得到 %d", notifyUnlimitedBurstPerTick, got)
}
// 渠道之间的令牌桶互相独立。
if got := n.takeTokens(1, 1, now.Add(time.Millisecond)); got != 0 {
if got := n.takeTokens(1, 1, notifyMaxSendsPerChannelPerTick, now.Add(time.Millisecond)); got != 0 {
t.Fatalf("渠道 1 的桶应仍然为空,得到 %d", got)
}
}
// TestNotifyTakeTokensKeepsUnusedTokens 锁住「只取 want 个」的语义。
//
// 曾经的实现把桶整个抽空后才由调用方截断,于是 rate=100/min 的渠道攒满桶、
// 一轮只用 5 条,剩下 95 个令牌直接丢弃;渠道这一轮没有待发投递时同样照扣。
// 结果是注释声称的「积压时可以一次性冲 rate_per_min 条」在任何情况下都做不到。
func TestNotifyTakeTokensKeepsUnusedTokens(t *testing.T) {
n := &Notifier{buckets: map[int64]*notifyBucket{}}
now := time.Now()
// 桶初始为满(100),本轮只要 5 个。
if got := n.takeTokens(1, 100, 5, now); got != 5 {
t.Fatalf("want=5 时应恰好取 5 个令牌,得到 %d", got)
}
// 关键断言:余下的 95 个必须还在桶里,而不是被抽空丢弃。
// 不推进时间,确保取到的只可能来自存量而非补充。
if got := n.takeTokens(1, 100, 95, now); got != 95 {
t.Fatalf("剩余令牌应仍可取用(期望 95),得到 %d——桶被整轮抽空了", got)
}
if got := n.takeTokens(1, 100, 1, now); got != 0 {
t.Fatalf("桶已取尽,应返回 0,得到 %d", got)
}
// want<=0 不应扣减任何令牌(空轮不收费)。
n2 := &Notifier{buckets: map[int64]*notifyBucket{}}
if got := n2.takeTokens(1, 20, 0, now); got != 0 {
t.Fatalf("want=0 应返回 0,得到 %d", got)
}
if got := n2.takeTokens(1, 20, 20, now); got != 20 {
t.Fatalf("want=0 的那次不该消耗令牌,应仍可取满 20,得到 %d", got)
}
}
// TestDigestTickPlanDecouplesBatchSizeFromSendBudget 钉住汇总模式的两个量纲。
//
// 汇总批次的大小一旦跟每轮请求预算挂上,rate_per_min=20 的渠道就只能在每个
// 3 秒 tick 里补到 1 个令牌,于是每条汇总消息只装 1 个漏洞——功能上等于没有
// 汇总,而消息头部还写着「近 30 分钟新增 1 个漏洞」。这个退化不会报错,
// 现有的端到端用例也看不出来(它们手动给 stepDigest 传一个够大的 limit,
// 绕过了 step 里的额度计算),所以在这里直接断言决策本身。
func TestDigestTickPlanDecouplesBatchSizeFromSendBudget(t *testing.T) {
tokens, claimLimit := digestTickPlan()
// 一批 = 一条消息 = 一次请求 = 一个令牌。令牌的单位是消息,不是漏洞。
if tokens != 1 {
t.Fatalf("汇总一批只发一条消息,应恰好消耗 1 个令牌,得到 %d", tokens)
}
if claimLimit != db.MaxDigestBatchSize {
t.Fatalf("汇总批次大小应为内存上界 db.MaxDigestBatchSize=%d,得到 %d",
db.MaxDigestBatchSize, claimLimit)
}
// 关键关系:批次大小必须远大于每轮请求预算。两者一旦同量级,
// 说明又把「发几条消息」和「一批装几条漏洞」混成了一个数。
if claimLimit <= notifyMaxSendsPerChannelPerTick {
t.Fatalf("汇总批次大小 %d 不应受每轮请求预算 %d 约束——"+
"请求预算是由租约倒推的「发几次请求」,与「一批装几条漏洞」是两个量纲",
claimLimit, notifyMaxSendsPerChannelPerTick)
}
}
// TestNotifyTickBudgetFitsWithinLease 是又一条防漂移断言。
//
// 单渠道每轮的投递条数上限(notifyMaxSendsPerChannelPerTick)是从租约时长倒推的: