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 不同 + 目标非空 + 资源阶段 失败",断言回滚后无来源范围残留、其它租户行未动、恢复后内容=预备份;正向用例断言 行落在目标范围且资源定位符指向目标存储。
263 lines
9.7 KiB
Go
263 lines
9.7 KiB
Go
package v071
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// 整库同步是"把来源知识库的内容搬到目标的租户/知识库",但归档行带着来源身份
|
||
// (tenant_id / knowledge_base_id)。原样插入会让行落在来源范围里:目标范围的
|
||
// 删除、校验、预备份与恢复全都看不见它们(真实事故:同步失败后"已恢复到预备份",
|
||
// 目标却多出 4 文件 / 38 分块 / 21 资源 / 4 绑定,行所属租户还是来源租户)。
|
||
//
|
||
// 因此写入与校验必须共用这一个改写入口,并且对不上就失败——宁可不写,也不写
|
||
// 一行目标范围外的数据。
|
||
|
||
// tablesWithKB 是真实 WeKnora v0.7.1 里带 knowledge_base_id 的知识域表。
|
||
// resources / resource_bindings 是多知识库共享表,只有 tenant_id;
|
||
// knowledge_tag_relations 两列都没有,归属完全由父表 knowledges 决定。
|
||
var tablesWithKB = map[string]bool{
|
||
"knowledges": true, "chunks": true, "knowledge_tags": true,
|
||
"wiki_folders": true, "wiki_pages": true, "wiki_page_issues": true, "wiki_log_entries": true,
|
||
}
|
||
|
||
// tableHasTenant 报告这张表是否自带租户列。
|
||
func tableHasTenant(table string) bool { return table != "knowledge_tag_relations" }
|
||
|
||
// bindSnapshotScope 记下快照的来源身份。没有来源身份就无法判断"这行要搬到哪个
|
||
// 范围",宁可失败也不要按行内容猜。
|
||
func (t *ProductionLibraryTarget) bindSnapshotScope(manifest manifestScope) error {
|
||
if manifest.tenantID == 0 || !validSearchIdentifier(manifest.knowledgeBaseID) {
|
||
return errors.New("快照缺少来源范围身份(tenant_id / knowledge_base_id),无法确定目标范围映射")
|
||
}
|
||
t.snapshotTenant = manifest.tenantID
|
||
t.snapshotKB = manifest.knowledgeBaseID
|
||
return nil
|
||
}
|
||
|
||
// manifestScope 只是把快照的两个范围字段抽出来,避免调用点重复解构。
|
||
type manifestScope struct {
|
||
tenantID uint64
|
||
knowledgeBaseID string
|
||
}
|
||
|
||
// retargetRow 把一行归档数据改写成目标范围的数据:tenant_id / knowledge_base_id
|
||
// 换成目标身份,资源行还要把定位符、存储后端、提供方换成目标实例自己的值。
|
||
func (t *ProductionLibraryTarget) retargetRow(table string, row map[string]json.RawMessage) error {
|
||
if !tableHasTenant(table) {
|
||
return nil
|
||
}
|
||
if err := t.retargetTenantField(row); err != nil {
|
||
return fmt.Errorf("%s:%w", table, err)
|
||
}
|
||
if tablesWithKB[table] {
|
||
if err := t.retargetKnowledgeBaseField(row); err != nil {
|
||
return fmt.Errorf("%s:%w", table, err)
|
||
}
|
||
}
|
||
if table == "resources" {
|
||
return t.rewriteResourceIdentity(row)
|
||
}
|
||
if table == "knowledges" {
|
||
return t.retargetEmbeddingModelField(row)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// retargetEmbeddingModelField 把文档的 embedding 模型换成目标知识库绑定的那一个。
|
||
// 来源模型 id 在目标实例里通常不存在(目标 models 表可能是空的),留着它等于让
|
||
// 应用拿一个解析不到的模型去查——库里的向量与查询向量就不是同一套了。
|
||
func (t *ProductionLibraryTarget) retargetEmbeddingModelField(row map[string]json.RawMessage) error {
|
||
if t.targetEmbeddingModel == "" {
|
||
return nil
|
||
}
|
||
if _, ok := row["embedding_model_id"]; !ok {
|
||
return nil
|
||
}
|
||
encoded, err := json.Marshal(t.targetEmbeddingModel)
|
||
if err != nil {
|
||
return errors.New("编码目标 embedding 模型失败")
|
||
}
|
||
row["embedding_model_id"] = encoded
|
||
return nil
|
||
}
|
||
|
||
// resolveEmbeddingModel 读目标知识库自己的 embedding 模型 id。读不到不算错误
|
||
// (目标可能确实没绑模型),此时保持来源值不动,由契约校验去发现不一致。
|
||
func (t *ProductionLibraryTarget) resolveEmbeddingModel(ctx context.Context) {
|
||
var model string
|
||
if err := t.pool.QueryRow(ctx,
|
||
`SELECT coalesce(embedding_model_id,'') FROM knowledge_bases WHERE id=$1 AND tenant_id=$2`,
|
||
t.scope.KnowledgeBaseID, t.scope.TenantID).Scan(&model); err == nil {
|
||
t.targetEmbeddingModel = model
|
||
}
|
||
}
|
||
|
||
func (t *ProductionLibraryTarget) retargetTenantField(row map[string]json.RawMessage) error {
|
||
raw, ok := row["tenant_id"]
|
||
if !ok {
|
||
return errors.New("行缺少 tenant_id,无法确定要写入哪个范围")
|
||
}
|
||
var current uint64
|
||
if err := json.Unmarshal(raw, ¤t); err != nil {
|
||
return errors.New("行 tenant_id 不可解析")
|
||
}
|
||
if t.snapshotTenant == 0 {
|
||
return errors.New("快照来源租户未知,拒绝猜测目标租户")
|
||
}
|
||
if current != t.snapshotTenant {
|
||
return fmt.Errorf("行 tenant_id=%d 不属于快照来源租户 %d", current, t.snapshotTenant)
|
||
}
|
||
encoded, err := json.Marshal(t.scope.TenantID)
|
||
if err != nil {
|
||
return errors.New("编码目标租户失败")
|
||
}
|
||
row["tenant_id"] = encoded
|
||
return nil
|
||
}
|
||
|
||
func (t *ProductionLibraryTarget) retargetKnowledgeBaseField(row map[string]json.RawMessage) error {
|
||
raw, ok := row["knowledge_base_id"]
|
||
if !ok {
|
||
return errors.New("行缺少 knowledge_base_id,无法确定要写入哪个知识库")
|
||
}
|
||
var current string
|
||
if err := json.Unmarshal(raw, ¤t); err != nil || current == "" {
|
||
return errors.New("行 knowledge_base_id 不可解析")
|
||
}
|
||
if t.snapshotKB == "" {
|
||
return errors.New("快照来源知识库未知,拒绝猜测目标知识库")
|
||
}
|
||
if current != t.snapshotKB {
|
||
return fmt.Errorf("行 knowledge_base_id=%s 不属于快照来源知识库 %s", current, t.snapshotKB)
|
||
}
|
||
encoded, err := json.Marshal(t.scope.KnowledgeBaseID)
|
||
if err != nil {
|
||
return errors.New("编码目标知识库失败")
|
||
}
|
||
row["knowledge_base_id"] = encoded
|
||
return nil
|
||
}
|
||
|
||
// rewriteResourceIdentity 把资源行的定位符与提供方换成目标实例的值。
|
||
//
|
||
// 只换桶不够:来源行的 scheme(oss/minio…)与对象键里的租户段也是来源实例的,
|
||
// 目标实例按它取文件会落到别的提供方或别的租户前缀上(真跑:storage backend
|
||
// not found → invalid path → 500 code1007)。
|
||
func (t *ProductionLibraryTarget) rewriteResourceIdentity(row map[string]json.RawMessage) error {
|
||
raw, ok := row["physical_path"]
|
||
if !ok {
|
||
return errors.New("资源行缺少定位符")
|
||
}
|
||
var locator string
|
||
if err := json.Unmarshal(raw, &locator); err != nil {
|
||
return errors.New("资源定位符不可解析")
|
||
}
|
||
rewritten, err := retargetLocator(locator, t.storageBackend, t.storageProvider, t.config.ObjectsBucket,
|
||
t.snapshotTenant, t.scope.TenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if rewritten != locator {
|
||
encoded, err := json.Marshal(rewritten)
|
||
if err != nil {
|
||
return errors.New("编码目标定位符失败")
|
||
}
|
||
row["physical_path"] = encoded
|
||
}
|
||
if t.storageBackend != "" {
|
||
if _, ok := row["storage_backend_id"]; ok {
|
||
encoded, err := json.Marshal(t.storageBackend)
|
||
if err != nil {
|
||
return errors.New("编码目标存储后端失败")
|
||
}
|
||
row["storage_backend_id"] = encoded
|
||
}
|
||
}
|
||
if t.storageProvider != "" {
|
||
if _, ok := row["provider"]; ok {
|
||
encoded, err := json.Marshal(t.storageProvider)
|
||
if err != nil {
|
||
return errors.New("编码目标提供方失败")
|
||
}
|
||
row["provider"] = encoded
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// retargetLocator 把 "storage://<backend>/<scheme>://<bucket>/<key>" 整体换成目标
|
||
// 实例的真实取值:后端 id、scheme/提供方、桶名,以及对象键里的租户段。
|
||
// 桶名为空表示目标没有配置对象存储(测试夹具),此时不动定位符。
|
||
func retargetLocator(locator, backendID, provider, bucket string, sourceTenant, targetTenant uint64) (string, error) {
|
||
if bucket == "" || locator == "" {
|
||
return locator, nil
|
||
}
|
||
if locator != strings.TrimSpace(locator) {
|
||
return "", errors.New("资源定位符带首尾空白")
|
||
}
|
||
prefix := ""
|
||
rest := locator
|
||
if strings.HasPrefix(locator, "storage://") {
|
||
trimmed := strings.TrimPrefix(locator, "storage://")
|
||
parts := strings.SplitN(trimmed, "/", 2)
|
||
if len(parts) != 2 || !validSearchIdentifier(parts[0]) || parts[1] == "" {
|
||
return "", errors.New("资源定位符结构不可解析")
|
||
}
|
||
if backendID != "" {
|
||
if !validSearchIdentifier(backendID) {
|
||
return "", errors.New("目标存储后端 id 形状不合法")
|
||
}
|
||
parts[0] = backendID
|
||
}
|
||
prefix, rest = "storage://"+parts[0]+"/", parts[1]
|
||
}
|
||
separator := strings.Index(rest, "://")
|
||
if separator < 0 {
|
||
// 裸对象键没有桶与租户段:落盘按配置桶,改写无意义。
|
||
return locator, nil
|
||
}
|
||
scheme := rest[:separator]
|
||
if !validSearchIdentifier(scheme) {
|
||
return "", errors.New("资源定位符的提供方不可解析")
|
||
}
|
||
schemeless := rest[separator+3:]
|
||
boundary := strings.Index(schemeless, "/")
|
||
if boundary <= 0 || boundary == len(schemeless)-1 {
|
||
return "", errors.New("资源定位符缺少对象键")
|
||
}
|
||
key := schemeless[boundary+1:]
|
||
if provider != "" {
|
||
if !validSearchIdentifier(provider) {
|
||
return "", errors.New("目标提供方形状不合法")
|
||
}
|
||
scheme = provider
|
||
}
|
||
return prefix + scheme + "://" + bucket + "/" + retargetKeyTenant(key, sourceTenant, targetTenant), nil
|
||
}
|
||
|
||
// retargetKeyTenant 把对象键里的来源租户段换成目标租户,并丢掉来源实例自己的
|
||
// 路径前缀(例如 MINIO_PATH_PREFIX=weknora/),让键与目标实例的写法一致。
|
||
// 找不到来源租户段时原样返回:此时键本身不含跨租户引用。
|
||
func retargetKeyTenant(key string, sourceTenant, targetTenant uint64) string {
|
||
if sourceTenant == 0 || targetTenant == 0 || sourceTenant == targetTenant {
|
||
return key
|
||
}
|
||
source, target := strconv.FormatUint(sourceTenant, 10), strconv.FormatUint(targetTenant, 10)
|
||
segments := strings.Split(key, "/")
|
||
for index := 0; index < len(segments) && index < 2; index++ {
|
||
// 只有"后面还有内容"的段才是路径前缀;整键就是租户号时不该动它。
|
||
if index >= len(segments)-1 || segments[index] != source {
|
||
continue
|
||
}
|
||
kept := segments[index:]
|
||
kept[0] = target
|
||
return strings.Join(kept, "/")
|
||
}
|
||
return key
|
||
}
|