Files
knowledge-governance/internal/platform/postgres/migrate.go
T
姚章浩 e6472b0ec6 feat(library): 云测试发布目标接入的角色分离、快照搬运与图谱门禁
为什么改:发布执行链是"与目标同机"假设写死的(本机 docker.sock、本机 drain.json、
本机 Redis/PG),把目标换成云测试不能只改展示 URL;直接搬整套 worker 又会抢走
本机封存/QA/周报队列并按本机配置写错目标。所以先把"谁消费哪段队列"变成显式角色,
再补齐跨主机搬运与目标侧门禁。

角色(LIBRARY_WORKER_ROLE=full/release-executor/capture)不只影响 handler 列表,
还决定是否拥有 DDL(release-executor 只读校验迁移版本,落后即拒绝启动)、
收拾哪些 library_jobs.kind、是否跑 release 接管扫描、是否起 scheduler/QA/周报/探测。
capture 保留本机原有全部业务;release-executor 只挂 sync/restore/recover/baseline。

快照跨主机:新增内容寻址的 bundle(版本清单先落、blob 按 sha256 命名、清单最后
Link 作提交点),pack/unpack 幂等且拒绝覆盖与同名不同内容;执行器使用前仍逐 blob
重算 sha256,因此传输不可变有两道证据。

目标侧门禁:目标知识库 graph_enabled=true 时拒绝发布(发布只写 PG/ES/OSS,不写
Neo4j;未知也拒绝),只读检测、不改目标配置、不做图谱迁移。

云端入口:新增 drain 感知的 ingress 闸门(建连超时与读超时分离,半关闭单向传播,
SIGTERM 排空后写 drain.json 并 exit 0/2)、compose 叠加片段(app 不再发布端口、
frontend 与 adapter 的入口收敛到闸门、指向真实网络 weknora-test-v071-network)、
执行器启动脚本与治理 PG 反向隧道脚本(默认 unix socket,不占 TCP)。

验证:deploy/cloud-test/test_gate.py 13 项全 PASS(含 12 秒静默流、半关闭、2MiB 完整、
排空 exit 0/active_at_close=1、排空超时 exit 2、上游不可达不挂住);go test 相关包全绿;
scripts/ci_checks.py 16 项全部 success(EXIT=0,1241 用例通过/0 失败/191 已声明可选集成跳过);
云测试只读预检 17 项 / 6 FAIL,FAIL 项即当前部署缺口(无闸门、app 仍发布端口、
adapter 与 frontend 仍直连 app、云端归档根未建),未部署、未改云端任何状态。
2026-10-30 18:38:38 +08:00

185 lines
5.5 KiB
Go

package postgres
import (
"context"
"embed"
"errors"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
type migration struct {
Version int64
UpSQL string
DownSQL string
}
func loadMigrations() ([]migration, error) {
paths, err := fs.Glob(migrationFS, "migrations/*.up.sql")
if err != nil {
return nil, fmt.Errorf("glob migrations: %w", err)
}
result := make([]migration, 0, len(paths))
for _, path := range paths {
base := strings.TrimPrefix(path, "migrations/")
versionText, _, ok := strings.Cut(base, "_")
if !ok {
return nil, fmt.Errorf("invalid migration filename %q", path)
}
version, err := strconv.ParseInt(versionText, 10, 64)
if err != nil {
return nil, fmt.Errorf("parse migration version %q: %w", path, err)
}
up, err := migrationFS.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read up migration %q: %w", path, err)
}
downPath := strings.TrimSuffix(path, ".up.sql") + ".down.sql"
down, err := migrationFS.ReadFile(downPath)
if err != nil {
return nil, fmt.Errorf("read down migration %q: %w", downPath, err)
}
result = append(result, migration{Version: version, UpSQL: string(up), DownSQL: string(down)})
}
sort.Slice(result, func(i, j int) bool { return result[i].Version < result[j].Version })
return result, nil
}
const migrationLockID int64 = 7346729101
func ApplyMigrations(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockID); err != nil {
return fmt.Errorf("lock migrations: %w", err)
}
defer func() { _, _ = pool.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", migrationLockID) }()
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
migrations, err := loadMigrations()
if err != nil {
return err
}
for _, item := range migrations {
var applied bool
if err := pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)", item.Version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %d: %w", item.Version, err)
}
if applied {
continue
}
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin migration %d: %w", item.Version, err)
}
if _, err := tx.Exec(ctx, item.UpSQL); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply migration %d: %w", item.Version, err)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", item.Version); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("record migration %d: %w", item.Version, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %d: %w", item.Version, err)
}
}
return nil
}
func RollbackLatestMigration(ctx context.Context, pool *pgxpool.Pool) error {
migrations, err := loadMigrations()
if err != nil {
return err
}
var version int64
if err := pool.QueryRow(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&version); err != nil {
if err == pgx.ErrNoRows {
return nil
}
return fmt.Errorf("read latest migration: %w", err)
}
var selected *migration
for i := range migrations {
if migrations[i].Version == version {
selected = &migrations[i]
break
}
}
if selected == nil {
return fmt.Errorf("missing down migration for version %d", version)
}
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin rollback %d: %w", version, err)
}
if _, err := tx.Exec(ctx, selected.DownSQL); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("rollback migration %d: %w", version, err)
}
if _, err := tx.Exec(ctx, "DELETE FROM schema_migrations WHERE version = $1", version); err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("delete migration ledger %d: %w", version, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit rollback %d: %w", version, err)
}
return nil
}
// PendingMigrations 只读地回答"治理库 schema 落后了没有"。
//
// 给"不拥有 DDL 的执行器"用(云端发布执行器):启动即校验,落后就拒绝启动,
// 绝不顺手迁移——治理库结构由控制面那个进程决定,不因为多了一台机器就多一个 DDL 源。
func PendingMigrations(ctx context.Context, pool *pgxpool.Pool) ([]int64, error) {
if pool == nil {
return nil, errors.New("database pool is required")
}
migrations, err := loadMigrations()
if err != nil {
return nil, err
}
var ledgerExists bool
if err := pool.QueryRow(ctx, "SELECT to_regclass('public.schema_migrations') IS NOT NULL").Scan(&ledgerExists); err != nil {
return nil, fmt.Errorf("check migration ledger: %w", err)
}
applied := map[int64]bool{}
if ledgerExists {
rows, err := pool.Query(ctx, "SELECT version FROM schema_migrations")
if err != nil {
return nil, fmt.Errorf("read migration ledger: %w", err)
}
defer rows.Close()
for rows.Next() {
var version int64
if err := rows.Scan(&version); err != nil {
return nil, err
}
applied[version] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
}
pending := make([]int64, 0, 4)
for _, item := range migrations {
if !applied[item.Version] {
pending = append(pending, item.Version)
}
}
return pending, nil
}