a42b101a7b
build / build (push) Successful in 1m25s
父审发现三处会让目标卡死或状态对不上实际入口,逐条修: 1. 登记成功但没来得及收尾:崩溃重入会撞上"已有生效版本"直接失败, 目标留在维护里、占用也不释放。现在按「本作业+同一快照」识别为自己的 版本,只做收尾(恢复服务/开入口/释放两把占用),不重新捕获、不重写; 别人的版本仍然拒绝。捕获完成但登记失败则明确失败并写"未对目标写入"。 2. 重入覆盖原运行状态:崩溃时服务已被停住,再读一次只会读到 stopped, 写回去会让恢复阶段永远不启动写入者。AcquireInstance/LibraryMaintenance 在 owner 相同的重入时保留已持久化原状态;新作业接管已释放窗口用自己的观察。 真实 PostgreSQL 集成用例覆盖(先红后绿)。 3. 版本登记移进维护窗口:捕获/校验/登记要么一起成立、要么目标原样恢复; 记录失败时窗口已开放,治理状态同步释放,不再"显示维护中、实际已开"。 索引映射读不到时拒绝登记,不再用空摘要写证据。 顺带: - 基线窗口统一收尾抽成 settleBaselineWindow,四种结局各有测试(成功/健康失败/ held 保持维护/已恢复则释放),并用真实 DB 用例证明 baseline 与 sync/restore 共用写互斥、幂等重试不产生第二个作业。 - 新增 cmd/librarydrill:维护隔离 → 整库预备份 → 一致性校验 → canary 漂移检测 → 按预备份恢复 → 逐类复核 → 按原状态恢复服务。用与 worker 相同的 LIBRARY_* 配置,绝不触碰封存源。 - 目标/预备份/维护栈装配收成 internal/librarystack 一份(worker 与演练共用)。 验证:go build/vet/test 全过;TEST_DATABASE_URL 指向临时 PostgreSQL 跑 TestMaintenanceReentryKeepsOriginalServiceState(修复前失败:got app=stopped) 与 TestLibraryWriteMutexCoversBaselineJobs;本地门禁 15/15。
202 lines
8.2 KiB
Go
202 lines
8.2 KiB
Go
// Package librarystack 是"目标整库读写栈"的唯一装配点:同步、恢复、基线,
|
|
// 以及主机侧的真实恢复演练都从这里拿同一个目标/预备份存储/维护协调器。
|
|
//
|
|
// 为什么单独成包:装配一旦被复制两份,"目标是谁、用哪份契约"就会随调用路径漂移。
|
|
// 这个包只做装配与配置校验,不包含任何业务编排。
|
|
package librarystack
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/librarymaintenance"
|
|
"knowledge-governance/internal/librarysnapshot"
|
|
v071 "knowledge-governance/internal/releaseadapter/v071"
|
|
)
|
|
|
|
// OpenMaintenance 按部署配置装配实例级维护目标;未接线时返回明确原因。
|
|
func OpenMaintenance(config appconfig.Config) (*librarymaintenance.Manager, error) {
|
|
database, err := librarymaintenance.DatabaseIdentity(config.LibraryProductionDatabaseURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return librarymaintenance.New(librarymaintenance.Config{
|
|
Instance: config.LibraryTargetInstance,
|
|
Project: config.LibraryTargetProject,
|
|
Writers: config.LibraryTargetWriters,
|
|
NonWriters: config.LibraryTargetNonWriters,
|
|
DockerSocket: config.LibraryTargetDockerSocket,
|
|
IngressURL: config.LibraryTargetIngressURL,
|
|
IngressService: config.LibraryTargetIngressService,
|
|
IngressDrainFile: config.LibraryTargetIngressDrainFile,
|
|
Database: database,
|
|
Index: config.LibraryProductionIndex,
|
|
Bucket: config.LibraryProductionS3Bucket,
|
|
DatabaseURL: config.LibraryProductionDatabaseURL,
|
|
RedisAddr: config.LibraryTargetRedisAddr,
|
|
RedisPassword: config.LibraryTargetRedisPassword,
|
|
RedisDB: config.LibraryTargetRedisDB,
|
|
})
|
|
}
|
|
|
|
// scopeConfig 是"某个 scope 的生产整库目标"的部署注入配置。
|
|
type scopeConfig struct {
|
|
target v071.ProductionScope
|
|
s3 v071.S3ObjectStoreConfig
|
|
}
|
|
|
|
func scopeConfigOf(config appconfig.Config, scope string) (scopeConfig, error) {
|
|
missing := make([]string, 0, 5)
|
|
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 scopeConfig{}, fmt.Errorf("生产整库目标未配置:缺少 %s", strings.Join(missing, "、"))
|
|
}
|
|
return scopeConfig{
|
|
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 newTarget(ctx context.Context, config appconfig.Config, scope scopeConfig) (*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 newBackups(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,
|
|
})
|
|
}
|
|
|
|
// OpenProductionStack 用部署注入的配置装配目标与独立预备份存储。
|
|
// 契约以"要同步的这份快照"为准:生产必须向它看齐,而不是反过来。
|
|
func OpenProductionStack(ctx context.Context, config appconfig.Config, scope, snapshotID, knownMapping string) (
|
|
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
|
|
scopeCfg, err := scopeConfigOf(config, scope)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
target, err := newTarget(ctx, config, scopeCfg)
|
|
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 := newBackups(config, scope, target, backupContract)
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, err
|
|
}
|
|
return target, backups, nil
|
|
}
|
|
|
|
// OpenBaselineStack 装配"给目标自己建基线"所需的目标与捕获器。
|
|
//
|
|
// 与同步的差别只有契约来源:基线还没有快照,它要捕获的正是目标当前状态,
|
|
// 所以契约取目标此刻的真实索引映射,绝不去借一份别人的快照当契约。
|
|
func OpenBaselineStack(ctx context.Context, config appconfig.Config, scope string) (
|
|
*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
|
|
scopeCfg, err := scopeConfigOf(config, scope)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
target, err := newTarget(ctx, config, scopeCfg)
|
|
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 := newBackups(config, scope, target, contract)
|
|
if err != nil {
|
|
target.Close()
|
|
return nil, nil, err
|
|
}
|
|
return target, backups, nil
|
|
}
|