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

228 lines
8.1 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"time"
"knowledge-governance/internal/governance"
)
const listSourceConnectionsSQL = `
SELECT
c.id,
c.source_type,
c.name,
c.enabled,
c.schedule <> '{}'::jsonb AS schedule_configured,
CASE
WHEN c.last_health->>'status' IN ('healthy', 'degraded', 'unhealthy')
THEN c.last_health->>'status'
ELSE 'unknown'
END AS health_status,
COUNT(i.id)::int AS item_count,
c.updated_at,
c.next_refresh_at,
scan.id,
scan.state,
scan.observed_items,
scan.unchanged_items,
scan.added_items,
scan.updated_items,
scan.over_limit_items,
scan.missing_items,
scan.planned_uploads,
scan.error_code,
scan.created_at,
scan.completed_at
FROM source_connections c
LEFT JOIN source_items i ON i.source_connection_id = c.id
LEFT JOIN LATERAL (
SELECT id, state, observed_items, unchanged_items, added_items,
updated_items, over_limit_items, missing_items, planned_uploads,
error_code, created_at, completed_at
FROM source_scans
WHERE source_connection_id = c.id
ORDER BY created_at DESC, id DESC
LIMIT 1
) scan ON TRUE
GROUP BY c.id, c.source_type, c.name, c.enabled, c.schedule, c.last_health, c.updated_at, c.next_refresh_at,
scan.id, scan.state, scan.observed_items, scan.unchanged_items,
scan.added_items, scan.updated_items, scan.over_limit_items,
scan.missing_items, scan.planned_uploads, scan.error_code,
scan.created_at, scan.completed_at
ORDER BY c.source_type, c.name, c.id
`
func (s *Store) ListSourceConnections(ctx context.Context) (governance.SourceConnectionPage, error) {
rows, err := s.db.Query(ctx, listSourceConnectionsSQL)
if err != nil {
return governance.SourceConnectionPage{}, fmt.Errorf("list source connections: %w", err)
}
defer rows.Close()
page := governance.SourceConnectionPage{Items: make([]governance.SourceConnectionView, 0)}
for rows.Next() {
var item governance.SourceConnectionView
var sourceType, healthStatus string
var scanID, scanState, scanError *string
var observed, unchanged, added, updated, overLimit, missing, planned *int
var scanCreatedAt, scanCompletedAt *time.Time
if err := rows.Scan(
&item.ID,
&sourceType,
&item.Name,
&item.Enabled,
&item.ScheduleConfigured,
&healthStatus,
&item.ItemCount,
&item.UpdatedAt,
&item.NextRefreshAt,
&scanID,
&scanState,
&observed,
&unchanged,
&added,
&updated,
&overLimit,
&missing,
&planned,
&scanError,
&scanCreatedAt,
&scanCompletedAt,
); err != nil {
return governance.SourceConnectionPage{}, fmt.Errorf("scan source connection: %w", err)
}
item.SourceType = governance.SourceType(sourceType)
item.HealthStatus = governance.SourceHealthStatus(healthStatus)
if !item.SourceType.Valid() || !item.HealthStatus.Valid() || item.ItemCount < 0 {
return governance.SourceConnectionPage{}, fmt.Errorf("invalid source connection projection")
}
if scanID != nil {
if scanState == nil || observed == nil || unchanged == nil || added == nil || updated == nil ||
overLimit == nil || missing == nil || planned == nil || scanError == nil || scanCreatedAt == nil {
return governance.SourceConnectionPage{}, fmt.Errorf("incomplete source scan projection")
}
state := governance.SourceScanState(*scanState)
if !state.Valid() || *observed < 0 || *unchanged < 0 || *added < 0 || *updated < 0 || *overLimit < 0 || *missing < 0 || *planned < 0 {
return governance.SourceConnectionPage{}, fmt.Errorf("invalid source scan projection")
}
item.LastScan = &governance.SourceScanSummary{
ID: *scanID, State: state, ObservedItems: *observed, UnchangedItems: *unchanged,
AddedItems: *added, UpdatedItems: *updated, OverLimitItems: *overLimit,
MissingItems: *missing, PlannedUploads: *planned, ErrorCode: *scanError,
CreatedAt: *scanCreatedAt, CompletedAt: scanCompletedAt,
}
}
page.Items = append(page.Items, item)
page.Summary.Total++
if item.Enabled {
page.Summary.Enabled++
} else {
page.Summary.Disabled++
}
switch item.HealthStatus {
case governance.SourceHealthHealthy:
page.Summary.Healthy++
case governance.SourceHealthDegraded:
page.Summary.Degraded++
case governance.SourceHealthUnhealthy:
page.Summary.Unhealthy++
case governance.SourceHealthUnknown:
page.Summary.Unknown++
}
}
if err := rows.Err(); err != nil {
return governance.SourceConnectionPage{}, fmt.Errorf("iterate source connections: %w", err)
}
return page, nil
}
// V1 per-source scheduler. One statement claims every enabled, due connection
// (FOR UPDATE OF c SKIP LOCKED so parallel dispatchers never share a row),
// skips sources whose manual or scheduled refresh job is still pending/running,
// enqueues one source.catalog.refresh per remaining source, and pushes each
// claimed source's next_refresh_at out by the interval configured on its
// ACTIVE revision. Enqueue and horizon-advance commit atomically in this single
// statement: a crash before commit rolls both back, and two racing dispatcher
// ticks serialize on the row lock, so the same window cannot double-enqueue.
// The deterministic idempotency key (scheduled-refresh:<id>:<window>) is a
// second belt. Disabling a source stops it from ever being claimed again while
// every historical scan, snapshot and job stays intact. Sources without a
// configured interval keep next_refresh_at NULL and are never due (manual-only).
const claimDueSourceRefreshesSQL = `
WITH due AS (
SELECT c.id, c.name, c.source_type, c.next_refresh_at,
COALESCE((r.schedule->>'refresh_interval_seconds')::INTEGER, 0) AS interval_seconds
FROM source_connections AS c
LEFT JOIN source_connection_revisions AS r
ON r.id = c.current_revision_id AND r.state = 'active'
WHERE c.enabled AND c.source_type <> 'manual'
AND c.next_refresh_at IS NOT NULL AND c.next_refresh_at <= $1
ORDER BY c.next_refresh_at, c.id
FOR UPDATE OF c SKIP LOCKED
),
enqueueable AS (
SELECT d.* FROM due AS d
WHERE NOT EXISTS (
SELECT 1 FROM jobs
WHERE jobs.type = 'source.catalog.refresh'
AND jobs.state IN ('pending', 'running')
AND jobs.payload->>'connection_id' = d.id
)
),
inserted AS (
INSERT INTO jobs (
id, type, payload, state, priority, available_at,
max_attempts, idempotency_key, created_at, updated_at
)
SELECT 'job-' || replace(gen_random_uuid()::text, '-', ''), 'source.catalog.refresh',
jsonb_build_object(
'connection_id', e.id, 'source_type', e.source_type, 'source_name', e.name,
'stage', 'queued', 'progress', 0, 'message', '计划刷新已排队', 'processed', 0, 'total', 0),
'pending', 20, $1, 3,
'scheduled-refresh:' || e.id || ':' || FLOOR(EXTRACT(EPOCH FROM e.next_refresh_at))::BIGINT::TEXT,
$1, $1
FROM enqueueable AS e
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id
),
advanced AS (
UPDATE source_connections AS c
SET next_refresh_at = $1 + make_interval(secs => GREATEST(d.interval_seconds, 300)),
last_scheduled_at = $1
FROM due AS d
WHERE c.id = d.id
RETURNING c.id
)
SELECT (SELECT COUNT(*)::INTEGER FROM advanced), (SELECT COUNT(*)::INTEGER FROM inserted)
`
func (s *Store) ClaimDueSourceRefreshes(ctx context.Context, now time.Time) (claimed int, enqueued int, err error) {
if now.IsZero() {
return 0, 0, errors.New("claim due source refreshes requires a time")
}
if err := s.db.QueryRow(ctx, claimDueSourceRefreshesSQL, now.UTC()).Scan(&claimed, &enqueued); err != nil {
return 0, 0, fmt.Errorf("claim due source refreshes: %w", err)
}
return claimed, enqueued, nil
}
// NextRefreshAtFor derives the scheduler horizon from the effective schedule;
// it is the ONLY place next_refresh_at values are computed from user input.
func NextRefreshAtFor(enabled bool, scheduleRaw []byte, from time.Time) (*time.Time, error) {
if !enabled {
return nil, nil
}
interval, err := governance.ParseSourceSchedule(scheduleRaw)
if err != nil {
return nil, err
}
if interval <= 0 {
return nil, nil
}
next := from.UTC().Add(interval)
return &next, nil
}