fe6e218d9c
build / build (push) Successful in 1m17s
候选归档 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 档案)。
208 lines
8.6 KiB
Go
208 lines
8.6 KiB
Go
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)
|
||
}
|