fix(memory): 冷启动放宽改为一次性 + 试算轮区分「没候选」与「向量不可用」
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)。
This commit is contained in:
1 parent
59672720bd
commit
6c30ca540a
7 files changed
+273
-72
No files matched your search
@@ -76,22 +76,30 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
|
||||
|
||||
out.Stats = stats
|
||||
out.Candidates = collected
|
||||
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」,
|
||||
// 与「向量不可用」不是一回事)。
|
||||
// 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。
|
||||
// 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出
|
||||
// (见 logDryRun 的说明与 recallLedger.unavailable)。
|
||||
out.RecallMode = ledger.label()
|
||||
out.DurationMS = t.now().Sub(started).Milliseconds()
|
||||
// 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的
|
||||
// `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点,
|
||||
// 只在成功时记就等于把这条口径丢掉。
|
||||
t.logDryRun(out, ledger)
|
||||
if len(failures) > 0 {
|
||||
return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...))
|
||||
}
|
||||
t.logDryRun(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
|
||||
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
|
||||
//
|
||||
// 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选
|
||||
// 可比」在观测面上同值(reviewer round1 的 p2)。
|
||||
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
|
||||
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
|
||||
if err != nil {
|
||||
ledger.noteUnavailable()
|
||||
t.log.Warn("memory tidy dry-run recall unavailable",
|
||||
"partition", p.label(), "items", len(items), "error", err)
|
||||
return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err)
|
||||
@@ -101,7 +109,17 @@ func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []Memo
|
||||
|
||||
// logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆
|
||||
// 正文** —— 只记计数、分区数与截断标签。
|
||||
func (t *Tidier) logDryRun(out TidyDryRun) {
|
||||
//
|
||||
// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开
|
||||
// (reviewer round1 的 p2:它们曾同值):
|
||||
// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度);
|
||||
// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级,
|
||||
// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`);
|
||||
// - mode=embedding/lexical:真的用该来源算过。
|
||||
//
|
||||
// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误
|
||||
// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。
|
||||
func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
|
||||
t.log.Info("memory tidy dry-run finished",
|
||||
"rule_version", TidyRuleVersion,
|
||||
"partitions", out.Stats.Partitions,
|
||||
@@ -113,6 +131,7 @@ func (t *Tidier) logDryRun(out TidyDryRun) {
|
||||
"veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons),
|
||||
"errors", out.Stats.Errors,
|
||||
"recall_mode", out.RecallMode,
|
||||
"recall_unavailable_partitions", ledger.unavailablePartitions(),
|
||||
"duration_ms", out.DurationMS)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,16 @@ type recallLedger struct {
|
||||
// embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」
|
||||
// (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。
|
||||
degraded int
|
||||
// unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径)
|
||||
// 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级
|
||||
// (degraded),试算轮只报错。
|
||||
//
|
||||
// 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时
|
||||
// `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。
|
||||
// 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分;
|
||||
// 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable),
|
||||
// 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。
|
||||
unavailable int
|
||||
}
|
||||
|
||||
// noteEmbedding 记一笔「这个分区真的用了语义 embedding」。
|
||||
@@ -66,10 +76,22 @@ func (l *recallLedger) noteLexical(degraded bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」
|
||||
// (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。
|
||||
func (l *recallLedger) noteUnavailable() {
|
||||
if l != nil {
|
||||
l.unavailable++
|
||||
}
|
||||
}
|
||||
|
||||
// label 返回本轮**实际**的召回来源:
|
||||
// - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」;
|
||||
// - embedding / lexical:所有算过的分区都走同一条路;
|
||||
// - mixed:两种都有(多分区里有一部分降级)。
|
||||
//
|
||||
// ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding /
|
||||
// lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger
|
||||
// 的 unavailable 字段说明)。
|
||||
func (l *recallLedger) label() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
@@ -94,6 +116,14 @@ func (l *recallLedger) degradedPartitions() int {
|
||||
return l.degraded
|
||||
}
|
||||
|
||||
// unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。
|
||||
func (l *recallLedger) unavailablePartitions() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return l.unavailable
|
||||
}
|
||||
|
||||
// tidyPair 是一条被召回的候选对。
|
||||
type tidyPair struct {
|
||||
a, b MemoryItem
|
||||
|
||||
@@ -151,24 +151,55 @@ func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRun_RecallModeIsActualNotConfigured 是**试算轮**(面板那一条)的同口径
|
||||
// 回归:配了 embedder 但向量不可用 ⇒ 试算如实报错(严格模式),且返回的 RecallMode
|
||||
// **不**写成 embedding。
|
||||
func TestTidyDryRun_RecallModeIsActualNotConfigured(t *testing.T) {
|
||||
// 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()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedDryRunFixture(t, store)
|
||||
|
||||
emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")}
|
||||
tr, _ := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb)
|
||||
out, err := tr.DryRun(ctx)
|
||||
if !errors.Is(err, ErrTidyRecallUnavailable) {
|
||||
t.Fatalf("试算轮必须如实上报不可用,err = %v", err)
|
||||
}
|
||||
if out.RecallMode == tidyRecallEmbedding {
|
||||
t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode)
|
||||
}
|
||||
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 的日志行。
|
||||
|
||||
Reference in new issue
Block a user