Files
Cyrene_v2/internal/runtime/embedding/api_url_test.go
T
AskaEth 59672720bd fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」
M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在
算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求)
排队到自己的超时 ⇒ 召回全线降级。

- embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式,
  批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令
  写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 +
  2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。
- embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动
  首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。
- memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源),
  并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。
- memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生**
  的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增
  recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。
- 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态
  (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0;
  memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。

不改 preinsert*、不改 .env、不动判定规则与任何阈值。
2026-10-13 20:33:26 +08:00

274 lines
9.8 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 embedding
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestNormalizeEndpointURL 钉住「回环别名 → 127.0.0.1」这张表,以及**不该被动**
// 的那些输入。全部是纯字符串断言,不连任何网络。
func TestNormalizeEndpointURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{
name: "localhost 改写",
in: "http://localhost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 大小写不敏感",
in: "http://LocalHost:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "localhost 无端口",
in: "http://localhost/v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "https 同样改写",
in: "https://localhost:8443/v1/embeddings",
want: "https://127.0.0.1:8443/v1/embeddings",
},
{
// 0.0.0.0 是「监听所有接口」的语义,不是可连接的地址(拿它当连接目标会
// 立即失败),因此这里是**防呆**:把写坏的地址掰回真正可连的回环字面量,
// 而不是「支持用 0.0.0.0 去连」。
name: "0.0.0.0 防呆改写",
in: "http://0.0.0.0:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
// 空主机同样只是防呆:原文拼出来的 URL 指向不了任何服务。
name: "空主机防呆改写",
in: "http:///v1/embeddings",
want: "http://127.0.0.1/v1/embeddings",
},
{
name: "显式 IPv6 字面量不改写",
in: "http://[::1]:11434/v1/embeddings",
want: "http://[::1]:11434/v1/embeddings",
},
{
name: "已是 127.0.0.1 原样返回",
in: "http://127.0.0.1:11434/v1/embeddings",
want: "http://127.0.0.1:11434/v1/embeddings",
},
{
name: "远端主机名不改写",
in: "https://embed.example.com/v1/embeddings",
want: "https://embed.example.com/v1/embeddings",
},
{
// 路径与查询串必须逐字不变:包括百分号编码、`+`、重复键与空值。
name: "路径与查询串逐字不变",
in: "http://localhost:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
want: "http://127.0.0.1:8080/v1/emb%65ddings?a=%2F+&b=1&b=&empty#frag",
},
{
// 未改写的主机(含显式 IPv6)必须**原串**返回,不做任何重排。
name: "未改写时逐字返回原串",
in: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
want: "https://example.com:443/v1/a%2Fb?q=%7B%7D&r=1#f",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := normalizeEndpointURL(tc.in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeEndpointURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestNormalizeEndpointURL_NonHTTPSchemeUntouched 保证非 http(s) 的输入不被改写:
// 那类地址不是本客户端能拨的,改主机只会掩盖更该暴露的配置错误。
func TestNormalizeEndpointURL_NonHTTPSchemeUntouched(t *testing.T) {
t.Parallel()
const in = "file://localhost/v1/embeddings"
got, err := normalizeEndpointURL(in)
if err != nil {
t.Fatalf("normalizeEndpointURL(%q) error: %v", in, err)
}
if got != in {
t.Fatalf("normalizeEndpointURL(%q) = %q, want unchanged", in, got)
}
}
// TestAPIEmbedder_NormalizesLoopbackHost 从**发出的请求**上验证规范化确实生效:
// 主机拨号目标变成 127.0.0.1,且 path + query 一字未动。用注入的 RoundTripper,
// 不连任何网络。
func TestAPIEmbedder_NormalizesLoopbackHost(t *testing.T) {
t.Parallel()
var gotHost, gotURI string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
gotURI = r.URL.RequestURI()
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://localhost:11434/v1/embeddings?k=v#frag"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "127.0.0.1:11434" {
t.Errorf("dial host = %q, want 127.0.0.1:11434", gotHost)
}
if gotURI != "/v1/embeddings?k=v" {
t.Errorf("request URI = %q, want /v1/embeddings?k=v", gotURI)
}
}
// TestAPIEmbedder_KeepsExplicitIPv6Host 尊重使用者显式写下的 IPv6 字面量:
// 不改写、不重排。同样走注入的 RoundTripper,不连网络。
func TestAPIEmbedder_KeepsExplicitIPv6Host(t *testing.T) {
t.Parallel()
var gotHost string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
gotHost = r.URL.Host
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"data":[{"index":0,"embedding":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://[::1]:11434/v1/embeddings"},
WithRoundTripper(rt),
)
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("Embed() error: %v", err)
}
if gotHost != "[::1]:11434" {
t.Errorf("dial host = %q, want [::1]:11434(显式 IPv6 不许被改写)", gotHost)
}
}
// TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer 是**真连本机**的场景验证
// (httptest 服务器只绑 127.0.0.1,正是 llama.cpp / ollama 的默认形态):
// 端点写成 localhost / 0.0.0.0 时,请求必须仍然落到那个 IPv4 回环服务上并拿回向量。
//
// 注意它证明的是「场景通」而非「不做规范化就必挂」:2026-10-07 在本机实测,
// Go 客户端的拨号器对 localhost 会很快回落到 127.0.0.1(约 57ms),把这一条测试
// 的规范化关掉它**仍然会通过**。真正把这条测试变红的是 path/query 被破坏之类的
// 回归;「localhost 一定不去碰 ::1」这个保证由上面的字符串断言守住。
// 全程只连本进程起在 127.0.0.1 上的临时服务器,不碰任何外部服务。
func TestAPIEmbedder_LoopbackAliasReachesIPv4OnlyServer(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"data":[{"index":0,"embedding":[0.1,0.2]}]}`)
}))
defer srv.Close()
for _, alias := range []string{"localhost", "0.0.0.0"} {
alias := alias
// 刻意**不用** t.Parallel():子测试若并行,父测试会先返回并执行
// defer srv.Close(),子测试再跑时就只能撞上「连接被拒绝」。
t.Run(alias, func(t *testing.T) {
// srv.URL 形如 http://127.0.0.1:PORT —— 只把主机换成别名,端口保留,
// 这样「服务确实只绑 IPv4」这个前提不变。
raw := strings.Replace(srv.URL, "127.0.0.1", alias, 1) + "/v1/embeddings"
emb := NewAPI(config.EmbeddingConfig{APIURL: raw})
vectors, err := emb.Embed(context.Background(), []string{"hi"})
if err != nil {
t.Fatalf("端点写成 %s 时未落到 IPv4 回环服务:%v", alias, err)
}
if len(vectors) != 1 || len(vectors[0]) != 2 {
t.Fatalf("vectors = %v, want 1 个 2 维向量", vectors)
}
})
}
}
// reviewBudgetRef 是「入库前审查」单条总预算的**参照值**,只用于本文件的断言:
// 权威定义在 internal/runtime/memory/preinsert_config.go 的 defaultReviewTimeout。
// 跨包的真实断言在 memory 包(preinsert_recall_test.go)里,这里钉住同一个口径,
// 免得 embedding 侧悄悄把超时调到 ≥ 审查预算。
const reviewBudgetRef = 20 * time.Second
// TestDefaultRequestTimeout 钉住单次 embedding 调用的超时值,并断言它**严格小于**
// 审查总预算:否则一次卡死的 embedding 连接就能吃光整条审查的预算,排在后面的
// T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实
// 打在了发出去的请求上。
//
// ⚠️ 冷启动那一次走的是 warmupTimeout(见 budget.go),因此这里**以稳态请求**
// (第二次及以后)为准;冷启动那一路由 budget_test.go 的
// TestAPIEmbedder_WarmupFirstCallBudget 单独钉。
func TestDefaultRequestTimeout(t *testing.T) {
t.Parallel()
if DefaultRequestTimeout <= 0 {
t.Fatalf("DefaultRequestTimeout = %s, 必须为正", DefaultRequestTimeout)
}
if DefaultRequestTimeout >= reviewBudgetRef {
t.Fatalf("embedding 单次超时 %s 必须严格小于审查总预算 %s",
DefaultRequestTimeout, reviewBudgetRef)
}
if DefaultRequestTimeout != 5*time.Second {
t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)",
DefaultRequestTimeout)
}
var slack time.Duration
rt := 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":[0.1]}]}`)),
Header: make(http.Header),
}, nil
})
emb := NewAPI(
config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"},
WithRoundTripper(rt),
)
// 第一次是冷启动调用(warmupTimeout),结果由它自己的测试断言;这里连发两次,
// 只看稳态那一次打出去的 deadline。
for i := 0; i < 2; i++ {
if _, err := emb.Embed(context.Background(), []string{"hi"}); err != nil {
t.Fatalf("第 %d 次 Embed() error: %v", i+1, err)
}
}
if slack <= 0 || slack > DefaultRequestTimeout {
t.Fatalf("请求 deadline 余量 = %s, want (0, %s]", slack, DefaultRequestTimeout)
}
}