Files
HomeAgent/internal/memory/access_hook_race_test.go
JianFeeeee 9036db9321 feat(memory): 图数据库自报真实读写事件,取代「按工具名猜节点」
## 问题(生产实证)

星图的「跟随 agent 活动」此前靠**工具名词元猜节点**:把 `memory_recall`
拆成 [memory, recall] 去匹配实体名。而图谱里的实体名是**中文概念**
(小宅/对话/待命),工具名是英文 —— 两套命名空间永不交集。

实测(生产库)每个工具名命中 **0** 个节点:

    hint=memory_recall  → memory/recall  → 0
    hint=files_read     → files/read     → 0
    hint=knowledge_search → knowledge/search → 0

匹配失败后走「补齐」分支,按 mention_count 取前 8 个 ⇒ **无论 agent 在干什么,
点亮的都是同一批无关节点**(小宅/对话/待命/投递/图片/文档/插件/老大)。
这就是「星图没有读写反馈」的真身。

## 改法:让图数据库在真正读写处上报

新增 `events.EventMemoryAccess` + `GraphDB.AccessHook`,在读写入口埋点上报
**真实块 ID**(不再猜):

| 操作 | 埋点 | 上报 |
|---|---|---|
| 读 | RecallSorted | blocks |
| 写 | commit | blocks + **created**(真正新建的) |
| 删 | DeleteEntity | removed |
| 合 | MergeBlocks | merged(from→to) + removed |

三处关键设计:

1. **钩子用独立锁** `hookMu`。用 `g.mu` 会**自死锁** —— 埋点方法已持该锁,
   而 Go 的 RWMutex 不可重入。
2. **上报在锁外**。拆 `commit`→`commitLocked` 两层解决 `defer Unlock` 的时序:
   钩子内部可能要回调内存系统(如 SSE 订阅者补数据),持锁调用必死锁。
3. **区分「新建」与「更新」**(`putBlockTx` 返回 isNew)。用 SELECT 判存在性
   而非 `RETURNING (xmax = 0)` —— 后者在**事务 + ON CONFLICT** 下报
   `no such column: xmax`(实测:autocommit 可用、包进 tx 就不可用),
   而本函数只在事务里被调用。重复提交同一事实不会重复播「生长」动画。

接线:`bootstrap.newMainAgent` 里 `memDB.SetAccessHook(...)` → `events.Bus`。
只在 `evBus != nil` 时接,把「内核进程」与「独立 CLI 工具」
(homed-graph-migrate / memgc 也用 NewGraphDB)区分开 —— 后者不该为一次
批量迁移向不存在的订阅者发几万条事件。

## 验证

- `access_hook_test.go`:钉住 commit/recall/delete 三类埋点真的触发、
  且重复提交不误报新建
- `access_hook_race_test.go`:4 写 + 4 读 + 钩子热替换并发,`-race` 下无竞态、
  无死锁;**断言事件数 > 0**(否则「钩子从未触发」也会假通过)
- 该测试起初是 flaky 的(读者可能在写者落库前查询 ⇒ 空结果不报事件 ⇒
  断言随机失败)。修法是先播种确定数据再并发读,已连跑 3 次稳定
- 生产实例端到端:CLI 注入一句话 → SSE 收到 6 条 memory_access,
  带真实块 ID 与 op/created
2026-10-08 10:41:07 +08:00

125 lines
3.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 memory
import (
"fmt"
"path/filepath"
"sync"
"testing"
"time"
)
// 钩子在并发读写下的竞态与死锁判据。
//
// ★ 为何必须专测这个:钩子用**独立锁 hookMu**(用 g.mu 会自死锁,因为
// commit/Recall 已持该锁),而订阅者回调又可能反过来访问图库。
// 这是典型的两把锁交叉场景,-race 是唯一可靠的判据。
func TestAccessHookConcurrentNoRace(t *testing.T) {
g, err := NewGraphDB(filepath.Join(t.TempDir(), "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
var mu sync.Mutex
var commits, recalls int
// counting 钩子:**所有**装上去的钩子都必须计数。
//
// ★ 为什么不能只在第一个钩子里计数:本测试有一个 goroutine 在并发
// 替换钩子(验 SetAccessHook 与读写并发安全)。若替换进去的是 no-op,
// 计数会被清零,断言「事件数 > 0」就永远失败、而断言「==0」又等于没验。
// ⇒ 让每个钩子都计数,才能既测替换安全、又保证计数有意义。
counting := func(ev AccessEvent) {
mu.Lock()
switch ev.Op {
case "commit":
commits++
case "recall":
recalls++
}
mu.Unlock()
if ev.Op == "recall" {
_ = g.Path() // 只读、无锁调用:验证钩子内不持 g.mu
}
}
g.SetAccessHook(counting)
// ★ 先**播种**确定存在的数据,再开始并发读。
//
// 为什么必须播种:recall 的钩子在**没有匹配块**时不报事件(空事件不上报,
// 否则星图收到空数组只会白重算一次)。若读者与写者同时起跑,
// 读者很可能在写者落库前查询 ⇒ 全部空结果 ⇒ recall 事件为 0,
// 断言「recall > 0」随机失败。实测正是如此(同一份代码,一次 recall=3、
// 一次 recall=0)——那是 flaky 测试,不是产品缺陷。
for i := 0; i < 20; i++ {
if _, _, err := g.Commit([]Triple{{
Subject: fmt.Sprintf("seed_实体_%d", i),
Relation: "关联",
Object: fmt.Sprintf("seed_目标_%d", i),
SentenceText: fmt.Sprintf("seed 句子 %d", i),
}}, "seed", i); err != nil {
t.Fatal(err)
}
}
done := make(chan struct{})
var wg sync.WaitGroup
// 写者
for w := 0; w < 4; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 25; i++ {
_, _, err := g.Commit([]Triple{{
Subject: fmt.Sprintf("w%d_实体_%d", id, i),
Relation: "关联",
Object: fmt.Sprintf("w%d_目标_%d", id, i),
SentenceText: fmt.Sprintf("w%d 的句子 %d", id, i),
}}, fmt.Sprintf("s%d", id), i)
if err != nil {
t.Errorf("commit: %v", err)
return
}
}
}(w)
}
// 读者
for r := 0; r < 4; r++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 25; i++ {
// 查播种数据:保证有匹配 ⇒ recall 事件必然上报,断言不再依赖时序。
if _, err := g.Recall([]string{fmt.Sprintf("seed_实体_%d", i%20)}, nil, 2, ""); err != nil {
t.Errorf("recall: %v", err)
return
}
}
}(r)
}
// 钩子的更换(SetAccessHook 与读写并发)
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 20; i++ {
g.SetAccessHook(counting)
time.Sleep(2 * time.Millisecond)
}
}()
go func() { wg.Wait(); close(done) }()
select {
case <-done:
case <-time.After(60 * time.Second):
t.Fatal("60s 未完成 —— 钩子/读写路径疑似死锁")
}
mu.Lock()
c, r := commits, recalls
mu.Unlock()
t.Logf("并发完成:commit 事件=%d recall 事件=%d", c, r)
// ★ 必须断言事件真的上报过,否则「钩子从未触发」也会让本测试通过。
// 这正是「判据失效」的典型:测了「不崩」,没测「真的工作」。
if c == 0 || r == 0 {
t.Fatalf("钩子未上报事件(commit=%d recall=%d)——说明埋点没触发或钩子被覆盖", c, r)
}
}