513aa9e6f5
build / build (push) Has been cancelled
为什么:Astra 复核证实五组实现缺陷——正式装配缺 scope/快照且来源与 before-image 混用;激活撤销不在 CAS 事务内、lease 用事务起始时间;激活只接受任意字符串且撤销可 被 SQL 重置/删除;准入忽略绑定冲突与 prepared 矛盾、收尾只看当前 plan;prepared 已 提交但 mark-intent 未落盘时被误判 legacy。 改了什么: - 绑定来自原锚点(scope/来源快照/backup/实例/目标指纹/索引 UUID/恢复投递),每次 重建都实时重读目标身份并与冻结值比较; - activation 行锁与 CAS 同事务,lease 用 clock_timestamp(),同事务核对 job/operation/ attempt/维护窗口/恢复 queue 与 operation,同 effect 换 plan 摘要拒绝; - 激活消费带摘要的证据文件(writer 退出+排空、目标/治理库身份、有效期),撤销终局, down 在 activation 有行时拒绝; - 前置/收尾共用权威窗口全集,绑定冲突与 prepared 矛盾零写 held,合法 dispatching+ 精确 marker 仍可确认; - prepared 无 mark-intent 仅在绑定一致且无矛盾时补同内容意图,旧无台账意图仍 held。 验证:真 PG 锁等待覆盖撤销先胜/CAS 先胜/等锁跨租约/错锚点/plan 别名/恢复投递; COMMIT 已到服务端但回包丢失与未决事务分别读回;真 ES+PG+对象存储的 DS01 用例全部 通过;go test ./... 全绿。未迁移正式库、未部署、未激活。
350 lines
16 KiB
Go
350 lines
16 KiB
Go
// Package librarystack 是"目标整库读写栈"的唯一装配点:同步、恢复、基线,
|
||
// 以及主机侧的真实恢复演练都从这里拿同一个目标/预备份存储/维护协调器。
|
||
//
|
||
// 为什么单独成包:装配一旦被复制两份,"目标是谁、用哪份契约"就会随调用路径漂移。
|
||
// 这个包只做装配与配置校验,不包含任何业务编排。
|
||
package librarystack
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"knowledge-governance/internal/appconfig"
|
||
"knowledge-governance/internal/librarymaintenance"
|
||
"knowledge-governance/internal/libraryrelease"
|
||
"knowledge-governance/internal/librarysnapshot"
|
||
"knowledge-governance/internal/objectgateway"
|
||
v071 "knowledge-governance/internal/releaseadapter/v071"
|
||
"knowledge-governance/internal/releasebundle"
|
||
)
|
||
|
||
// 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: config.LibraryProductionS3Provider == "minio", MaxObjectBytes: 256 * 1024 * 1024,
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// RestoreDispatchWiring 是恢复派发身份的唯一装配来源:真实 attempt(objectgateway
|
||
// claim)、原 window 作业、部署目标实例与 scope。adapter 不接受调用方另填身份。
|
||
type RestoreDispatchWiring struct {
|
||
TargetIdentity string
|
||
Scope string
|
||
Instance string
|
||
OperationID string
|
||
WindowJobID string
|
||
AttemptOwner string
|
||
AttemptEpoch int64
|
||
RecoveryJobID string
|
||
RecoveryOperationID string
|
||
}
|
||
|
||
// RestoreDispatchWiringFor 把本次真实执行的 claim 映射成派发身份,并在字段缺失时
|
||
// 失败关闭。它不读取快照:来源/写前镜像只能从原 WriteSet 与 backup 引用取得。
|
||
func RestoreDispatchWiringFor(config appconfig.Config, scope string, claim objectgateway.Claim) (RestoreDispatchWiring, error) {
|
||
if scope != "internal" && scope != "external" {
|
||
return RestoreDispatchWiring{}, fmt.Errorf("未知 scope %q", scope)
|
||
}
|
||
identity := config.LibraryTargetIdentity()
|
||
if identity == "" || config.LibraryTargetInstance == "" || config.LibraryProductionDatabaseURL == "" {
|
||
return RestoreDispatchWiring{}, errors.New("恢复派发缺少目标身份/实例")
|
||
}
|
||
if (scope == "internal" && config.LibraryProductionInternalKB == "") ||
|
||
(scope == "external" && config.LibraryProductionExternalKB == "") {
|
||
return RestoreDispatchWiring{}, errors.New("恢复派发缺少 scope 对应的知识库")
|
||
}
|
||
if claim.JobID == "" || claim.OperationID == "" || claim.Owner == "" || claim.AttemptEpoch <= 0 {
|
||
return RestoreDispatchWiring{}, errors.New("恢复派发缺少本次真实 attempt 身份")
|
||
}
|
||
if (claim.RecoveryJobID == "") != (claim.RecoveryOperationID == "") {
|
||
return RestoreDispatchWiring{}, errors.New("恢复投递身份不完整")
|
||
}
|
||
return RestoreDispatchWiring{
|
||
TargetIdentity: identity, Scope: scope, Instance: config.LibraryTargetInstance,
|
||
OperationID: claim.OperationID, WindowJobID: claim.JobID,
|
||
AttemptOwner: claim.Owner, AttemptEpoch: claim.AttemptEpoch,
|
||
RecoveryJobID: claim.RecoveryJobID, RecoveryOperationID: claim.RecoveryOperationID,
|
||
}, nil
|
||
}
|
||
|
||
func newTarget(ctx context.Context, config appconfig.Config, scope scopeConfig, scopeName string,
|
||
gate libraryrelease.RestoreMarkerGate) (*v071.ProductionLibraryTarget, error) {
|
||
objects, err := v071.NewS3ObjectStore(scope.s3)
|
||
if err != nil {
|
||
return nil, errors.New("生产对象存储配置无效")
|
||
}
|
||
if config.LibraryObjectGatewayURL == "" {
|
||
return nil, errors.New("对象写入网关未配置:发布目标不允许直写对象存储")
|
||
}
|
||
var writer v071.ObjectStore = objects
|
||
if config.LibraryObjectGatewayURL != "" || config.LibraryObjectGatewayToken != "" || config.LibraryObjectGatewayGrantDirectory != "" {
|
||
identity := objectgateway.Digest([]byte(scope.s3.Provider + "\x00" + scope.s3.Endpoint + "\x00" + scope.s3.Bucket))
|
||
client, err := objectgateway.NewClient(config.LibraryObjectGatewayURL, config.LibraryObjectGatewayToken, identity)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
gateway := &v071.GatewayObjectStore{Automatic: true, Claim: objectgateway.ClaimFromContext(ctx), Client: client, Provider: scope.s3.Provider, Bucket: scope.s3.Bucket, GrantDirectory: config.LibraryObjectGatewayGrantDirectory}
|
||
if err := gateway.ConditionalCapability(ctx); err != nil {
|
||
return nil, err
|
||
}
|
||
writer = gateway
|
||
}
|
||
// DS01:派发身份只能来自本次真实 attempt(objectgateway claim)与原窗口作业。
|
||
// gate 为 nil 时该能力整体关闭,adapter 对新续作 fail closed;baseline 永远传 nil。
|
||
claim := objectgateway.ClaimFromContext(ctx)
|
||
targetConfig := v071.ProductionLibraryConfig{
|
||
Postgres: config.LibraryProductionDatabaseURL, SearchURL: config.LibraryProductionElasticURL,
|
||
SearchIndex: config.LibraryProductionIndex, Objects: writer, ObjectsReader: objects,
|
||
ArchiveRoot: config.LibraryArchiveRoot,
|
||
ObjectsBucket: config.LibraryProductionS3Bucket,
|
||
MaxDocuments: 200000,
|
||
RestoreDispatch: gate,
|
||
}
|
||
if gate != nil {
|
||
wiring, err := RestoreDispatchWiringFor(config, scopeName, claim)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
targetConfig.RestoreTargetIdentity = wiring.TargetIdentity
|
||
targetConfig.RestoreScope = wiring.Scope
|
||
targetConfig.RestoreInstance = wiring.Instance
|
||
targetConfig.RestoreOperationID = wiring.OperationID
|
||
targetConfig.RestoreWindowJobID = wiring.WindowJobID
|
||
targetConfig.RestoreAttemptOwner = wiring.AttemptOwner
|
||
targetConfig.RestoreAttemptEpoch = wiring.AttemptEpoch
|
||
targetConfig.RestoreRecoveryJobID = wiring.RecoveryJobID
|
||
targetConfig.RestoreRecoveryOperationID = wiring.RecoveryOperationID
|
||
}
|
||
return v071.NewProductionLibraryTarget(ctx, targetConfig, 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,
|
||
gate libraryrelease.RestoreMarkerGate) (*v071.ProductionLibraryTarget, *v071.ProductionBackupStore, error) {
|
||
scopeCfg, err := scopeConfigOf(config, scope)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
target, err := newTarget(ctx, config, scopeCfg, scope, gate)
|
||
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
|
||
}
|
||
// 目标实时契约:来源快照的声明只说明"数据来自哪",不能当目标合同(F17)。
|
||
// 读不到就不许发布——猜一个目标模型正是"知识行声明 B、向量却是 A"的来源。
|
||
live, liveErr := target.LiveTargetContract(ctx)
|
||
if liveErr != nil {
|
||
target.Close()
|
||
return nil, nil, fmt.Errorf("读不到目标实时模型契约,拒绝发布:%w", liveErr)
|
||
}
|
||
if verifyErr := v071.VerifySourceAgainstTarget(contract, live); verifyErr != nil {
|
||
target.Close()
|
||
return nil, nil, verifyErr
|
||
}
|
||
// 图谱门禁:目标开了 graph_enabled 时,发布只写 PG/ES/OSS,图谱会静默变旧。
|
||
// 只读检测 + 拒绝;不关闭目标图谱、不搬 Neo4j(那属于扩围)。
|
||
if graphErr := target.VerifyGraphGate(ctx); graphErr != nil {
|
||
target.Close()
|
||
return nil, nil, graphErr
|
||
}
|
||
// 生产索引存在时必须与快照契约一致;不存在或为空基线时跳过比对
|
||
// (空索引的映射没有可比性,非空之后仍然严格比对)。
|
||
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 := v071.ContractFromLive(contract, live)
|
||
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 装配"给目标自己建基线"所需的目标与捕获器。
|
||
//
|
||
// 契约来源按"能验证的优先级"取,绝不去借别人的快照:
|
||
// - WeKnora 版本 / 检索引擎:受支持栈的常量(v0.7.1 / elasticsearch_v8),
|
||
// 目标镜像与 ES 实测同属这一代;
|
||
// - schema 版本:**读目标数据库**的 schema_migrations,必须等于受支持版本且不 dirty;
|
||
// - embedding 模型与维度:优先读目标库的模型绑定,读不到(这台测试目标的
|
||
// models 表是空的、KB 里的模型引用悬空)才回落到部署声明,并在错误信息里说清楚;
|
||
// - 索引映射:目标此刻的真实映射。
|
||
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, scope, nil)
|
||
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)
|
||
}
|
||
var schemaVersion int
|
||
var dirty bool
|
||
if err := target.Pool().QueryRow(ctx,
|
||
`SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 1`).Scan(&schemaVersion, &dirty); err != nil {
|
||
target.Close()
|
||
return nil, nil, fmt.Errorf("读取目标 WeKnora schema 版本失败: %w", err)
|
||
}
|
||
if dirty || schemaVersion != releasebundle.WeKnoraMigrationV071 {
|
||
target.Close()
|
||
return nil, nil, fmt.Errorf("目标 schema 版本 %d(dirty=%v)与受支持的 %d 不一致,拒绝建基线",
|
||
schemaVersion, dirty, releasebundle.WeKnoraMigrationV071)
|
||
}
|
||
embeddingModel := config.LibraryEmbeddingModel
|
||
embeddingDimension := config.LibraryEmbeddingDimension
|
||
var dbModel string
|
||
var dbDimension int
|
||
err = target.Pool().QueryRow(ctx, `
|
||
SELECT model.name, COALESCE(NULLIF(model.parameters->'embedding_parameters'->>'dimension', '')::int, 0)
|
||
FROM knowledge_bases AS kb
|
||
JOIN models AS model ON model.id = kb.embedding_model_id AND model.deleted_at IS NULL
|
||
WHERE kb.id = $1 AND kb.deleted_at IS NULL`, scopeCfg.target.KnowledgeBaseID).Scan(&dbModel, &dbDimension)
|
||
switch {
|
||
case err == nil && dbModel != "" && dbDimension > 0:
|
||
// 目标库自己说得清楚时以它为准;与部署声明不一致就不建(否则基线记录的是另一套栈)。
|
||
if config.LibraryEmbeddingDimension != 0 && dbDimension != config.LibraryEmbeddingDimension {
|
||
target.Close()
|
||
return nil, nil, fmt.Errorf("目标库声明的 embedding 维度 %d 与部署声明 %d 不一致,拒绝建基线",
|
||
dbDimension, config.LibraryEmbeddingDimension)
|
||
}
|
||
embeddingModel, embeddingDimension = dbModel, dbDimension
|
||
case errors.Is(err, pgx.ErrNoRows):
|
||
// 这台目标没有模型绑定(models 表为空 / KB 引用悬空):用部署声明的值,
|
||
// 并在基线的阶段说明里标注来源,别把它说成"从目标读到的"。
|
||
if embeddingModel == "" || embeddingDimension <= 0 {
|
||
target.Close()
|
||
return nil, nil, errors.New("目标库没有模型绑定,且部署没有声明 LIBRARY_EMBEDDING_MODEL/DIMENSION")
|
||
}
|
||
default:
|
||
target.Close()
|
||
return nil, nil, fmt.Errorf("读取目标 embedding 模型绑定失败: %w", err)
|
||
}
|
||
contract := librarysnapshot.Compatibility{
|
||
WeKnoraVersion: releasebundle.WeKnoraVersionV071,
|
||
SchemaVersion: schemaVersion,
|
||
RetrieverEngine: "elasticsearch_v8",
|
||
EmbeddingModel: embeddingModel,
|
||
EmbeddingDimension: embeddingDimension,
|
||
IndexMappingSHA256: mapping,
|
||
}
|
||
backups, err := newBackups(config, scope, target, contract)
|
||
if err != nil {
|
||
target.Close()
|
||
return nil, nil, err
|
||
}
|
||
return target, backups, nil
|
||
}
|