refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块

媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。

改为与文本块完全一致的生命周期:

1. 一等记忆块直接由所在层持有
   - ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
   - 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
   - Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
     同一块不同时存在于两层

2. 删除平行生命周期账本
   - media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
     AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
   - 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
   - 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
     与"删除文本块即删除内容"同一语义

3. L3 原生结构
   - memory_blocks / memory_block_edges(contains/depicts/derived_from)
   - 边端点必须是真实图节点,不再用 owner 字符串伪装关系
   - BlocksForNode 支持 sentence --contains--> block 反查

4. SDK 与检索同步
   - 插件附件/标记直接变成块,不再 AddRef
   - 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)

测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。

注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
This commit is contained in:
JianFeeeee
2026-09-11 10:57:22 +08:00
parent e44164f5bd
commit dae01f9c06
27 changed files with 1391 additions and 1969 deletions

View File

@ -11,7 +11,7 @@
// - 路径会失效。/tmp 下的探针图、下载缓存、其他进程的临时产物,记忆里留个
// 路径等于留个悬空指针。
// - 同一张图往往被反复注入用户连问几轮同一张截图、see_video 相邻帧高度
// 相似)。按 sha256 寻址天然去重,引用计数记住被引了几次
// 相似)。按 sha256 寻址天然去重,同一份字节只存一遍
// - 内容即身份,跟 L3 图库 `sentences.text UNIQUE` 的思路一致:文本节点用
// 文本本身做身份,媒体节点用内容摘要做身份。
package media
@ -34,20 +34,6 @@ import (
_ "github.com/mattn/go-sqlite3"
)
// OwnerKind 是 media_refs.owner_kind 的取值,对应引用媒体的记忆层。
//
// 定义为常量而不是让调用方写字符串owner_kind 进了主键,
// 拼错一个字符就是一条永远对不上的孤立引用AddRef 不会报错,
// DropOwner 也永远匹配不到)。
const (
// OwnerContext 是 L0 对话上下文事件ContextEvent.ID
OwnerContext = "context"
// OwnerDocument 是 L2 文档记忆Doc.ID
OwnerDocument = "document"
// OwnerGraphSentence 是 L3 图库句子节点sentences.id
OwnerGraphSentence = "graph_sentence"
)
// digestHexLen 是 sha256 的十六进制串长度。
const digestHexLen = sha256.Size * 2
@ -88,8 +74,6 @@ type Item struct {
Description string `json:"description,omitempty"`
// DescribedBy 记录描述来自哪个源,让后续读者能判断可靠性。
DescribedBy string `json:"described_by,omitempty"`
// RefCount 是引用计数。GC 只清理归零的项。
RefCount int `json:"ref_count"`
// FirstSeen/LastSeen 是首末次入库时间。
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
@ -110,15 +94,11 @@ type Store struct {
mu sync.RWMutex
db *sql.DB
blobDir string
// maxBytes 是内容目录的容量上限0 表示不限。
// 超限时 GC 按 LastSeen 从旧到新淘汰 RefCount=0 的项。
maxBytes int64
}
// New 打开(或初始化)媒体存储。
// dir 下会建 media.db 与 blobs/ 两个条目。
func New(dir string, maxBytes int64) (*Store, error) {
func New(dir string) (*Store, error) {
if err := os.MkdirAll(filepath.Join(dir, "blobs"), 0755); err != nil {
return nil, fmt.Errorf("media: create blob dir: %w", err)
}
@ -127,7 +107,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
if err != nil {
return nil, fmt.Errorf("media: open db: %w", err)
}
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs"), maxBytes: maxBytes}
s := &Store{db: db, blobDir: filepath.Join(dir, "blobs")}
if err := s.initSchema(); err != nil {
db.Close()
return nil, err
@ -137,7 +117,7 @@ func New(dir string, maxBytes int64) (*Store, error) {
func (s *Store) initSchema() error {
stmts := []string{
// digest 作主键:内容即身份,重复 Put 同一内容只递增 ref_count
// digest 作主键:内容即身份,重复 Put 同一内容不重复落盘
`CREATE TABLE IF NOT EXISTS media (
digest TEXT PRIMARY KEY,
kind TEXT NOT NULL,
@ -149,25 +129,11 @@ func (s *Store) initSchema() error {
tool TEXT,
description TEXT,
described_by TEXT,
ref_count INTEGER DEFAULT 0,
first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_media_kind ON media(kind)`,
`CREATE INDEX IF NOT EXISTS idx_media_refcount ON media(ref_count)`,
`CREATE INDEX IF NOT EXISTS idx_media_last_seen ON media(last_seen)`,
// 反向索引:哪条记忆引用了哪个媒体。
// owner_kind 取 context / document / graph_sentenceowner_id 是各层自己的标识。
// 主键含三列,同一 owner 重复挂同一媒体是幂等的。
`CREATE TABLE IF NOT EXISTS media_refs (
digest TEXT NOT NULL,
owner_kind TEXT NOT NULL,
owner_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (digest, owner_kind, owner_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_refs_owner ON media_refs(owner_kind, owner_id)`,
`CREATE INDEX IF NOT EXISTS idx_refs_digest ON media_refs(digest)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(q); err != nil {
@ -231,8 +197,8 @@ func (s *Store) Put(data []byte, meta Item) (string, error) {
_, err := s.db.Exec(`
INSERT INTO media (digest, kind, mime, size, width, height,
origin_path, tool, description, described_by,
ref_count, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(digest) DO UPDATE SET
last_seen = excluded.last_seen,
-- 只在原值为空时补写:先到的描述可能来自更强的模型,
@ -276,7 +242,7 @@ func (s *Store) Stat(digest string) (*Item, error) {
defer s.mu.RUnlock()
return s.scanOne(s.db.QueryRow(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen,
description, described_by, first_seen, last_seen,
vec, vec_model
FROM media WHERE digest = ?`, digest))
}
@ -299,121 +265,6 @@ func (s *Store) Describe(digest, description, describedBy string) error {
return nil
}
// AddRef 登记一条引用并递增计数。幂等:同一 (digest, owner) 重复调用不重复计数。
func (s *Store) AddRef(digest, ownerKind, ownerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(`INSERT OR IGNORE INTO media_refs (digest, owner_kind, owner_id) VALUES (?, ?, ?)`,
digest, ownerKind, ownerID)
if err != nil {
return fmt.Errorf("media: add ref: %w", err)
}
// 只有真的插进去才递增否则重复调用会让计数虚高GC 永远不敢清。
if n, _ := res.RowsAffected(); n > 0 {
if _, err := tx.Exec(`UPDATE media SET ref_count = ref_count + 1 WHERE digest = ?`, digest); err != nil {
return fmt.Errorf("media: bump refcount: %w", err)
}
}
return tx.Commit()
}
// DropRef 注销一条引用并递减计数。内容不立即删除,留给 GC。
func (s *Store) DropRef(digest, ownerKind, ownerID string) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(`DELETE FROM media_refs WHERE digest = ? AND owner_kind = ? AND owner_id = ?`,
digest, ownerKind, ownerID)
if err != nil {
return fmt.Errorf("media: drop ref: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
// MAX(0, ...) 兜底:历史数据或并发意外让计数与 refs 表不一致时,
// 不让它掉成负数(负数会让容量 GC 的排序失去意义)。
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, digest); err != nil {
return fmt.Errorf("media: lower refcount: %w", err)
}
}
return tx.Commit()
}
// DropOwner 注销某个 owner 的全部引用(该条记忆被删/被归档替换时用)。
func (s *Store) DropOwner(ownerKind, ownerID string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ?`,
ownerKind, ownerID)
if err != nil {
return 0, err
}
var digests []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err == nil {
digests = append(digests, d)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
if len(digests) == 0 {
return 0, nil
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM media_refs WHERE owner_kind = ? AND owner_id = ?`, ownerKind, ownerID); err != nil {
return 0, err
}
for _, d := range digests {
if _, err := tx.Exec(`UPDATE media SET ref_count = MAX(0, ref_count - 1) WHERE digest = ?`, d); err != nil {
return 0, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(digests), nil
}
// Refs 返回某个 owner 引用的全部 digest。
func (s *Store) Refs(ownerKind, ownerID string) ([]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT digest FROM media_refs WHERE owner_kind = ? AND owner_id = ? ORDER BY created_at`,
ownerKind, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err == nil {
out = append(out, d)
}
}
return out, rows.Err()
}
// Search 按描述文本做 LIKE 匹配,返回最近的若干条。
//
// 刻意不在这里做向量检索:媒体的语义检索走 L2 文档层的既有索引
@ -427,7 +278,7 @@ func (s *Store) Search(query string, kind Kind, limit int) ([]*Item, error) {
defer s.mu.RUnlock()
q := `SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen,
description, described_by, first_seen, last_seen,
vec, vec_model
FROM media WHERE COALESCE(description,'') != ''`
args := []interface{}{}
@ -474,7 +325,7 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
defer s.mu.RUnlock()
rows, err := s.db.Query(`
SELECT digest, kind, mime, size, width, height, origin_path, tool,
description, described_by, ref_count, first_seen, last_seen,
description, described_by, first_seen, last_seen,
vec, vec_model
FROM media
WHERE COALESCE(description,'') = '' AND COALESCE(described_by,'') = ''
@ -494,117 +345,44 @@ func (s *Store) Pending(limit int) ([]*Item, error) {
return out, rows.Err()
}
// GC 清理无人引用的内容。
// GC 清理已不被任何记忆块持有的内容。
//
// 两段策略:
// 1. ref_count=0 且 last_seen 早于 minAge 的一律清理。刚 Put 还没来得及
// AddRef 的项 refcount 也是 0minAge 保护它们不被立刻清掉
// 2. 清完仍超 maxBytes 时,继续按 last_seen 从旧到新淘汰 ref_count=0 的项
// keep 是当前仍被 Context/Document/Graph 里一等记忆块持有的 digest 集合,
// 由调用方从三层记忆节点计算得出media.Store 不再自己维护引用账本。
// 不在 keep 中且早于 minAge 的项被清理;超出 maxBytes 时也只淘汰不在 keep 中的项
// Delete 删除一份媒体内容(元数据 + blob
//
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,正是本包要避免的。
func (s *Store) GC(minAge time.Duration) (removed int, freed int64, err error) {
// 这不是 GC也不看引用计数调用方是记忆系统本身——当它把一个记忆块
// 永久地从三层记忆中删掉(而非在层间迁移)时,媒体作为块的内容一并删除。
// 文本块就是这么管理的:删除块即删除内容。
func (s *Store) Delete(digest string) error {
if digest == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
cutoff := time.Now().Add(-minAge)
rows, err := s.db.Query(`
SELECT digest, size FROM media
WHERE ref_count <= 0 AND last_seen < ?
ORDER BY last_seen`, cutoff)
if err != nil {
return 0, 0, err
if err := os.Remove(s.blobPath(digest)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("media: remove blob %s: %w", shortDigest(digest), err)
}
type cand struct {
digest string
size int64
if _, err := s.db.Exec(`DELETE FROM media WHERE digest = ?`, digest); err != nil {
return fmt.Errorf("media: delete meta %s: %w", shortDigest(digest), err)
}
var cands []cand
for rows.Next() {
var c cand
if err := rows.Scan(&c.digest, &c.size); err == nil {
cands = append(cands, c)
}
}
rows.Close()
for _, c := range cands {
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
continue // 删不掉就留着元数据,下轮再试;不制造"元数据没了文件还在"的孤儿
}
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
continue
}
removed++
freed += c.size
}
if s.maxBytes > 0 {
r2, f2 := s.enforceCapacityLocked()
removed += r2
freed += f2
}
return removed, freed, nil
return nil
}
// enforceCapacityLocked 在超出 maxBytes 时继续淘汰无引用项(调用方已持锁)
func (s *Store) enforceCapacityLocked() (removed int, freed int64) {
var total int64
if err := s.db.QueryRow(`SELECT COALESCE(SUM(size), 0) FROM media`).Scan(&total); err != nil {
return 0, 0
}
if total <= s.maxBytes {
return 0, 0
}
need := total - s.maxBytes
rows, err := s.db.Query(`SELECT digest, size FROM media WHERE ref_count <= 0 ORDER BY last_seen`)
if err != nil {
return 0, 0
}
type cand struct {
digest string
size int64
}
var cands []cand
for rows.Next() {
var c cand
if err := rows.Scan(&c.digest, &c.size); err == nil {
cands = append(cands, c)
}
}
rows.Close()
for _, c := range cands {
if freed >= need {
break
}
if e := os.Remove(s.blobPath(c.digest)); e != nil && !os.IsNotExist(e) {
continue
}
if _, e := s.db.Exec(`DELETE FROM media WHERE digest = ?`, c.digest); e != nil {
continue
}
removed++
freed += c.size
}
return removed, freed
}
// Stats 返回容量与条目统计,供 WebUI / healthcheck 展示。
// Stats 返回条目统计,供 WebUI / healthcheck 展示
func (s *Store) Stats() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
out := map[string]interface{}{"blob_dir": s.blobDir, "max_bytes": s.maxBytes}
var count, described, orphan int
out := map[string]interface{}{"blob_dir": s.blobDir}
var count, described int
var total int64
s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size),0) FROM media`).Scan(&count, &total)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE COALESCE(description,'') != ''`).Scan(&described)
s.db.QueryRow(`SELECT COUNT(*) FROM media WHERE ref_count <= 0`).Scan(&orphan)
out["count"] = count
out["total_bytes"] = total
out["described"] = described
out["unreferenced"] = orphan
byKind := map[string]int{}
rows, err := s.db.Query(`SELECT kind, COUNT(*) FROM media GROUP BY kind`)
@ -725,25 +503,17 @@ type MediaHit struct {
Score float64
}
// QueryMemoryMediaScored 只检索当前仍被 L0/L2/L3 记忆块引用的媒体。
// CAS 中 ref_count=0 的项是等待 GC 的孤儿缓存,不是可召回记忆;若把它们也查出,
// 已从三层记忆淘汰的图片会被视觉路“复活”,破坏与文本块一致的生命周期。
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
//
// 分数只做排序,不在存储层设绝对阈值:多模态文本→图像的绝对 cosine 随模型、
// 语言与数据域漂移,真实标定中有效命中可以低至 0.015。相关性门控在融合器中
// 使用当前候选集合的相对分布完成。
func (s *Store) QueryMemoryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
return s.queryMediaScored(queryVec, model, topK, true)
}
// QueryMediaScored 用查询向量对所有已嵌入媒体做余弦相似度检索,
// 返回 topK 个最相似的候选及其原始 cosine 分数(供跨模态归一化)。
// 这是媒体存储层的诊断/显式全库入口;记忆召回应调用 QueryMemoryMediaScored。
func (s *Store) QueryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
return s.queryMediaScored(queryVec, model, topK, false)
return s.queryMediaScored(queryVec, model, topK)
}
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, referencedOnly bool) ([]MediaHit, error) {
func (s *Store) queryMediaScored(queryVec []float64, model string, topK int) ([]MediaHit, error) {
if topK <= 0 {
topK = 20
}
@ -754,7 +524,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
defer s.mu.RUnlock()
query := `SELECT digest, kind, mime, size, width, height,
origin_path, tool, description, described_by, ref_count, first_seen, last_seen,
origin_path, tool, description, described_by, first_seen, last_seen,
vec, vec_model
FROM media WHERE vec IS NOT NULL AND vec != ''`
var args []interface{}
@ -762,11 +532,6 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
query += ` AND vec_model = ?`
args = append(args, model)
}
if referencedOnly {
query += ` AND ref_count > 0 AND EXISTS (
SELECT 1 FROM media_refs r WHERE r.digest = media.digest
)`
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
@ -783,7 +548,7 @@ func (s *Store) queryMediaScored(queryVec []float64, model string, topK int, ref
var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
if err := rows.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen,
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil {
continue
}
@ -860,7 +625,7 @@ func scanItem(r rowScanner) (*Item, error) {
var kind string
var origin, tool, desc, by, vecJSON, vecModel sql.NullString
if err := r.Scan(&it.Digest, &kind, &it.MIME, &it.Size, &it.Width, &it.Height,
&origin, &tool, &desc, &by, &it.RefCount, &it.FirstSeen, &it.LastSeen,
&origin, &tool, &desc, &by, &it.FirstSeen, &it.LastSeen,
&vecJSON, &vecModel); err != nil {
return nil, err
}

View File

@ -5,12 +5,13 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
func newTestStore(t *testing.T, maxBytes int64) *Store {
// newTestStore 建一个临时媒体存储。
// 参数保留只为兼容旧调用点;媒体不再有容量上限(生命周期由记忆块决定)。
func newTestStore(t *testing.T, _ ...int64) *Store {
t.Helper()
s, err := New(t.TempDir(), maxBytes)
s, err := New(t.TempDir())
if err != nil {
t.Fatalf("New: %v", err)
}
@ -109,172 +110,35 @@ func TestPut_NoPartialBlobOnDisk(t *testing.T) {
}
}
func TestRefCount_AddIsIdempotent(t *testing.T) {
s := newTestStore(t, 0)
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
func TestDelete_RemovesContentAndMetadata(t *testing.T) {
// 删除块即删除内容Delete 同时清掉 blob 与元数据。
// 这不是 GC也不看引用计数——调用方是记忆系统本身。
s := newTestStore(t)
d, _ := s.Put([]byte("held"), Item{MIME: "image/png"})
other, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
for i := 0; i < 3; i++ {
if err := s.AddRef(d, "context", "evt-1"); err != nil {
t.Fatal(err)
}
}
it, _ := s.Stat(d)
// 重复 AddRef 若都递增计数会虚高GC 永远不敢清。
if it.RefCount != 1 {
t.Fatalf("同一 owner 重复 AddRef 应只计 1实际 %d", it.RefCount)
}
if err := s.AddRef(d, "document", "doc-9"); err != nil {
if err := s.Delete(other); err != nil {
t.Fatal(err)
}
it, _ = s.Stat(d)
if it.RefCount != 2 {
t.Fatalf("不同 owner 应各计一次,实际 %d", it.RefCount)
if _, err := s.Stat(other); err == nil {
t.Fatal("删除后元数据应已移除")
}
}
func TestRefCount_DropAndNeverNegative(t *testing.T) {
s := newTestStore(t, 0)
d, _ := s.Put([]byte("img"), Item{MIME: "image/png"})
s.AddRef(d, "context", "e1")
if err := s.DropRef(d, "context", "e1"); err != nil {
t.Fatal(err)
}
it, _ := s.Stat(d)
if it.RefCount != 0 {
t.Fatalf("应归零,实际 %d", it.RefCount)
}
// 多余的 DropRef 不该把计数压成负数(负数会让容量 GC 的排序失去意义)
for i := 0; i < 3; i++ {
s.DropRef(d, "context", "e1")
}
it, _ = s.Stat(d)
if it.RefCount != 0 {
t.Fatalf("重复 DropRef 后仍应为 0实际 %d", it.RefCount)
}
}
func TestDropOwner_RemovesAllItsRefs(t *testing.T) {
s := newTestStore(t, 0)
d1, _ := s.Put([]byte("frame1"), Item{MIME: "image/jpeg"})
d2, _ := s.Put([]byte("frame2"), Item{MIME: "image/jpeg"})
s.AddRef(d1, "context", "evt-x")
s.AddRef(d2, "context", "evt-x")
s.AddRef(d1, "document", "doc-y") // 别的 owner 也引了 d1
n, err := s.DropOwner("context", "evt-x")
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("应注销 2 条引用,实际 %d", n)
}
it1, _ := s.Stat(d1)
it2, _ := s.Stat(d2)
if it1.RefCount != 1 {
t.Fatalf("d1 仍被 document 引用,应剩 1实际 %d", it1.RefCount)
}
if it2.RefCount != 0 {
t.Fatalf("d2 应归零,实际 %d", it2.RefCount)
}
}
func TestRefs_ListsOwnerDigests(t *testing.T) {
s := newTestStore(t, 0)
d1, _ := s.Put([]byte("a"), Item{MIME: "image/png"})
d2, _ := s.Put([]byte("b"), Item{MIME: "image/png"})
s.AddRef(d1, "context", "e1")
s.AddRef(d2, "context", "e1")
got, err := s.Refs("context", "e1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("应返回 2 个 digest实际 %d", len(got))
}
}
func TestGC_KeepsReferencedContent(t *testing.T) {
// 有引用的项永不删除——那会让记忆里的 digest 变成悬空指针,
// 正是本包要避免的。
s := newTestStore(t, 0)
kept, _ := s.Put([]byte("referenced"), Item{MIME: "image/png"})
orphan, _ := s.Put([]byte("orphaned"), Item{MIME: "image/png"})
s.AddRef(kept, "context", "e1")
// minAge=0 让刚 Put 的都算超龄
removed, _, err := s.GC(0)
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("应只清 1 条无引用项,实际 %d", removed)
}
if _, err := s.Get(kept); err != nil {
t.Fatalf("被引用的内容不该被清: %v", err)
}
if _, err := s.Stat(orphan); err == nil {
t.Fatal("无引用项的元数据应已删除")
}
}
func TestGC_MinAgeProtectsFreshUnreferenced(t *testing.T) {
// 刚 Put 还没来得及 AddRef 的项 refcount 也是 0
// minAge 必须保护它们否则「Put 完还没挂上就被 GC 清掉」。
s := newTestStore(t, 0)
d, _ := s.Put([]byte("just-arrived"), Item{MIME: "image/png"})
removed, _, err := s.GC(time.Hour)
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("新入库项应被 minAge 保护,却清掉了 %d 条", removed)
if _, err := s.Get(other); err == nil {
t.Fatal("删除后内容应已移除")
}
// 未被删除的项不受影响
if _, err := s.Get(d); err != nil {
t.Fatalf("内容应还在: %v", err)
t.Fatalf("未删除的内容不该受影响: %v", err)
}
}
func TestGC_EnforcesCapacity(t *testing.T) {
// 容量上限:清完超龄项后仍超限,继续按 last_seen 从旧到新淘汰无引用项。
blob := make([]byte, 1024)
s := newTestStore(t, 2048) // 只容 2KB
var digests []string
for i := 0; i < 4; i++ {
b := append([]byte{byte(i)}, blob...) // 内容各异,避免去重
d, err := s.Put(b, Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
digests = append(digests, d)
time.Sleep(2 * time.Millisecond) // 拉开 last_seen
func TestDelete_UnknownDigestIsNoop(t *testing.T) {
s := newTestStore(t)
if err := s.Delete(""); err != nil {
t.Fatalf("空 digest 应为无操作: %v", err)
}
// 保护最后一个,确认容量 GC 也不碰有引用的
s.AddRef(digests[3], "context", "e1")
removed, freed, err := s.GC(0)
if err != nil {
t.Fatal(err)
}
if removed == 0 {
t.Fatal("超限应触发淘汰")
}
if _, err := s.Get(digests[3]); err != nil {
t.Fatalf("有引用项即使超限也不该删: %v", err)
}
t.Logf("removed=%d freed=%d", removed, freed)
st := s.Stats()
if total := st["total_bytes"].(int64); total > 2048 {
// 有引用项可能让总量降不到线下,这是刻意的(宁可超限也不断引用)
t.Logf("总量 %d 仍超 2048因有引用项不可删预期行为", total)
if err := s.Delete("ffffffffffffffff"); err != nil {
t.Fatalf("不存在的 digest 应为无操作: %v", err)
}
}
@ -420,7 +284,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) {
s.Put([]byte("i2"), Item{MIME: "image/jpeg"})
s.Put([]byte("a1"), Item{MIME: "audio/wav"})
s.Describe(d1, "描述", "vis")
s.AddRef(d1, "context", "e1")
st := s.Stats()
if st["count"].(int) != 3 {
@ -429,9 +292,6 @@ func TestStats_CountsByKindAndDescription(t *testing.T) {
if st["described"].(int) != 1 {
t.Fatalf("described 应为 1实际 %v", st["described"])
}
if st["unreferenced"].(int) != 2 {
t.Fatalf("unreferenced 应为 2实际 %v", st["unreferenced"])
}
byKind := st["by_kind"].(map[string]int)
if byKind["image"] != 2 || byKind["audio"] != 1 {
t.Fatalf("by_kind 不对: %v", byKind)
@ -448,16 +308,15 @@ func TestPut_RejectsEmpty(t *testing.T) {
func TestReopen_PersistsAcrossRestart(t *testing.T) {
// 记忆的意义就在于跨重启还在。
dir := t.TempDir()
s1, err := New(dir, 0)
s1, err := New(dir)
if err != nil {
t.Fatal(err)
}
d, _ := s1.Put([]byte("persistent-img"), Item{MIME: "image/png", OriginPath: "/tmp/x.png"})
s1.Describe(d, "跨重启的描述", "vis")
s1.AddRef(d, "context", "e1")
s1.Close()
s2, err := New(dir, 0)
s2, err := New(dir)
if err != nil {
t.Fatal(err)
}
@ -467,7 +326,7 @@ func TestReopen_PersistsAcrossRestart(t *testing.T) {
if err != nil {
t.Fatalf("重开后应能查到: %v", err)
}
if it.Description != "跨重启的描述" || it.RefCount != 1 {
if it.Description != "跨重启的描述" {
t.Fatalf("元数据应持久化: %+v", it)
}
data, err := s2.Get(d)

View File

@ -11,19 +11,12 @@ import (
// 冒烟测试:走真实数据路径的端到端场景,而非孤立的 API 单测。
//
// 之前这套场景是 internal/memory/media/smoke/ 下一个带 //go:build smoke 的
// 独立 main得记着加 -tags smoke 才跑得到——那种早晚会被忘掉。搬成普通
// 测试后它随 go test ./... 一起跑,冒烟的意义(每次改动都过一遍真实链路)
// 才真正成立。
// 媒体存储现在只做内容寻址CAS字节 + 元数据 + 向量。
// “哪些字节还活着”由三层记忆持有的一等记忆块决定,调用方把该集合传给
// GC/检索,本层不维护 media_refs/ref_count 这类平行账本。
// makePNG 生成一张 w×h 的条带 PNG用真 PNG 而不是随机字节,
// 让入库/回读/digest 走的是与生产一致的数据形态。
//
// variant 注入到像素而不只用于选色:最初写的是
// palette[(variant+y*3/h)%5],调色盘只 5 色,于是 variant=0 与 5 产出
// 逐字节相同的 PNG——冒烟跑出「6 帧只得 5 条」,看着像存储丢了一帧,
// 实际是 CAS 正确去重了两张真同图。冒烟要验的是「不同帧各存一份」,
// 夹具就必须保证帧间真的不同。
func makePNG(w, h, variant int) []byte {
palette := [][3]byte{
{255, 0, 0}, {0, 192, 0}, {0, 0, 255}, {255, 220, 0}, {160, 0, 200},
@ -71,7 +64,7 @@ func makePNG(w, h, variant int) []byte {
func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
// 场景用户连问几轮同一张截图。multimodal 每轮都会重新注入,
// 磁盘上应该只有一份,但每轮的 context 事件各持一个引用
// 内容寻址天然去重,磁盘上只应有一份
s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0)
@ -96,9 +89,6 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
} else if d != d0 {
t.Fatalf("同一张图第 %d 轮 digest 变了", turn)
}
if err := s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn)); err != nil {
t.Fatalf("第 %d 轮 AddRef: %v", turn, err)
}
}
st := s.Stats()
@ -108,18 +98,16 @@ func TestSmoke_SamePictureAcrossTurns(t *testing.T) {
if total := st["total_bytes"].(int64); total != int64(len(png)) {
t.Fatalf("字节数应等于单张原图 %d实际 %d", len(png), total)
}
it, _ := s.Stat(d0)
if it.RefCount != 5 {
t.Fatalf("应有 5 个引用,实际 %d", it.RefCount)
// 三层记忆持有它;内容应仍可读
if _, err := s.Get(d0); err != nil {
t.Fatalf("内容应仍可读: %v", err)
}
t.Logf("同图 5 轮:条目=1 字节=%d refcount=%d", len(png), it.RefCount)
checkRefIntegrity(t, s)
}
func TestSmoke_VideoFramesDistinct(t *testing.T) {
// 场景see_video 抽 6 帧,帧间内容不同,应各存一份并共享一个 owner
// 场景see_video 抽 6 帧,帧间内容不同,应各存一份。
s := newTestStore(t, 50*1024*1024)
var frames []string
keep := map[string]bool{}
for i := 0; i < 6; i++ {
d, err := s.Put(makePNG(320, 240, i), Item{
MIME: "image/jpeg", Width: 320, Height: 240, Tool: "multimodal_see_video",
@ -127,24 +115,12 @@ func TestSmoke_VideoFramesDistinct(t *testing.T) {
if err != nil {
t.Fatalf("第 %d 帧: %v", i, err)
}
frames = append(frames, d)
if err := s.AddRef(d, "context", "evt-video"); err != nil {
t.Fatal(err)
}
keep[d] = true
}
st := s.Stats()
if st["count"].(int) != 6 {
if st := s.Stats(); st["count"].(int) != 6 {
t.Fatalf("6 帧应各存一份,实际 %v 条", st["count"])
}
refs, err := s.Refs("context", "evt-video")
if err != nil {
t.Fatal(err)
}
if len(refs) != 6 {
t.Fatalf("evt-video 应引用 6 帧,实际 %d", len(refs))
}
checkRefIntegrity(t, s)
}
func TestSmoke_DescribeThenRetrieve(t *testing.T) {
@ -156,10 +132,8 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
if err := s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm"); err != nil {
t.Fatal(err)
}
var frames []string
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
frames = append(frames, d)
if err := s.Describe(d, fmt.Sprintf("视频第 %d 帧:测试图卡,含彩条与计数器", i+1), "visionllm"); err != nil {
t.Fatal(err)
}
@ -175,89 +149,65 @@ func TestSmoke_DescribeThenRetrieve(t *testing.T) {
if len(pend) != 0 {
t.Fatalf("应全部已描述,仍有 %d 条待描述", len(pend))
}
_ = frames
}
func TestSmoke_ArchiveTransfersOwnership(t *testing.T) {
// 场景:L0 的 context 事件被 Prune 归档进 L2 文档,
// 媒体引用需从 context owner 转到 document owner期间内容不能被 GC 掉
func TestSmoke_ContentSurvivesLayerMigration(t *testing.T) {
// 场景:同一份媒体随记忆块从 Context 迁移到 Document 再到 Graph。
// 迁移的是块本身digest 不变,因此内容在整条链路上始终可读
s := newTestStore(t, 50*1024*1024)
png := makePNG(400, 400, 0)
d, _ := s.Put(png, Item{MIME: "image/png", Tool: "multimodal_see_picture"})
for turn := 1; turn <= 5; turn++ {
s.AddRef(d, "context", fmt.Sprintf("evt-%d", turn))
}
// evt-1 被淘汰,其内容归档为一篇文档
n, err := s.DropOwner("context", "evt-1")
if err != nil {
t.Fatal(err)
// 迁移过程中该 digest 始终可读
for _, layer := range []string{"context", "document", "graph"} {
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
t.Fatalf("迁移到 %s 时内容应完好: %v", layer, err)
}
}
if n != 1 {
t.Fatalf("应注销 1 条引用,实际 %d", n)
}
if err := s.AddRef(d, "document", "doc_archived_001"); err != nil {
t.Fatal(err)
}
it, _ := s.Stat(d)
if it.RefCount != 5 {
t.Fatalf("引用转移后总数应仍为 54 context + 1 document实际 %d", it.RefCount)
}
// 归档过程中内容必须始终可读
if got, err := s.Get(d); err != nil || !bytes.Equal(got, png) {
t.Fatalf("归档后内容应完好: %v", err)
}
checkRefIntegrity(t, s)
}
func TestSmoke_GCSweepsToolLeftovers(t *testing.T) {
// 场景:别的工具cmd_run 之类)产出的一次性图片没人引用
// 应被 GC 清掉;而被记忆引用的媒体一个都不能少。
s := newTestStore(t, 50*1024*1024)
func TestSmoke_DeleteRemovesOnlyThatContent(t *testing.T) {
// 场景:某个工具产出的一次性图片所在的记忆块被删除时
// 只有它自己的内容被删;其他块的内容一个都不能少。
s := newTestStore(t)
keep, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
s.AddRef(keep, "document", "doc-1")
held, _ := s.Put(makePNG(400, 400, 0), Item{MIME: "image/png"})
var frames []string
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg"})
s.AddRef(d, "context", "evt-video")
frames = append(frames, d)
}
// 1000+i 保证与上面的帧、以及彼此都不重复
var ephemeral []string
for i := 0; i < 20; i++ {
s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
d, _ := s.Put(makePNG(100, 100, 1000+i), Item{MIME: "image/png", Tool: "cmd_run"})
ephemeral = append(ephemeral, d)
}
before := s.Stats()["count"].(int)
removed, freed, err := s.GC(0)
if err != nil {
t.Fatal(err)
for _, d := range ephemeral {
if err := s.Delete(d); err != nil {
t.Fatal(err)
}
}
after := s.Stats()["count"].(int)
if removed != 20 {
t.Fatalf("应清 20 条孤儿,实际 %d", removed)
}
if after != before-20 {
t.Fatalf("条目数应从 %d 降到 %d实际 %d", before, before-20, after)
}
if _, err := s.Get(keep); err != nil {
t.Fatalf("被文档引用的图被误删: %v", err)
if _, err := s.Get(held); err != nil {
t.Fatalf("被保留的内容被误删: %v", err)
}
for i, f := range frames {
if _, err := s.Get(f); err != nil {
t.Fatalf("第 %d 帧被误删: %v", i, err)
}
}
t.Logf("GC: %d 条 → 清 %d 条(%d 字节)→ %d 条", before, removed, freed, after)
checkRefIntegrity(t, s)
}
func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
// 端到端:入库 → 描述 → 引用 → GC → 重启 → 检索,
// 端到端:入库 → 描述 → 删除一些内容 → 重启 → 检索,
// 并确认磁盘与元数据不出现双向孤儿。记忆的意义就在于跨重启还在。
dir := t.TempDir()
s, err := New(dir, 50*1024*1024)
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
@ -265,22 +215,20 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
png := makePNG(400, 400, 0)
pic, _ := s.Put(png, Item{MIME: "image/png", Width: 400, Height: 400, Tool: "multimodal_see_picture"})
s.Describe(pic, "一张 400x400 的三色带图:上红、中绿、下蓝", "visionllm")
s.AddRef(pic, "graph_sentence", "sent-42")
for i := 0; i < 6; i++ {
d, _ := s.Put(makePNG(320, 240, i), Item{MIME: "image/jpeg", Tool: "multimodal_see_video"})
s.Describe(d, fmt.Sprintf("视频第 %d 帧", i+1), "visionllm")
s.AddRef(d, "context", "evt-video")
}
for i := 0; i < 10; i++ {
s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
}
if _, _, err := s.GC(0); err != nil {
t.Fatal(err)
d, _ := s.Put(makePNG(64, 64, 2000+i), Item{MIME: "image/png", Tool: "cmd_run"})
if err := s.Delete(d); err != nil {
t.Fatal(err)
}
}
beforeCount := s.Stats()["count"].(int)
s.Close()
s2, err := New(dir, 50*1024*1024)
s2, err := New(dir)
if err != nil {
t.Fatalf("重开失败: %v", err)
}
@ -293,16 +241,13 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
if err != nil {
t.Fatalf("重开后查不到: %v", err)
}
if it.Description == "" || it.RefCount != 1 {
if it.Description == "" {
t.Fatalf("元数据未持久化: %+v", it)
}
data, err := s2.Get(pic)
if err != nil || !bytes.Equal(data, png) {
t.Fatalf("重开后内容不一致: %v", err)
}
if refs, _ := s2.Refs("context", "evt-video"); len(refs) != 6 {
t.Fatalf("重开后视频帧引用应为 6实际 %d", len(refs))
}
if hits, _ := s2.Search("三色带", KindImage, 10); len(hits) != 1 {
t.Fatal("重开后描述应仍可检索")
}
@ -311,6 +256,5 @@ func TestSmoke_FullLifecycleAcrossRestart(t *testing.T) {
if n := blobFileCount(t, s2); n != beforeCount {
t.Fatalf("磁盘 blob=%d 与元数据=%d 不一致", n, beforeCount)
}
checkRefIntegrity(t, s2)
t.Logf("跨重启:%d 条目、描述与引用全部完好", beforeCount)
t.Logf("跨重启:%d 条目、描述与内容全部完好", beforeCount)
}

View File

@ -12,14 +12,16 @@ import (
// TestSoak_SustainedMixedLoad 长稳测试:持续混合负载下不变量不破。
// 用 -run TestSoak -timeout 300s 单独跑,默认 short 模式跳过。
//
// 媒体没有独立生命周期管理blob 是记忆块的内容,块被删除时内容随之删除。
func TestSoak_SustainedMixedLoad(t *testing.T) {
if testing.Short() {
t.Skip("long soak test; run with -run TestSoak")
}
dur := 60 * time.Second
s := newTestStore(t, 8*1024*1024) // 8MB 上限,逼 GC 频繁工作
s := newTestStore(t)
// 常驻受保护
// 常驻受保护区:全程被记忆块持有,模拟 Graph L3 中的块
const keepN = 20
keep := make([]string, keepN)
keepData := make([][]byte, keepN)
@ -31,16 +33,13 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := s.AddRef(dg, "graph_sentence", fmt.Sprintf("s-%d", i)); err != nil {
t.Fatal(err)
}
keep[i] = dg
keepData[i] = d
}
stop := make(chan struct{})
var wg sync.WaitGroup
var puts, gets, gcs, describes, searches, refOps atomic.Int64
var puts, gets, deletes, describes, searches atomic.Int64
var fatal atomic.Int64
worker := func(name string, fn func(iter int) error) {
@ -62,29 +61,17 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
}()
}
// 写入者 ×3
// 写入者 ×3:持续写入一次性内容(无人持有)
for w := 0; w < 3; w++ {
wid := w
worker(fmt.Sprintf("put-%d", wid), func(i int) error {
b := make([]byte, 2048)
rand.Read(b)
b = append([]byte(fmt.Sprintf("eph-%d-%d-", wid, i)), b...)
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
if err != nil {
if _, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"}); err != nil {
return err
}
puts.Add(1)
// 三分之一挂上引用再立刻注销,模拟短命引用
if i%3 == 0 {
own := fmt.Sprintf("tmp-%d-%d", wid, i)
if err := s.AddRef(d, "context", own); err != nil {
return err
}
if err := s.DropRef(d, "context", own); err != nil {
return err
}
refOps.Add(2)
}
return nil
})
}
@ -105,13 +92,20 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
})
}
// GC 者
worker("gc", func(i int) error {
if _, _, err := s.GC(0); err != nil {
// 删除者:持续删除一次性内容(模拟块创建后又被遗忘)
worker("delete", func(i int) error {
b := make([]byte, 2048)
rand.Read(b)
b = append([]byte(fmt.Sprintf("del-%d-", i)), b...)
d, err := s.Put(b, Item{MIME: "image/png", Tool: "cmd_run"})
if err != nil {
return err
}
gcs.Add(1)
time.Sleep(5 * time.Millisecond)
if err := s.Delete(d); err != nil {
return err
}
deletes.Add(1)
time.Sleep(time.Millisecond)
return nil
})
@ -152,8 +146,8 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
t.Fatalf("%d 个 worker 报致命错误", n)
}
t.Logf("%v 内: put=%d get=%d gc=%d describe=%d search=%d refOps=%d",
dur, puts.Load(), gets.Load(), gcs.Load(), describes.Load(), searches.Load(), refOps.Load())
t.Logf("%v 内: put=%d get=%d delete=%d describe=%d search=%d",
dur, puts.Load(), gets.Load(), deletes.Load(), describes.Load(), searches.Load())
// 收尾断言
for i, d := range keep {
@ -164,17 +158,9 @@ func TestSoak_SustainedMixedLoad(t *testing.T) {
if !bytes.Equal(got, keepData[i]) {
t.Fatalf("受保护项内容变了 %s", shortDigest(d))
}
it, err := s.Stat(d)
if err != nil || it.RefCount != 1 {
t.Fatalf("受保护项引用计数应为 1: %+v", it)
}
}
checkRefIntegrity(t, s)
st := s.Stats()
t.Logf("收尾: 条目=%v 字节=%v 未引用=%v 已描述=%v",
st["count"], st["total_bytes"], st["unreferenced"], st["described"])
if total := st["total_bytes"].(int64); total > 8*1024*1024*3 {
t.Fatalf("容量失控: %d 远超上限", total)
}
t.Logf("收尾: 条目=%v 字节=%v 已描述=%v",
st["count"], st["total_bytes"], st["described"])
}

View File

@ -16,10 +16,12 @@ import (
// 压力测试与冒烟测试。
//
// 关注点不是吞吐数字,而是并发下的不变量是否被破坏:
// 1. ref_count 与 media_refs 表的行数必须始终一致(错位会让 GC 误删或永不清)
// 2. GC 与读写并发时,有引用的内容绝不能被删
// 3. 同内容并发 Put 只落一份磁盘、digest 一致
// 4. SQLite 在多 goroutine 下不出现 "database is locked"
// 1. GC 与读写并发时,被记忆块持有的内容绝不能被删
// 2. 同内容并发 Put 只落一份磁盘、digest 一致
// 3. SQLite 在多 goroutine 下不出现 "database is locked"
//
// 存活判定不再依赖 media_refs/ref_count调用方把「三层记忆当前持有的
// digest 集合」传给 GC本层只做 CAS。
func randBytes(t *testing.T, n int) []byte {
t.Helper()
@ -30,37 +32,6 @@ func randBytes(t *testing.T, n int) []byte {
return b
}
// checkRefIntegrity 校验核心不变量:每个 digest 的 ref_count 等于
// media_refs 里指向它的行数。这条对不上就意味着 GC 的判断依据是错的。
func checkRefIntegrity(t *testing.T, s *Store) {
t.Helper()
rows, err := s.db.Query(`
SELECT m.digest, m.ref_count, COUNT(r.digest)
FROM media m LEFT JOIN media_refs r ON m.digest = r.digest
GROUP BY m.digest, m.ref_count`)
if err != nil {
t.Fatalf("integrity query: %v", err)
}
defer rows.Close()
var bad int
for rows.Next() {
var d string
var stored, actual int
if err := rows.Scan(&d, &stored, &actual); err != nil {
continue
}
if stored != actual {
bad++
if bad <= 5 {
t.Errorf("ref 计数错位 %s: ref_count=%d 实际引用行=%d", shortDigest(d), stored, actual)
}
}
}
if bad > 0 {
t.Fatalf("共 %d 条 digest 的 ref_count 与 media_refs 不一致", bad)
}
}
// blobFileCount 统计 CAS 目录下的实际文件数(不含 .tmp
func blobFileCount(t *testing.T, s *Store) int {
t.Helper()
@ -117,7 +88,6 @@ func TestStress_ConcurrentPutSameContent(t *testing.T) {
if got, err := s.Get(first); err != nil || !bytes.Equal(got, data) {
t.Fatalf("内容应可完整读回: err=%v len=%d", err, len(got))
}
checkRefIntegrity(t, s)
}
func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
@ -180,69 +150,71 @@ func TestStress_ConcurrentPutDistinctContent(t *testing.T) {
if st["count"].(int) != len(records) {
t.Fatalf("库内条目应为 %d实际 %v", len(records), st["count"])
}
checkRefIntegrity(t, s)
}
func TestStress_ConcurrentRefChurn(t *testing.T) {
// 引用增删风暴:多 owner 对少量 digest 反复 AddRef/DropRef。
// 核心断言是最终 ref_count 与 media_refs 行数一致——错位就意味着
// GC 会误删(计数偏低)或永不清(计数虚高)。
s := newTestStore(t, 0)
func TestStress_ConcurrentDeleteAndPut(t *testing.T) {
// 删除与写入并发:核心断言是被保留的内容永远可读,
// 删除只影响目标 digest不误伤其他内容。
s := newTestStore(t)
const digestCount = 8
digests := make([]string, digestCount)
for i := range digests {
const heldCount = 8
held := make([]string, heldCount)
for i := range held {
d, err := s.Put([]byte(fmt.Sprintf("payload-%d", i)), Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
digests[i] = d
held[i] = d
}
const workers = 24
const rounds = 40
const workers = 16
const rounds = 30
var wg sync.WaitGroup
var addErr, dropErr atomic.Int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func(wid int) {
defer wg.Done()
owner := fmt.Sprintf("evt-%d", wid)
for r := 0; r < rounds; r++ {
d := digests[(wid+r)%digestCount]
if err := s.AddRef(d, "context", owner); err != nil {
addErr.Add(1)
d, err := s.Put([]byte(fmt.Sprintf("tmp-%d-%d", wid, r)), Item{MIME: "image/png"})
if err != nil {
t.Errorf("Put: %v", err)
return
}
// 故意重复 AddRef幂等性在并发下也必须成立
if err := s.AddRef(d, "context", owner); err != nil {
addErr.Add(1)
}
if r%2 == 0 {
if err := s.DropRef(d, "context", owner); err != nil {
dropErr.Add(1)
}
if err := s.Delete(d); err != nil {
t.Errorf("Delete: %v", err)
return
}
}
}(w)
}
// 并发读取被保留内容
for rdr := 0; rdr < 4; rdr++ {
wg.Add(1)
go func() {
defer wg.Done()
for r := 0; r < rounds; r++ {
for i, d := range held {
if _, err := s.Get(d); err != nil {
t.Errorf("内容 %d 被误删: %v", i, err)
return
}
}
}
}()
}
wg.Wait()
if n := addErr.Load(); n > 0 {
t.Fatalf("AddRef 失败 %d 次", n)
for i, d := range held {
if _, err := s.Get(d); err != nil {
t.Fatalf("仍被保留的第 %d 项不可读: %v", i, err)
}
}
if n := dropErr.Load(); n > 0 {
t.Fatalf("DropRef 失败 %d 次", n)
}
checkRefIntegrity(t, s)
}
func TestStress_GCConcurrentWithWrites(t *testing.T) {
// GC 与读并发。最重要的断言:有引用的内容在整个过程中始终可读。
// 这条一旦破,记忆里的 digest 就成了悬空指针。
s := newTestStore(t, 0)
func TestStress_DeleteConcurrentWithReads(t *testing.T) {
// 删除与读并发。最重要的断言:被保留的内容在整个过程中始终可读。
s := newTestStore(t)
// 一批"受保护"的内容,全程持有引用
const protectedCount = 10
protected := make([]string, protectedCount)
protectedData := make([][]byte, protectedCount)
@ -252,9 +224,6 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if err := s.AddRef(d, "document", fmt.Sprintf("doc-%d", i)); err != nil {
t.Fatal(err)
}
protected[i] = d
protectedData[i] = data
}
@ -262,10 +231,10 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
stop := make(chan struct{})
var wg sync.WaitGroup
var readErr atomic.Int64
var gcRuns atomic.Int64
var deleteCount atomic.Int64
var putCount atomic.Int64
// 写入者:持续 Put 一次性内容(不加引用,是 GC 的正常目标
// 写入者:持续 Put 一次性内容再删除(模拟块创建后又被遗忘
for w := 0; w < 4; w++ {
wg.Add(1)
go func(wid int) {
@ -278,8 +247,13 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
default:
}
data := append([]byte(fmt.Sprintf("ephemeral-%d-%d-", wid, i)), randBytes(t, 128)...)
if _, err := s.Put(data, Item{MIME: "image/png"}); err == nil {
putCount.Add(1)
d, err := s.Put(data, Item{MIME: "image/png"})
if err != nil {
continue
}
putCount.Add(1)
if err := s.Delete(d); err == nil {
deleteCount.Add(1)
}
i++
}
@ -314,33 +288,14 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
}()
}
// GC 者minAge=0 让所有无引用项立刻可清,最大化与写入的冲突
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if _, _, err := s.GC(0); err != nil {
t.Errorf("GC 报错: %v", err)
return
}
gcRuns.Add(1)
time.Sleep(time.Millisecond)
}
}()
time.Sleep(1500 * time.Millisecond)
close(stop)
wg.Wait()
if n := readErr.Load(); n > 0 {
t.Fatalf("受保护内容读取失败 %d 次——GC 误删了有引用的项", n)
t.Fatalf("受保护内容读取失败 %d 次", n)
}
t.Logf("并发窗口内: Put=%d GC=%d", putCount.Load(), gcRuns.Load())
t.Logf("并发窗口内: Put=%d Delete=%d", putCount.Load(), deleteCount.Load())
// 收尾确认:受保护的一个都没少
for i, d := range protected {
@ -348,12 +303,7 @@ func TestStress_GCConcurrentWithWrites(t *testing.T) {
if err != nil || !bytes.Equal(got, protectedData[i]) {
t.Fatalf("收尾检查失败 %s: %v", shortDigest(d), err)
}
it, err := s.Stat(d)
if err != nil || it.RefCount != 1 {
t.Fatalf("受保护项引用计数应为 1: %+v err=%v", it, err)
}
}
checkRefIntegrity(t, s)
}
func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
@ -427,61 +377,46 @@ func TestStress_DescribeConcurrentWithSearch(t *testing.T) {
}
}
func TestStress_CapacityGCUnderLoad(t *testing.T) {
// 容量上限在持续写入下必须真正生效,且不碰有引用的项
const cap = 256 * 1024 // 256KB
s := newTestStore(t, cap)
func TestStress_DeleteUnderLoad(t *testing.T) {
// 持续写入 + 删除下,被保留的项必须始终可读
s := newTestStore(t)
// 先放 3 个有引用的大项(合计约 96KB它们永不可删
const keepN = 3
keep := make([]string, keepN)
for i := range keep {
keepList := make([]string, keepN)
for i := range keepList {
data := append([]byte(fmt.Sprintf("keep-%d-", i)), randBytes(t, 32*1024)...)
d, err := s.Put(data, Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
if err := s.AddRef(d, "graph_sentence", fmt.Sprintf("sent-%d", i)); err != nil {
t.Fatal(err)
}
keep[i] = d
keepList[i] = d
}
// 持续写入无引用内容,交替 GC
for round := 0; round < 30; round++ {
for i := 0; i < 3; i++ {
data := append([]byte(fmt.Sprintf("tmp-%d-%d-", round, i)), randBytes(t, 16*1024)...)
if _, err := s.Put(data, Item{MIME: "image/png"}); err != nil {
d, err := s.Put(data, Item{MIME: "image/png"})
if err != nil {
t.Fatalf("round %d Put: %v", round, err)
}
}
if _, _, err := s.GC(0); err != nil {
t.Fatalf("round %d GC: %v", round, err)
if err := s.Delete(d); err != nil {
t.Fatalf("round %d Delete: %v", round, err)
}
}
}
st := s.Stats()
total := st["total_bytes"].(int64)
t.Logf("上限 %d收尾总量 %d条目 %v", cap, total, st["count"])
// 有引用的项必须都在
for _, d := range keep {
// 被保留的项必须都在
for _, d := range keepList {
if _, err := s.Get(d); err != nil {
t.Fatalf("有引用项被容量 GC 删了 %s: %v", shortDigest(d), err)
t.Fatalf("被保留项被误删 %s: %v", shortDigest(d), err)
}
}
// 无引用项应被压到上限附近:允许略超(有引用项本身可能就占了大头),
// 但不该无界增长——30 轮 × 3 × 16KB = 1.4MB 若全留下就是失控。
if total > cap*2 {
t.Fatalf("容量 GC 未生效:总量 %d 远超上限 %d", total, cap)
}
checkRefIntegrity(t, s)
}
func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
// 大量写入 + GC 之后重开:元数据与磁盘不该出现互相不认的孤儿。
// 大量写入 + 删除之后重开:元数据与磁盘不该出现互相不认的孤儿。
dir := t.TempDir()
s1, err := New(dir, 0)
s1, err := New(dir)
if err != nil {
t.Fatal(err)
}
@ -494,19 +429,15 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
t.Fatal(err)
}
if i%5 == 0 {
if err := s1.AddRef(d, "context", fmt.Sprintf("e-%d", i)); err != nil {
t.Fatal(err)
}
kept = append(kept, d)
} else if err := s1.Delete(d); err != nil {
t.Fatal(err)
}
}
if _, _, err := s1.GC(0); err != nil {
t.Fatal(err)
}
beforeStats := s1.Stats()
s1.Close()
s2, err := New(dir, 0)
s2, err := New(dir)
if err != nil {
t.Fatalf("重开失败: %v", err)
}
@ -547,10 +478,9 @@ func TestStress_ReopenAfterHeavyChurn(t *testing.T) {
for _, d := range kept {
if _, err := s2.Get(d); err != nil {
t.Fatalf("有引用项重开后读不到 %s: %v", shortDigest(d), err)
t.Fatalf("被持有项重开后读不到 %s: %v", shortDigest(d), err)
}
}
checkRefIntegrity(t, s2)
}
func TestStress_LargeBlob(t *testing.T) {
@ -597,5 +527,4 @@ func TestStress_DataURLRoundTripAtScale(t *testing.T) {
t.Fatalf("第 %d 次入库回读不一致: %v", i, err)
}
}
checkRefIntegrity(t, s)
}