Files
agent-bot ee1e53b510
CI / go-vet (pull_request) Failing after 10s
CI / backup-restore-drill (pull_request) Failing after 25s
CI / web (pull_request) Successful in 33s
fix(tests): per-package test databases + legitimate approval fixtures (issue #2 close)
Root causes of the remaining 5 CI PG failures (and the hidden flaky set):
1. All PG-integration packages shared ONE database under parallel package
   execution - migrations/seeds/truncates raced (nondeterministic failure
   sets, locally and in CI).
   Fix: RequireTestDatabaseURL/SkipIfNoTestDatabaseURL now derive a
   per-package database (<base>_<pkgHash>, advisory-lock-guarded
   CREATE); 9 raw-env straggler tests funnelled through the helpers;
   db-package tests call it unqualified (no import cycle).
2. Approval-chain fixtures were not production-shaped: freezeReviewSnapshot
   only honors head_snapshot_id, fake content hashes never froze a
   snapshot, so ApproverApprove rejected with 'approved requires frozen
   review_snapshot_id'. Errors were swallowed (_, _ =) hiding this.
   Fix (ownership-respecting, arch-enforced):
   - docrevision.SeedContentSnapshotForTest seeds the ledger tables it owns
   - docgov GovernanceEngine.FreezeHeadSnapshotForTest freezes head/review
     pointers on document_records (docgov-owned) and clears engine cache
   - all four integration fixtures use the pair; swallowed errors checked
3. Fixed-name fixtures collide once a package shares one DB (projects
   uq_projects_name_global): uniquified in projectinstance + server tests.
4. CAS/ctxmgr tests never bootstrapped migrations (relied on other packages
   having migrated the shared DB): explicit RunMigrationsOnceForTest.

Verified: full parallel short suite = 75 packages, 0 failures against an
isolated throwaway PG. CI skip list removed - short tests fully required.
2026-09-29 09:54:43 +00:00

545 lines
15 KiB
Go

package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Embedder 向量生成抽象接口
type Embedder interface {
EmbedOne(ctx context.Context, text string) ([]float32, error)
}
// CAS 内容寻址存储与切片防爆炸去重管理器
type CAS struct {
baseDir string
pool *pgxpool.Pool
embedder Embedder
// 离线/降级内存索引 (当数据库未连接时)
mu sync.RWMutex
memoryBlobs map[string]*BlobInfo
memoryRefs map[int64]*FileReference
memoryChunks []*MemoryChunk
nextRefID int64
nextChunkID int64
}
// NewCAS 创建 CAS 存储管理器
func NewCAS(baseDir string, pool *pgxpool.Pool, embedder Embedder) (*CAS, error) {
if strings.TrimSpace(baseDir) == "" {
return nil, errors.New("storage: baseDir is required (runtime roots come from config, not package defaults)")
}
blobDir := filepath.Join(baseDir, "blobs")
if err := os.MkdirAll(blobDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create blob dir: %w", err)
}
return &CAS{
baseDir: baseDir,
pool: pool,
embedder: embedder,
memoryBlobs: make(map[string]*BlobInfo),
memoryRefs: make(map[int64]*FileReference),
memoryChunks: make([]*MemoryChunk, 0),
nextRefID: 1,
nextChunkID: 1,
}, nil
}
// StoreFile 写入物理文件并进行 CAS Hash 去重
func (c *CAS) StoreFile(ctx context.Context, projectID, userID int64, fileName, fileCategory string, data []byte, mimeType string) (*FileReference, error) {
if len(data) == 0 {
return nil, errors.New("cannot store empty file")
}
if fileCategory == "" {
fileCategory = "material"
}
if mimeType == "" {
mimeType = "application/octet-stream"
}
hashBytes := sha256.Sum256(data)
hashStr := hex.EncodeToString(hashBytes[:])
sizeBytes := int64(len(data))
relPath := fmt.Sprintf("blobs/%s/%s/%s.bin", hashStr[:2], hashStr[2:4], hashStr)
fullPath := filepath.Join(c.baseDir, relPath)
if c.pool != nil {
return c.storeFileDB(ctx, projectID, userID, fileName, fileCategory, mimeType, hashStr, relPath, fullPath, sizeBytes, data)
}
return c.storeFileMemory(projectID, userID, fileName, fileCategory, mimeType, hashStr, relPath, fullPath, sizeBytes, data)
}
func (c *CAS) storeFileDB(ctx context.Context, projectID, userID int64, fileName, fileCategory, mimeType, hashStr, relPath, fullPath string, sizeBytes int64, data []byte) (*FileReference, error) {
tx, err := c.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin tx failed: %w", err)
}
defer tx.Rollback(ctx)
var existingHash string
var refCount int
err = tx.QueryRow(ctx, "SELECT sha256, ref_count FROM storage_blobs WHERE sha256 = $1 FOR UPDATE", hashStr).Scan(&existingHash, &refCount)
isDedup := false
if err == nil && existingHash != "" {
// 命中去重:物理文件已存在,递增引用计数
isDedup = true
_, err = tx.Exec(ctx, "UPDATE storage_blobs SET ref_count = ref_count + 1 WHERE sha256 = $1", hashStr)
if err != nil {
return nil, fmt.Errorf("update ref_count failed: %w", err)
}
} else {
// 新文件:写入物理磁盘 (采用原子写入)
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("mkdir failed: %w", err)
}
tmpPath := fullPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return nil, fmt.Errorf("write tmp file failed: %w", err)
}
if err := os.Rename(tmpPath, fullPath); err != nil {
return nil, fmt.Errorf("rename file failed: %w", err)
}
_, err = tx.Exec(ctx,
"INSERT INTO storage_blobs (sha256, size_bytes, mime_type, storage_path, ref_count, created_at) VALUES ($1, $2, $3, $4, 1, now())",
hashStr, sizeBytes, mimeType, relPath)
if err != nil {
return nil, fmt.Errorf("insert blob failed: %w", err)
}
}
// 插入引用记录
var refID int64
var createdAt time.Time
err = tx.QueryRow(ctx, `
INSERT INTO file_references (project_id, user_id, blob_sha256, file_name, file_category, summary, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, '', '{}'::jsonb, now())
RETURNING id, created_at
`, projectID, userID, hashStr, fileName, fileCategory).Scan(&refID, &createdAt)
if err != nil {
return nil, fmt.Errorf("insert file reference failed: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit tx failed: %w", err)
}
return &FileReference{
ID: refID,
ProjectID: projectID,
UserID: userID,
BlobSHA256: hashStr,
FileName: fileName,
FileCategory: fileCategory,
CreatedAt: createdAt,
IsDeduplicated: isDedup,
}, nil
}
func (c *CAS) storeFileMemory(projectID, userID int64, fileName, fileCategory, mimeType, hashStr, relPath, fullPath string, sizeBytes int64, data []byte) (*FileReference, error) {
c.mu.Lock()
defer c.mu.Unlock()
isDedup := false
blob, exists := c.memoryBlobs[hashStr]
if exists {
isDedup = true
blob.RefCount++
} else {
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("mkdir failed: %w", err)
}
if err := os.WriteFile(fullPath, data, 0644); err != nil {
return nil, fmt.Errorf("write file failed: %w", err)
}
c.memoryBlobs[hashStr] = &BlobInfo{
SHA256: hashStr,
SizeBytes: sizeBytes,
MimeType: mimeType,
StoragePath: relPath,
RefCount: 1,
CreatedAt: time.Now(),
}
}
refID := c.nextRefID
c.nextRefID++
ref := &FileReference{
ID: refID,
ProjectID: projectID,
UserID: userID,
BlobSHA256: hashStr,
FileName: fileName,
FileCategory: fileCategory,
CreatedAt: time.Now(),
IsDeduplicated: isDedup,
}
c.memoryRefs[refID] = ref
return ref, nil
}
// OpenFileByRef 根据文件引用 ID 打开物理文件流
func (c *CAS) OpenFileByRef(ctx context.Context, refID int64) (io.ReadCloser, *FileReference, error) {
if c.pool != nil {
var ref FileReference
var storagePath string
err := c.pool.QueryRow(ctx, `
SELECT r.id, r.project_id, r.user_id, r.blob_sha256, r.file_name, r.file_category, r.summary, r.created_at, b.storage_path
FROM file_references r
JOIN storage_blobs b ON r.blob_sha256 = b.sha256
WHERE r.id = $1
`, refID).Scan(&ref.ID, &ref.ProjectID, &ref.UserID, &ref.BlobSHA256, &ref.FileName, &ref.FileCategory, &ref.Summary, &ref.CreatedAt, &storagePath)
if err != nil {
return nil, nil, fmt.Errorf("file reference not found: %w", err)
}
fullPath := filepath.Join(c.baseDir, storagePath)
f, err := os.Open(fullPath)
if err != nil {
return nil, nil, fmt.Errorf("open physical file failed: %w", err)
}
return f, &ref, nil
}
c.mu.RLock()
defer c.mu.RUnlock()
ref, ok := c.memoryRefs[refID]
if !ok {
return nil, nil, errors.New("file reference not found in memory")
}
blob, ok := c.memoryBlobs[ref.BlobSHA256]
if !ok {
return nil, nil, errors.New("blob not found in memory")
}
fullPath := filepath.Join(c.baseDir, blob.StoragePath)
f, err := os.Open(fullPath)
if err != nil {
return nil, nil, fmt.Errorf("open physical file failed: %w", err)
}
return f, ref, nil
}
// StoreMemoryChunk 存储记忆切片并自动去重向量化计算
func (c *CAS) StoreMemoryChunk(ctx context.Context, in MemoryChunkInput) (*MemoryChunk, error) {
if strings.TrimSpace(in.Content) == "" {
return nil, errors.New("chunk content cannot be empty")
}
if in.ImportanceWeight <= 0 {
in.ImportanceWeight = 1.00
}
normalized := strings.TrimSpace(in.Title) + "\n" + strings.TrimSpace(in.Content)
h := sha256.Sum256([]byte(normalized))
chunkHash := hex.EncodeToString(h[:])
var embedding []float32
// 1. 检查是否存在相同切片 Hash,复用向量结果以避免重复调用大模型 API 与浪费存储
if c.pool != nil {
var vecStr *string
err := c.pool.QueryRow(ctx, "SELECT embedding::text FROM context_memory_chunks WHERE chunk_hash = $1 AND embedding IS NOT NULL LIMIT 1", chunkHash).Scan(&vecStr)
if err == nil && vecStr != nil {
embedding = parseVectorText(*vecStr)
}
}
// 2. 若未命中现有切片,且配置了 Embedder,则调用生成向量
if len(embedding) == 0 && c.embedder != nil {
if vec, err := c.embedder.EmbedOne(ctx, in.Content); err == nil && len(vec) > 0 {
embedding = vec
}
}
metaJSON, _ := json.Marshal(in.Metadata)
if len(metaJSON) == 0 {
metaJSON = []byte("{}")
}
if c.pool != nil {
var chunkID int64
var createdAt time.Time
var vecLiteral any
if len(embedding) > 0 {
vecLiteral = vectorLiteral(embedding)
}
err := c.pool.QueryRow(ctx, `
INSERT INTO context_memory_chunks
(domain, project_id, user_id, reference_id, title, content, chunk_hash, embedding, tsv, importance_weight, metadata, created_at)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8::vector, to_tsvector('simple', $5 || ' ' || $6), $9, $10, now())
RETURNING id, created_at
`, in.Domain, in.ProjectID, in.UserID, in.ReferenceID, in.Title, in.Content, chunkHash, vecLiteral, in.ImportanceWeight, metaJSON).Scan(&chunkID, &createdAt)
if err != nil {
return nil, fmt.Errorf("insert context memory chunk failed: %w", err)
}
return &MemoryChunk{
ID: chunkID,
Domain: in.Domain,
ProjectID: in.ProjectID,
UserID: in.UserID,
ReferenceID: in.ReferenceID,
Title: in.Title,
Content: in.Content,
ChunkHash: chunkHash,
Embedding: embedding,
ImportanceWeight: in.ImportanceWeight,
Metadata: in.Metadata,
CreatedAt: createdAt,
}, nil
}
// 降级内存存储
c.mu.Lock()
defer c.mu.Unlock()
chunkID := c.nextChunkID
c.nextChunkID++
chunk := &MemoryChunk{
ID: chunkID,
Domain: in.Domain,
ProjectID: in.ProjectID,
UserID: in.UserID,
ReferenceID: in.ReferenceID,
Title: in.Title,
Content: in.Content,
ChunkHash: chunkHash,
Embedding: embedding,
ImportanceWeight: in.ImportanceWeight,
Metadata: in.Metadata,
CreatedAt: time.Now(),
}
c.memoryChunks = append(c.memoryChunks, chunk)
return chunk, nil
}
// SearchMemory 统一多维度记忆语义检索 (向量相似度 + 全文检索 + 时间衰减权重)
func (c *CAS) SearchMemory(ctx context.Context, opts MemorySearchOptions) ([]*MemoryChunkHit, error) {
if opts.TopK <= 0 {
opts.TopK = 5
}
if opts.DecayHalfLifeDays <= 0 {
opts.DecayHalfLifeDays = 7.0 // 默认 7 天半衰期
}
if c.pool != nil {
return c.searchMemoryDB(ctx, opts)
}
return c.searchMemoryFallback(opts), nil
}
func (c *CAS) searchMemoryDB(ctx context.Context, opts MemorySearchOptions) ([]*MemoryChunkHit, error) {
var queryVector []float32
if len(opts.QueryVector) > 0 {
queryVector = opts.QueryVector
} else if c.embedder != nil && strings.TrimSpace(opts.QueryText) != "" {
if vec, err := c.embedder.EmbedOne(ctx, opts.QueryText); err == nil {
queryVector = vec
}
}
var sb strings.Builder
var args []any
argIdx := 1
sb.WriteString(`
SELECT id, domain, project_id, user_id, reference_id, title, content, chunk_hash, importance_weight, metadata, created_at,
`)
if len(queryVector) > 0 {
args = append(args, vectorLiteral(queryVector))
sb.WriteString(fmt.Sprintf(" (1 - (embedding <=> $%d::vector)) AS raw_score ", argIdx))
argIdx++
} else {
sb.WriteString(" 1.0 AS raw_score ")
}
sb.WriteString(" FROM context_memory_chunks WHERE 1=1 ")
if opts.Domain != "" {
sb.WriteString(fmt.Sprintf(" AND domain = $%d ", argIdx))
args = append(args, opts.Domain)
argIdx++
}
if opts.ProjectID > 0 {
sb.WriteString(fmt.Sprintf(" AND project_id = $%d ", argIdx))
args = append(args, opts.ProjectID)
argIdx++
}
if opts.UserID > 0 {
sb.WriteString(fmt.Sprintf(" AND user_id = $%d ", argIdx))
args = append(args, opts.UserID)
argIdx++
}
if strings.TrimSpace(opts.QueryText) != "" {
if len(queryVector) > 0 {
sb.WriteString(fmt.Sprintf(" AND (embedding IS NOT NULL OR tsv @@ plainto_tsquery('simple', $%d) OR title ILIKE '%%' || $%d || '%%' OR content ILIKE '%%' || $%d || '%%') ", argIdx, argIdx, argIdx))
} else {
sb.WriteString(fmt.Sprintf(" AND (tsv @@ plainto_tsquery('simple', $%d) OR title ILIKE '%%' || $%d || '%%' OR content ILIKE '%%' || $%d || '%%') ", argIdx, argIdx, argIdx))
}
args = append(args, opts.QueryText)
argIdx++
}
sb.WriteString(" ORDER BY created_at DESC LIMIT 50 ")
rows, err := c.pool.Query(ctx, sb.String(), args...)
if err != nil {
return nil, fmt.Errorf("search memory failed: %w", err)
}
defer rows.Close()
var hits []*MemoryChunkHit
lambda := math.Ln2 / opts.DecayHalfLifeDays
for rows.Next() {
var chunk MemoryChunk
var metaJSON []byte
var rawScore float64
err := rows.Scan(
&chunk.ID, &chunk.Domain, &chunk.ProjectID, &chunk.UserID, &chunk.ReferenceID,
&chunk.Title, &chunk.Content, &chunk.ChunkHash, &chunk.ImportanceWeight,
&metaJSON, &chunk.CreatedAt, &rawScore,
)
if err != nil {
continue
}
_ = json.Unmarshal(metaJSON, &chunk.Metadata)
timeDecay := 1.0
if opts.ApplyTimeDecay {
daysDiff := time.Since(chunk.CreatedAt).Hours() / 24.0
if daysDiff > 0 {
timeDecay = math.Exp(-lambda * daysDiff)
}
}
finalScore := rawScore * timeDecay * chunk.ImportanceWeight
hits = append(hits, &MemoryChunkHit{
Chunk: &chunk,
RawScore: rawScore,
TimeDecay: timeDecay,
FinalScore: finalScore,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan cas chunks failed: %w", err)
}
// 按 FinalScore 降序排列
sortChunkHits(hits)
if len(hits) > opts.TopK {
hits = hits[:opts.TopK]
}
return hits, nil
}
func (c *CAS) searchMemoryFallback(opts MemorySearchOptions) []*MemoryChunkHit {
c.mu.RLock()
defer c.mu.RUnlock()
var hits []*MemoryChunkHit
lambda := math.Ln2 / opts.DecayHalfLifeDays
for _, chunk := range c.memoryChunks {
if opts.Domain != "" && chunk.Domain != opts.Domain {
continue
}
if opts.ProjectID > 0 && chunk.ProjectID != opts.ProjectID {
continue
}
if opts.UserID > 0 && chunk.UserID != opts.UserID {
continue
}
rawScore := 1.0
if opts.QueryText != "" {
if !strings.Contains(chunk.Title, opts.QueryText) && !strings.Contains(chunk.Content, opts.QueryText) {
continue
}
}
timeDecay := 1.0
if opts.ApplyTimeDecay {
daysDiff := time.Since(chunk.CreatedAt).Hours() / 24.0
if daysDiff > 0 {
timeDecay = math.Exp(-lambda * daysDiff)
}
}
finalScore := rawScore * timeDecay * chunk.ImportanceWeight
hits = append(hits, &MemoryChunkHit{
Chunk: chunk,
RawScore: rawScore,
TimeDecay: timeDecay,
FinalScore: finalScore,
})
}
sortChunkHits(hits)
if len(hits) > opts.TopK {
hits = hits[:opts.TopK]
}
return hits
}
func sortChunkHits(hits []*MemoryChunkHit) {
for i := 0; i < len(hits); i++ {
for j := i + 1; j < len(hits); j++ {
if hits[j].FinalScore > hits[i].FinalScore {
hits[i], hits[j] = hits[j], hits[i]
}
}
}
}
func vectorLiteral(v []float32) string {
parts := make([]string, len(v))
for i, x := range v {
parts[i] = fmt.Sprintf("%.6f", x)
}
return "[" + strings.Join(parts, ",") + "]"
}
func parseVectorText(s string) []float32 {
s = strings.Trim(s, "[] ")
if s == "" {
return nil
}
parts := strings.Split(s, ",")
res := make([]float32, 0, len(parts))
for _, p := range parts {
var f float32
if _, err := fmt.Sscanf(strings.TrimSpace(p), "%f", &f); err == nil {
res = append(res, f)
}
}
return res
}