review round1 的 p1/p2 收口(commit 5967272 之后)。
p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。
- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
同源并由编译期/常量断言复核。
p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。
测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
394 lines
15 KiB
Go
394 lines
15 KiB
Go
package embedding
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// 本文件把 budget.go 的**不变式**钉成可执行的断言,两态各一条:
|
||
//
|
||
// - 「批内耗时 < 超时」⇒ 不降级:DerivedBatchCompletesWithinTimeout;
|
||
// - 「批内耗时 ≥ 超时」⇒ 超时(本次故障形态):LegacyBatchOf64TimesOut;
|
||
//
|
||
// 以及**变异自证**:把批上限调回旧值 64(或把推演调松)会让哪条测试红,
|
||
// 见 TestBatchBudget_InvariantHolds 与 TestAPIEmbedder_LegacyBatchOf64TimesOut。
|
||
//
|
||
// 假客户端用的是**实测曲线**(不是随口编的常数):单次请求耗时
|
||
// ≈ measuredOverhead + 字符总量 × measuredPerChar,取值见下面两个常量
|
||
// (2026-10-13 本机实测:固定开销 ~50ms、每字符 2.7~3.9ms,此处取上界)。
|
||
const (
|
||
measuredOverhead = 50 * time.Millisecond
|
||
measuredPerChar = 4 * time.Millisecond
|
||
latencyModelChars = 33 // 活库短正文的平均长度(实测 33 汉字)
|
||
)
|
||
|
||
// latencyTripper 是一个按上面那条曲线**真的睡够时间**的 RoundTripper:
|
||
// 它模拟「服务端会算完,不管你客户端还在不在等」。这正是本次故障的形态 ——
|
||
// 客户端 5s 放弃、服务端还在算,后面每个请求都排在它后面。
|
||
type latencyTripper struct {
|
||
mu sync.Mutex
|
||
requests int
|
||
maxItems int
|
||
maxChars int
|
||
// abandoned 计数「客户端先放弃、服务端还在算」的次数 —— 本次故障的形态。
|
||
// 修好之后它必须是 0(见 TestAPIEmbedder_OversizedBatchIsSplit 的断言)。
|
||
abandoned int
|
||
// firstSleep 非零时,第一次请求额外多睡这么久(模拟冷启动的模型加载)。
|
||
firstSleep time.Duration
|
||
}
|
||
|
||
func (l *latencyTripper) RoundTrip(r *http.Request) (*http.Response, error) {
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var req openAICompatibleRequest
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
return nil, err
|
||
}
|
||
chars := 0
|
||
for _, s := range req.Input {
|
||
chars += len([]rune(s))
|
||
}
|
||
|
||
l.mu.Lock()
|
||
l.requests++
|
||
if len(req.Input) > l.maxItems {
|
||
l.maxItems = len(req.Input)
|
||
}
|
||
if chars > l.maxChars {
|
||
l.maxChars = chars
|
||
}
|
||
first := l.requests == 1
|
||
extra := l.firstSleep
|
||
l.mu.Unlock()
|
||
|
||
wait := measuredOverhead + time.Duration(chars)*measuredPerChar
|
||
if first && extra > 0 {
|
||
wait += extra
|
||
}
|
||
select {
|
||
case <-time.After(wait):
|
||
case <-r.Context().Done():
|
||
// 客户端放弃了:服务端那一侧不会因为客户端走了就把已排队的算力还回来
|
||
// (实测的级联堆积正是从这一步开始的),因此这里明确记一笔。
|
||
l.mu.Lock()
|
||
l.abandoned++
|
||
l.mu.Unlock()
|
||
return nil, r.Context().Err()
|
||
}
|
||
|
||
resp := openAICompatibleResponse{Data: make([]openAICompatibleDatum, 0, len(req.Input))}
|
||
for i := range req.Input {
|
||
resp.Data = append(resp.Data, openAICompatibleDatum{Index: i, Embedding: []float32{1, 0}})
|
||
}
|
||
raw, err := json.Marshal(resp)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Body: io.NopCloser(strings.NewReader(string(raw))),
|
||
Header: make(http.Header),
|
||
}, nil
|
||
}
|
||
|
||
// shortTexts 造 n 条与活库短正文等长的文本。
|
||
func shortTexts(n int) []string {
|
||
out := make([]string, n)
|
||
line := strings.Repeat("河", latencyModelChars)
|
||
for i := range out {
|
||
out[i] = line
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestBatchBudget_InvariantHolds 是**推演本身**的断言(不连任何网络):
|
||
// 由超时推出来的一对上限,必须使「一次请求的耗时上界 ≤ 超时的一半」成立。
|
||
//
|
||
// 变异自证:把 DefaultBatchLimit 的推演改成旧口径(条数写回 64)⇒ 第一条断言红;
|
||
// 若改成「把超时放大到盖住 64 条」⇒ 最后一条断言红(旧配对必须仍是越界的)。
|
||
func TestBatchBudget_InvariantHolds(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if DefaultBatchLimit < 1 || DefaultBatchChars < 1 {
|
||
t.Fatalf("推出来的上限必须 ≥ 1:条数=%d 字符=%d", DefaultBatchLimit, DefaultBatchChars)
|
||
}
|
||
share := DefaultRequestTimeout / callBudgetShare
|
||
byItems := requestOverhead + time.Duration(DefaultBatchLimit)*perItemBudget
|
||
if byItems > share {
|
||
t.Fatalf("条数上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||
DefaultBatchLimit, byItems, share)
|
||
}
|
||
byChars := requestOverhead + time.Duration(DefaultBatchChars)*perCharBudget
|
||
if byChars > share {
|
||
t.Fatalf("字符上限 %d 的耗时上界 %s 超过超时的一半 %s(推演被调松了)",
|
||
DefaultBatchChars, byChars, share)
|
||
}
|
||
// 旧配对(批次 64 + 5s 超时)必须仍被判为越界 —— 那正是 2026-10-13 的故障形态:
|
||
// 实测 64 条 7.5~8.5s > 5s。这里用同一套预算常量复核它仍然出界。
|
||
legacy := requestOverhead + 64*perItemBudget
|
||
if legacy <= DefaultRequestTimeout {
|
||
t.Fatalf("旧配对(64 条)的耗时上界 %s 竟然落在单次超时 %s 之内 —— "+
|
||
"说明超时被放大到迁就批次了,本次修法不允许", legacy, DefaultRequestTimeout)
|
||
}
|
||
}
|
||
|
||
// TestChunkTexts_RespectsBothLimits 钉住切片:条数与字符总量两道界都要守,
|
||
// 单条本身越界的文本独占一段且**不被截断**。
|
||
func TestChunkTexts_RespectsBothLimits(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := chunkTexts(shortTexts(9), 2, 100)
|
||
if len(got) != 5 {
|
||
t.Fatalf("2 条/段 切 9 条 = %d 段,want 5", len(got))
|
||
}
|
||
for i, seg := range got {
|
||
if len(seg) > 2 {
|
||
t.Fatalf("第 %d 段有 %d 条,超过条数上限 2", i, len(seg))
|
||
}
|
||
}
|
||
|
||
// 字符界绑定:4 条 33 字符 = 132 字符 ⇒ 100 字符/段时必须收口(3 条 = 99 字符满,
|
||
// 余下 1 条自成一段)。
|
||
byChars := chunkTexts(shortTexts(4), 64, 100)
|
||
if len(byChars) != 2 {
|
||
t.Fatalf("100 字符/段 切 4×33 字符 = %d 段,want 2", len(byChars))
|
||
}
|
||
for i, seg := range byChars {
|
||
total := 0
|
||
for _, s := range seg {
|
||
total += len([]rune(s))
|
||
}
|
||
if total > 100 {
|
||
t.Fatalf("第 %d 段 %d 字符,超过字符上限 100", i, total)
|
||
}
|
||
}
|
||
|
||
// 超长单条:不切碎、不截断,独占一段。
|
||
huge := strings.Repeat("长", 500)
|
||
segs := chunkTexts([]string{huge, "短"}, 64, 100)
|
||
if len(segs) != 2 || segs[0][0] != huge {
|
||
t.Fatalf("超长单条应独占一段且不改写,got %d 段", len(segs))
|
||
}
|
||
|
||
if chunkTexts(nil, 4, 100) != nil {
|
||
t.Fatal("空输入应返回 nil")
|
||
}
|
||
}
|
||
|
||
// TestAPIEmbedder_DerivedBatchCompletesWithinTimeout 钉**不降级态**:
|
||
// 按推出来的上限发一次(Embed 内部切成 1 段),在实测曲线下必须在单次超时内拿到向量。
|
||
func TestAPIEmbedder_DerivedBatchCompletesWithinTimeout(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
rt := &latencyTripper{}
|
||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||
WithRoundTripper(rt))
|
||
|
||
texts := shortTexts(DefaultBatchLimit)
|
||
vectors, err := emb.Embed(context.Background(), texts)
|
||
if err != nil {
|
||
t.Fatalf("上限内的批不该超时:%v", err)
|
||
}
|
||
if len(vectors) != len(texts) {
|
||
t.Fatalf("got %d vectors, want %d", len(vectors), len(texts))
|
||
}
|
||
if rt.requests != 1 {
|
||
t.Fatalf("上限内的批应发 1 次请求,实际 %d 次", rt.requests)
|
||
}
|
||
}
|
||
|
||
// TestAPIEmbedder_OversizedBatchIsSplit 钉**不变式与调用方无关**:
|
||
// 调用方给 3 倍上限的切片时,Embed 自己切成多段、每段都在界内,向量顺序与入参一致。
|
||
func TestAPIEmbedder_OversizedBatchIsSplit(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
rt := &latencyTripper{}
|
||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||
WithRoundTripper(rt))
|
||
|
||
texts := shortTexts(DefaultBatchLimit * 3)
|
||
vectors, err := emb.Embed(context.Background(), texts)
|
||
if err != nil {
|
||
t.Fatalf("超大切片应被切开而不是超时:%v", err)
|
||
}
|
||
if len(vectors) != len(texts) {
|
||
t.Fatalf("got %d vectors, want %d(切开后必须按序拼回)", len(vectors), len(texts))
|
||
}
|
||
if rt.requests < 3 {
|
||
t.Fatalf("3 倍上限应至少切成 3 段,实际 %d 段", rt.requests)
|
||
}
|
||
if rt.maxItems > DefaultBatchLimit {
|
||
t.Fatalf("有请求带了 %d 条,超过推出来的条数上限 %d", rt.maxItems, DefaultBatchLimit)
|
||
}
|
||
if rt.maxChars > DefaultBatchChars {
|
||
t.Fatalf("有请求带了 %d 字符,超过推出来的字符上限 %d", rt.maxChars, DefaultBatchChars)
|
||
}
|
||
// 🔴 本次修复的核心不变式,确定性断言(不依赖机器负载):
|
||
// 没有任何一次请求发生「客户端先放弃、服务端还在算」——那正是级联堆积的起点。
|
||
if rt.abandoned != 0 {
|
||
t.Fatalf("有 %d 次请求被客户端先放弃(服务端还在算)⇒ 不变式没守住", rt.abandoned)
|
||
}
|
||
// 服务端侧的同一条不变式:任何一次请求的建模耗时(实测曲线)都落在单次超时内。
|
||
if cost := measuredOverhead + time.Duration(rt.maxChars)*measuredPerChar; cost > DefaultRequestTimeout {
|
||
t.Fatalf("最大一次请求的实测曲线耗时 %s 越过单次超时 %s", cost, DefaultRequestTimeout)
|
||
}
|
||
}
|
||
|
||
// TestAPIEmbedder_LegacyBatchOf64TimesOut 是**变异自证**的另一半:同样是实测曲线,
|
||
// 旧配对(一次请求 64 条 + 5s 超时)必须**超时**。它证明把上限写回 64 不是「保守」,而是
|
||
// 必然把客户端与服务端撕裂(客户端走了、服务端还在算 ⇒ 后续请求全排队)。
|
||
func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
rt := &latencyTripper{}
|
||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||
WithRoundTripper(rt))
|
||
|
||
// 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。
|
||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
|
||
t.Fatalf("预热调用失败:%v", err)
|
||
}
|
||
start := time.Now()
|
||
_, err := emb.embedChunk(context.Background(), shortTexts(64))
|
||
elapsed := time.Since(start)
|
||
if err == nil {
|
||
t.Fatalf("旧配对(64 条 + %s 超时)在实测曲线下不该成功", DefaultRequestTimeout)
|
||
}
|
||
if elapsed > DefaultRequestTimeout+2*time.Second {
|
||
t.Fatalf("超时保护没生效:等了 %s", elapsed)
|
||
}
|
||
}
|
||
|
||
// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态
|
||
// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。
|
||
//
|
||
// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是
|
||
// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast
|
||
// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。
|
||
func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
rt func(t *testing.T) http.RoundTripper
|
||
}{
|
||
{
|
||
name: "连接被拒",
|
||
rt: func(t *testing.T) http.RoundTripper {
|
||
return roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||
return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝")
|
||
})
|
||
},
|
||
},
|
||
{
|
||
name: "HTTP 500",
|
||
rt: func(t *testing.T) http.RoundTripper {
|
||
return roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||
return &http.Response{
|
||
StatusCode: http.StatusInternalServerError,
|
||
Body: io.NopCloser(strings.NewReader("server exploded")),
|
||
Header: make(http.Header),
|
||
}, nil
|
||
})
|
||
},
|
||
},
|
||
{
|
||
name: "接受连接但永不回包",
|
||
rt: func(t *testing.T) http.RoundTripper {
|
||
return roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||
<-r.Context().Done() // 只等服务端超时,永不回包
|
||
return nil, r.Context().Err()
|
||
})
|
||
},
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||
WithRoundTripper(tc.rt(t)))
|
||
|
||
// 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽),
|
||
// 下界只对会真正等到截止的那一种(挂死)有意义。
|
||
start := time.Now()
|
||
_, err := emb.Embed(context.Background(), shortTexts(1))
|
||
first := time.Since(start)
|
||
if err == nil {
|
||
t.Fatal("这一组失败形态不该成功")
|
||
}
|
||
t.Logf("首次耗时 = %s", first.Round(time.Millisecond))
|
||
if first > warmupTimeout+time.Second {
|
||
t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout)
|
||
}
|
||
|
||
// 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。
|
||
start = time.Now()
|
||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil {
|
||
t.Fatal("这一组失败形态不该成功")
|
||
}
|
||
second := time.Since(start)
|
||
t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout)
|
||
if second > DefaultRequestTimeout+time.Second {
|
||
t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout)
|
||
}
|
||
// 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。
|
||
if emb.warmupLeft.Load() {
|
||
t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求
|
||
// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout
|
||
// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。
|
||
func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second}
|
||
emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
|
||
WithRoundTripper(rt))
|
||
|
||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
|
||
t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err)
|
||
}
|
||
if emb.warmupLeft.Load() {
|
||
t.Fatal("放宽许可应在第一次请求时就被花掉")
|
||
}
|
||
|
||
var slack time.Duration
|
||
steady := roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||
dl, ok := r.Context().Deadline()
|
||
if !ok {
|
||
t.Error("稳态请求没有 deadline")
|
||
} else {
|
||
slack = time.Until(dl)
|
||
}
|
||
return &http.Response{
|
||
StatusCode: http.StatusOK,
|
||
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[1]}]}`)),
|
||
Header: make(http.Header),
|
||
}, nil
|
||
})
|
||
emb.client = &http.Client{Transport: steady}
|
||
if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil {
|
||
t.Fatalf("稳态调用失败:%v", err)
|
||
}
|
||
if slack <= 0 || slack > DefaultRequestTimeout {
|
||
t.Fatalf("稳态单次超时余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
|
||
}
|
||
}
|