Files
knowledge-governance/internal/librarystack/stack.go
T
姚章浩 513aa9e6f5
build / build (push) Has been cancelled
fix(library): 收紧恢复派发真实绑定与同事务授权
为什么: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 ./... 全绿。未迁移正式库、未部署、未激活。
2026-11-02 18:09:26 +08:00

350 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}