Files
knowledge-governance/internal/releaseadapter/v071/library_backup_store.go
T
姚章浩 5defe238e0
build / build (push) Successful in 1m34s
fix(library): 跨范围同步按目标范围改写并校验残留
真实事故:源 KB(10000/0c84a098) 同步到目标 KB(10001/e64e82b5) 时,归档行被原样
插入目标库,落在来源租户/知识库;资源阶段随即报"生产缺少资源 ... 的存储路径"
(资源查询按目标租户过滤,看不见刚写进去的行)。回滚只按目标范围回放,于是页面
报"已恢复到预备份",目标却多出 4 文件/38 分块/21 资源/4 绑定。

改:
- 写入与校验共用 retargetRow:tenant_id / knowledge_base_id / 资源定位符(后端、
  provider、桶、租户前缀) / storage_backend_id / provider / embedding_model_id
  统一映射到目标范围;行里的来源身份对不上就失败,宁可不写。
- Verify 增加"本次写集合仍以来源身份残留"判定;Restore 增加整库指纹比对(预备份
  时刻 vs 恢复后)与孤儿资源清理,回滚后用 Verify 复核,恢复工具退出 0 不再等于
  恢复完成。
- 新增 cmd/libraryresidue:按归档身份精确、可备份、可复核地清理已知的范围外残留。

验证:make verify 全绿;真实 PG+ES 用例复现"源目标 KB 不同 + 目标非空 + 资源阶段
失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言
行落在目标范围且资源定位符指向目标存储。
2026-10-29 22:11:59 +08:00

