Files
knowledge-governance/internal/releaseadapter/v071/library_universe.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

127 lines
4.5 KiB
Go

package v071
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
"knowledge-governance/internal/libraryrelease"
"knowledge-governance/internal/librarysnapshot"
)
// universeFingerprint 是"整实例"的知识域指纹:不限租户/知识库的行数 + 身份集合摘要。
//
// 为什么不能只比目标范围:恢复只按目标范围删除/回放,写到范围外的残留它看不见
// (真实事故:作业报"已恢复到预备份",目标却多出 4 文件 / 38 分块 / 21 资源 /
// 4 绑定)。整实例指纹让"多写了什么"必然暴露,同时也能证明恢复没有误删别的
// 知识库的行。
func universeFingerprint(ctx context.Context, pool *pgxpool.Pool) (map[string]libraryrelease.TableFingerprint, error) {
result := make(map[string]libraryrelease.TableFingerprint, len(productionLibraryTables))
for _, table := range productionLibraryTables {
identity := identityExpr(table)
statement := fmt.Sprintf(
`SELECT count(*), coalesce(md5(string_agg((%s)::text, E'\n' ORDER BY (%s)::text)), '') FROM %s`,
identity, identity, table)
var fingerprint libraryrelease.TableFingerprint
if err := pool.QueryRow(ctx, statement).Scan(&fingerprint.Count, &fingerprint.Digest); err != nil {
return nil, fmt.Errorf("读取整库 %s 指纹失败: %w", table, err)
}
result[table] = fingerprint
}
return result, nil
}
// archivedRowIdentities 读出一份归档部件的行身份(与校验用的身份口径一致)。
func archivedRowIdentities(archive *librarysnapshot.Archive, part librarysnapshot.Part) ([]string, error) {
reader, err := archive.OpenPart(part)
if err != nil {
return nil, err
}
defer reader.Close()
decoder := json.NewDecoder(reader)
token, err := decoder.Token()
if err != nil || token != json.Delim('[') {
return nil, errors.New("归档部件不是行数组")
}
ids := make([]string, 0, 64)
for decoder.More() {
var row map[string]json.RawMessage
if err := decoder.Decode(&row); err != nil {
return nil, err
}
identity := archiveRowIdentity(part.Name, row)
if identity == "" {
continue
}
ids = append(ids, identity)
}
return ids, nil
}
// universeDiff 返回第一处不一致的表;空字符串表示整实例与参照完全一致。
func universeDiff(want, got map[string]libraryrelease.TableFingerprint) string {
tables := make([]string, 0, len(want))
for table := range want {
tables = append(tables, table)
}
sort.Strings(tables)
for _, table := range tables {
current, ok := got[table]
if !ok {
return fmt.Sprintf("%s(目标读不到)", table)
}
if current.Count != want[table].Count {
return fmt.Sprintf("%s 行数 %d,参照 %d", table, current.Count, want[table].Count)
}
if current.Digest != want[table].Digest {
return fmt.Sprintf("%s 身份集合与参照不同(同数量改写也会被这条拦住)", table)
}
}
return ""
}
// assertNoSourceScopeRows 找出"这次要写的这些行,仍然以来源身份躺在目标里"。
//
// 判据按归档里的身份集合逐表核对,而不是"这个租户/知识库有没有行":同一个租户
// 里本来就可能住着别的知识库(共享资源、别的 KB 的绑定),按范围数会把它们误
// 报成残留。这里只认"本次写集合里、仍属于来源租户"的行——它们只可能来自逃出
// 目标范围的写入,而恢复只按目标范围回放,永远删不到它们。
func (t *ProductionLibraryTarget) assertNoSourceScopeRows(ctx context.Context, archive *librarysnapshot.Archive,
manifest librarysnapshot.Manifest) error {
if t.snapshotTenant == 0 {
return nil
}
if t.snapshotTenant == t.scope.TenantID && t.snapshotKB == t.scope.KnowledgeBaseID {
return nil
}
for _, part := range manifest.Parts {
if part.Kind != "database" && part.Kind != "wiki" {
continue
}
if !tableHasTenant(part.Name) {
continue
}
ids, err := archivedRowIdentities(archive, part)
if err != nil {
return err
}
if len(ids) == 0 {
continue
}
statement := fmt.Sprintf(`SELECT count(*) FROM %s WHERE tenant_id=$1 AND id = ANY($2::text[])`, part.Name)
var count int64
if err := t.pool.QueryRow(ctx, statement, t.snapshotTenant, ids).Scan(&count); err != nil {
return fmt.Errorf("检查目标 %s 的来源范围残留失败: %w", part.Name, err)
}
if count > 0 {
return fmt.Errorf("目标 %s 残留 %d 行仍属于来源范围 %d/%s:有写入逃出了目标范围,恢复不会清掉它们",
part.Name, count, t.snapshotTenant, t.snapshotKB)
}
}
return nil
}