98294b9583
build / build (push) Failing after 24s
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 集成、故障注入、部署。
236 lines
9.9 KiB
Go
236 lines
9.9 KiB
Go
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)
|
||
}
|