Files
knowledge-governance/internal/httpapi/library_capture.go
T
姚章浩 47eb23cf68 feat(library): 封存执行器可用性与还原条件来自真实观测
页面上的"可发起封存"原来只判 LIBRARY_AGENT_SOCKET 非空:正式栈的 API 根本没挂
那个 socket,而 agent 曾顶着正式单元名冻结候选实例——配置非空、页面可点、真发起
却封错库。

现在:agent 新增 health 动作(不占封存操作锁、不跑子进程,只回答身份/降级/忙),
worker 每 30 秒探测并写入迁移 074 的 library_executor_status;overview、快照列表与
POST /library/captures 共用同一份判定(过期、不可达、实例不符、降级、正忙都拒绝并
给原因)。封存结果由 agent 附上自报身份,worker 在停写入者之前再探测一次、执行后
用结果复核,不一致就不登记测试封存——心跳只能证明"刚才"。

期望身份用 LIBRARY_SOURCE_*(封存源),不是 LIBRARY_TARGET_*(同步目标),否则
正确的执行器会被判成不匹配。另加 GET /library/restore-availability:恢复能否执行由
双闸、该快照的恢复授权与实例维护状态决定,页面不再写死 disabled。

真跑(候选隔离栈,两处 socket 隔离不变):overview 返回 executor
{kgmaint-weknora/kgmaint-app};停 agent 后转不可达并 503 带原因;重启后恢复。
2026-10-29 13:01:37 +08:00

