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:
JianFeeeee
2026-10-12 17:37:08 +08:00
parent fdfbcea479
commit baf2ecd7c9

View 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)
}