Files
knowledge-governance/internal/librarysnapshot/usage.go
T
姚章浩 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

274 lines
8.3 KiB
Go

package librarysnapshot
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"math"
"os"
"slices"
"sort"
"strings"
"syscall"
)
type Usage struct {
VersionID string `json:"version_id"`
LogicalBytes int64 `json:"logical_bytes"`
UniqueBytes int64 `json:"unique_bytes"`
SharedBytes int64 `json:"shared_bytes"`
ExclusiveBytes int64 `json:"exclusive_bytes"`
}
// OpenPart provides archive-owned recovery bytes, never a source-side locator.
// Verify and rewind the same descriptor, so path replacement cannot swap the
// verified file for an unchecked dependency between these two operations.
func (a *Archive) OpenPart(p Part) (io.ReadCloser, error) {
if !validDigest(p.SHA256) || p.Bytes < 0 || p.Bytes > a.maxBlobBytes {
return nil, errors.New("invalid recovery blob")
}
f, err := regular(a.blobs, p.SHA256, a.maxBlobBytes)
if err != nil {
return nil, err
}
h := sha256.New()
n, err := io.Copy(h, io.LimitReader(f, a.maxBlobBytes+1))
if err != nil || n != p.Bytes || hex.EncodeToString(h.Sum(nil)) != p.SHA256 {
f.Close()
return nil, errors.New("corrupt recovery blob")
}
if _, err = f.Seek(0, io.SeekStart); err != nil {
f.Close()
return nil, err
}
return f, nil
}
// Usage reports content bytes per version and is **verified**: every referenced
// blob is re-read and re-hashed through Load. Execution paths (sync/restore) and
// the operator CLI use it, because acting on a version must not trust the
// manifest's own claim about bytes it did not check.
func (a *Archive) Usage() ([]Usage, error) { return a.usage(a.Load) }
// UsageFromManifests is the read-only listing counterpart: the same accounting,
// but it validates only each manifest's own digest/structure instead of
// re-hashing every blob.
//
// Why it exists: a listing does not execute anything, and re-hashing every blob
// of every version turned a 15 ms inventory into a 13.8 s timeout at 79 versions
// (measured on the candidate archive). Keep the numbers real: they are recomputed
// from the current inventory on every call, under the same "inventory must not
// change while reading" guard, and an unreadable/invalid manifest still fails the
// whole read. What this does *not* prove is that the referenced bytes are intact —
// Load/OpenPart still decide that, and callers must not report this as
// "integrity verified".
func (a *Archive) UsageFromManifests() ([]Usage, error) { return a.usage(a.LoadManifest) }
func (a *Archive) usage(load func(string) (Manifest, error)) ([]Usage, error) {
ids, err := a.versionIDs()
if err != nil {
return nil, err
}
type inventory struct {
usage Usage
blobs map[string]int64
}
inventories := make([]inventory, 0, len(ids))
refs := map[string]int{}
for _, id := range ids {
m, err := load(id)
if err != nil {
return nil, err
}
v := inventory{usage: Usage{VersionID: id}, blobs: map[string]int64{}}
for _, p := range m.Parts {
if v.usage.LogicalBytes > math.MaxInt64-p.Bytes {
return nil, errors.New("snapshot usage overflow")
}
v.usage.LogicalBytes += p.Bytes
v.blobs[p.SHA256] = p.Bytes
}
for sha := range v.blobs {
refs[sha]++
}
inventories = append(inventories, v)
}
after, err := a.versionIDs()
if err != nil {
return nil, err
}
if !slices.Equal(ids, after) {
return nil, errors.New("snapshot inventory changed; retry usage read")
}
result := make([]Usage, 0, len(inventories))
for _, v := range inventories {
for sha, size := range v.blobs {
v.usage.UniqueBytes += size
if refs[sha] > 1 {
v.usage.SharedBytes += size
} else {
v.usage.ExclusiveBytes += size
}
}
result = append(result, v.usage)
}
return result, nil
}
// ContentUsage is the archive-wide accounting of *content* bytes.
//
// It exists because per-version Usage() deduplicates only within one version: a
// blob shared by two versions is counted in both UniqueBytes, so summing those
// numbers over-reports the archive. ContentBytes here is deduplicated across
// every sealed version.
//
// It is still not filesystem occupancy: manifests, unreferenced blobs left by an
// interrupted Put and filesystem allocation (block rounding, metadata) are
// reported separately, and none of these numbers are reclaimable space. Cleanup
// needs retention/protection and live-job references from the coordinator.
type ContentUsage struct {
Versions int `json:"versions"`
ReferencedBlobs int `json:"referenced_blobs"`
ContentBytes int64 `json:"content_bytes"`
UnreferencedBlobs int `json:"unreferenced_blobs"`
UnreferencedBytes int64 `json:"unreferenced_bytes"`
BlobFileBytes int64 `json:"blob_file_bytes"`
BlobAllocatedBytes int64 `json:"blob_allocated_bytes"`
ManifestFileBytes int64 `json:"manifest_file_bytes"`
ManifestAllocatedBytes int64 `json:"manifest_allocated_bytes"`
}
// ContentUsage walks the sealed versions and the blob directory once, with the
// same "inventory must not change under us" guard as Usage.
func (a *Archive) ContentUsage() (ContentUsage, error) {
var usage ContentUsage
ids, err := a.versionIDs()
if err != nil {
return usage, err
}
referenced := map[string]int64{}
for _, id := range ids {
// 盘点只读清单:逐版重算 blob 会把一次统计放大到全部版本字节(实测 13.8s)。
m, err := a.LoadManifest(id)
if err != nil {
return usage, err
}
for _, p := range m.Parts {
referenced[p.SHA256] = p.Bytes
}
}
after, err := a.versionIDs()
if err != nil {
return usage, err
}
if !slices.Equal(ids, after) {
return usage, errors.New("snapshot inventory changed; retry usage read")
}
usage.Versions = len(ids)
usage.ReferencedBlobs = len(referenced)
for _, size := range referenced {
if usage.ContentBytes > math.MaxInt64-size {
return usage, errors.New("snapshot usage overflow")
}
usage.ContentBytes += size
}
blobs, err := directoryBytes(a.blobs, func(name string) error {
if !validDigest(name) {
return errors.New("unexpected archive blob entry")
}
return nil
})
if err != nil {
return usage, err
}
usage.BlobFileBytes, usage.BlobAllocatedBytes = blobs.fileBytes, blobs.allocatedBytes
usage.UnreferencedBlobs = blobs.files - len(referenced)
usage.UnreferencedBytes = blobs.fileBytes
for _, size := range referenced {
usage.UnreferencedBytes -= size
}
if usage.UnreferencedBytes < 0 || usage.UnreferencedBlobs < 0 {
return usage, errors.New("archive blob inventory is inconsistent")
}
manifests, err := directoryBytes(a.versions, func(name string) error {
if strings.HasPrefix(name, ".pending-") {
return nil
}
if !strings.HasSuffix(name, ".json") {
return errors.New("unexpected archive version entry")
}
return nil
})
if err != nil {
return usage, err
}
usage.ManifestFileBytes, usage.ManifestAllocatedBytes = manifests.fileBytes, manifests.allocatedBytes
return usage, nil
}
type directoryUsage struct {
files int
fileBytes int64
allocatedBytes int64
}
// directoryBytes reports apparent size and allocated size (st_blocks) so callers
// can tell content size from what the filesystem actually reserved.
func directoryBytes(root *os.Root, validate func(string) error) (directoryUsage, error) {
var usage directoryUsage
dir, err := root.Open(".")
if err != nil {
return usage, err
}
defer dir.Close()
entries, err := dir.ReadDir(-1)
if err != nil {
return usage, err
}
for _, entry := range entries {
if err := validate(entry.Name()); err != nil {
return usage, err
}
info, err := entry.Info()
if err != nil {
return usage, err
}
if !info.Mode().IsRegular() {
return usage, errors.New("archive entry is not a regular file")
}
usage.files++
usage.fileBytes += info.Size()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
usage.allocatedBytes += stat.Blocks * 512
}
}
return usage, nil
}
func (a *Archive) versionIDs() ([]string, error) {
f, err := a.versions.Open(".")
if err != nil {
return nil, err
}
defer f.Close()
entries, err := f.ReadDir(-1)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(entries))
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".pending-") {
continue
}
name := entry.Name()
id := strings.TrimSuffix(name, ".json")
if !strings.HasSuffix(name, ".json") || !validDigest(id) || entry.IsDir() {
return nil, errors.New("unexpected archive version entry")
}
ids = append(ids, id)
}
sort.Strings(ids)
return ids, nil
}