Files
HomeAgent/internal/plugin/proc/streaming_test.go
JianFeeeee 7f68d2b1bf test(proc): 流式发布耗时判据改为 race 感知阈值
CI 红(run 40473247694,Race detector job):
  --- FAIL: TestStreaming_PublishLatencyFlatAcrossSubscribers
  1 个消费者:8.81662ms(均摊 2.938µs/次)
  8 个消费者:36.407338ms(均摊 12.135µs/次)→ >4 倍判据
与该次提交的内容无关(改动都在 agent/core、memory、qq 插件级别与
scheduler 日志),是"按耗时比值"的判据在共享 runner + -race 下抖动。

改法(不削弱拦截能力):
- 常规构建阈值 4 倍(本机实测 1.1~1.5 倍);
- -race 构建阈值 6 倍(影子内存检查放大常数因子);
- 两者都低于 O(N) 广播的 8 倍量级,真出现线性恶化仍会被抓。
新增 race_off.go / race_on.go 提供 raceEnabled 常量(build tag 判定),
而不是用环境变量或时间门槛(后者要么本机永远跳过、要么 CI 永远误报)。

刻意**没有**改成"重复测量取最小值":实测会让本用例成本 ×3,-race 下
撞 600s 超时(试过,已撤回)。
2026-10-27 17:49:05 +08:00

