a42b101a7b
build / build (push) Successful in 1m25s
父审发现三处会让目标卡死或状态对不上实际入口,逐条修: 1. 登记成功但没来得及收尾:崩溃重入会撞上"已有生效版本"直接失败, 目标留在维护里、占用也不释放。现在按「本作业+同一快照」识别为自己的 版本,只做收尾(恢复服务/开入口/释放两把占用),不重新捕获、不重写; 别人的版本仍然拒绝。捕获完成但登记失败则明确失败并写"未对目标写入"。 2. 重入覆盖原运行状态:崩溃时服务已被停住,再读一次只会读到 stopped, 写回去会让恢复阶段永远不启动写入者。AcquireInstance/LibraryMaintenance 在 owner 相同的重入时保留已持久化原状态;新作业接管已释放窗口用自己的观察。 真实 PostgreSQL 集成用例覆盖(先红后绿)。 3. 版本登记移进维护窗口:捕获/校验/登记要么一起成立、要么目标原样恢复; 记录失败时窗口已开放,治理状态同步释放,不再"显示维护中、实际已开"。 索引映射读不到时拒绝登记,不再用空摘要写证据。 顺带: - 基线窗口统一收尾抽成 settleBaselineWindow,四种结局各有测试(成功/健康失败/ held 保持维护/已恢复则释放),并用真实 DB 用例证明 baseline 与 sync/restore 共用写互斥、幂等重试不产生第二个作业。 - 新增 cmd/librarydrill:维护隔离 → 整库预备份 → 一致性校验 → canary 漂移检测 → 按预备份恢复 → 逐类复核 → 按原状态恢复服务。用与 worker 相同的 LIBRARY_* 配置,绝不触碰封存源。 - 目标/预备份/维护栈装配收成 internal/librarystack 一份(worker 与演练共用)。 验证:go build/vet/test 全过;TEST_DATABASE_URL 指向临时 PostgreSQL 跑 TestMaintenanceReentryKeepsOriginalServiceState(修复前失败:got app=stopped) 与 TestLibraryWriteMutexCoversBaselineJobs;本地门禁 15/15。
98 lines
4.1 KiB
Go
98 lines
4.1 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"knowledge-governance/internal/governance"
|
||
"knowledge-governance/internal/jobs"
|
||
)
|
||
|
||
// nonEmptyTarget 模拟"目标整库非空且可读"的探测结果(真实实现直连目标库逐表计数)。
|
||
func nonEmptyTarget(context.Context, string) (bool, map[string]int64, error) {
|
||
return false, map[string]int64{"knowledges": 1, "chunks": 2}, nil
|
||
}
|
||
|
||
// scopedProbe:internal 可读非空,external 没有目标 KB(与当前部署一致)。
|
||
func scopedProbe(_ context.Context, scope string) (bool, map[string]int64, error) {
|
||
if scope == "external" {
|
||
return false, nil, errors.New("该范围的目标知识库未配置")
|
||
}
|
||
return nonEmptyTarget(context.Background(), scope)
|
||
}
|
||
|
||
// baselineMutexStore 在 fake 之外**真的**实现"同一 scope 只允许一个在飞写作业"这条不变量,
|
||
// 用来验证接口的重试/双击行为(真实索引由 integration test
|
||
// TestLibraryWriteMutexCoversBaselineJobs 在 PostgreSQL 上证明)。
|
||
type baselineMutexStore struct {
|
||
fakeLibraryStore
|
||
activeByScope map[string]string
|
||
enqueues int
|
||
}
|
||
|
||
func (s *baselineMutexStore) CreateLibraryJob(ctx context.Context, job governance.LibraryJob, stages []string, now time.Time) (governance.LibraryJob, bool, error) {
|
||
if s.activeByScope == nil {
|
||
s.activeByScope = map[string]string{}
|
||
}
|
||
if owner := s.activeByScope[job.Scope]; owner != "" {
|
||
return governance.LibraryJob{}, false, governance.ErrLibraryJobActive
|
||
}
|
||
s.activeByScope[job.Scope] = job.ID
|
||
return s.fakeLibraryStore.CreateLibraryJob(ctx, job, stages, now)
|
||
}
|
||
|
||
func (s *baselineMutexStore) Enqueue(ctx context.Context, request jobs.EnqueueRequest, now time.Time) (*jobs.Job, error) {
|
||
s.enqueues++
|
||
return s.fakeLibraryStore.fakeJobStore.Enqueue(ctx, request, now)
|
||
}
|
||
|
||
// 双击/重试:第一次建作业成功,第二次被互斥拒绝,且**不会**再投递一个队列作业。
|
||
func TestLibraryBaselineDoubleSubmitCreatesOneJob(t *testing.T) {
|
||
store := &baselineMutexStore{fakeLibraryStore: fakeLibraryStore{fakeJobStore: fakeJobStore{enqueueEchoRequestedID: true}}}
|
||
config := Config{Store: store, LibraryScopeProbe: nonEmptyTarget}
|
||
first := httptest.NewRecorder()
|
||
libraryBaselineEnqueue(first, baselineRequest(`{"scope":"internal"}`, roleAdmin, "admin"), config)
|
||
if first.Code != http.StatusAccepted {
|
||
t.Fatalf("first status=%d body=%s", first.Code, first.Body.String())
|
||
}
|
||
if store.enqueues != 1 {
|
||
t.Fatalf("first submit enqueues=%d, want 1", store.enqueues)
|
||
}
|
||
second := httptest.NewRecorder()
|
||
libraryBaselineEnqueue(second, baselineRequest(`{"scope":"internal"}`, roleAdmin, "admin"), config)
|
||
if second.Code != http.StatusConflict {
|
||
t.Fatalf("second status=%d body=%s", second.Code, second.Body.String())
|
||
}
|
||
if body := decodeBody(t, second); body["code"] != "library_write_active" {
|
||
t.Fatalf("second body=%v", body)
|
||
}
|
||
if store.enqueues != 1 {
|
||
t.Fatalf("rejected submit must not enqueue again: enqueues=%d", store.enqueues)
|
||
}
|
||
}
|
||
|
||
// 不同 scope 不会被同一把锁挡住(互斥粒度是 scope/实例,不是全局)。
|
||
func TestLibraryBaselineDifferentScopesAreNotBlocked(t *testing.T) {
|
||
store := &baselineMutexStore{fakeLibraryStore: fakeLibraryStore{fakeJobStore: fakeJobStore{enqueueEchoRequestedID: true}}}
|
||
config := Config{Store: store, LibraryScopeProbe: scopedProbe}
|
||
internal := httptest.NewRecorder()
|
||
libraryBaselineEnqueue(internal, baselineRequest(`{"scope":"internal"}`, roleAdmin, "admin"), config)
|
||
if internal.Code != http.StatusAccepted {
|
||
t.Fatalf("internal status=%d body=%s", internal.Code, internal.Body.String())
|
||
}
|
||
// external 在探针阶段就会被拒(该范围没有目标 KB),但绝不能是因为互斥。
|
||
external := httptest.NewRecorder()
|
||
libraryBaselineEnqueue(external, baselineRequest(`{"scope":"external"}`, roleAdmin, "admin"), config)
|
||
if external.Code == http.StatusAccepted {
|
||
t.Fatalf("external without a target KB must not be accepted: %s", external.Body.String())
|
||
}
|
||
body := decodeBody(t, external)
|
||
if body["code"] == "library_write_active" {
|
||
t.Fatalf("external must not be blocked by the internal scope's mutex: %v", body)
|
||
}
|
||
}
|