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 集成、故障注入、部署。
68 lines
2.5 KiB
Go
68 lines
2.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
|
|
"knowledge-governance/internal/appconfig"
|
|
"knowledge-governance/internal/libraryretention"
|
|
"knowledge-governance/internal/librarysnapshot"
|
|
)
|
|
|
|
// libraryWriteFootprint 是本次写入的容量需求下界:内容字节 + 等量恢复余量。
|
|
//
|
|
// 为什么要算两份:写入前必须留出"目标被写坏后按预备份恢复"的空间。只按本次内容算,
|
|
// 预算刚好够写、不够恢复,故障时就地无法回滚——那比不发布更糟。
|
|
func libraryWriteFootprint(contentBytes int64) (int64, error) {
|
|
if contentBytes < 0 {
|
|
return 0, errors.New("内容字节为负:容量口径异常")
|
|
}
|
|
if contentBytes > math.MaxInt64/2 {
|
|
return 0, errors.New("内容字节过大:容量需求溢出,拒绝放行")
|
|
}
|
|
return contentBytes * 2, nil
|
|
}
|
|
|
|
// checkLibraryCapacity 是**执行前**的容量门禁(F11)。
|
|
//
|
|
// 旧实现只在存储页 GET 里调用 CheckCapacity:那个数字是给人看的,worker 从来不读它,
|
|
// 所以"预算为正但明显不够"照样会进入维护、拍备份、写目标。这里把它接到真正要写之前。
|
|
//
|
|
// 读不到容量/预算时不放行:无法证明装得下就不该动生产。
|
|
func checkLibraryCapacity(ctx context.Context, config appconfig.Config, snapshotID string) error {
|
|
if config.LibraryCapacityBudgetBytes <= 0 {
|
|
return errors.New("容量预算未设置(LIBRARY_CAPACITY_BUDGET_BYTES):无法证明装得下,拒绝进入维护")
|
|
}
|
|
archive, err := librarysnapshot.Open(config.LibraryArchiveRoot, 256*1024*1024)
|
|
if err != nil {
|
|
return fmt.Errorf("归档不可用,无法核算容量:%w", err)
|
|
}
|
|
defer archive.Close()
|
|
usage, usageErr := archive.ContentUsage()
|
|
if usageErr != nil {
|
|
return fmt.Errorf("读不到当前归档内容占用,拒绝放行:%w", usageErr)
|
|
}
|
|
manifest, loadErr := archive.Load(snapshotID)
|
|
if loadErr != nil {
|
|
return fmt.Errorf("读不到待写入快照的清单,无法核算容量:%w", loadErr)
|
|
}
|
|
var incoming int64
|
|
for _, part := range manifest.Parts {
|
|
if part.Bytes < 0 || incoming > math.MaxInt64-part.Bytes {
|
|
return errors.New("快照分片字节异常:拒绝放行")
|
|
}
|
|
incoming += part.Bytes
|
|
}
|
|
required, footprintErr := libraryWriteFootprint(incoming)
|
|
if footprintErr != nil {
|
|
return footprintErr
|
|
}
|
|
capacity := libraryretention.CheckCapacity(usage.ContentBytes, required, config.LibraryCapacityBudgetBytes)
|
|
if !capacity.OK {
|
|
return fmt.Errorf("容量门禁拒绝,未打开维护窗口:%s", capacity.Reason)
|
|
}
|
|
return nil
|
|
}
|