Files
knowledge-governance/internal/httpapi/library_storage.go
姚章浩 fe6e218d9c
build / build (push) Successful in 1m17s
fix(library): 整库只读接口改清单级读取,修掉真实超时
候选归档 79 版 / 56MB blob 上,overview、storage、snapshots 三个只读接口
都在 10s 请求期限后返回 503。分段计时定位到唯一瓶颈是按版本重读全部 blob:
Open 0ms、LoadManifest×79 11~22ms、ContentUsage 15~25ms、
Usage(逐版 Load 验字节)13.75~14.11s;单版 Load 261~290ms。

- Usage 拆成 Usage(逐版复核字节,同步/恢复与运维 CLI 用)与
  UsageFromManifests(同一份去重实现,只做清单级校验),列举路径改用后者;
- library_storage.go 每版两次 Load、snapshots 每版一次 Load 改为一次清单读取;
- wiki/文档差异只读所需部件,被读部件仍由 OpenPart 复核摘要;
- LoadManifest 与 Load 共用同一套清单判据(严格解码 + 重新编码校验);
- 列举响应带 verification=manifest / verified=false,详情整版复核后才是 content,
  避免把"清单可读"读成"完整性已验证";
- libraryWithDeadline 去掉超时后再写已结束响应的 goroutine 写法
  (候选环境实测 http: superfluous response.WriteHeader call)。

回归:多版本共享大 blob 时枚举不读 blob 字节(篡改后枚举仍成功),
而 Load/OpenPart/Usage 仍拒绝坏 blob;坏清单两条路径都整条报错。
make verify 15 项全绿(local 档案)。
2026-10-28 21:52:14 +08:00

208 lines
8.6 KiB
Go
Raw Permalink 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 (
"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{}
store, storeOK := libraryStore(config)
if storeOK {
for _, scope := range []string{"internal", "external"} {
versions, listErr := store.LibraryVersions(r.Context(), scope, 100)
if listErr == nil {
for _, item := range versions {
kinds[item.SnapshotID] = item
if item.Kind == governance.LibraryVersionProductionEffective && item.SupersededAt == nil {
protectedBy[item.SnapshotID] = append(protectedBy[item.SnapshotID], "生产生效版本")
}
if item.Kind == governance.LibraryVersionProductionPrebackup {
protectedBy[item.SnapshotID] = append(protectedBy[item.SnapshotID], "生产预备份")
}
}
}
jobs, jobErr := store.ListLibraryJobs(r.Context(), scope, 20)
if jobErr == nil {
for _, job := range jobs {
if job.State != governance.LibraryJobPending && job.State != governance.LibraryJobRunning {
continue
}
for _, id := range []string{job.SnapshotID, job.TargetSnapshotID} {
if id != "" {
protectedBy[id] = append(protectedBy[id], "在飞作业引用")
}
}
}
}
}
} else {
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]
kind := "unregistered"
if record, ok := kinds[id]; ok {
kind = string(record.Kind)
} else if manifest.ValidationPolicy != "" {
kind = "test_sealed"
}
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 ||
(kindOf == "" && manifest.ValidationPolicy != "") {
if manifest.CreatedAt.Equal(latestSealed[manifest.Scope]) {
protectedBy[id] = append(protectedBy[id], "最近测试封存("+manifest.Scope+")")
}
}
}
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)
}