Files
knowledge-governance/internal/monitoring/real.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

321 lines
14 KiB
Go

package monitoring
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
type Database interface {
Ping(context.Context) error
Query(context.Context, string, ...any) (pgx.Rows, error)
QueryRow(context.Context, string, ...any) pgx.Row
}
type RealProviderConfig struct {
DB Database
Now func() time.Time
HTTPClient *http.Client
WeKnoraBaseURL string
MinerUBaseURL string
// WeKnoraReadiness optionally exercises a real read against a routing
// knowledge base (nil = API key not configured; liveness-only).
WeKnoraReadiness func(ctx context.Context) error
// WorkerStaleAfter bounds how long a missing worker heartbeat still counts
// as alive (P10).
WorkerStaleAfter time.Duration
}
type RealProvider struct {
db Database
now func() time.Time
httpClient *http.Client
weknoraBaseURL string
minerUBaseURL string
weknoraReadiness func(ctx context.Context) error
workerStaleAfter time.Duration
health *healthTracker
}
func NewRealProvider(config RealProviderConfig) (*RealProvider, error) {
if config.DB == nil {
return nil, errors.New("monitor database is required")
}
if config.Now == nil {
config.Now = time.Now
}
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
}
stale := config.WorkerStaleAfter
if stale <= 0 {
stale = time.Minute
}
return &RealProvider{db: config.DB, now: config.Now, httpClient: config.HTTPClient, weknoraBaseURL: strings.TrimSpace(config.WeKnoraBaseURL), minerUBaseURL: strings.TrimSpace(config.MinerUBaseURL), weknoraReadiness: config.WeKnoraReadiness, workerStaleAfter: stale, health: newHealthTracker()}, nil
}
func (provider *RealProvider) Overview(ctx context.Context) (governance.MonitorOverview, error) {
now := provider.now().UTC()
overview := governance.MonitorOverview{Mode: "real", Simulated: false, ObservedAt: now, Services: []governance.MonitorService{}, KnowledgeBases: []governance.KnowledgeBaseProgress{}}
overview.Host = collectHostMetrics()
overview.Services = append(overview.Services, provider.postgresHealth(ctx, now), provider.queueHealth(ctx, now))
overview.Health = provider.Services(ctx)
overview.Services = append(overview.Services,
provider.httpHealth(ctx, "weknora", "WeKnora API", provider.weknoraBaseURL, now),
governance.MonitorService{Key: "embedding", Name: "Embedding模型", State: "unknown", Detail: "由WeKnora管理,尚无独立探测端点", ObservedAt: now},
governance.MonitorService{Key: "llm", Name: "LLM模型", State: "unknown", Detail: "由WeKnora管理,尚无独立探测端点", ObservedAt: now},
provider.httpHealth(ctx, "mineru", "MinerU", provider.minerUBaseURL, now),
governance.MonitorService{Key: "redis", Name: "Redis / MQ", State: "unknown", Detail: "未部署;任务由PostgreSQL持久队列承载", ObservedAt: now},
governance.MonitorService{Key: "oss", Name: "OSS", State: "unknown", Detail: "未配置独立健康探测", ObservedAt: now},
)
rows, err := provider.db.Query(ctx, `SELECT p.test_kb_id,
CASE WHEN COUNT(DISTINCT p.target_scope)=1 THEN MIN(p.target_scope) ELSE 'mixed' END,
COUNT(*)::integer,
COUNT(*) FILTER (WHERE p.processing_state='completed')::integer,
COUNT(*) FILTER (WHERE p.processing_state='pending')::integer,
COUNT(*) FILTER (WHERE p.processing_state='processing')::integer,
COUNT(*) FILTER (WHERE p.processing_state='finalizing')::integer,
COUNT(*) FILTER (WHERE p.processing_state IN ('failed','stalled'))::integer,
COUNT(*) FILTER (WHERE p.target_scope='internal')::integer,
COUNT(*) FILTER (WHERE p.target_scope='internal' AND p.processing_state='completed')::integer,
COUNT(*) FILTER (WHERE p.target_scope='external')::integer,
COUNT(*) FILTER (WHERE p.target_scope='external' AND p.processing_state='completed')::integer
FROM test_publications p
GROUP BY p.test_kb_id
ORDER BY p.test_kb_id`)
if err != nil {
return governance.MonitorOverview{}, fmt.Errorf("list monitored knowledge bases: %w", err)
}
defer rows.Close()
for rows.Next() {
var item governance.KnowledgeBaseProgress
if err := rows.Scan(&item.ID, &item.Visibility, &item.Imported, &item.Completed, &item.Pending, &item.Processing, &item.Finalizing, &item.Failed, &item.InternalImported, &item.InternalCompleted, &item.ExternalImported, &item.ExternalCompleted); err != nil {
return governance.MonitorOverview{}, fmt.Errorf("scan monitored knowledge base: %w", err)
}
switch item.Visibility {
case "internal":
item.Name = "对内知识库"
case "external":
item.Name = "对外知识库"
default:
item.Name = "测试知识库"
}
overview.KnowledgeBases = append(overview.KnowledgeBases, item)
}
if err := rows.Err(); err != nil {
return governance.MonitorOverview{}, fmt.Errorf("iterate monitored knowledge bases: %w", err)
}
return overview, nil
}
func (provider *RealProvider) ListFiles(ctx context.Context, knowledgeBaseID, status string, limit, offset int) (governance.KnowledgeMonitorFilePage, error) {
if strings.TrimSpace(knowledgeBaseID) == "" || limit < 1 || limit > 100 || offset < 0 || !validMonitorStatus(status) {
return governance.KnowledgeMonitorFilePage{}, errors.New("invalid monitor file query")
}
page := governance.KnowledgeMonitorFilePage{Items: []governance.KnowledgeMonitorFile{}, Limit: limit, Offset: offset, SampleOnly: false}
if err := provider.db.QueryRow(ctx, `SELECT COUNT(*)::integer FROM test_publications WHERE test_kb_id=$1 AND ($2='' OR CASE WHEN processing_state='stalled' THEN 'failed' ELSE processing_state END=$2)`, knowledgeBaseID, status).Scan(&page.Total); err != nil {
return governance.KnowledgeMonitorFilePage{}, fmt.Errorf("count monitored files: %w", err)
}
rows, err := provider.db.Query(ctx, `SELECT item.title,item.normalized_path,publication.target_scope,
CASE WHEN publication.processing_state='stalled' THEN 'failed' ELSE publication.processing_state END,
GREATEST(0,EXTRACT(EPOCH FROM ($5-COALESCE(publication.processing_started_at,publication.updated_at)))::integer),
publication.ingest_state,publication.last_error
FROM test_publications publication
JOIN source_item_versions version ON version.id=publication.source_item_version_id
JOIN source_items item ON item.id=version.source_item_id
WHERE publication.test_kb_id=$1
AND ($2='' OR CASE WHEN publication.processing_state='stalled' THEN 'failed' ELSE publication.processing_state END=$2)
ORDER BY CASE publication.processing_state WHEN 'failed' THEN 0 WHEN 'stalled' THEN 0 WHEN 'processing' THEN 1 WHEN 'finalizing' THEN 2 WHEN 'pending' THEN 3 ELSE 4 END,
publication.updated_at DESC,publication.id
LIMIT $3 OFFSET $4`, knowledgeBaseID, status, limit, offset, provider.now().UTC())
if err != nil {
return governance.KnowledgeMonitorFilePage{}, fmt.Errorf("list monitored files: %w", err)
}
defer rows.Close()
for rows.Next() {
var item governance.KnowledgeMonitorFile
var ingestState string
if err := rows.Scan(&item.Name, &item.Directory, &item.Visibility, &item.Status, &item.ElapsedSecond, &ingestState, &item.ErrorCode); err != nil {
return governance.KnowledgeMonitorFilePage{}, fmt.Errorf("scan monitored file: %w", err)
}
item.StageDetail = monitorStageDetail(ingestState, item.Status, item.ErrorCode)
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return governance.KnowledgeMonitorFilePage{}, fmt.Errorf("iterate monitored files: %w", err)
}
return page, nil
}
func (provider *RealProvider) postgresHealth(ctx context.Context, now time.Time) governance.MonitorService {
started := time.Now()
state, detail := "healthy", "可连接并可查询"
if err := provider.db.Ping(ctx); err != nil {
state, detail = "unhealthy", "连接或查询失败"
}
return governance.MonitorService{Key: "postgres", Name: "PostgreSQL", State: state, Detail: detail, LatencyMS: int(time.Since(started).Milliseconds()), ObservedAt: now}
}
func (provider *RealProvider) queueHealth(ctx context.Context, now time.Time) governance.MonitorService {
started := time.Now()
var pending, running, failed int
err := provider.db.QueryRow(ctx, `SELECT COUNT(*) FILTER (WHERE state IN ('pending','retry'))::integer,COUNT(*) FILTER (WHERE state='running')::integer,COUNT(*) FILTER (WHERE state='failed')::integer FROM jobs`).Scan(&pending, &running, &failed)
state, detail := "healthy", fmt.Sprintf("可接单;等待%d,运行%d,失败%d", pending, running, failed)
if err != nil {
state, detail = "unhealthy", "队列不可查询"
}
return governance.MonitorService{Key: "queue", Name: "任务队列", State: state, Detail: detail, LatencyMS: int(time.Since(started).Milliseconds()), ObservedAt: now}
}
func (provider *RealProvider) httpHealth(ctx context.Context, key, name, baseURL string, now time.Time) governance.MonitorService {
if baseURL == "" {
return governance.MonitorService{Key: key, Name: name, State: "unknown", Detail: "未配置探测地址", ObservedAt: now}
}
parsed, err := url.Parse(baseURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return governance.MonitorService{Key: key, Name: name, State: "unhealthy", Detail: "探测配置无效", ObservedAt: now}
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/health"
parsed.RawQuery, parsed.Fragment = "", ""
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return governance.MonitorService{Key: key, Name: name, State: "unhealthy", Detail: "无法创建健康探测", ObservedAt: now}
}
started := time.Now()
response, err := provider.httpClient.Do(request)
latency := int(time.Since(started).Milliseconds())
if err != nil {
return governance.MonitorService{Key: key, Name: name, State: "unhealthy", Detail: "健康端点不可达", LatencyMS: latency, ObservedAt: now}
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return governance.MonitorService{Key: key, Name: name, State: "degraded", Detail: fmt.Sprintf("健康端点返回HTTP %d", response.StatusCode), LatencyMS: latency, ObservedAt: now}
}
return governance.MonitorService{Key: key, Name: name, State: "healthy", Detail: "健康端点可用", LatencyMS: latency, ObservedAt: now}
}
func collectHostMetrics() governance.MonitorHost {
host := governance.MonitorHost{GPUState: "unknown", GPUDetail: "容器未暴露GPU遥测", Simulated: false}
first, err := os.ReadFile("/proc/stat")
if err == nil {
idle1, total1, parseErr := parseCPUStat(string(first))
if parseErr == nil {
time.Sleep(100 * time.Millisecond)
if second, readErr := os.ReadFile("/proc/stat"); readErr == nil {
idle2, total2, secondErr := parseCPUStat(string(second))
if secondErr == nil {
host.CPUPercent = cpuPercent(idle1, total1, idle2, total2)
}
}
}
}
if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
host.MemoryUsedBytes, host.MemoryTotalBytes, _ = parseMemoryInfo(string(raw))
}
var stats syscall.Statfs_t
if syscall.Statfs("/", &stats) == nil {
host.DiskTotalBytes = int64(stats.Blocks) * int64(stats.Bsize)
host.DiskUsedBytes = int64(stats.Blocks-stats.Bavail) * int64(stats.Bsize)
}
if entries, err := os.ReadDir("/proc/driver/nvidia/gpus"); err == nil && len(entries) > 0 {
host.GPUState, host.GPUDetail = "healthy", fmt.Sprintf("检测到%d块NVIDIA GPU;未读取利用率", len(entries))
}
return host
}
func parseCPUStat(raw string) (idle, total uint64, err error) {
line := strings.SplitN(raw, "\n", 2)[0]
fields := strings.Fields(line)
if len(fields) < 5 || fields[0] != "cpu" {
return 0, 0, errors.New("invalid cpu stat")
}
values := make([]uint64, 0, len(fields)-1)
for _, field := range fields[1:] {
value, parseErr := strconv.ParseUint(field, 10, 64)
if parseErr != nil {
return 0, 0, errors.New("invalid cpu counter")
}
values = append(values, value)
total += value
}
idle = values[3]
if len(values) > 4 {
idle += values[4]
}
return idle, total, nil
}
func parseMemoryInfo(raw string) (used, total int64, err error) {
values := map[string]int64{}
for _, line := range strings.Split(raw, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
key := strings.TrimSuffix(fields[0], ":")
if key != "MemTotal" && key != "MemAvailable" {
continue
}
value, parseErr := strconv.ParseInt(fields[1], 10, 64)
if parseErr != nil || value < 0 {
return 0, 0, errors.New("invalid memory counter")
}
values[key] = value * 1024
}
total, totalOK := values["MemTotal"]
available, availableOK := values["MemAvailable"]
if !totalOK || !availableOK || available > total {
return 0, 0, errors.New("incomplete memory info")
}
return total - available, total, nil
}
func cpuPercent(idle1, total1, idle2, total2 uint64) float64 {
if total2 <= total1 || idle2 < idle1 || idle2-idle1 > total2-total1 {
return 0
}
return float64((total2-total1)-(idle2-idle1)) * 100 / float64(total2-total1)
}
func validMonitorStatus(status string) bool {
switch status {
case "", "completed", "pending", "processing", "finalizing", "failed", "cancelled":
return true
default:
return false
}
}
func monitorStageDetail(ingestState, processingState, errorCode string) string {
if errorCode != "" {
return "处理失败,可查看错误码后重试"
}
switch {
case ingestState == "pending":
return "等待确认或上传"
case ingestState == "uploading":
return "正在上传测试知识库"
case processingState == "processing":
return "WeKnora正在解析、切片或向量化"
case processingState == "finalizing":
return "基础解析完成,索引或增强处理中"
case processingState == "completed":
return "解析完成,等待或已完成验收"
default:
return "等待远端状态更新"
}
}