Files
knowledge-governance/internal/workflows/release/handler.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

300 lines
9.7 KiB
Go

package release
import (
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
)
// debugReleaseItemErrors enables fail-closed-safe diagnostics: only the
// generic step-level cause text is logged, never payload or credentials.
var debugReleaseItemErrors = os.Getenv("RELEASE_DEBUG_ITEM_ERRORS") == "true"
const (
ErrorCodeContractMismatch = "release_contract_mismatch"
ErrorCodeBundleInvalid = "release_bundle_invalid"
ErrorCodeTargetUnavailable = "release_target_unavailable"
ErrorCodeTargetWriteFailed = "release_target_write_failed"
ErrorCodeVerificationFailed = "release_verification_failed"
ErrorCodeRollbackArtifactMissing = "release_rollback_artifact_missing"
ErrorCodeRollbackFailed = "release_rollback_failed"
ErrorCodeStoppedAfterFailure = "release_stopped_after_failure"
ErrorCodeInternal = "release_internal_error"
)
var (
ErrInvalidReleaseJob = errors.New("invalid_release_job")
ErrReleaseRetryable = errors.New("release_retryable")
ErrReleaseState = errors.New("release_state_failed")
)
type PlanItem struct {
ReleaseExecutionID string `json:"release_execution_id"`
PublicationID string `json:"publication_id"`
Action string `json:"action"`
SourceItemVersionID string `json:"source_item_version_id"`
SourceRevision string `json:"source_revision"`
ParseRevision string `json:"parse_revision"`
ReleaseRevision string `json:"release_revision"`
TestKnowledgeBaseID string `json:"test_knowledge_base_id"`
KnowledgeID string `json:"knowledge_id"`
DecidedVisibility governance.Visibility `json:"decided_visibility"`
PreviousProductionRevision string `json:"previous_production_revision"`
}
type ItemResult struct {
BundleSHA256 string
PreviousProductionRevision string
ProductionRevision string
}
type StartExecutionRequest struct {
ExecutionID string
JobID string
Operation governance.ReleaseOperation
Now time.Time
}
type CompleteItemRequest struct {
ExecutionID string
PublicationID string
BundleSHA256 string
PreviousProductionRevision string
ProductionRevision string
Now time.Time
}
type FailItemRequest struct {
ExecutionID string
PublicationID string
ErrorCode string
Now time.Time
}
type Store interface {
StartExecution(context.Context, StartExecutionRequest) ([]PlanItem, error)
BeginBatch(context.Context, string, int, int, time.Time) error
CompleteItem(context.Context, CompleteItemRequest) error
FailItem(context.Context, FailItemRequest) error
FinishExecution(context.Context, string, time.Time) error
ScheduleAutomaticRollback(context.Context, string, time.Time) error
}
type Publisher interface {
Publish(context.Context, PlanItem) (ItemResult, error)
Delete(context.Context, PlanItem) (ItemResult, error)
Rollback(context.Context, PlanItem) (ItemResult, error)
}
type ItemError struct {
Code string
Retryable bool
Cause error
}
func (err *ItemError) Error() string {
if err == nil || !ValidErrorCode(err.Code) {
return ErrorCodeInternal
}
return err.Code
}
func (err *ItemError) Unwrap() error {
if err == nil {
return nil
}
return err.Cause
}
type jobPayload struct {
ExecutionID string `json:"execution_id"`
ReleaseBatchID string `json:"release_batch_id"`
Operation string `json:"operation"`
PriorExecutionID string `json:"prior_execution_id"`
}
func NewHandler(store Store, publisher Publisher, now func() time.Time) (jobs.Handler, error) {
if store == nil || publisher == nil || now == nil {
return nil, errors.New("release handler dependencies are required")
}
return func(ctx context.Context, job jobs.Job) error {
payload, operation, err := parseJob(job)
if err != nil {
return err
}
items, err := store.StartExecution(ctx, StartExecutionRequest{
ExecutionID: payload.ExecutionID, JobID: job.ID, Operation: operation, Now: now().UTC(),
})
if err != nil {
return ErrReleaseState
}
retryableFailure := false
permanentFailure := false
failureIndex := -1
const batchSize = 50
totalBatches := (len(items) + batchSize - 1) / batchSize
for start := 0; start < len(items); start += batchSize {
batchNumber := start/batchSize + 1
if err := store.BeginBatch(ctx, payload.ExecutionID, batchNumber, totalBatches, now().UTC()); err != nil {
return ErrReleaseState
}
end := start + batchSize
if end > len(items) {
end = len(items)
}
for index := start; index < end; index++ {
item := items[index]
result, itemErr := publishItem(ctx, publisher, operation, item)
if itemErr != nil {
code, retryable := classifyItemError(itemErr)
if debugReleaseItemErrors {
var itemErrDetail *ItemError
causeText := ""
if errors.As(itemErr, &itemErrDetail) && itemErrDetail != nil && itemErrDetail.Cause != nil {
causeText = itemErrDetail.Cause.Error()
}
log.Printf("release item %s (%s/%s) failed code=%s cause=%s", item.PublicationID, item.Action, item.KnowledgeID, code, causeText)
}
if retryable && job.Attempt < job.MaxAttempts {
retryableFailure = true
continue
}
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: code, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
permanentFailure = true
failureIndex = index
break
}
if !validResult(operation, result) {
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeBundleInvalid, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
permanentFailure = true
failureIndex = index
break
}
if err := store.CompleteItem(ctx, CompleteItemRequest{
ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID,
BundleSHA256: result.BundleSHA256, PreviousProductionRevision: result.PreviousProductionRevision,
ProductionRevision: result.ProductionRevision, Now: now().UTC(),
}); err != nil {
return ErrReleaseState
}
}
if permanentFailure {
break
}
}
if permanentFailure {
for _, item := range items[failureIndex+1:] {
if err := store.FailItem(ctx, FailItemRequest{ExecutionID: payload.ExecutionID, PublicationID: item.PublicationID, ErrorCode: ErrorCodeStoppedAfterFailure, Now: now().UTC()}); err != nil {
return ErrReleaseState
}
}
}
if retryableFailure {
return ErrReleaseRetryable
}
if err := store.FinishExecution(ctx, payload.ExecutionID, now().UTC()); err != nil {
return ErrReleaseState
}
if permanentFailure && operation == governance.ReleaseOperationPublish {
if err := store.ScheduleAutomaticRollback(ctx, payload.ExecutionID, now().UTC()); err != nil {
return ErrReleaseState
}
}
return nil
}, nil
}
func parseJob(job jobs.Job) (jobPayload, governance.ReleaseOperation, error) {
var payload jobPayload
decoder := json.NewDecoder(strings.NewReader(string(job.Payload)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return jobPayload{}, "", ErrInvalidReleaseJob
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return jobPayload{}, "", ErrInvalidReleaseJob
}
operation := governance.ReleaseOperation(payload.Operation)
expectedType := "release." + payload.Operation
if !validID(payload.ExecutionID, "rx-") || !validID(payload.ReleaseBatchID, "rb-") || !operation.Valid() || job.Type != expectedType || job.Attempt < 1 || job.MaxAttempts < job.Attempt {
return jobPayload{}, "", ErrInvalidReleaseJob
}
return payload, operation, nil
}
func publishItem(ctx context.Context, publisher Publisher, operation governance.ReleaseOperation, item PlanItem) (ItemResult, error) {
if operation == governance.ReleaseOperationRollback {
return publisher.Rollback(ctx, item)
}
if item.Action == "delete" {
return publisher.Delete(ctx, item)
}
return publisher.Publish(ctx, item)
}
func classifyItemError(err error) (string, bool) {
var itemErr *ItemError
if errors.As(err, &itemErr) && ValidErrorCode(itemErr.Code) {
return itemErr.Code, itemErr.Retryable
}
return ErrorCodeInternal, true
}
func validResult(operation governance.ReleaseOperation, result ItemResult) bool {
if strings.TrimSpace(result.ProductionRevision) == "" || len(result.ProductionRevision) > 128 {
return false
}
if operation == governance.ReleaseOperationPublish {
return validSHA256(result.BundleSHA256)
}
return true
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, char := range value {
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
return false
}
}
return true
}
func validID(value, prefix string) bool {
if !strings.HasPrefix(value, prefix) || len(value) > 36 || len(value) <= len(prefix) {
return false
}
for _, char := range value {
if (char < 'a' || char > 'z') && (char < '0' || char > '9') && char != '-' {
return false
}
}
return true
}
func ValidErrorCode(code string) bool {
switch code {
case ErrorCodeContractMismatch, ErrorCodeBundleInvalid, ErrorCodeTargetUnavailable,
ErrorCodeTargetWriteFailed, ErrorCodeVerificationFailed, ErrorCodeRollbackArtifactMissing,
ErrorCodeRollbackFailed, ErrorCodeStoppedAfterFailure, ErrorCodeInternal:
return true
default:
return false
}
}