mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
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:
@ -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_sentence,owner_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 也是 0,minAge 保护它们不被立刻清掉。
|
||||
// 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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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("引用转移后总数应仍为 5(4 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)
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user