Files
knowledge-governance/internal/platform/postgres/test_publications.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

807 lines
26 KiB
Go

package postgres
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
var (
ErrInvalidAcceptanceResult = errors.New("invalid acceptance result")
ErrInvalidErrorCode = errors.New("invalid test publication error code")
ErrPublicationNotUploadable = errors.New("test publication is not uploadable")
ErrPublicationNotRetryable = errors.New("test publication is not retryable")
ErrPublicationNotFound = errors.New("test publication not found")
ErrPublicationStateConflict = errors.New("test publication state conflict")
)
type PublicationForUpload struct {
ID string
SourceItemVersionID string
TestKBID string
KnowledgeID string
IngestState governance.IngestState
ProcessingState governance.ProcessingState
TargetScope governance.Visibility
ContentObjectID string
SHA256 string
SizeBytes int64
MIME string
ValidatedFormat string
LocalSpoolRef string
Title string
}
type PublicationForPoll struct {
ID string
TestKBID string
KnowledgeID string
ProcessingState governance.ProcessingState
PollSequence int
LastRemoteStatus string
LastRemoteUpdatedAt *time.Time
ProcessingStartedAt *time.Time
LastObservedAt *time.Time
LastProgressAt *time.Time
}
type PublicationForValidation struct {
ID string
TestKBID string
KnowledgeID string
}
type LegacyVerificationCandidate struct {
ID string
TestKBID string
KnowledgeID string
}
const loadPublicationForUploadSQL = `
SELECT p.id, p.source_item_version_id, p.test_kb_id, p.knowledge_id,
p.ingest_state, p.processing_state, p.target_scope,
co.id, co.sha256, co.size_bytes, co.mime, co.validated_format,
co.local_spool_ref, si.title
FROM test_publications AS p
JOIN source_item_versions AS siv ON siv.id = p.source_item_version_id
JOIN content_objects AS co ON co.id = siv.content_object_id
JOIN source_items AS si ON si.id = siv.source_item_id
WHERE p.id = $1
AND p.ingest_state IN ('pending', 'uploading')
AND p.processing_state = 'pending'
AND p.knowledge_id = ''
AND siv.content_state = 'verified'
AND co.content_state = 'verified'
AND co.local_spool_ref <> ''
AND co.size_bytes > 0
`
const loadPublicationForPollSQL = `
SELECT id, test_kb_id, knowledge_id, processing_state, poll_sequence,
last_remote_status, last_remote_updated_at, processing_started_at,
last_observed_at, updated_at
FROM test_publications
WHERE id = $1
`
const loadPublicationForValidationSQL = `
SELECT id, test_kb_id, knowledge_id
FROM test_publications
WHERE id = $1
AND ingest_state = 'uploaded'
AND processing_state = 'completed'
AND knowledge_id <> ''
`
const listLegacyVerificationCandidatesSQL = `
SELECT p.id, p.test_kb_id, p.knowledge_id
FROM test_publications p
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
JOIN source_items si ON si.id = siv.source_item_id
JOIN source_connections sc ON sc.id = si.source_connection_id
WHERE p.knowledge_id <> ''
AND sc.source_type = 'pp'
ORDER BY p.id
`
const countTestPublicationsSQL = `
SELECT COUNT(*)
FROM test_publications
WHERE ($1 = '' OR processing_state = $1) AND ($2 = '' OR target_scope = $2)
`
const listTestPublicationsSQL = `
SELECT p.id, si.title, p.ingest_state, p.processing_state, p.retrieval_state,
p.target_scope, COALESCE(p.change_set_id, ''),
CASE WHEN EXISTS (
SELECT 1
FROM test_publications other
JOIN source_item_versions other_version ON other_version.id=other.source_item_version_id
WHERE other.id<>p.id AND other_version.source_item_id=siv.source_item_id
) THEN 'updated' ELSE 'added' END AS change_type,
(p.ingest_state='pending' AND p.processing_state='pending'
AND p.retrieval_state='unpublished' AND p.knowledge_id=''
AND co.content_state='verified' AND co.local_spool_ref<>'' AND co.size_bytes>0
AND NOT EXISTS (SELECT 1 FROM processing_confirmations confirmation WHERE confirmation.publication_id=p.id)
AND NOT EXISTS (SELECT 1 FROM jobs job WHERE job.type='test.upload' AND job.payload->>'publication_id'=p.id)) AS can_start,
p.retry_count, p.last_error,
((p.processing_state IN ('failed', 'stalled')
OR (p.ingest_state = 'failed' AND p.processing_state = 'pending'))
AND co.content_state = 'verified'
AND co.local_spool_ref <> '') AS can_retry,
p.updated_at
FROM test_publications p
JOIN source_item_versions siv ON siv.id = p.source_item_version_id
JOIN source_items si ON si.id = siv.source_item_id
JOIN content_objects co ON co.id = siv.content_object_id
WHERE ($1 = '' OR p.processing_state = $1) AND ($2 = '' OR p.target_scope = $2)
ORDER BY p.updated_at DESC, p.id
LIMIT $3 OFFSET $4
`
const listAcceptanceResultsSQL = `
SELECT check_type, state, policy_version, evidence_summary, observed_at
FROM acceptance_results
WHERE publication_id = $1
ORDER BY observed_at DESC
`
const loadRetryStateSQL = `
SELECT p.processing_state, p.ingest_state,
EXISTS (
SELECT 1 FROM source_item_versions siv
JOIN content_objects co ON co.id = siv.content_object_id
WHERE siv.id = p.source_item_version_id
AND co.content_state = 'verified'
AND co.local_spool_ref <> ''
)
FROM test_publications p
WHERE p.id = $1
`
const retryPublicationSQL = `
WITH updated AS (
UPDATE test_publications
SET ingest_state = 'pending',
processing_state = 'pending',
knowledge_id = '',
poll_sequence = 0,
last_remote_status = '',
last_remote_updated_at = NULL,
last_observed_at = NULL,
processing_started_at = NULL,
completed_at = NULL,
stalled_at = NULL,
last_error = '',
retry_count = retry_count + 1,
updated_at = $2
WHERE id = $1
AND (
processing_state IN ('failed', 'stalled')
OR (ingest_state = 'failed' AND processing_state = 'pending')
)
RETURNING id, retry_count
)
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $3, 'test.upload', $4::jsonb, 'pending', 0, $2, 5,
'test-retry:' || updated.id || ':' || updated.retry_count, $2, $2
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
`
const markUploadingSQL = `
UPDATE test_publications
SET ingest_state = 'uploading',
last_error = '',
updated_at = $2
WHERE id = $1
AND ingest_state IN ('pending', 'uploading')
AND processing_state = 'pending'
AND knowledge_id = ''
`
const recordUploadAndSchedulePollSQL = `
WITH updated AS (
UPDATE test_publications
SET knowledge_id = $2,
ingest_state = 'uploaded',
processing_state = 'pending',
poll_sequence = 1,
last_remote_status = 'pending',
processing_started_at = COALESCE(processing_started_at, $3),
last_observed_at = $3,
last_error = '',
updated_at = $3
WHERE id = $1
AND processing_state = 'pending'
AND (
(ingest_state = 'uploading' AND knowledge_id = '' AND poll_sequence = 0)
OR (ingest_state = 'uploaded' AND knowledge_id = $2 AND poll_sequence = 1)
)
RETURNING poll_sequence
), enqueued AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $4, 'test.poll', $5::jsonb, 'pending', $6, $7, $8, $9, $3, $3
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordRemoteStatusAndScheduleNextPollSQL = `
WITH updated AS (
UPDATE test_publications
SET processing_state = $3::varchar,
last_remote_status = $3::varchar,
last_remote_updated_at = $4,
last_observed_at = $5,
updated_at = CASE
WHEN last_remote_status IS DISTINCT FROM $3::varchar
OR last_remote_updated_at IS DISTINCT FROM $4
THEN $5
ELSE updated_at
END,
processing_started_at = COALESCE(processing_started_at, $5),
poll_sequence = $2 + 1
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
RETURNING poll_sequence
), enqueued AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $6, 'test.poll', $7::jsonb, 'pending', $8, $9, $10, $11, $5, $5
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordCompletedAndScheduleValidationSQL = `
WITH updated AS (
UPDATE test_publications
SET processing_state = 'completed',
last_remote_status = 'completed',
last_remote_updated_at = $3,
last_observed_at = $4,
completed_at = $4,
parser_generation = $11,
index_generation = $12,
last_error = '',
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
RETURNING poll_sequence
), enqueued AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT $5, 'test.validate', $6::jsonb, 'pending', $7, $8, $9, $10, $4, $4
FROM updated
ON CONFLICT (idempotency_key) DO NOTHING
)
SELECT poll_sequence FROM updated
`
const recordProcessingFailureSQL = `
UPDATE test_publications
SET processing_state = 'failed',
last_remote_status = 'failed',
last_observed_at = $4,
last_error = $3,
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
`
const recordUploadFailureSQL = `
UPDATE test_publications
SET ingest_state = 'failed',
processing_state = 'failed',
last_error = $2,
updated_at = $3
WHERE id = $1
AND ingest_state IN ('pending', 'uploading')
AND processing_state = 'pending'
AND knowledge_id = ''
`
const markStalledSQL = `
UPDATE test_publications
SET processing_state = 'stalled',
last_observed_at = $4,
last_error = 'processing_stalled',
updated_at = $4
WHERE id = $1
AND poll_sequence = $2
AND ingest_state = 'uploaded'
AND knowledge_id <> ''
AND processing_state IN ('pending', 'processing', 'finalizing')
AND updated_at <= $3
`
const appendAcceptanceResultSQL = `
INSERT INTO acceptance_results (
id, publication_id, run_id, check_type, state,
policy_version, evidence_summary, observed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
ON CONFLICT (id) DO NOTHING
`
func (s *Store) LoadPublicationForUpload(ctx context.Context, publicationID string) (PublicationForUpload, error) {
var candidate PublicationForUpload
var ingestState string
var processingState string
err := s.db.QueryRow(ctx, loadPublicationForUploadSQL, publicationID).Scan(
&candidate.ID,
&candidate.SourceItemVersionID,
&candidate.TestKBID,
&candidate.KnowledgeID,
&ingestState,
&processingState,
&candidate.TargetScope,
&candidate.ContentObjectID,
&candidate.SHA256,
&candidate.SizeBytes,
&candidate.MIME,
&candidate.ValidatedFormat,
&candidate.LocalSpoolRef,
&candidate.Title,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForUpload{}, ErrPublicationNotUploadable
}
if err != nil {
return PublicationForUpload{}, fmt.Errorf("load test publication for upload: %w", err)
}
candidate.IngestState = governance.IngestState(ingestState)
candidate.ProcessingState = governance.ProcessingState(processingState)
return candidate, nil
}
func (s *Store) LoadPublicationForPoll(ctx context.Context, publicationID string) (PublicationForPoll, error) {
var publication PublicationForPoll
var processingState string
err := s.db.QueryRow(ctx, loadPublicationForPollSQL, publicationID).Scan(
&publication.ID,
&publication.TestKBID,
&publication.KnowledgeID,
&processingState,
&publication.PollSequence,
&publication.LastRemoteStatus,
&publication.LastRemoteUpdatedAt,
&publication.ProcessingStartedAt,
&publication.LastObservedAt,
&publication.LastProgressAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForPoll{}, ErrPublicationNotFound
}
if err != nil {
return PublicationForPoll{}, fmt.Errorf("load test publication for poll: %w", err)
}
publication.ProcessingState = governance.ProcessingState(processingState)
return publication, nil
}
func (s *Store) LoadPublicationForValidation(ctx context.Context, publicationID string) (PublicationForValidation, error) {
var publication PublicationForValidation
err := s.db.QueryRow(ctx, loadPublicationForValidationSQL, publicationID).Scan(
&publication.ID,
&publication.TestKBID,
&publication.KnowledgeID,
)
if errors.Is(err, pgx.ErrNoRows) {
return PublicationForValidation{}, ErrPublicationStateConflict
}
if err != nil {
return PublicationForValidation{}, fmt.Errorf("load test publication for validation: %w", err)
}
return publication, nil
}
func (s *Store) ListLegacyVerificationCandidates(ctx context.Context) ([]LegacyVerificationCandidate, error) {
rows, err := s.db.Query(ctx, listLegacyVerificationCandidatesSQL)
if err != nil {
return nil, fmt.Errorf("list legacy verification candidates: %w", err)
}
defer rows.Close()
var candidates []LegacyVerificationCandidate
for rows.Next() {
var candidate LegacyVerificationCandidate
if err := rows.Scan(&candidate.ID, &candidate.TestKBID, &candidate.KnowledgeID); err != nil {
return nil, fmt.Errorf("scan legacy verification candidate: %w", err)
}
candidates = append(candidates, candidate)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate legacy verification candidates: %w", err)
}
return candidates, nil
}
func (s *Store) ListTestPublications(ctx context.Context, filter governance.TestPublicationFilter) (governance.TestPublicationPage, error) {
var page governance.TestPublicationPage
if filter.TargetScope != "" && filter.TargetScope != string(governance.VisibilityInternal) && filter.TargetScope != string(governance.VisibilityExternal) {
return governance.TestPublicationPage{}, errors.New("invalid test publication target scope")
}
if err := s.db.QueryRow(ctx, countTestPublicationsSQL, filter.ProcessingState, filter.TargetScope).Scan(&page.Total); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("count test publications: %w", err)
}
rows, err := s.db.Query(ctx, listTestPublicationsSQL, filter.ProcessingState, filter.TargetScope, filter.Limit, filter.Offset)
if err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("list test publications: %w", err)
}
defer rows.Close()
page.Items = make([]governance.TestPublication, 0)
for rows.Next() {
var item governance.TestPublication
if err := rows.Scan(
&item.ID,
&item.Title,
&item.IngestState,
&item.ProcessingState,
&item.RetrievalState,
&item.TargetScope,
&item.ChangeSetID,
&item.ChangeType,
&item.CanStart,
&item.RetryCount,
&item.LastError,
&item.CanRetry,
&item.UpdatedAt,
); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("scan test publication: %w", err)
}
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return governance.TestPublicationPage{}, fmt.Errorf("iterate test publications: %w", err)
}
return page, nil
}
func (s *Store) ListAcceptanceResults(ctx context.Context, publicationID string) ([]governance.AcceptanceRecord, error) {
rows, err := s.db.Query(ctx, listAcceptanceResultsSQL, publicationID)
if err != nil {
return nil, fmt.Errorf("list acceptance results: %w", err)
}
defer rows.Close()
results := make([]governance.AcceptanceRecord, 0)
for rows.Next() {
var record governance.AcceptanceRecord
if err := rows.Scan(&record.CheckType, &record.State, &record.PolicyVersion, &record.EvidenceSummary, &record.ObservedAt); err != nil {
return nil, fmt.Errorf("scan acceptance result: %w", err)
}
results = append(results, record)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate acceptance results: %w", err)
}
return results, nil
}
func (s *Store) RetryPublicationWithOperation(ctx context.Context, publicationID string, now time.Time, operationID string) (bool, error) {
fingerprint, err := operationFingerprint(struct {
PublicationID string `json:"publication_id"`
}{publicationID})
if err != nil {
return false, err
}
raw, replayed, err := s.executeOperation(ctx, "test.publication.retry", operationID, fingerprint, now, func(tx pgx.Tx) (json.RawMessage, error) {
if err := NewStore(tx).RetryPublication(ctx, publicationID, now); err != nil {
return nil, err
}
return json.Marshal(map[string]string{"id": publicationID, "status": "retry_queued"})
})
if err != nil {
return false, err
}
if !json.Valid(raw) {
return false, errors.New("invalid stored retry result")
}
return replayed, nil
}
func (s *Store) RetryPublication(ctx context.Context, publicationID string, now time.Time) error {
var processingState, ingestState string
var spoolAvailable bool
err := s.db.QueryRow(ctx, loadRetryStateSQL, publicationID).Scan(&processingState, &ingestState, &spoolAvailable)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPublicationNotFound
}
if err != nil {
return fmt.Errorf("load test publication retry state: %w", err)
}
legacyUploadFailed := ingestState == string(governance.IngestFailed) && processingState == string(governance.ProcessingPending)
if processingState != string(governance.ProcessingFailed) && processingState != string(governance.ProcessingStalled) && !legacyUploadFailed {
return ErrPublicationStateConflict
}
if !spoolAvailable {
return ErrPublicationNotRetryable
}
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
}{PublicationID: publicationID})
if err != nil {
return errors.New("encode retry upload job")
}
jobID := orchestrationJobID("test.upload", publicationID, fmt.Sprint(now.UnixNano()))
tag, err := s.db.Exec(ctx, retryPublicationSQL, publicationID, now, jobID, payload)
if err != nil {
return fmt.Errorf("retry test publication: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) MarkUploading(ctx context.Context, publicationID string, now time.Time) error {
tag, err := s.db.Exec(ctx, markUploadingSQL, publicationID, now)
if err != nil {
return fmt.Errorf("mark test publication uploading: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) RecordUploadAndSchedulePoll(ctx context.Context, publicationID, knowledgeID string, now, nextAt time.Time) (int, error) {
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
Sequence int `json:"sequence"`
}{PublicationID: publicationID, Sequence: 1})
if err != nil {
return 0, errors.New("encode first test poll job")
}
idempotencyKey := "test-poll:" + publicationID + ":1"
jobID := orchestrationJobID("test.poll", publicationID, "1")
var sequence int
err = s.db.QueryRow(
ctx,
recordUploadAndSchedulePollSQL,
publicationID,
knowledgeID,
now,
jobID,
payload,
0,
nextAt,
5,
idempotencyKey,
).Scan(&sequence)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPublicationStateConflict
}
if err != nil {
return 0, fmt.Errorf("record test upload and schedule poll: %w", err)
}
return sequence, nil
}
func (s *Store) RecordRemoteStatusAndScheduleNextPoll(
ctx context.Context,
publicationID string,
sequence int,
status governance.ProcessingState,
remoteUpdatedAt *time.Time,
observedAt time.Time,
nextAt time.Time,
) (int, error) {
if sequence < 1 || (status != governance.ProcessingPending && status != governance.ProcessingProcessing && status != governance.ProcessingFinalizing) {
return 0, ErrPublicationStateConflict
}
nextSequence := sequence + 1
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
Sequence int `json:"sequence"`
}{PublicationID: publicationID, Sequence: nextSequence})
if err != nil {
return 0, errors.New("encode next test poll job")
}
idempotencyKey := fmt.Sprintf("test-poll:%s:%d", publicationID, nextSequence)
jobID := orchestrationJobID("test.poll", publicationID, fmt.Sprint(nextSequence))
var storedSequence int
err = s.db.QueryRow(
ctx,
recordRemoteStatusAndScheduleNextPollSQL,
publicationID,
sequence,
string(status),
remoteUpdatedAt,
observedAt,
jobID,
payload,
0,
nextAt,
5,
idempotencyKey,
).Scan(&storedSequence)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrPublicationStateConflict
}
if err != nil {
return 0, fmt.Errorf("record remote status and schedule next test poll: %w", err)
}
return storedSequence, nil
}
func (s *Store) RecordCompletedAndScheduleValidation(
ctx context.Context,
publicationID string,
sequence int,
remoteUpdatedAt *time.Time,
observedAt time.Time,
) error {
if sequence < 1 {
return ErrPublicationStateConflict
}
// The release gate requires a parse revision for every completed
// publication. WeKnora reports a single terminal update timestamp for the
// parse+index pipeline, so both generations are stamped from it (falling
// back to the observation time) together with the pinned WeKnora contract
// identity. This keeps manual/seeded publications releasable without
// pretending the document was parsed by a different pipeline.
generationAt := observedAt.UTC()
if remoteUpdatedAt != nil && !remoteUpdatedAt.IsZero() {
generationAt = remoteUpdatedAt.UTC()
}
generation := "weknora-v0.7.1-m74@" + generationAt.Format(time.RFC3339)
payload, err := json.Marshal(struct {
PublicationID string `json:"publication_id"`
}{PublicationID: publicationID})
if err != nil {
return errors.New("encode test validation job")
}
idempotencyKey := fmt.Sprintf("test-validate:%s:%d", publicationID, sequence)
jobID := orchestrationJobID("test.validate", publicationID, fmt.Sprint(sequence))
var storedSequence int
err = s.db.QueryRow(
ctx,
recordCompletedAndScheduleValidationSQL,
publicationID,
sequence,
remoteUpdatedAt,
observedAt,
jobID,
payload,
0,
observedAt,
5,
idempotencyKey,
generation,
generation,
).Scan(&storedSequence)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPublicationStateConflict
}
if err != nil {
return fmt.Errorf("record test completion and schedule validation: %w", err)
}
return nil
}
func (s *Store) RecordProcessingFailure(ctx context.Context, publicationID string, sequence int, code string, observedAt time.Time) error {
if sequence < 1 || !validErrorCode(code) {
return ErrInvalidErrorCode
}
tag, err := s.db.Exec(ctx, recordProcessingFailureSQL, publicationID, sequence, code, observedAt)
if err != nil {
return fmt.Errorf("record test processing failure: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) RecordUploadFailure(ctx context.Context, publicationID, code string, observedAt time.Time) error {
if !validErrorCode(code) {
return ErrInvalidErrorCode
}
tag, err := s.db.Exec(ctx, recordUploadFailureSQL, publicationID, code, observedAt)
if err != nil {
return fmt.Errorf("record test upload failure: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) MarkStalled(ctx context.Context, publicationID string, sequence int, cutoff, observedAt time.Time) error {
if sequence < 1 || cutoff.After(observedAt) {
return ErrPublicationStateConflict
}
tag, err := s.db.Exec(ctx, markStalledSQL, publicationID, sequence, cutoff, observedAt)
if err != nil {
return fmt.Errorf("mark test publication stalled: %w", err)
}
if tag.RowsAffected() != 1 {
return ErrPublicationStateConflict
}
return nil
}
func (s *Store) AppendAcceptanceResult(ctx context.Context, result governance.AcceptanceResult) error {
if !validShortID(result.ID) || !validShortID(result.PublicationID) || !validShortID(result.RunID) ||
!validErrorCode(result.CheckType) || !validErrorCode(result.PolicyVersion) || !result.State.Valid() ||
result.ObservedAt.IsZero() || len(result.EvidenceSummary) == 0 || len(result.EvidenceSummary) > 4096 || !json.Valid(result.EvidenceSummary) {
return ErrInvalidAcceptanceResult
}
var evidence map[string]any
if err := json.Unmarshal(result.EvidenceSummary, &evidence); err != nil || evidence == nil {
return ErrInvalidAcceptanceResult
}
_, err := s.db.Exec(
ctx,
appendAcceptanceResultSQL,
result.ID,
result.PublicationID,
result.RunID,
result.CheckType,
string(result.State),
result.PolicyVersion,
[]byte(result.EvidenceSummary),
result.ObservedAt,
)
if err != nil {
return fmt.Errorf("append acceptance result: %w", err)
}
return nil
}
func validShortID(value string) bool {
return len(value) > 0 && len(value) <= 36 && !strings.ContainsAny(value, " /\\ \r\n")
}
func validErrorCode(code string) bool {
if len(code) == 0 || len(code) > 64 {
return false
}
for _, character := range code {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || strings.ContainsRune("_.:-", character) {
continue
}
return false
}
return true
}
func orchestrationJobID(parts ...string) string {
digest := sha256.New()
for _, part := range parts {
_, _ = digest.Write([]byte{0})
_, _ = digest.Write([]byte(part))
}
return "job-" + hex.EncodeToString(digest.Sum(nil))[:32]
}