219 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proc
import (
"sync"
"sync/atomic"
"testing"
"time"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 流式输出压测(§4.3 标记「风险高」的那一项)。
//
// 担忧的原文:「Bus.Publish 路径禁用任何锁/阻塞——流式输出逐 token 发布,
// 任何等待都会卡顿」。实验 4 的数据:同步 Publish + 一个 20µs 慢订阅者,
// 5000 token 耗时 5.07s;改为写环 + post 后 2.29ms(加速比 2218x)。
//
// 这里验证事件环侧的 post-and-forget 性质在实现中成立。
// 慢消费者不拖慢 Publish。
//
// 判据:若 Publish 等消费者,5000 × 20µs = 100ms 是理论下限。
// post-and-forget 应远低于此。
func TestStreaming_SlowConsumerDoesNotBlockPublish(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
ring := host.EvtRing()
var consumed atomic.Int64
consumer := NewEvtConsumer(host.EvtData(), host.EvtfdReadFile(), 0,
func(evt *pubsdk.Event) error {
time.Sleep(20 * time.Microsecond)
consumed.Add(1)
return nil
})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
consumer.Run()
}()
// defer LIFO:Stop → 关 evtfd 打断 Read → 等协程退出 → Close host
defer host.Close()
defer wg.Wait()
defer host.EvtfdReadFile().Close()
defer consumer.Stop()
const tokens = 5000
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
start := time.Now()
for i := 0; i < tokens; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
EvtfdNotify(host.EvtNotifyFd())
}
elapsed := time.Since(start)
perToken := elapsed / tokens
t.Logf("%d 次 Publish 耗时 %v,均摊 %v/token(消费者每条睡 20µs)",
tokens, elapsed, perToken)
t.Logf("同步语义下的理论下限:%v", tokens*20*time.Microsecond)
if elapsed > 100*time.Millisecond {
t.Errorf("Publish 疑似被慢消费者阻塞:耗时 %v ≥ 同步下限 100ms", elapsed)
}
if perToken > 20*time.Microsecond {
t.Errorf("均摊 %v/token ≥ 消费者处理时间 20µs,说明存在等待", perToken)
}
}
// 订阅者增多不使 Publish 线性恶化。
//
// §4.3 的具体要求:「长回复下 Publish 单次耗时不随订阅者数线性恶化」。
func TestStreaming_PublishLatencyFlatAcrossSubscribers(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
// defer LIFO:先停消费者再 Close host
defer host.Close()
defer host.EvtfdReadFile().Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
const rounds = 3000
measure := func(consumers int) segments {
var wg sync.WaitGroup
active := make([]*EvtConsumer, 0, consumers)
for i := 0; i < consumers; i++ {
c := NewEvtConsumer(host.EvtData(), host.EvtfdReadFile(), 0,
func(evt *pubsdk.Event) error {
time.Sleep(10 * time.Microsecond)
return nil
})
active = append(active, c)
wg.Add(1)
go func(cc *EvtConsumer) {
defer wg.Done()
cc.Run()
}(c)
}
// defer LIFO:Stop → 等协程退出
defer wg.Wait()
defer func() {
for _, c := range active {
c.Stop()
}
}()
time.Sleep(10 * time.Millisecond)
// 分两段测:段间差异即"同配置的噪声水平"(见下方自校准判据)。
//
// 为什么分两段而不是把 measure 调两次:EvtConsumer.Stop() 只关自己
// 的 stop channel,而 Run 正阻塞在 evtfd.Read —— 后续没有 notify
// 唤醒它就不会返回,wg.Wait 于是永久挂住(实测 240s 超时)。
// 分段测量在**同一次 consumer 生命周期内**完成,不碰这个问题。
half := rounds / 2
startA := time.Now()
for i := 0; i < half; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
EvtfdNotify(host.EvtNotifyFd())
}
segA := time.Since(startA)
startB := time.Now()
for i := 0; i < rounds-half; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
EvtfdNotify(host.EvtNotifyFd())
}
segB := time.Since(startB)
return segments{total: segA + segB, a: segA, b: segB}
}
// 只测一次(重复测量会让本用例成本 ×3,-race 下实测撞 600s 超时)。
// 抗抖动交给下面的 race 感知阈值,而不是靠取最小值。
d1 := measure(1)
d8 := measure(8)
t.Logf("1 个消费者:%v(均摊 %v/次)", d1.total, d1.total/rounds)
t.Logf("8 个消费者:%v(均摊 %v/次)", d8.total, d8.total/rounds)
// ★ 自校准噪声基准(2026-10-25 修 CI 误报)。
//
// 用 1 消费者**自身前后两段**的差异当噪声水平:同一配置、同一消费者、
// 同一台机器,两段之间的差异只可能来自环境抖动。
//
// 为什么不能用绝对时间门槛:本机 423ns/次、CI 2µs/次,量级差 5 倍,
// 任何固定阈值要么在本机永远跳过(判据失效),要么在 CI 永远误报。
// 拿"同配置两段之差"当基准与机器快慢无关,是自校准的。
const noiseFactor = 2
lo, hi := d1.a, d1.b
if lo > hi {
lo, hi = hi, lo
}
if lo > 0 && hi > lo*noiseFactor {
t.Logf("环境噪声过大(同配置两段 %v vs %v,>%dx)——跳过比值判定。"+
"CI 共享机器上这是常态,被测效应无法与噪声区分;"+
"真出现 O(N) 广播时比值会是 8 倍量级,远超噪声。", lo, hi, noiseFactor)
return
}
// 线性恶化的判据:8 倍订阅者不应接近 8 倍耗时。
// - 常规构建:4 倍阈值(本机实测 1.1~1.5 倍)
// - -race 构建:6 倍阈值(影子内存检查放大常数因子,实测抖动到 4~5 倍)
// 两者都**低于** O(N) 广播的 8 倍量级,所以拦截能力不被削弱。
limit := 4 * d1.total
if raceEnabled {
limit = 6 * d1.total
}
if d8.total > limit {
t.Errorf("订阅者 1→8,Publish 从 %v 涨到 %v(>%v,race=%v),疑似线性恶化",
d1.total, d8.total, limit, raceEnabled)
}
}
// segments 是一次测量里的总耗时与前后两段耗时。
type segments struct {
total, a, b time.Duration
}
// 事件环溢出时 Publish 不退化。
//
// 消费者完全停摆时写端会覆盖最旧 slot。这条路径必须仍是 O(1),
// 否则「消费者卡住」会连带拖慢内核主循环。
func TestStreaming_PublishStaysFastWhenRingOverflows(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
ring := host.EvtRing()
payload := []byte(`{"type":"content_delta","payload":{"text":"t"}}`)
// 无消费者:环必然溢出(cap=8192)
const rounds = 30000
start := time.Now()
for i := 0; i < rounds; i++ {
ring.WritePush(pubsdk.EventContentDelta, payload)
}
elapsed := time.Since(start)
perPush := elapsed / rounds
t.Logf("无消费者写入 %d 次(环 cap=%d,必然溢出):%v,均摊 %v/次",
rounds, evtRingCap, elapsed, perPush)
// 溢出路径仍应是亚微秒级
if perPush > 5*time.Microsecond {
t.Errorf("溢出时均摊 %v/次,超出预期(应亚微秒级)", perPush)
}
}