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)。
217 lines
7.9 KiB
Go
217 lines
7.9 KiB
Go
package memory
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||
)
|
||
|
||
// 本文件钉 M6(2026-10-13)的 medium finding:整理轮/试算轮的 `recall_mode`
|
||
// 必须是**实际发生**的那一种,不是配置态。
|
||
//
|
||
// 旧实现(tidyRecallMode(hasEmbedder bool))按「有没有配 embedder」写,于是
|
||
// 「配了但是向量不可用 ⇒ 全量降级」在汇总行里被写成 embedding —— 汇总与事实相反。
|
||
//
|
||
// 「配了但不可用」的 embedder 复用 tidy_dryrun_test.go 的 failingEmbedder
|
||
// (正是线上那 2568 次 `embedding recall unavailable` 的形态)。
|
||
|
||
// newLogCaptureTidier 构建一个把汇总行写进 buffer 的整理器(便于断言日志字段)。
|
||
func newLogCaptureTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *bytes.Buffer) {
|
||
t.Helper()
|
||
buf := &bytes.Buffer{}
|
||
tr, err := NewTidier(cfg, TidyDeps{
|
||
Store: store,
|
||
Embedder: emb,
|
||
Log: slog.New(slog.NewTextHandler(buf, nil)),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewTidier: %v", err)
|
||
}
|
||
return tr, buf
|
||
}
|
||
|
||
// TestRecallLedger_LabelComesFromActualUse 钉记账口本身:label 完全由「真的用了
|
||
// 哪条路」推出,混合与降级都不许被抹平。
|
||
func TestRecallLedger_LabelComesFromActualUse(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
notes func(l *recallLedger)
|
||
want string
|
||
degraded int
|
||
}{
|
||
{name: "没算过(没候选)", notes: func(*recallLedger) {}, want: ""},
|
||
{name: "全部语义", notes: func(l *recallLedger) { l.noteEmbedding() }, want: tidyRecallEmbedding},
|
||
{name: "全部词项", notes: func(l *recallLedger) { l.noteLexical(false) }, want: tidyRecallLexical},
|
||
{
|
||
name: "降级到词项",
|
||
notes: func(l *recallLedger) { l.noteLexical(true) },
|
||
want: tidyRecallLexical,
|
||
degraded: 1,
|
||
},
|
||
{
|
||
name: "多分区混合(部分降级)",
|
||
notes: func(l *recallLedger) { l.noteEmbedding(); l.noteLexical(true) },
|
||
want: tidyRecallMixed,
|
||
degraded: 1,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
var l recallLedger
|
||
tc.notes(&l)
|
||
if got := l.label(); got != tc.want {
|
||
t.Fatalf("label = %q, want %q", got, tc.want)
|
||
}
|
||
if got := l.degradedPartitions(); got != tc.degraded {
|
||
t.Fatalf("degraded = %d, want %d", got, tc.degraded)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeReportsDegradation 是 finding 的正面回归:配了 embedder
|
||
// 但它报错 ⇒ 汇总行必须写 `recall_mode=lexical` 且 `recall_degraded_partitions=1`,
|
||
// **绝不能**写成 embedding。
|
||
func TestTidyRunLog_RecallModeReportsDegradation(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce 应当降级跑完而不是失败:%v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, "recall_mode=lexical") {
|
||
t.Fatalf("降级轮的汇总行必须写 lexical,实际:%s", line)
|
||
}
|
||
if strings.Contains(line, "recall_mode=embedding") {
|
||
t.Fatalf("降级轮被报成了 embedding(正是本 finding 的形态):%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=1") {
|
||
t.Fatalf("降级分区数没写出来:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy 是反面:embedder 正常时
|
||
// 汇总行必须如实写 embedding、降级数为 0(不能因为「怕误报」而一律写降级)。
|
||
func TestTidyRunLog_RecallModeReportsEmbeddingWhenHealthy(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder())
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, "recall_mode=embedding") {
|
||
t.Fatalf("健康轮必须写 embedding,实际:%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=0") {
|
||
t.Fatalf("健康轮的降级数必须为 0:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyRunLog_RecallModeEmptyWhenNoPairs 钉「没候选」与「向量不可用」可区分:
|
||
// 分区里只有一条(压根没有对可比)时,来源是**空串**,而不是任何一条召回路径。
|
||
func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
if _, err := tr.RunOnce(ctx); err != nil {
|
||
t.Fatalf("RunOnce: %v", err)
|
||
}
|
||
|
||
line := lastLogLine(t, buf, "memory tidy run finished")
|
||
if !strings.Contains(line, `recall_mode=""`) {
|
||
t.Fatalf("没有候选可比时来源应为空串(区别于降级),实际:%s", line)
|
||
}
|
||
if !strings.Contains(line, "recall_degraded_partitions=0") {
|
||
t.Fatalf("没有候选可比不算降级:%s", line)
|
||
}
|
||
}
|
||
|
||
// TestTidyDryRun_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2:
|
||
// 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。
|
||
//
|
||
// 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的),
|
||
// 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方
|
||
// (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是
|
||
// `500 tidy_recall_unavailable`。
|
||
func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
t.Run("向量源不可用", func(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
seedDryRunFixture(t, store)
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
out, err := tr.DryRun(context.Background())
|
||
if !errors.Is(err, ErrTidyRecallUnavailable) {
|
||
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
|
||
}
|
||
if out.RecallMode == tidyRecallEmbedding {
|
||
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
|
||
}
|
||
line := lastLogLine(t, buf, "memory tidy dry-run finished")
|
||
if !strings.Contains(line, "recall_unavailable_partitions=1") {
|
||
t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line)
|
||
}
|
||
})
|
||
|
||
t.Run("没有候选可比", func(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||
|
||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||
tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||
if _, err := tr.DryRun(context.Background()); err != nil {
|
||
t.Fatalf("没有对可比时试算不该报错:%v", err)
|
||
}
|
||
line := lastLogLine(t, buf, "memory tidy dry-run finished")
|
||
if !strings.Contains(line, "recall_unavailable_partitions=0") {
|
||
t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line)
|
||
}
|
||
if !strings.Contains(line, `recall_mode=""`) {
|
||
t.Fatalf("没候选时来源为空串,实际:%s", line)
|
||
}
|
||
})
|
||
}
|
||
|
||
// lastLogLine 返回 buffer 里最后一条含 marker 的日志行。
|
||
func lastLogLine(t *testing.T, buf *bytes.Buffer, marker string) string {
|
||
t.Helper()
|
||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||
for i := len(lines) - 1; i >= 0; i-- {
|
||
if strings.Contains(lines[i], marker) {
|
||
return lines[i]
|
||
}
|
||
}
|
||
t.Fatalf("日志里没有找到 %q:\n%s", marker, buf.String())
|
||
return ""
|
||
}
|