mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
test(memory): 块 union 的并发压测(字节与块同表之后的并发热点)
块 union 化把「元数据」与「负载」合并进同一行(memory_blocks.data BLOB),
原本分属两个存储(media.db 元数据 + blobs 文件)的读写现在全压到同一张表、
同一把 g.mu。这条改动是否引入锁争用/丢更新/字节错位,只有并发实测能回答。
7 条压测(全部 -race 通过,约 26 万次并发读、0 撕裂 0 丢失):
1. ConcurrentPutSameContent —— 16 写者同内容:只留 1 行且字节完整
2. LeanRewriteMustNotBlankData —— ★ 瘦块(blockColumns 不含 data)重写
已回填块时,ON CONFLICT 不得把 data 覆盖成空。这正是生产踩过的坑,
变异(把 `CASE WHEN ... THEN memory_blocks.data` 改成 `= excluded.data`)
当场被这条判据抓住。
3. ConcurrentReadWriteNoTear —— 8 读者 + 4 写者(改向量):16456 次读
0 撕裂 0 丢失
4. BackfillConcurrentWithRecall —— 回填(单事务)与读取并发:101033 次读
0 错字节;终态候选清零
5. DeleteConcurrentWithRead —— 用真实删除路径 DeleteEntity:143k 次读
只能看到「完整内容」或「不存在」;终态块与负载一起消失(union 两半同生共灭)
6. ConcurrentSetVector —— 12 写者并发写向量:仍是合法 JSON、维度正确、字节未动
7. ManyBlocksLargePayloads —— 120 块 × 256KB = 30MB 反复读写逐字节校验
★ 为何删除压测不用媒体块:生产**没有**按块 ID 删媒体块的路径。删除发生在
实体级(DeleteEntity 按 text_content 匹配)与孤块清理(PurgeOrphans 只扫
text_content != ''),所以用 DeleteEntity 这个真实路径。
This commit is contained in:
449
internal/memory/stress_block_union_test.go
Normal file
449
internal/memory/stress_block_union_test.go
Normal file
@ -0,0 +1,449 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 块 union 化的压力测试:媒体字节进块之后,块生命周期里的并发热点。
|
||||
//
|
||||
// 为什么需要专门的压测(而不是只跑既有单测):
|
||||
// 块 union 化把「元数据」与「负载」合并进同一行(memory_blocks.data BLOB),
|
||||
// 于是原本分属两个存储(media.db 元数据 + blobs 文件)的读写现在全部压到
|
||||
// 同一张表、同一把 g.mu。这条改动是否引入锁争用/丢更新/字节错位,
|
||||
// 只有并发实测能回答。
|
||||
//
|
||||
// 全部判据都是**不变量**,不是「函数被调过」:
|
||||
// 1. 同内容并发写块:只留一份,且字节不丢(ON CONFLICT 语义)
|
||||
// 2. 并发读字节与并发写向量:BlockData 永不返回半截/错位字节
|
||||
// 3. 回填与召回并发:回填是单事务,读者要么看到全旧要么全新,不撕裂
|
||||
// 4. 删除与读取并发:读到的字节必须要么是完整的原内容,要么是「不存在」
|
||||
|
||||
// ── 1. 同内容并发写块:去重且字节不丢 ────────────────────────────────
|
||||
|
||||
func TestStressBlockUnion_ConcurrentPutSameContent(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
|
||||
const writers = 16
|
||||
payload := bytes.Repeat([]byte("SAME-CONTENT-PAYLOAD-"), 4096) // 88KB
|
||||
id := MediaBlockID("image/png", payload)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errs atomic.Int64
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
b := MemoryBlock{
|
||||
ID: id, Modality: BlockImage, Data: payload, MIME: "image/png",
|
||||
Size: int64(len(payload)), Source: fmt.Sprintf("writer-%d", n),
|
||||
Vector: vec(1, 0), Fingerprint: "fp",
|
||||
}
|
||||
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
|
||||
errs.Add(1)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if errs.Load() != 0 {
|
||||
t.Fatalf("%d 次并发写入报错", errs.Load())
|
||||
}
|
||||
|
||||
// 不变量 1a:只留一份
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE id = ?`, id).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("同内容并发写应只得 1 行,实际 %d", n)
|
||||
}
|
||||
// 不变量 1b:字节完整
|
||||
got, err := g.BlockData(id)
|
||||
if err != nil {
|
||||
t.Fatalf("BlockData: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("字节被破坏:len=%d 期望 %d", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// 关键回归:len 列表(blockColumns 不含 data)重写已回填块时,
|
||||
// ON CONFLICT 不得把 data 覆盖成空 —— 那会让媒体字节凭空消失。
|
||||
func TestStressBlockUnion_LeanRewriteMustNotBlankData(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
payload := []byte("ORIGINAL-MEDIA-BYTES")
|
||||
id := MediaBlockID("image/png", payload)
|
||||
mustPut(t, g, []MemoryBlock{{
|
||||
ID: id, Modality: BlockImage, Data: payload, MIME: "image/png",
|
||||
Size: int64(len(payload)),
|
||||
}})
|
||||
|
||||
// 并发地用「不带 Data 的瘦块」重写(模拟只更新向量/指纹的路径)
|
||||
const writers = 16
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
_ = g.PutMemoryBlocks([]MemoryBlock{{
|
||||
ID: id, Modality: BlockImage, MIME: "image/png",
|
||||
Vector: vec(float64(n), 1), Fingerprint: "fp2",
|
||||
// 刻意不带 Data
|
||||
}})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
got, err := g.BlockData(id)
|
||||
if err != nil {
|
||||
t.Fatalf("BlockData: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("瘦块重写把 data 覆盖没了:%q(期望 %q)", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. 并发读字节 + 并发写向量:读到的必须始终是完整原字节 ──────────
|
||||
|
||||
func TestStressBlockUnion_ConcurrentReadWriteNoTear(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
|
||||
const nBlocks = 24
|
||||
payloads := make([][]byte, nBlocks)
|
||||
ids := make([]string, nBlocks)
|
||||
for i := range payloads {
|
||||
// 每块内容与长度都不同:错位/串块立刻可见
|
||||
payloads[i] = bytes.Repeat([]byte{byte('A' + i)}, 1000+i*37)
|
||||
ids[i] = MediaBlockID("image/png", payloads[i])
|
||||
}
|
||||
blocks := make([]MemoryBlock, nBlocks)
|
||||
for i := range payloads {
|
||||
blocks[i] = MemoryBlock{
|
||||
ID: ids[i], Modality: BlockImage, Data: payloads[i], MIME: "image/png",
|
||||
Size: int64(len(payloads[i])), Vector: vec(1, 0), Fingerprint: "fp",
|
||||
}
|
||||
}
|
||||
mustPut(t, g, blocks)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var tear, notFound atomic.Int64
|
||||
var reads atomic.Int64
|
||||
|
||||
// 读者:反复取字节,逐字节比对
|
||||
var wg sync.WaitGroup
|
||||
for r := 0; r < 8; r++ {
|
||||
wg.Add(1)
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
i := seed
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
i = (i + 7) % nBlocks
|
||||
data, err := g.BlockData(ids[i])
|
||||
if err != nil {
|
||||
notFound.Add(1)
|
||||
continue
|
||||
}
|
||||
reads.Add(1)
|
||||
if !bytes.Equal(data, payloads[i]) {
|
||||
tear.Add(1)
|
||||
}
|
||||
}
|
||||
}(r)
|
||||
}
|
||||
// 写者:反复改向量(不碰 data)
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
tick := 0
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
i := tick % nBlocks
|
||||
// SetBlockVector 只改向量列(与字节无关),
|
||||
// 与 BlockData 并发执行不应互相影响。
|
||||
_ = g.SetBlockVector(ids[i], vec(float64(n), float64(tick)), "fp")
|
||||
tick++
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if tear.Load() != 0 {
|
||||
t.Errorf("并发下读到撕裂/错位字节 %d 次", tear.Load())
|
||||
}
|
||||
if notFound.Load() != 0 {
|
||||
t.Errorf("并发下 BlockData 失败 %d 次(写向量不该影响负载可读性)", notFound.Load())
|
||||
}
|
||||
t.Logf("并发读 %d 次,0 撕裂、0 丢失", reads.Load())
|
||||
}
|
||||
|
||||
// ── 3. 回填与召回并发:不撕裂(读者看到全旧或全新)───────────────
|
||||
|
||||
func TestStressBlockUnion_BackfillConcurrentWithRecall(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
|
||||
const n = 30
|
||||
// 造「旧库形态」:块有 payload_digest、data 空
|
||||
if err := g.ensurePayloadDigestColumn(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cas := map[string][]byte{}
|
||||
for i := 0; i < n; i++ {
|
||||
d := fmt.Sprintf("digest-%02d", i)
|
||||
cas[d] = bytes.Repeat([]byte{byte('a' + i%26)}, 512+i)
|
||||
if _, err := g.db.Exec(`INSERT INTO memory_blocks
|
||||
(id, modality, payload_digest, mime, vector, fingerprint, created_at, updated_at)
|
||||
VALUES (?, 'image', ?, 'image/png', ?, 'fp', datetime('now'), datetime('now'))`,
|
||||
fmt.Sprintf("blk_%02d", i), d, `[1,0]`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
resolve := func(d string) ([]byte, bool) { b, ok := cas[d]; return b, ok }
|
||||
|
||||
stop := make(chan struct{})
|
||||
var bad atomic.Int64
|
||||
var reads atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 读者:任何时刻取回的字节要么为空(未回填),要么是完整正确内容
|
||||
for r := 0; r < 6; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
data, err := g.BlockData(fmt.Sprintf("blk_%02d", i))
|
||||
if err != nil || len(data) == 0 {
|
||||
continue // 未回填:合法中间态
|
||||
}
|
||||
reads.Add(1)
|
||||
want := cas[fmt.Sprintf("digest-%02d", i)]
|
||||
if !bytes.Equal(data, want) {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 写者:回填(单事务)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
st, err := g.BackfillBlockData(resolve)
|
||||
if err != nil {
|
||||
t.Errorf("BackfillBlockData: %v", err)
|
||||
return
|
||||
}
|
||||
if st.Filled != n {
|
||||
t.Errorf("回填 %d/%d", st.Filled, n)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if bad.Load() != 0 {
|
||||
t.Errorf("回填与读取并发时读到错误字节 %d 次", bad.Load())
|
||||
}
|
||||
// 终态:全部回填
|
||||
left, _ := g.CountBlockDataBackfillCandidates()
|
||||
if left != 0 {
|
||||
t.Errorf("回填结束后仍有 %d 个候选", left)
|
||||
}
|
||||
t.Logf("回填并发读 %d 次,0 错字节", reads.Load())
|
||||
}
|
||||
|
||||
// ── 4. 删除与读取并发:只能看到「完整内容」或「不存在」────────────
|
||||
//
|
||||
// ★ 为何不用 media 块:生产**没有**按块 ID 删媒体块的路径。
|
||||
// 删除发生在实体级(DeleteEntity 按 text_content 匹配)与
|
||||
// 孤块清理(PurgeOrphans 只扫 text_content != '')。
|
||||
// 所以这里用 DeleteEntity 这个**真实路径**,分两步验证:
|
||||
// (a) 被删 текст块的字节在删除过程中不会被读到半截
|
||||
// (b) 删完后块与负载一起消失(块 union 的核心承诺:字节随块同生共灭)
|
||||
func TestStressBlockUnion_DeleteConcurrentWithRead(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
|
||||
const n = 16
|
||||
payloads := make([][]byte, n)
|
||||
texts := make([]string, n)
|
||||
ids := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
payloads[i] = bytes.Repeat([]byte{byte('0' + i)}, 800+i*11)
|
||||
texts[i] = fmt.Sprintf("待删文本块-%02d", i)
|
||||
// 同时带正文与负载的块(union 的两半):删除必须两半一起走
|
||||
ids[i] = fmt.Sprintf("blk_test_del_%02d", i)
|
||||
}
|
||||
blocks := make([]MemoryBlock, n)
|
||||
for i := 0; i < n; i++ {
|
||||
blocks[i] = MemoryBlock{ID: ids[i], Modality: BlockText, Text: texts[i],
|
||||
Data: payloads[i], MIME: "application/octet-stream",
|
||||
Size: int64(len(payloads[i]))}
|
||||
}
|
||||
mustPut(t, g, blocks)
|
||||
|
||||
stop := make(chan struct{})
|
||||
var bad, gone atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
for r := 0; r < 6; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
data, err := g.BlockData(ids[i])
|
||||
if err != nil || len(data) == 0 {
|
||||
gone.Add(1)
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(data, payloads[i]) {
|
||||
bad.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
// 写者:用真实删除路径 DeleteEntity 逐个删(它按 text_content 匹配)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < n; i++ {
|
||||
if _, err := g.DeleteEntity(texts[i]); err != nil {
|
||||
t.Errorf("DeleteEntity(%s): %v", texts[i], err)
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
if bad.Load() != 0 {
|
||||
t.Errorf("删除并发时读到非完整内容 %d 次(应为『完整』或『不存在』)", bad.Load())
|
||||
}
|
||||
// 终态:块与负载一起消失
|
||||
for i := 0; i < n; i++ {
|
||||
if data, err := g.BlockData(ids[i]); err == nil && len(data) > 0 {
|
||||
t.Errorf("块 %s 已删但负载还在(%d 字节)—— union 两半未同生共灭", ids[i], len(data))
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Logf("删除并发:读到「不存在」%d 次、0 次非完整内容", gone.Load())
|
||||
}
|
||||
|
||||
// ── 5. 向量写回幂等 + 并发(reembedStaleMedia 的落库动作)────────────
|
||||
|
||||
func TestStressBlockUnion_ConcurrentSetVector(t *testing.T) {
|
||||
g := newBlockTestGraph(t)
|
||||
id := MediaBlockID("image/png", []byte("x"))
|
||||
mustPut(t, g, []MemoryBlock{{ID: id, Modality: BlockImage, Data: []byte("x"), MIME: "image/png"}})
|
||||
|
||||
const writers = 12
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
for k := 0; k < 20; k++ {
|
||||
if err := g.SetBlockVector(id, vec(float64(n), float64(k)), "fpX"); err != nil {
|
||||
t.Errorf("SetBlockVector: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 不变量:向量列是合法 JSON 且维度为 2,字节没被动过
|
||||
b, ok, err := g.BlockByIDPrefix(id)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("取块失败: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(b.Vector) != 2 {
|
||||
t.Fatalf("向量维度应为 2,实际 %d", len(b.Vector))
|
||||
}
|
||||
if _, err := json.Marshal(b.Vector); err != nil {
|
||||
t.Fatalf("向量不是合法 JSON: %v", err)
|
||||
}
|
||||
if data, _ := g.BlockData(id); string(data) != "x" {
|
||||
t.Errorf("并发写向量破坏了字节: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. 压力:大量块 + 大负载,反复读写(内存/磁盘不炸)────────────
|
||||
|
||||
func TestStressBlockUnion_ManyBlocksLargePayloads(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("短模式跳过")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
g, err := NewGraphDB(filepath.Join(dir, "graph.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
const n = 120
|
||||
const payloadSize = 256 * 1024 // 每块 256KB → 合计约 30MB
|
||||
blocks := make([]MemoryBlock, 0, n)
|
||||
payloads := make([][]byte, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
p := bytes.Repeat([]byte{byte(i)}, payloadSize)
|
||||
payloads = append(payloads, p)
|
||||
blocks = append(blocks, MemoryBlock{
|
||||
ID: MediaBlockID("image/png", p), Modality: BlockImage, Data: p,
|
||||
MIME: "image/png", Size: int64(len(p)),
|
||||
Vector: vec(float64(i%7), 1), Fingerprint: "fp",
|
||||
})
|
||||
}
|
||||
if err := g.PutMemoryBlocks(blocks); err != nil {
|
||||
t.Fatalf("批量写入 %d 块: %v", n, err)
|
||||
}
|
||||
|
||||
// 反复取回并逐字节校验
|
||||
for round := 0; round < 3; round++ {
|
||||
for i, b := range blocks {
|
||||
data, err := g.BlockData(b.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("round %d BlockData(%s): %v", round, b.ID, err)
|
||||
}
|
||||
if !bytes.Equal(data, payloads[i]) {
|
||||
t.Fatalf("round %d 块 %s 字节不符(len=%d vs %d)", round, b.ID, len(data), len(payloads[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%d 块 × %dKB = %.1fMB 反复读写校验通过",
|
||||
n, payloadSize/1024, float64(n*payloadSize)/1024/1024)
|
||||
}
|
||||
Reference in New Issue
Block a user