202 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package v071
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/libraryrelease"
"knowledge-governance/internal/librarysnapshot"
)
// ProductionBackupStore 在生产写入前创建**独立完整预备份**:从生产读一次整库
// (PG RepeatableRead + ES 导出一致快照 + 资源对象),密封成归档里的一个版本。
//
// 为什么要有静默检查:预备份期间生产若仍在变,导出的 PG/ES/对象就不是同一个切点,
// 恢复它等于恢复到"没人见过的状态"。所以导出前后各读一次行数,不一致就失败,
// 让作业重试,而不是拿一个不一致的备份当恢复点。
type ProductionBackupStore struct {
pool *pgxpool.Pool
search *SearchExporter
objects ObjectReader
archiveRoot string
scope string
identity PostgresIdentity
compatibility librarysnapshot.Compatibility
maxDocuments int
maxObjectByte int64
now func() time.Time
}
type ProductionBackupConfig struct {
Pool *pgxpool.Pool
Search *SearchExporter
Objects ObjectReader
ArchiveRoot string
Scope string
Identity PostgresIdentity
Compatibility librarysnapshot.Compatibility
MaxDocuments int
MaxObjectByte int64
Now func() time.Time
}
func NewProductionBackupStore(config ProductionBackupConfig) (*ProductionBackupStore, error) {
if config.Pool == nil || config.Search == nil || config.Objects == nil || config.ArchiveRoot == "" ||
(config.Scope != "internal" && config.Scope != "external") || config.Identity.TenantID == 0 ||
!validSearchIdentifier(config.Identity.KnowledgeBaseID) || config.MaxDocuments < 1 {
return nil, errors.New("生产预备份配置不完整")
}
if config.MaxObjectByte <= 0 {
config.MaxObjectByte = 256 * 1024 * 1024
}
now := config.Now
if now == nil {
now = time.Now
}
return &ProductionBackupStore{pool: config.Pool, search: config.Search, objects: config.Objects,
archiveRoot: config.ArchiveRoot, scope: config.Scope, identity: config.Identity,
compatibility: config.Compatibility, maxDocuments: config.MaxDocuments,
maxObjectByte: config.MaxObjectByte, now: now}, nil
}
// tableFingerprint 是一张表的"数量 + 内容摘要"指纹:同数量改写也会被识别。
type tableFingerprint struct {
Count int64
Digest string
}
// fingerprints 读取当前生产指纹;Prepare 在导出前后各取一次做一致性判断。
func (s *ProductionBackupStore) fingerprints(ctx context.Context) (map[string]tableFingerprint, error) {
result := map[string]tableFingerprint{}
for _, table := range productionLibraryTables {
rows, err := s.pool.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`,
table, table, scopeWhere(table)), s.identity.TenantID, s.identity.KnowledgeBaseID)
if err != nil {
return nil, fmt.Errorf("读取生产 %s 指纹失败", table)
}
digests := make([]string, 0, 64)
for rows.Next() {
var raw map[string]json.RawMessage
if err := rows.Scan(&raw); err != nil {
rows.Close()
return nil, err
}
digest, err := canonicalRowDigest(raw)
if err != nil {
rows.Close()
return nil, err
}
digests = append(digests, digest)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
result[table] = tableFingerprint{Count: int64(len(digests)), Digest: combineRowDigests(digests)}
}
return result, nil
}
func (s *ProductionBackupStore) Prepare(ctx context.Context, _ string, current libraryrelease.CurrentState) (libraryrelease.BackupRef, error) {
archive, err := librarysnapshot.Open(s.archiveRoot, 256*1024*1024)
if err != nil {
return libraryrelease.BackupRef{}, errors.New("归档不可用,无法创建预备份")
}
defer archive.Close()
createdAt := s.now().UTC()
before, err := s.fingerprints(ctx)
if err != nil {
return libraryrelease.BackupRef{}, err
}
// 整实例指纹:恢复之后要拿它证明"多写的、少写的都没有"。只按目标范围比,
// 写到范围外的行会被漏掉(真实事故:恢复报成功但目标多 67 行)。
universeBefore, err := universeFingerprint(ctx, s.pool)
if err != nil {
return libraryrelease.BackupRef{}, err
}
pg, err := ExportLibrary(ctx, s.pool, s.identity)
if err != nil {
return libraryrelease.BackupRef{}, fmt.Errorf("读取生产整库失败: %w", err)
}
// 整库导出用 ExportLibrary(按租户+知识库,不带单文档身份),
// 与封存路径复用同一份实现,避免两套范围规则。
es, err := s.search.ExportLibrary(ctx, SearchIdentity{TenantID: s.identity.TenantID,
KnowledgeBaseID: s.identity.KnowledgeBaseID}, s.maxDocuments)
if err != nil {
return libraryrelease.BackupRef{}, fmt.Errorf("读取生产索引失败: %w", err)
}
if es.MappingSHA256 == "" {
// 生产索引不存在:只有数据库也为空时才是合法的"空基线",否则说明索引丢了。
if len(pg.Table("knowledges").Rows) != 0 || len(pg.Table("chunks").Rows) != 0 {
return libraryrelease.BackupRef{}, errors.New("生产索引缺失但数据库非空:先修索引再发布")
}
} else if es.MappingSHA256 != s.compatibility.IndexMappingSHA256 {
// 契约不符说明生产换了索引映射:此时备份出来的东西无法用同一套恢复。
// 唯一例外是"空生产":零文档零分块时映射差异不影响可恢复性。
emptyProduction := len(pg.Table("knowledges").Rows) == 0 && len(pg.Table("chunks").Rows) == 0 && len(es.Documents) == 0
if !emptyProduction {
return libraryrelease.BackupRef{}, errors.New("生产索引映射与归档契约不一致")
}
}
// ArchiveLibraryCandidate 自己按 resources 行读取对象字节并计算摘要,
// 所以这里直接给它生产对象读取器,不再重复导出一次资源。
snapshotID, err := ArchiveLibraryCandidate(ctx, archive, s.scope, s.identity, s.compatibility, pg, es, s.objects, createdAt)
if err != nil {
return libraryrelease.BackupRef{}, fmt.Errorf("密封预备份失败: %w", err)
}
// 静默检查:导出期间生产内容不能变。只比行数会被"同数量改写"骗过,
// 所以这里比对逐行内容摘要(行数 + 身份 + 内容)。
after, err := s.fingerprints(ctx)
if err != nil {
return libraryrelease.BackupRef{}, err
}
for table, beforeState := range before {
if after[table] != beforeState {
return libraryrelease.BackupRef{}, fmt.Errorf("预备份期间生产 %s 内容发生变化,切点不一致", table)
}
}
universeAfter, err := universeFingerprint(ctx, s.pool)
if err != nil {
return libraryrelease.BackupRef{}, err
}
if diff := universeDiff(universeBefore, universeAfter); diff != "" {
return libraryrelease.BackupRef{}, fmt.Errorf("预备份期间整实例知识域发生变化(%s),切点不一致", diff)
}
for table, want := range current.Counts {
if table == "index" {
continue
}
if after[table].Count != want {
return libraryrelease.BackupRef{}, fmt.Errorf("预备份期间生产 %s 行数从 %d 变为 %d", table, want, after[table].Count)
}
}
manifest, err := archive.Load(snapshotID)
if err != nil {
return libraryrelease.BackupRef{}, errors.New("预备份封存后无法校验")
}
var total int64
for _, part := range manifest.Parts {
total += part.Bytes
}
return libraryrelease.BackupRef{ID: "backup-" + snapshotID, SnapshotID: snapshotID, Bytes: total,
CreatedAt: createdAt, Universe: universeBefore}, nil
}
func (s *ProductionBackupStore) rowCounts(ctx context.Context) (map[string]int64, error) {
counts := map[string]int64{}
for _, table := range productionLibraryTables {
var count int64
if err := s.pool.QueryRow(ctx, fmt.Sprintf(`SELECT count(*) FROM %s WHERE %s`,
table, scopeWhere(table)), s.identity.TenantID, s.identity.KnowledgeBaseID).Scan(&count); err != nil {
return nil, fmt.Errorf("读取生产 %s 行数失败", table)
}
counts[table] = count
}
return counts, nil
}