mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
VS-side evidence handler (QueryBlockPromotionEvidence) reads live blockvol.Status() + V2 core projection at call time. Fail-closed: no core projection → ineligible with reason "missing_engine_projection". Engine CommittedLSN used unconditionally when core present (no WALHeadLSN overstatement). Eligibility owned by local V2 engine, not master. Master-side selection (selectDurabilityFirstCandidate): durability-first ordering by CommittedLSN, tie-break WALHeadLSN then HealthScore. All ineligible → fail-closed, no promotion. Pluggable querier (BlockPromotionEvidenceQuerier) for T3 wiring. Proto messages added to volume_server.proto. gRPC transport binding pending proto regen on M01 — this commit delivers evidence semantics and selection substrate, not full end-to-end RPC closure. Phase 20 doc updated with T2-T5 reviewer packs and cross-task guardrails. 13 tests: live facts, core projection mode, fail-closed no-core, 4 gated modes, missing volume, epoch mismatch, CommittedLSN ordering, WALHeadLSN tie-break, HealthScore tie-break, all-ineligible, mixed collection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
317 lines
9.3 KiB
Go
317 lines
9.3 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
engine "github.com/seaweedfs/seaweedfs/sw-block/engine/replication"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
|
)
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_ReturnsLiveFacts(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
path := createTestVolDirect(t, bs, "evidence-live")
|
|
|
|
vol, ok := bs.blockStore.GetBlockVolume(path)
|
|
if !ok {
|
|
t.Fatal("volume not found")
|
|
}
|
|
if err := vol.HandleAssignment(5, blockvol.RolePrimary, 30000); err != nil {
|
|
t.Fatalf("handle assignment: %v", err)
|
|
}
|
|
data := make([]byte, 4096)
|
|
data[0] = 0xAB
|
|
if err := vol.WriteLBA(0, data); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
|
|
// Inject V2 core projection so evidence uses engine-derived facts.
|
|
walHead := vol.Status().WALHeadLSN
|
|
bs.coreProjMu.Lock()
|
|
bs.coreProj[path] = engine.PublicationProjection{
|
|
Mode: engine.ModeView{Name: engine.ModePublishHealthy},
|
|
Boundary: engine.BoundaryView{
|
|
CommittedLSN: walHead,
|
|
},
|
|
}
|
|
bs.coreProjMu.Unlock()
|
|
|
|
ev, err := bs.QueryBlockPromotionEvidence(path, 0)
|
|
if err != nil {
|
|
t.Fatalf("query evidence: %v", err)
|
|
}
|
|
if ev.Epoch != 5 {
|
|
t.Fatalf("epoch=%d, want 5", ev.Epoch)
|
|
}
|
|
if ev.WALHeadLSN == 0 {
|
|
t.Fatal("wal_head_lsn should be >0 after write")
|
|
}
|
|
if ev.CommittedLSN == 0 {
|
|
t.Fatal("committed_lsn should be >0 from core projection")
|
|
}
|
|
if ev.HealthScore <= 0 {
|
|
t.Fatalf("health_score=%f, want >0", ev.HealthScore)
|
|
}
|
|
if !ev.Eligible {
|
|
t.Fatalf("expected eligible for publish_healthy with core, reason=%s", ev.Reason)
|
|
}
|
|
if ev.EngineProjectionMode != "publish_healthy" {
|
|
t.Fatalf("engine_projection_mode=%q, want publish_healthy", ev.EngineProjectionMode)
|
|
}
|
|
}
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_NoCoreProjectionFailsClosed(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
path := createTestVolDirect(t, bs, "evidence-nocore")
|
|
|
|
vol, ok := bs.blockStore.GetBlockVolume(path)
|
|
if !ok {
|
|
t.Fatal("volume not found")
|
|
}
|
|
if err := vol.HandleAssignment(3, blockvol.RolePrimary, 30000); err != nil {
|
|
t.Fatalf("handle assignment: %v", err)
|
|
}
|
|
|
|
// No core projection injected — should fail closed.
|
|
ev, err := bs.QueryBlockPromotionEvidence(path, 0)
|
|
if err != nil {
|
|
t.Fatalf("query evidence: %v", err)
|
|
}
|
|
if ev.Eligible {
|
|
t.Fatal("expected ineligible without V2 core projection")
|
|
}
|
|
if ev.Reason != "missing_engine_projection" {
|
|
t.Fatalf("reason=%q, want %q", ev.Reason, "missing_engine_projection")
|
|
}
|
|
if ev.EngineProjectionMode != "" {
|
|
t.Fatalf("engine_projection_mode=%q, want empty without core", ev.EngineProjectionMode)
|
|
}
|
|
// Storage facts should still be present even when ineligible.
|
|
if ev.WALHeadLSN == 0 && ev.Epoch == 0 {
|
|
t.Fatal("expected storage facts even for ineligible evidence")
|
|
}
|
|
}
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_ReturnsCoreProjectionMode(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
path := createTestVolDirect(t, bs, "evidence-epm")
|
|
|
|
vol, ok := bs.blockStore.GetBlockVolume(path)
|
|
if !ok {
|
|
t.Fatal("volume not found")
|
|
}
|
|
if err := vol.SetRole(blockvol.RolePrimary); err != nil {
|
|
t.Fatalf("set role: %v", err)
|
|
}
|
|
|
|
// Inject a core projection for this path.
|
|
bs.coreProjMu.Lock()
|
|
bs.coreProj[path] = engine.PublicationProjection{
|
|
Mode: engine.ModeView{
|
|
Name: engine.ModePublishHealthy,
|
|
Reason: "all_ready",
|
|
},
|
|
Boundary: engine.BoundaryView{
|
|
CommittedLSN: 42,
|
|
},
|
|
}
|
|
bs.coreProjMu.Unlock()
|
|
|
|
ev, err := bs.QueryBlockPromotionEvidence(path, 0)
|
|
if err != nil {
|
|
t.Fatalf("query evidence: %v", err)
|
|
}
|
|
if ev.EngineProjectionMode != "publish_healthy" {
|
|
t.Fatalf("engine_projection_mode=%q, want %q", ev.EngineProjectionMode, "publish_healthy")
|
|
}
|
|
if ev.CommittedLSN != 42 {
|
|
t.Fatalf("committed_lsn=%d, want 42 from core projection", ev.CommittedLSN)
|
|
}
|
|
if !ev.Eligible {
|
|
t.Fatalf("expected eligible for publish_healthy, reason=%s", ev.Reason)
|
|
}
|
|
}
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_IneligibleForGatedStates(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
path := createTestVolDirect(t, bs, "evidence-gated")
|
|
|
|
vol, ok := bs.blockStore.GetBlockVolume(path)
|
|
if !ok {
|
|
t.Fatal("volume not found")
|
|
}
|
|
if err := vol.SetRole(blockvol.RolePrimary); err != nil {
|
|
t.Fatalf("set role: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
mode engine.ModeName
|
|
reason string
|
|
}{
|
|
{engine.ModeDegraded, "barrier_timeout"},
|
|
{engine.ModeNeedsRebuild, "gap_too_large"},
|
|
{engine.ModeBootstrapPending, "awaiting_shipper"},
|
|
{engine.ModeAllocatedOnly, "no_role"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(string(tt.mode), func(t *testing.T) {
|
|
bs.coreProjMu.Lock()
|
|
bs.coreProj[path] = engine.PublicationProjection{
|
|
Mode: engine.ModeView{
|
|
Name: tt.mode,
|
|
Reason: tt.reason,
|
|
},
|
|
}
|
|
bs.coreProjMu.Unlock()
|
|
|
|
ev, err := bs.QueryBlockPromotionEvidence(path, 0)
|
|
if err != nil {
|
|
t.Fatalf("query evidence: %v", err)
|
|
}
|
|
if ev.Eligible {
|
|
t.Fatalf("expected ineligible for mode=%s", tt.mode)
|
|
}
|
|
if ev.Reason == "" {
|
|
t.Fatal("expected non-empty reason for ineligible candidate")
|
|
}
|
|
if ev.EngineProjectionMode != string(tt.mode) {
|
|
t.Fatalf("engine_projection_mode=%q, want %q", ev.EngineProjectionMode, tt.mode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_MissingVolumeFailsCleanly(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
|
|
_, err := bs.QueryBlockPromotionEvidence("/nonexistent/path.blk", 0)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing volume")
|
|
}
|
|
|
|
_, err = bs.QueryBlockPromotionEvidence("", 0)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty path")
|
|
}
|
|
}
|
|
|
|
func TestBlockService_QueryBlockPromotionEvidence_EpochMismatchIneligible(t *testing.T) {
|
|
bs := newTestBlockServiceDirect(t)
|
|
path := createTestVolDirect(t, bs, "evidence-epoch")
|
|
|
|
vol, ok := bs.blockStore.GetBlockVolume(path)
|
|
if !ok {
|
|
t.Fatal("volume not found")
|
|
}
|
|
if err := vol.SetEpoch(3); err != nil {
|
|
t.Fatalf("set epoch: %v", err)
|
|
}
|
|
if err := vol.SetRole(blockvol.RolePrimary); err != nil {
|
|
t.Fatalf("set role: %v", err)
|
|
}
|
|
|
|
ev, err := bs.QueryBlockPromotionEvidence(path, 99)
|
|
if err != nil {
|
|
t.Fatalf("query evidence: %v", err)
|
|
}
|
|
if ev.Eligible {
|
|
t.Fatal("expected ineligible for epoch mismatch")
|
|
}
|
|
if ev.Reason == "" {
|
|
t.Fatal("expected reason for epoch mismatch")
|
|
}
|
|
}
|
|
|
|
// Master-side durability-first selection logic tests.
|
|
|
|
func TestSelectDurabilityFirstCandidate_HighestCommittedLSNWins(t *testing.T) {
|
|
evidence := []BlockPromotionEvidence{
|
|
{Path: "/a.blk", CommittedLSN: 10, WALHeadLSN: 10, HealthScore: 1.0, Eligible: true},
|
|
{Path: "/b.blk", CommittedLSN: 20, WALHeadLSN: 20, HealthScore: 0.5, Eligible: true},
|
|
{Path: "/c.blk", CommittedLSN: 15, WALHeadLSN: 30, HealthScore: 1.0, Eligible: true},
|
|
}
|
|
best, err := selectDurabilityFirstCandidate(evidence)
|
|
if err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if best.Path != "/b.blk" {
|
|
t.Fatalf("selected %q, want /b.blk (highest CommittedLSN)", best.Path)
|
|
}
|
|
}
|
|
|
|
func TestSelectDurabilityFirstCandidate_WALHeadLSNBreaksTie(t *testing.T) {
|
|
evidence := []BlockPromotionEvidence{
|
|
{Path: "/a.blk", CommittedLSN: 20, WALHeadLSN: 25, HealthScore: 1.0, Eligible: true},
|
|
{Path: "/b.blk", CommittedLSN: 20, WALHeadLSN: 30, HealthScore: 0.5, Eligible: true},
|
|
}
|
|
best, err := selectDurabilityFirstCandidate(evidence)
|
|
if err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if best.Path != "/b.blk" {
|
|
t.Fatalf("selected %q, want /b.blk (higher WALHeadLSN on tie)", best.Path)
|
|
}
|
|
}
|
|
|
|
func TestSelectDurabilityFirstCandidate_HealthScoreBreaksFinalTie(t *testing.T) {
|
|
evidence := []BlockPromotionEvidence{
|
|
{Path: "/a.blk", CommittedLSN: 20, WALHeadLSN: 30, HealthScore: 0.8, Eligible: true},
|
|
{Path: "/b.blk", CommittedLSN: 20, WALHeadLSN: 30, HealthScore: 1.0, Eligible: true},
|
|
}
|
|
best, err := selectDurabilityFirstCandidate(evidence)
|
|
if err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if best.Path != "/b.blk" {
|
|
t.Fatalf("selected %q, want /b.blk (higher HealthScore on full tie)", best.Path)
|
|
}
|
|
}
|
|
|
|
func TestSelectDurabilityFirstCandidate_AllIneligibleFailsClosed(t *testing.T) {
|
|
evidence := []BlockPromotionEvidence{
|
|
{Path: "/a.blk", CommittedLSN: 10, Eligible: false, Reason: "degraded"},
|
|
{Path: "/b.blk", CommittedLSN: 20, Eligible: false, Reason: "needs_rebuild"},
|
|
}
|
|
_, err := selectDurabilityFirstCandidate(evidence)
|
|
if err == nil {
|
|
t.Fatal("expected fail-closed error when all candidates ineligible")
|
|
}
|
|
}
|
|
|
|
func TestSelectDurabilityFirstCandidate_EmptyEvidenceFailsClosed(t *testing.T) {
|
|
_, err := selectDurabilityFirstCandidate(nil)
|
|
if err == nil {
|
|
t.Fatal("expected fail-closed error on empty evidence")
|
|
}
|
|
}
|
|
|
|
func TestQueryAllCandidateEvidence_CollectsSuccessAndErrors(t *testing.T) {
|
|
querier := func(_ context.Context, server, path string, epoch uint64) (BlockPromotionEvidence, error) {
|
|
if server == "bad-server" {
|
|
return BlockPromotionEvidence{}, fmt.Errorf("connection refused")
|
|
}
|
|
return BlockPromotionEvidence{
|
|
Path: path,
|
|
Epoch: epoch,
|
|
CommittedLSN: 10,
|
|
Eligible: true,
|
|
}, nil
|
|
}
|
|
|
|
candidates := []promotionCandidate{
|
|
{server: "good-server", path: "/a.blk", expectedEpoch: 5},
|
|
{server: "bad-server", path: "/b.blk", expectedEpoch: 5},
|
|
{server: "good-server2", path: "/c.blk", expectedEpoch: 5},
|
|
}
|
|
|
|
evidence, errs := queryAllCandidateEvidence(querier, candidates)
|
|
if len(evidence) != 2 {
|
|
t.Fatalf("evidence count=%d, want 2", len(evidence))
|
|
}
|
|
if len(errs) != 1 {
|
|
t.Fatalf("error count=%d, want 1", len(errs))
|
|
}
|
|
}
|