Files
knowledge-governance/internal/platform/postgres/release_change_sets.go
T
zheromiku dcdc8d1ddd feat: 知识治理平台(含已验证发布/回滚契约)
全栈知识治理平台首次入库(P1-P10 + 发布链路模拟验证闭环):

- 治理核心:源目录/变更集/人工审核/发布批次/发布执行(fail-closed 闸口)
- 测试通道:WeKnora 测试 KB 上传/轮询/验收;poll 完成时写入
  parser_generation/index_generation,修复 manual/seed 路径发布闸口永久拦截
- 发布适配器 v071(本地 source=测试角色 / 云端 target=生产角色 模拟验证通过):
  * ES 导出/导入 mapping 自适应(.keyword 子字段、可选 tenant_id)
  * OSS locator 支持嵌套 scheme;chunks.seq_id 由目标序列分配
  * bundle 归档同身份重试原子替换;verifier 按 bundle chunk_type 计数
  * 回滚 job payload 支持 prior_execution_id(strict 解码)
- 三通道发布+回滚演练实测:publish 4/4 verified,检索命中,
  rollback 4/4 rolled_back,目标端 PG is_enabled=false、ES is_enabled=false
- 前端 A·Data-console 风格全站统一,120 用例通过
- 门禁:go vet / go test 全包通过,gofmt 干净
2026-09-30 10:54:06 +08:00

