fix(memory): 中心向量过期改为异步节流重建(此前永久失效)

现场:'centroid invalidated: samples 1391 -> store 1877' 每轮召回刷一行。
过期分支只打印然后 return nil,false,nil,而重建入口只存在于三个 CLI 工具里
⇒ 块数一旦越过 20% 线就永远过期,中心化校正永久关闭。

修法:过期时登记一次后台重建(每 fingerprint 10 分钟一次,延迟 2s 等读锁释放)。
不能在锁内就地重建 —— LoadCentroid 全程持 RLock,RebuildCentroid/SaveCentroid
需要写锁,就地调用必然自死锁(实测 internal/memory 测试 180s 超时)。
This commit is contained in:
JianFeeeee
2026-10-27 13:04:25 +08:00
parent 1dc32be736
commit e6b6cd12e3

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
)
@ -187,8 +188,18 @@ func (g *GraphDB) LoadCentroid(fingerprint string, queryDim int) (*Centroid, boo
delta = -delta
}
if delta > centroidRebuildRatio {
log.Printf("[graph] centroid invalidated: samples %d -> store %d (change %.0f%% > %.0f%%)",
// ★ 过期就**就地重建**(2026-10-27 现场):此前这里只打印一行然后
// `return nil, false, nil`,而重建入口只存在于三个 CLI 工具里。
// 结果:库里的带向量块数一旦越过 20% 线(实测 1391 → 1877),
// 该库就永远处于"过期"状态 —— 每轮召回都读同一个旧 samples、
// 每次都判过期、每次都不重建,中心化校正永久关闭,且日志每轮刷一行。
log.Printf("[graph] centroid stale: samples %d -> store %d (change %.0f%% > %.0f%%)",
vecCount, curVectored, delta*100, centroidRebuildRatio*100)
// ★ 重建必须**离开锁之后**做(2026-10-27 实测踩坑):
// 本函数全程持有 g.mu.RLock,而 RebuildCentroid/SaveCentroid 需要写锁
// ⇒ 就地重建必然自死锁(internal/memory 的测试 180s 超时,真机召回挂住)。
// 所以这里只登记一次异步重建,本轮照旧返回"不用校正"。
g.scheduleCentroidRebuild(fingerprint)
return nil, false, nil
}
if !meta.Anomalous {
@ -299,6 +310,42 @@ func (g *GraphDB) RebuildCentroid(fingerprint string) (*Centroid, bool, error) {
return c, c.Stats().Anomalous, nil
}
// scheduleCentroidRebuild 在后台重建中心(每 fingerprint 至少间隔
// centroidRebuildMinInterval)。
//
// 为什么异步 + 节流:
// - 同步重建会与调用方的读锁冲突(见 LoadCentroid 里的注释);
// - 中心过期是**常态**(块数持续增长),每次召回都重建会把召回拖到秒级。
//
// 召回失败方向是安全的:本轮拿不到中心 ⇒ 不做中心化校正,只是分数未去主轴。
var (
centroidRebuildMu sync.Mutex
centroidRebuiltAt = map[string]time.Time{}
centroidRebuildWait = 10 * time.Minute
)
func (g *GraphDB) scheduleCentroidRebuild(fingerprint string) {
centroidRebuildMu.Lock()
if last, ok := centroidRebuiltAt[fingerprint]; ok && time.Since(last) < centroidRebuildWait {
centroidRebuildMu.Unlock()
return
}
centroidRebuiltAt[fingerprint] = time.Now()
centroidRebuildMu.Unlock()
go func(fp string) {
// 等本轮召回把读锁放掉(LoadCentroid 用 defer 解锁)。
time.Sleep(2 * time.Second)
c, anomalous, err := g.RebuildCentroid(fp)
if err != nil {
log.Printf("[graph] centroid rebuild failed, recall continues without centering: %v", err)
return
}
log.Printf("[graph] centroid rebuilt: dim=%d samples=%d anomalous=%v",
len(c.Mean()), c.Count, anomalous)
}(fingerprint)
}
// CentroidStatus 供运维/CLI 报告中心状态。
type CentroidStatus struct {
Saved bool `json:"saved"`