Files
knowledge-governance/internal/httpapi/library_storage.go
T
姚章浩 98294b9583
build / build (push) Failing after 24s
fix(library): 统一执行身份、终态收尾、准入与数据身份
why:审计 19 项(12 P1 / 7 P2)来自同一批根因,逐点试错会把协议撕成十几份:
  · 意图与执行混为一谈(F01/F15):`library.sync:<scope>:<快照>` 是终身幂等键,
    A→B→A 的第三次发布拿到第一次的历史成功作业,什么都不做;
  · 版本行既是实体又是事件(F02/F09):主键只由 (kind,scope,快照) 决定、写入是
    upsert,第二个作业覆盖 `source_job_id`/`evidence`/`created_at`;
  · 接管没有代次(F13):两个 worker 都能认领同一 pending 作业,失租的旧执行还能
    写业务状态、释放别人的维护锁;
  · 早退与终态没有统一政策(F03/F04/F05/F06):半写窗口的前置检查失败会重新开门、
    服务起不来也照样开入口、journal 终态后收尾不可重建、恢复成功不回写 current;
  · 数据身份与准入各写一套(F07/F08/F14/F17):接管丢了复用语义、全库搬运不改
    location_hash、通用入口可造 library.* 作业、目标模型从没被问过;
  · 存储/容量/UI/CI 判据各自为政(F10/F11/F12/F16/F18/F19)。

what:按批准的一套协议集中实施(详见 handoffs/release-consolidated-implementation/
current-contract.md):
  · operation(一次明确意图)/ attempt(epoch 代次)/ 不可变版本事件 / current 指针;
    治理库迁移 76,只动治理库,不碰 WeKnora Schema/源码/镜像;
  · 所有业务状态、阶段、版本事件、维护锁写入带 epoch 守卫(失租代次写不进去);
    心跳失租立刻取消 handler;
  · 半写窗口早退保持关闭;服务未全部健康绝不开入口;journal 终态可从持久事实重建
    收尾;恢复成功登记 recovery 事件并推进 current;
  · worker 在动维护前核对业务行/kind/scope/队列关联;通用入口拒绝 library.*;
  · 全库搬运重算 location_hash 并与文档级导入共用实现;接管消费写集合的复用决定;
    发布前核目标实时模型契约、容量门禁前置;
  · 存储保护闭包改为全量依赖、类型只认显式登记、保护未知则不出可删候选;
  · 页面核 HTTP 状态与结构、恢复纳入轮询、Wiki 差异覆盖标题/摘要/父级。

how:`make verify` rc=0(16 项必需检查全绿,含 go test 全量与前端 485 例);
`go vet ./...` rc=0;`npx tsc -p tsconfig.json --noEmit` rc=0;逐项负例见各包测试
(F03/F04/F08/F12/F13/F14/F16/F18/F19 都有先红后绿记录)。
未执行:治理库迁移到任何已有库、真实 PG/ES/S3/WeKnora 集成、故障注入、部署。
2026-10-30 16:14:54 +08:00

