5defe238e0
build / build (push) Successful in 1m34s
真实事故:源 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 不同 + 目标非空 + 资源阶段 失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言 行落在目标范围且资源定位符指向目标存储。
127 lines
4.5 KiB
Go
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
|
|
}
|