202 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/jobs"
)
// 页面发起封存:只投递一个作业,真正的停机/导出/恢复发生在主机侧 agent。
// 这里没有任何 Docker 访问,也没有来源凭据 —— 调用方只能选择范围与作业号。
const libraryCaptureJobType = "library.capture"
var libraryCaptureJobPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
type libraryCaptureRequest struct {
Scope string `json:"scope"`
Job string `json:"job"`
}
func libraryCaptureEnqueue(w http.ResponseWriter, r *http.Request, config Config) {
var request libraryCaptureRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&request); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if request.Scope != "internal" && request.Scope != "external" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "scope must be internal or external"})
return
}
// The job id is the idempotency key handed to the agent: repeating the same
// scope+job returns the already sealed version instead of sealing twice.
captureJob := strings.TrimSpace(request.Job)
if captureJob == "" {
captureJob = "lib-" + request.Scope + "-" + time.Now().UTC().Format("20060102T150405")
}
if !libraryCaptureJobPattern.MatchString(captureJob) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid job id"})
return
}
// 请求形状先校验(400),再看执行器(503):不只看"配了 socket"——执行器可能
// 不可达、可能指向别的实例、可能降级。页面与接口必须用同一份判定,否则又会出现
// "点了才失败"或"封错库"。
if available, reason, executor := libraryExecutorAvailability(r.Context(), config); !available {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"code": "library_capture_unavailable",
"reason": reason,
"executor": executor,
})
return
}
requestedBy := ""
if identity, ok := sessionFrom(r.Context()); ok {
requestedBy = identity.Username
}
payload, err := json.Marshal(map[string]string{
"scope": request.Scope, "capture_job": captureJob, "requested_by": requestedBy,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot build capture job"})
return
}
now := time.Now().UTC()
// 业务作业先建:互斥由 library_jobs 的唯一索引保证,这样不会出现"队列里排着两个封存"。
// 队列投递失败或请求冲突时把业务作业取消,不留下永远 pending 的占位。
libraryJobID := "libseal-" + captureJob
createdSealJob := false
if store, ok := libraryStore(config); ok {
digest, digestErr := governance.LibraryRequestDigest(request.Scope, governance.LibraryJobSeal,
map[string]any{"capture_job": captureJob})
if digestErr == nil {
sealJob, reusedSeal, sealErr := store.CreateLibraryJob(r.Context(), governance.LibraryJob{
ID: libraryJobID, Scope: request.Scope, Kind: governance.LibraryJobSeal,
IdempotencyKey: "library.seal:" + captureJob, RequestDigest: digest, Actor: requestedBy,
}, governance.SealStages(), now)
switch {
case errors.Is(sealErr, governance.ErrLibraryJobActive):
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_seal_active", "error": "该目标已有在飞的封存作业,等它结束再发起",
})
return
case errors.Is(sealErr, governance.ErrLibraryJobIdempotencyConflict):
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_seal_scope_conflict", "error": "同一作业号已按另一个范围封存",
})
return
case sealErr != nil:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot create seal job"})
return
case !reusedSeal:
createdSealJob = true
default:
libraryJobID = sealJob.ID
}
}
}
// 队列作业 id 落进 jobs.id(varchar(36)):原来拼 "library-capture-<scope>-<nano>"
// 是 45 个字符,真实请求 100% 被 PG 拒(value too long)——只是页面一直停在
// "未接线"上,没人走到这里。这里按列宽拼短 id,并留出 scope 全称。
requestedID := fmt.Sprintf("libcap-%s-%d", request.Scope, now.UnixNano())
// The idempotency key is the capture job id: the same logical capture must map
// to one queue row. Binding the scope into the key would silently create a
// second job instead of reporting a conflicting request, so the conflict is
// detected by comparing the stored payload below.
job, err := config.Store.Enqueue(r.Context(), jobs.EnqueueRequest{
ID: requestedID,
Type: libraryCaptureJobType,
Payload: payload,
AvailableAt: now,
MaxAttempts: 2,
IdempotencyKey: "library.capture:" + captureJob,
}, now)
if err != nil || job == nil {
if store, ok := libraryStore(config); ok && createdSealJob {
_ = store.CancelLibraryJob(r.Context(), libraryJobID, "队列投递失败,未开始封存", now)
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot enqueue capture job"})
return
}
if store, ok := libraryStore(config); ok {
if attachErr := store.AttachLibraryQueueJob(r.Context(), libraryJobID, job.ID, now); attachErr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot attach capture job"})
return
}
}
// Enqueue returns the existing row for a reused key. Answer from that row, not
// from the current request: a reused job id with another scope must be refused
// instead of being reported as a job for the new scope.
var stored struct {
Scope string `json:"scope"`
CaptureJob string `json:"capture_job"`
RequestedBy string `json:"requested_by"`
}
cancelSeal := func(reason string) {
if store, ok := libraryStore(config); ok && createdSealJob {
_ = store.CancelLibraryJob(r.Context(), libraryJobID, reason, now)
}
}
if err = json.Unmarshal(job.Payload, &stored); err != nil || stored.CaptureJob != captureJob {
cancelSeal("请求冲突,未开始封存")
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_capture_conflict", "error": "该作业号已被另一个封存请求占用",
"existing_scope": stored.Scope, "requested_scope": request.Scope,
})
return
}
if stored.Scope != request.Scope {
cancelSeal("同一作业号已按另一个范围投递")
writeJSON(w, http.StatusConflict, map[string]any{
"code": "library_capture_scope_conflict", "error": "同一作业号已按另一个范围投递",
"existing_scope": stored.Scope, "requested_scope": request.Scope,
})
return
}
writeJSON(w, http.StatusAccepted, map[string]any{
"job_id": job.ID, "capture_job": stored.CaptureJob, "scope": stored.Scope,
"state": job.State, "reused": job.ID != requestedID, "requested_by": stored.RequestedBy,
})
}
// libraryCaptureJobs lists recent capture jobs so the page can show real state
// instead of an optimistic spinner. It reads the queue, not the archive.
func libraryCaptureJobs(w http.ResponseWriter, r *http.Request, config Config) {
// Ask the store for this type only. Reading the global queue and filtering in
// the handler would hide capture jobs behind unrelated traffic and make the
// page claim "idle" while a capture is still queued.
byType, ok := config.Store.(interface {
ListByType(ctx context.Context, jobType string, limit int) ([]jobs.Job, error)
})
if !ok {
writeJSON(w, http.StatusOK, map[string]any{"available": false,
"reason": "任务存储不支持按类型查询,页面不会据此判断空闲", "jobs": []any{}})
return
}
queue, err := byType.ListByType(r.Context(), libraryCaptureJobType, 20)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "cannot read capture jobs"})
return
}
views := make([]map[string]any, 0, len(queue))
for _, job := range queue {
var payload struct {
Scope string `json:"scope"`
CaptureJob string `json:"capture_job"`
RequestedBy string `json:"requested_by"`
}
_ = json.Unmarshal(job.Payload, &payload)
views = append(views, map[string]any{"job_id": job.ID, "capture_job": payload.CaptureJob,
"scope": payload.Scope, "state": job.State, "attempt": job.Attempt,
"last_error": job.LastError, "requested_by": payload.RequestedBy,
"created_at": job.CreatedAt, "updated_at": job.UpdatedAt})
}
writeJSON(w, http.StatusOK, map[string]any{"available": true, "jobs": views})
}