236 lines
9.9 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"
"net/http"
"sort"
"strings"
"time"
"knowledge-governance/internal/governance"
"knowledge-governance/internal/libraryretention"
"knowledge-governance/internal/librarysnapshot"
)
// 存储管理接口:容量、保留策略、依赖保护与清理预览。
//
// 全部只读:没有删除动作,自动清理恒为关闭。候选版本只是"按策略如果清理会考虑这些",
// 受保护版本(生产生效、每库最近封存、每次生产预备份、在飞作业引用)永不进入候选。
type storageVersionView struct {
ID string `json:"id"`
Kind string `json:"kind"`
Scope string `json:"scope"`
CreatedAt time.Time `json:"created_at"`
Protected bool `json:"protected"`
ProtectedBy []string `json:"protected_by,omitempty"`
ExclusiveBytes int64 `json:"exclusive_bytes"`
SharedBytes int64 `json:"shared_bytes"`
}
type libraryStorageResponse struct {
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
ArchiveRoot string `json:"archive_root,omitempty"`
Versions []storageVersionView `json:"versions"`
ContentBytes int64 `json:"content_bytes"`
BlobFileBytes int64 `json:"blob_file_bytes"`
BlobAllocatedBytes int64 `json:"blob_allocated_bytes"`
UnreferencedBlobs int `json:"unreferenced_blobs"`
UnreferencedBytes int64 `json:"unreferenced_bytes"`
ManifestFileBytes int64 `json:"manifest_file_bytes"`
Policy libraryretention.Policy `json:"policy"`
Plan *libraryretention.Plan `json:"plan,omitempty"`
Capacity libraryretention.Capacity `json:"capacity"`
AutomaticCleanup bool `json:"automatic_cleanup"`
Notes []string `json:"notes"`
// 容量/保留计数是清单级口径:真实去重、每次重算,但"被引用字节是否完好"
// 只在实际执行(同步/恢复)时逐部件复核,这两件事不能混成一句"已验证"。
Verification string `json:"verification"`
VerificationNote string `json:"verification_note"`
}
func libraryStorageHandler(w http.ResponseWriter, r *http.Request, config Config) {
response := libraryStorageResponse{Versions: []storageVersionView{}, Notes: []string{}, AutomaticCleanup: false,
Policy: libraryretention.Policy{KeepVersions: int(config.LibraryRetentionVersions), KeepDays: int(config.LibraryRetentionDays)},
Verification: libraryVerificationManifest, VerificationNote: libraryVerificationNote}
if strings.TrimSpace(config.LibraryArchiveRoot) == "" {
response.Reason = "未接线:LIBRARY_ARCHIVE_ROOT 未配置,没有可统计的归档"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
archive, err := librarysnapshot.Open(config.LibraryArchiveRoot, 256*1024*1024)
if err != nil {
response.Reason = "归档根不可用"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
defer archive.Close()
total, err := archive.ContentUsage()
if err != nil {
response.Reason = "归档用量不可读"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
response.Available = true
response.ArchiveRoot = config.LibraryArchiveRoot
response.ContentBytes = total.ContentBytes
response.BlobFileBytes = total.BlobFileBytes
response.BlobAllocatedBytes = total.BlobAllocatedBytes
response.UnreferencedBlobs = total.UnreferencedBlobs
response.UnreferencedBytes = total.UnreferencedBytes
response.ManifestFileBytes = total.ManifestFileBytes
usage, err := archive.UsageFromManifests()
if err != nil {
response.Reason = "归档逐版本用量不可读"
writeJSON(w, http.StatusServiceUnavailable, response)
return
}
usageByID := map[string]librarysnapshot.Usage{}
ids := make([]string, 0, len(usage))
for _, item := range usage {
usageByID[item.VersionID] = item
ids = append(ids, item.VersionID)
}
sort.Strings(ids)
// 版本类型与保护依据来自持久登记与在飞作业,不靠文件时间猜。
kinds := map[string]governance.LibraryVersion{}
protectedBy := map[string][]string{}
protectionUnknown := false
store, storeOK := libraryStore(config)
if storeOK {
// 类型与保护依据只认**持久登记**的精确查询:分页窗口(最近 100 个版本/20 个作业)
// 一旦被历史挤满,需要恢复的旧预备份与 failed-but-held 作业就会被读成"可删除候选"(F10)。
for _, scope := range []string{"internal", "external"} {
versions, listErr := store.LibraryVersions(r.Context(), scope, 100)
if listErr != nil {
protectionUnknown = true
continue
}
for _, item := range versions {
kinds[item.SnapshotID] = item
}
if current, found, currentErr := store.LibraryCurrentVersion(r.Context(), scope); currentErr == nil && found {
if _, seen := kinds[current.SnapshotID]; !seen {
kinds[current.SnapshotID] = current
}
} else if currentErr != nil {
protectionUnknown = true
}
}
if protector, ok := config.Store.(interface {
LibraryProtection(ctx context.Context) (map[string][]string, error)
}); ok {
if reasons, protectErr := protector.LibraryProtection(r.Context()); protectErr == nil {
for snapshot, items := range reasons {
for _, reason := range items {
protectedBy[snapshot] = appendUnique(protectedBy[snapshot], reason)
}
}
} else {
protectionUnknown = true
response.Notes = append(response.Notes, "保护依据读取失败("+protectErr.Error()+"):本次不生成可删除候选")
}
} else {
protectionUnknown = true
response.Notes = append(response.Notes, "存储不支持全量保护查询:本次不生成可删除候选")
}
} else {
protectionUnknown = true
response.Notes = append(response.Notes, "版本登记存储不可用:只按归档文件统计,无法识别受保护版本")
}
versions := make([]libraryretention.Version, 0, len(ids))
latestSealed := map[string]time.Time{}
var requiredBytes int64
// 版本元数据走清单级读取:这里只做容量/保留盘点,不执行任何版本。
// 需要动某个版本时(同步/恢复)仍然整版复核字节。
manifests, err := libraryManifests(r.Context(), archive, ids)
if err != nil {
status, reason := libraryArchiveErrorStatus(err, "归档清单不可读")
response.Reason = reason
writeJSON(w, status, response)
return
}
for index, id := range ids {
manifest := manifests[index]
// 备份类型只能来自显式登记:非空 ValidationPolicy 也被生产预备份使用,
// 拿它猜类型会把生产预备份显示成测试封存(F10)。
kind := "unregistered"
if record, ok := kinds[id]; ok {
kind = string(record.Kind)
}
if kind == "test_sealed" {
// 每库最近一次封存受保护:它是最新可发布的测试版本。
if current, seen := latestSealed[manifest.Scope]; !seen || manifest.CreatedAt.After(current) {
latestSealed[manifest.Scope] = manifest.CreatedAt
}
}
item := usageByID[id]
view := storageVersionView{ID: id, Kind: kind, Scope: manifest.Scope, CreatedAt: manifest.CreatedAt,
ExclusiveBytes: item.ExclusiveBytes, SharedBytes: item.SharedBytes}
versions = append(versions, libraryretention.Version{ID: id, Kind: kind, CreatedAt: manifest.CreatedAt,
ExclusiveBytes: item.ExclusiveBytes, SharedBytes: item.SharedBytes})
response.Versions = append(response.Versions, view)
}
// 用与上面相同的清单再定位一次最新测试封存,避免把时间映射回 ID 时出错。
for index, id := range ids {
manifest := manifests[index]
if kindOf := kinds[id].Kind; kindOf == governance.LibraryVersionTestSealed {
if manifest.CreatedAt.Equal(latestSealed[manifest.Scope]) {
protectedBy[id] = appendUnique(protectedBy[id], "最近测试封存("+manifest.Scope+")")
}
}
}
if protectionUnknown {
// 保护依据未知 ⇒ 不许产出"可删除候选":不确定的东西不能按可删处理(fail closed)。
for index := range versions {
protectedBy[versions[index].ID] = appendUnique(protectedBy[versions[index].ID], "保护依据未知(拒绝生成可删候选)")
}
}
for index := range versions {
if reasons := protectedBy[versions[index].ID]; len(reasons) > 0 {
versions[index].Protected = true
versions[index].ProtectedBy = reasons
}
}
for index := range response.Versions {
if reasons := protectedBy[response.Versions[index].ID]; len(reasons) > 0 {
response.Versions[index].Protected = true
response.Versions[index].ProtectedBy = reasons
}
}
now := time.Now().UTC()
plan := libraryretention.Build(versions, total.ContentBytes, response.Policy, now)
response.Plan = &plan
response.Notes = append(response.Notes, "候选版本只是清理预览;共享 blob 不会因删除单个版本释放")
response.Notes = append(response.Notes, "自动清理关闭,页面不提供删除动作;实际回收需要单独授权")
if !plan.PolicyConfigured {
response.Notes = append(response.Notes, "保留数量/天数未设置(LIBRARY_RETENTION_VERSIONS / LIBRARY_RETENTION_DAYS)")
}
for index := range response.Versions {
item := response.Versions[index]
if item.Kind == "test_sealed" && item.CreatedAt.Equal(latestSealed[item.Scope]) {
requiredBytes = item.ExclusiveBytes + item.SharedBytes
break
}
}
response.Capacity = libraryretention.CheckCapacity(total.ContentBytes, requiredBytes, config.LibraryCapacityBudgetBytes)
writeJSON(w, http.StatusOK, response)
}
// appendUnique 只追加还没出现过的标签:同一条版本可能同时被展示窗口与精确查询命中,
// 重复标签会让"保护依据"看起来像两条不同理由。
func appendUnique(labels []string, value string) []string {
for _, item := range labels {
if item == value {
return labels
}
}
return append(labels, value)
}