517d5f862d
目标非空又没有生效版本时,首次发布以前只能停在"权威版本未知"。现在有了 真实入口:管理员发起 baseline 作业,worker 在维护隔离下把目标整库捕获成 归档快照,再用同一套 Verify 证明目标与快照逐类一致,最后登记 production_effective(证据写明 origin=target_baseline、快照 ID、逐表行数、 目标索引映射)。不写目标内容、不清空目标、不手写版本。 同时修两处会误导人的判定: - productionScopeEmpty 原来只数 knowledges,"文档删了但分块/Wiki/资源还在" 的目标会被当成空库;改用适配层同一份表清单与范围过滤逐表计数。 - 概览里的"测试封存候选"原来取该 scope 最新一份归档,而归档里还有生产 预备份与目标基线快照;改为只认版本登记里 kind=test_sealed 的那一份。 迁移 075 放开 kind='baseline' 并把它并入 sync/restore 的写互斥索引; worker 重启时用同一作业 id 接管未完成的基线窗口。维护接口补 capability, 页面才能区分"未运行维护"与"维护能力没接线"。 验证:go build ./... / go vet ./... 通过;新增用例覆盖管理员校验、已有生效 版本拒绝、目标读不到时零作业零版本、候选来自登记、缺项文案指向基线入口、 capability 逐项缺失;反向自检(把候选改回按最新归档取、把文案改回旧句、 去掉管理员判断)三项均按预期变红后复原。
182 lines
7.9 KiB
Go
182 lines
7.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/librarysnapshot"
|
|
v071 "knowledge-governance/internal/releaseadapter/v071"
|
|
)
|
|
|
|
// productionScopeConfig 是"某个 scope 的生产整库目标"的部署注入配置。
|
|
// 校验、错误措辞与 S3 参数只在这里有一份:同步、恢复与基线共用同一套装配,
|
|
// 不允许任何一条路径自己拼一份(那会让"目标是谁"随路径漂移)。
|
|
type productionScopeConfig struct {
|
|
target v071.ProductionScope
|
|
s3 v071.S3ObjectStoreConfig
|
|
}
|
|
|
|
func productionScopeOf(ctx context.Context, config appconfig.Config, scope string) (productionScopeConfig, error) {
|
|
missing := make([]string, 0, 4)
|
|
if config.LibraryProductionDatabaseURL == "" {
|
|
missing = append(missing, "LIBRARY_PRODUCTION_DATABASE_URL")
|
|
}
|
|
if config.LibraryProductionElasticURL == "" {
|
|
missing = append(missing, "LIBRARY_PRODUCTION_ELASTIC_URL")
|
|
}
|
|
if config.LibraryProductionTenantID == 0 {
|
|
missing = append(missing, "LIBRARY_PRODUCTION_TENANT_ID")
|
|
}
|
|
kb := config.LibraryProductionInternalKB
|
|
if scope == "external" {
|
|
kb = config.LibraryProductionExternalKB
|
|
}
|
|
if kb == "" {
|
|
missing = append(missing, "LIBRARY_PRODUCTION_KB_"+strings.ToUpper(scope))
|
|
}
|
|
if config.LibraryProductionS3Endpoint == "" || config.LibraryProductionS3Bucket == "" ||
|
|
config.LibraryProductionS3AccessKey == "" || config.LibraryProductionS3SecretKey == "" {
|
|
missing = append(missing, "LIBRARY_PRODUCTION_S3_ENDPOINT/BUCKET/ACCESS_KEY/SECRET_KEY")
|
|
}
|
|
if len(missing) > 0 {
|
|
return productionScopeConfig{}, fmt.Errorf("生产整库目标未配置:缺少 %s", strings.Join(missing, "、"))
|
|
}
|
|
return productionScopeConfig{
|
|
target: v071.ProductionScope{TenantID: config.LibraryProductionTenantID, KnowledgeBaseID: kb},
|
|
s3: v071.S3ObjectStoreConfig{
|
|
Provider: config.LibraryProductionS3Provider, Endpoint: config.LibraryProductionS3Endpoint,
|
|
Region: config.LibraryProductionS3Region, Bucket: config.LibraryProductionS3Bucket,
|
|
AccessKeyID: config.LibraryProductionS3AccessKey, SecretAccessKey: config.LibraryProductionS3SecretKey,
|
|
Secure: strings.HasPrefix(config.LibraryProductionS3Endpoint, "https://"),
|
|
ForcePathStyle: true, MaxObjectBytes: 256 * 1024 * 1024,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func newProductionTarget(ctx context.Context, config appconfig.Config, scope productionScopeConfig) (*v071.ProductionLibraryTarget, error) {
|
|
objects, err := v071.NewS3ObjectStore(scope.s3)
|
|
if err != nil {
|
|
return nil, errors.New("生产对象存储配置无效")
|
|
}
|
|
return v071.NewProductionLibraryTarget(ctx, v071.ProductionLibraryConfig{
|
|
Postgres: config.LibraryProductionDatabaseURL, SearchURL: config.LibraryProductionElasticURL,
|
|
SearchIndex: config.LibraryProductionIndex, Objects: objects, ObjectsReader: objects,
|
|
ArchiveRoot: config.LibraryArchiveRoot,
|
|
ObjectsBucket: config.LibraryProductionS3Bucket,
|
|
MaxDocuments: 200000,
|
|
}, scope.target)
|
|
}
|
|
|
|
func newProductionBackupStore(config appconfig.Config, scope string,
|
|
target *v071.ProductionLibraryTarget, contract librarysnapshot.Compatibility) (*v071.ProductionBackupStore, error) {
|
|
return v071.NewProductionBackupStore(v071.ProductionBackupConfig{
|
|
Pool: target.Pool(), Search: target.Search(), Objects: target.Objects(),
|
|
ArchiveRoot: config.LibraryArchiveRoot, Scope: scope, Identity: target.Identity(),
|
|
Compatibility: contract, MaxDocuments: 200000, MaxObjectByte: 256 * 1024 * 1024,
|
|
})
|
|
}
|
|
|
|
// buildLibraryProductionStack 用部署注入的配置装配生产整库目标与独立预备份存储。
|
|
// 任何一项缺失都返回明确原因,调用方据此把作业失败并把缺项写进阶段,
|
|
// 不用"未配置"的占位对象假装能跑。
|
|
func buildLibraryProductionStack(ctx context.Context, config appconfig.Config, scope, snapshotID, knownMapping string) (
|
|
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
|
|
scopeConfig, err := productionScopeOf(ctx, config, scope)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
target, err := newProductionTarget(ctx, config, scopeConfig)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
// 契约以"要同步的这份快照"为准:生产必须向它看齐,而不是反过来。
|
|
// 生产为空时索引可能根本不存在,这时不能拿生产映射当契约(会永远启动不了)。
|
|
archive, err := librarysnapshot.Open(config.LibraryArchiveRoot, 256*1024*1024)
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, fmt.Errorf("归档不可用,无法读取快照契约: %w", err)
|
|
}
|
|
manifest, err := archive.Load(snapshotID)
|
|
archive.Close()
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, fmt.Errorf("读取目标快照失败: %w", err)
|
|
}
|
|
contract := manifest.Compatibility
|
|
if contract.EmbeddingDimension == 0 {
|
|
contract.EmbeddingModel = config.LibraryEmbeddingModel
|
|
contract.EmbeddingDimension = config.LibraryEmbeddingDimension
|
|
}
|
|
// 生产索引存在时必须与快照契约一致;不存在或为空基线时跳过比对
|
|
// (空索引的映射没有可比性,非空之后仍然严格比对)。
|
|
if mapping, mapErr := target.Search().MappingSHA256(ctx); mapErr == nil {
|
|
// 映射基线:优先用"上一次生产生效版本登记的实际映射",
|
|
// 它才是这份生产索引的既有事实;快照契约只管"来自哪份数据"。
|
|
baseline := knownMapping
|
|
if baseline == "" {
|
|
// 没有历史基线时(首次接管这套生产索引),把当前实际映射当作基线并随
|
|
// 预备份登记;之后任何改动都会与登记值不符而被拒绝。
|
|
baseline = mapping
|
|
}
|
|
if baseline != "" && mapping != baseline {
|
|
current, currentErr := target.Current(ctx)
|
|
emptyBaseline := currentErr == nil && current.Counts["index"] == 0 && current.Counts["chunks"] == 0
|
|
if !emptyBaseline {
|
|
target.Close()
|
|
return nil, nil, errors.New("生产索引映射与上次生效版本不一致(索引被改动过),先对齐索引映射")
|
|
}
|
|
}
|
|
}
|
|
// 预备份描述的是"生产现在的索引",不是"快照的来源索引":
|
|
// 生产映射基线优先用上次生效版本登记的实际值。
|
|
backupContract := contract
|
|
if knownMapping != "" {
|
|
backupContract.IndexMappingSHA256 = knownMapping
|
|
}
|
|
backups, err := newProductionBackupStore(config, scope, target, backupContract)
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, err
|
|
}
|
|
return target, backups, nil
|
|
}
|
|
|
|
// buildLibraryBaselineStack 装配"给目标自己建基线"所需的目标与捕获器。
|
|
//
|
|
// 与同步的差别只有契约来源:同步要拿**待发布快照**的兼容性做契约,而基线
|
|
// 还没有快照——它要捕获的正是目标当前状态,所以契约取目标此刻的真实索引映射,
|
|
// 绝不去借一份别人的快照来当契约(那会把基线登记成"与某份快照一致"的假象)。
|
|
func buildLibraryBaselineStack(ctx context.Context, config appconfig.Config, scope string) (
|
|
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
|
|
scopeConfig, err := productionScopeOf(ctx, config, scope)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
target, err := newProductionTarget(ctx, config, scopeConfig)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
mapping, mapErr := target.Search().MappingSHA256(ctx)
|
|
if mapErr != nil {
|
|
target.Close()
|
|
return nil, nil, fmt.Errorf("读取目标索引映射失败,无法确定基线契约: %w", mapErr)
|
|
}
|
|
// 只写能证明的字段:embedding 配置来自部署声明(与目标实例同一份配置),
|
|
// 索引映射来自目标此刻的真实映射。目标实例的 WeKnora/schema 版本这里没有
|
|
// 可靠来源,就不填——空白比编一个数字诚实,迁移/兼容判断不会因此放行任何东西。
|
|
contract := librarysnapshot.Compatibility{
|
|
EmbeddingModel: config.LibraryEmbeddingModel,
|
|
EmbeddingDimension: config.LibraryEmbeddingDimension,
|
|
IndexMappingSHA256: mapping,
|
|
}
|
|
backups, err := newProductionBackupStore(config, scope, target, contract)
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, err
|
|
}
|
|
return target, backups, nil
|
|
}
|