Files
knowledge-governance/internal/httpapi/library_baseline_mutex_test.go
T
姚章浩 a42b101a7b
build / build (push) Successful in 1m25s
fix(library): 基线崩溃续作与维护原状态保留
父审发现三处会让目标卡死或状态对不上实际入口,逐条修:

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。
2026-10-29 19:08:58 +08:00

98 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}