dcdc8d1ddd
全栈知识治理平台首次入库(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 干净
807 lines
26 KiB
Go
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]
|
|
}
|