291 lines
13 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"knowledge-governance/internal/governance"
)
// Errors for R1 release ChangeSets.
var (
ErrInvalidReleaseChangeSet = errors.New("invalid release change set request")
ErrReleaseChangeSetNotFound = errors.New("release change set not found")
ErrReleaseChangeSetStateConflict = errors.New("release change set state conflict")
ErrReleaseChangeSetEmpty = errors.New("release plan has no releasable items")
)
const releaseChangeSetColumns = `id,number,name,state,source_snapshot_ids,source_change_set_ids,acceptance_cutoff,production_baseline_ref,item_count,add_count,update_count,delete_count,blocked_count,manifest_sha256,created_by,created_at,confirmed_by,COALESCE(confirmed_at::text,'')`
func scanReleaseChangeSet(row pgx.Row) (governance.ReleaseChangeSet, error) {
var item governance.ReleaseChangeSet
var snapshots, changeSets []byte
var confirmedAt string
err := row.Scan(&item.ID, &item.Number, &item.Name, &item.State, &snapshots, &changeSets, &item.AcceptanceCutoff, &item.ProductionBaselineRef,
&item.ItemCount, &item.AddCount, &item.UpdateCount, &item.DeleteCount, &item.BlockedCount, &item.ManifestSHA256, &item.CreatedBy, &item.CreatedAt, &item.ConfirmedBy, &confirmedAt)
if err != nil {
return governance.ReleaseChangeSet{}, err
}
_ = json.Unmarshal(snapshots, &item.SourceSnapshotIDs)
_ = json.Unmarshal(changeSets, &item.SourceChangeSetIDs)
if item.SourceSnapshotIDs == nil {
item.SourceSnapshotIDs = []string{}
}
if item.SourceChangeSetIDs == nil {
item.SourceChangeSetIDs = []string{}
}
if confirmedAt != "" {
if parsed, parseErr := time.Parse(time.RFC3339Nano, confirmedAt); parseErr == nil {
item.ConfirmedAt = &parsed
}
}
return item, nil
}
// ComputeReleaseChangeSetManifest is exported for tests: the manifest seals
// every frozen decision in seq order with an exact delimiter.
func ComputeReleaseChangeSetManifest(items []governance.ReleaseChangeSetItem) string {
lines := make([]string, 0, len(items))
for _, item := range items {
lines = append(lines, strings.Join([]string{
strconv.Itoa(item.Seq), item.PublicationID, item.TargetScope, item.Action,
item.TestState, item.ProductionState, strconv.FormatBool(item.CanRelease), item.Blocker,
}, "|"))
}
digest := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return hex.EncodeToString(digest[:])
}
// GenerateReleaseChangeSet freezes the current live plan into an immutable
// versioned set. Repeated generation with an identical plan reuses the latest
// PLANNED set (dedup), so concurrent generation cannot create duplicates.
func (s *Store) GenerateReleaseChangeSet(ctx context.Context, request governance.GenerateReleaseChangeSetRequest) (governance.ReleaseChangeSet, error) {
if request.Name == "" || request.Name != strings.TrimSpace(request.Name) || len(request.Name) > 200 ||
strings.ContainsAny(request.Name, "\x00\r\n") || !validReviewerRef(request.CreatedBy) || request.Now.IsZero() {
return governance.ReleaseChangeSet{}, ErrInvalidReleaseChangeSet
}
plan, err := s.ListReleasePlan(ctx)
if err != nil {
return governance.ReleaseChangeSet{}, err
}
items := make([]governance.ReleaseChangeSetItem, 0, len(plan.Items))
for index, planItem := range plan.Items {
items = append(items, governance.ReleaseChangeSetItem{Seq: index + 1, PublicationID: planItem.PublicationID, Title: planItem.Title, TargetScope: planItem.TargetScope, Action: planItem.Action, TestState: planItem.TestState, ProductionState: planItem.ProductionState, CanRelease: planItem.CanRelease, Blocker: planItem.Blocker})
}
if len(items) == 0 {
return governance.ReleaseChangeSet{}, ErrReleaseChangeSetEmpty
}
manifest := ComputeReleaseChangeSetManifest(items)
counts := map[string]int{}
for _, item := range items {
counts[item.Action]++
}
blocked := 0
for _, item := range items {
if item.Action == "blocked" {
blocked++
}
}
// Same plan twice (sequentially) reuses the latest PLANNED set instead of
// piling up identical versions. Concurrent racers fall through to the
// unique-number conflict path below.
if existing, lookupErr := s.findPlannedByManifest(ctx, manifest); lookupErr == nil && existing.ManifestSHA256 == manifest {
return existing, nil
} else if lookupErr != nil && !errors.Is(lookupErr, pgx.ErrNoRows) {
return governance.ReleaseChangeSet{}, lookupErr
}
for attempt := 0; attempt < 3; attempt++ {
result, retry, err := s.tryInsertReleaseChangeSet(ctx, request, items, manifest, counts["add"], counts["update"], counts["delete"], blocked)
if err == nil {
return result, nil
}
if !retry {
return governance.ReleaseChangeSet{}, err
}
// A concurrent identical generation won; return its set (dedup) if the
// manifest matches, otherwise loop to take the next version number.
existing, lookupErr := s.findPlannedByManifest(ctx, manifest)
if lookupErr == nil && existing.ManifestSHA256 == manifest {
return existing, nil
}
if lookupErr != nil && !errors.Is(lookupErr, ErrReleaseChangeSetNotFound) {
return governance.ReleaseChangeSet{}, lookupErr
}
}
return governance.ReleaseChangeSet{}, fmt.Errorf("release change set generation kept conflicting")
}
func (s *Store) tryInsertReleaseChangeSet(ctx context.Context, request governance.GenerateReleaseChangeSetRequest, items []governance.ReleaseChangeSetItem, manifest string, add, update, del, blocked int) (governance.ReleaseChangeSet, bool, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return governance.ReleaseChangeSet{}, false, err
}
defer func() { _ = tx.Rollback(ctx) }()
id, err := newOpaqueID("rcs-")
if err != nil {
return governance.ReleaseChangeSet{}, false, err
}
set, err := scanReleaseChangeSet(tx.QueryRow(ctx, `
INSERT INTO release_change_sets(id,name,number,state,source_snapshot_ids,source_change_set_ids,acceptance_cutoff,production_baseline_ref,item_count,add_count,update_count,delete_count,blocked_count,manifest_sha256,created_by,created_at)
SELECT $1,$2,(SELECT COALESCE(MAX(number),0)+1 FROM release_change_sets),'planned',
COALESCE((SELECT jsonb_agg(snapshot_id ORDER BY snapshot_id) FROM (SELECT DISTINCT snapshot_id FROM source_catalog_heads) s),'[]'::jsonb),
COALESCE((SELECT jsonb_agg(change_set_id ORDER BY change_set_id) FROM (SELECT DISTINCT id AS change_set_id FROM source_catalog_change_sets WHERE state IN ('confirmed','applied')) c),'[]'::jsonb),
COALESCE((SELECT MAX(updated_at) FROM test_publications),(SELECT now())),
(SELECT COALESCE(MAX(completed_at)::text,'') FROM release_executions WHERE state='completed'),
$3,$4,$5,$6,$7,$8,$9,$10
RETURNING `+releaseChangeSetColumns,
id, request.Name, len(items), add, update, del, blocked, manifest, request.CreatedBy, request.Now))
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.ReleaseChangeSet{}, true, err
}
return governance.ReleaseChangeSet{}, false, fmt.Errorf("create release change set: %w", err)
}
// R6 audit chain: mirror the JSONB binding into real FK rows so the trace
// survives even if someone later edits denormalized display fields.
if _, err := tx.Exec(ctx, `
INSERT INTO release_change_set_snapshot_links(change_set_id, snapshot_id)
SELECT $1, elem.value
FROM jsonb_array_elements_text($2::jsonb) AS elem(value)
ON CONFLICT DO NOTHING`, set.ID, mustMarshalJSON(set.SourceSnapshotIDs)); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("link release snapshots: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO release_change_set_catalog_links(change_set_id, catalog_change_set_id)
SELECT $1, elem.value
FROM jsonb_array_elements_text($2::jsonb) AS elem(value)
ON CONFLICT DO NOTHING`, set.ID, mustMarshalJSON(set.SourceChangeSetIDs)); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("link release catalog sets: %w", err)
}
for _, item := range items {
if _, err := tx.Exec(ctx, `INSERT INTO release_change_set_items(change_set_id,seq,publication_id,title,target_scope,action,test_state,production_state,can_release,blocker) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
set.ID, item.Seq, item.PublicationID, item.Title, item.TargetScope, item.Action, item.TestState, item.ProductionState, item.CanRelease, item.Blocker); err != nil {
return governance.ReleaseChangeSet{}, false, fmt.Errorf("insert release change set item: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return governance.ReleaseChangeSet{}, true, err
}
return governance.ReleaseChangeSet{}, false, err
}
return set, false, nil
}
func (s *Store) findPlannedByManifest(ctx context.Context, manifest string) (governance.ReleaseChangeSet, error) {
return scanReleaseChangeSet(s.db.QueryRow(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets WHERE manifest_sha256=$1 AND state='planned' ORDER BY number DESC LIMIT 1`, manifest))
}
// ConfirmReleaseChangeSet freezes a planned set for batching. Re-confirming an
// already-confirmed set replays the stored confirmation (immutability).
func (s *Store) ConfirmReleaseChangeSet(ctx context.Context, request governance.ConfirmReleaseChangeSetRequest) (governance.ReleaseChangeSet, error) {
if !validRouteID(request.ChangeSetID) || !validReviewerRef(request.ConfirmedBy) || request.Now.IsZero() {
return governance.ReleaseChangeSet{}, ErrInvalidReleaseChangeSet
}
set, err := scanReleaseChangeSet(s.db.QueryRow(ctx, `UPDATE release_change_sets SET state='confirmed',confirmed_by=$2,confirmed_at=$3 WHERE id=$1 AND state='planned' RETURNING `+releaseChangeSetColumns, request.ChangeSetID, request.ConfirmedBy, request.Now))
if errors.Is(err, pgx.ErrNoRows) {
current, lookupErr := s.GetReleaseChangeSet(ctx, request.ChangeSetID)
if lookupErr != nil {
return governance.ReleaseChangeSet{}, lookupErr
}
if current.State == "confirmed" || current.State == "consumed" {
return current.ReleaseChangeSet, nil
}
return governance.ReleaseChangeSet{}, ErrReleaseChangeSetStateConflict
}
if err != nil {
return governance.ReleaseChangeSet{}, fmt.Errorf("confirm release change set: %w", err)
}
return set, nil
}
func (s *Store) GetReleaseChangeSet(ctx context.Context, id string) (governance.ReleaseChangeSetDetail, error) {
set, err := scanReleaseChangeSet(s.db.QueryRow(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets WHERE id=$1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseChangeSetDetail{}, ErrReleaseChangeSetNotFound
}
if err != nil {
return governance.ReleaseChangeSetDetail{}, err
}
detail := governance.ReleaseChangeSetDetail{ReleaseChangeSet: set, Items: []governance.ReleaseChangeSetItem{}}
rows, err := s.db.Query(ctx, `SELECT seq,publication_id,title,target_scope,action,test_state,production_state,can_release,blocker FROM release_change_set_items WHERE change_set_id=$1 ORDER BY seq`, id)
if err != nil {
return governance.ReleaseChangeSetDetail{}, fmt.Errorf("list release change set items: %w", err)
}
defer rows.Close()
for rows.Next() {
var item governance.ReleaseChangeSetItem
if err := rows.Scan(&item.Seq, &item.PublicationID, &item.Title, &item.TargetScope, &item.Action, &item.TestState, &item.ProductionState, &item.CanRelease, &item.Blocker); err != nil {
return governance.ReleaseChangeSetDetail{}, err
}
detail.Items = append(detail.Items, item)
}
return detail, rows.Err()
}
func (s *Store) ListReleaseChangeSets(ctx context.Context, filter governance.ReleaseBatchFilter) (governance.ReleaseChangeSetPage, error) {
limit, offset := filter.Limit, filter.Offset
if limit <= 0 {
limit = 20
}
if offset < 0 {
offset = 0
}
page := governance.ReleaseChangeSetPage{Items: []governance.ReleaseChangeSet{}, Limit: limit, Offset: offset}
if limit > 100 {
return page, errors.New("release change set limit must be between 1 and 100")
}
var total int
if err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM release_change_sets`).Scan(&total); err != nil {
return page, err
}
page.Total = total
rows, err := s.db.Query(ctx, `SELECT `+releaseChangeSetColumns+` FROM release_change_sets ORDER BY number DESC LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return page, err
}
defer rows.Close()
for rows.Next() {
item, err := scanReleaseChangeSet(rows)
if err != nil {
return page, err
}
page.Items = append(page.Items, item)
}
return page, rows.Err()
}
func (s *Store) LatestReleaseChangeSet(ctx context.Context) (governance.ReleaseChangeSetDetail, error) {
var id string
if err := s.db.QueryRow(ctx, `SELECT id FROM release_change_sets ORDER BY number DESC LIMIT 1`).Scan(&id); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return governance.ReleaseChangeSetDetail{}, ErrReleaseChangeSetNotFound
}
return governance.ReleaseChangeSetDetail{}, err
}
return s.GetReleaseChangeSet(ctx, id)
}
func mustMarshalJSON(values []string) []byte {
raw, err := json.Marshal(values)
if err != nil {
return []byte("[]")
}
return raw
}