mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-20 13:30:46 +02:00
P3: Add explicit bounded read-only diagnosis surfaces for all symptom classes: - FailoverDiagnostic: volume-oriented failover state with per-volume DeferredPromotion/PendingRebuild entries and proper timer lifecycle - PublicationDiagnostic: two-read coherence check (LookupBlockVolume vs registry authority) with computed Coherent verdict - RecoveryDiagnostic: minimal ActiveTasks surface (Path A) - Blocker ledger: 3 diagnosed + 3 unresolved, finite, from actual file - Runbook references only exposed surfaces, no internal state P4: Add bounded performance floor + rollout-gate package: - Engine-local floor measurement with explicit IOPS gates per workload - Cost characterization: WAL 2x write amp, -56% replication tax - Rollout gates with semantic cross-checks against cited evidence (baseline numbers, transport/network matrix, blocker counts) - Launch envelope tightened to actually measured combinations only Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
9.7 KiB
Go
271 lines
9.7 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
)
|
|
|
|
// ============================================================
|
|
// Phase 12 P3: Diagnosability / Blocker Accounting
|
|
//
|
|
// All diagnosis conclusions use ONLY explicit bounded read-only
|
|
// diagnosis surfaces:
|
|
// - LookupBlockVolume (product-visible publication)
|
|
// - FailoverDiagnostic (volume-oriented failover state)
|
|
// - PublicationDiagnostic (lookup vs authority coherence)
|
|
// - RecoveryDiagnostic (active recovery task set)
|
|
// - phase-12-p3-blockers.md (finite blocker ledger)
|
|
//
|
|
// NOT performance, NOT rollout readiness.
|
|
// ============================================================
|
|
|
|
// --- S1: Failover convergence diagnosable via FailoverDiagnostic ---
|
|
|
|
func TestP12P3_FailoverConvergence_Diagnosable(t *testing.T) {
|
|
s := newSoakSetup(t)
|
|
ctx := context.Background()
|
|
|
|
s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "diag-vol-1", SizeBytes: 1 << 20,
|
|
})
|
|
|
|
entry, _ := s.ms.blockRegistry.Lookup("diag-vol-1")
|
|
s.bs.localServerID = entry.VolumeServer
|
|
s.deliver(entry.VolumeServer)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Surface 1: LookupBlockVolume shows current primary before failover.
|
|
lookupBefore, _ := s.ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "diag-vol-1"})
|
|
if lookupBefore.VolumeServer != entry.VolumeServer {
|
|
t.Fatal("lookup should show original primary before failover")
|
|
}
|
|
|
|
// Surface 2: FailoverDiagnostic — no volumes in failover state yet.
|
|
failoverBefore := s.ms.blockFailover.DiagnosticSnapshot()
|
|
for _, v := range failoverBefore.Volumes {
|
|
if v.VolumeName == "diag-vol-1" {
|
|
t.Fatalf("S1: diag-vol-1 should not appear in failover diagnostic before failover, got %+v", v)
|
|
}
|
|
}
|
|
|
|
// Trigger failover: expire lease, then failover.
|
|
s.ms.blockRegistry.UpdateEntry("diag-vol-1", func(e *BlockVolumeEntry) {
|
|
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
|
|
})
|
|
s.ms.failoverBlockVolumes(entry.VolumeServer)
|
|
|
|
// Surface 1 after: LookupBlockVolume shows NEW primary.
|
|
lookupAfter, _ := s.ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "diag-vol-1"})
|
|
if lookupAfter.VolumeServer == entry.VolumeServer {
|
|
t.Fatal("S1 stall: lookup still shows old primary after failover")
|
|
}
|
|
|
|
// Surface 2 after: FailoverDiagnostic shows volume-level failover state.
|
|
failoverAfter := s.ms.blockFailover.DiagnosticSnapshot()
|
|
|
|
// Classify via explicit diagnosis surface: find diag-vol-1 in failover volumes.
|
|
var found *FailoverVolumeState
|
|
for i := range failoverAfter.Volumes {
|
|
if failoverAfter.Volumes[i].VolumeName == "diag-vol-1" {
|
|
found = &failoverAfter.Volumes[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("S1: diag-vol-1 not found in FailoverDiagnostic after failover")
|
|
}
|
|
|
|
// Diagnosis conclusion from explicit surfaces only:
|
|
// - Lookup changed (old → new primary)
|
|
// - FailoverDiagnostic classifies state as rebuild_pending
|
|
// - AffectedServer identifies the dead server
|
|
if !found.PendingRebuild {
|
|
t.Fatal("S1: FailoverDiagnostic should show PendingRebuild=true")
|
|
}
|
|
if found.Reason != "rebuild_pending" {
|
|
t.Fatalf("S1: expected reason=rebuild_pending, got %q", found.Reason)
|
|
}
|
|
if found.AffectedServer != entry.VolumeServer {
|
|
t.Fatalf("S1: AffectedServer should be dead server %s, got %s", entry.VolumeServer, found.AffectedServer)
|
|
}
|
|
if found.CurrentPrimary != lookupAfter.VolumeServer {
|
|
t.Fatalf("S1: CurrentPrimary should match lookup %s, got %s", lookupAfter.VolumeServer, found.CurrentPrimary)
|
|
}
|
|
|
|
t.Logf("P12P3 S1: diagnosed via LookupBlockVolume(%s→%s) + FailoverDiagnostic(vol=%s, reason=%s, affected=%s)",
|
|
lookupBefore.VolumeServer, lookupAfter.VolumeServer, found.VolumeName, found.Reason, found.AffectedServer)
|
|
}
|
|
|
|
// --- S2: Publication mismatch diagnosable via PublicationDiagnostic ---
|
|
|
|
func TestP12P3_PublicationMismatch_Diagnosable(t *testing.T) {
|
|
s := newSoakSetup(t)
|
|
ctx := context.Background()
|
|
|
|
s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "diag-vol-2", SizeBytes: 1 << 20,
|
|
})
|
|
|
|
entry, _ := s.ms.blockRegistry.Lookup("diag-vol-2")
|
|
s.bs.localServerID = entry.VolumeServer
|
|
s.deliver(entry.VolumeServer)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Surface 1: PublicationDiagnostic before failover — should be coherent.
|
|
pubBefore, ok := s.ms.PublicationDiagnosticFor("diag-vol-2")
|
|
if !ok {
|
|
t.Fatal("S2: PublicationDiagnosticFor should find diag-vol-2")
|
|
}
|
|
if !pubBefore.Coherent {
|
|
t.Fatalf("S2: publication should be coherent before failover, got reason=%q", pubBefore.Reason)
|
|
}
|
|
|
|
// Surface 2: LookupBlockVolume — repeated lookups self-consistent.
|
|
lookup1, _ := s.ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "diag-vol-2"})
|
|
lookup2, _ := s.ms.LookupBlockVolume(ctx, &master_pb.LookupBlockVolumeRequest{Name: "diag-vol-2"})
|
|
if lookup1.IscsiAddr != lookup2.IscsiAddr || lookup1.VolumeServer != lookup2.VolumeServer {
|
|
t.Fatalf("S2: repeated lookup mismatch: %s/%s vs %s/%s",
|
|
lookup1.VolumeServer, lookup1.IscsiAddr, lookup2.VolumeServer, lookup2.IscsiAddr)
|
|
}
|
|
|
|
// Trigger failover.
|
|
s.ms.blockRegistry.UpdateEntry("diag-vol-2", func(e *BlockVolumeEntry) {
|
|
e.LastLeaseGrant = time.Now().Add(-1 * time.Minute)
|
|
})
|
|
s.ms.failoverBlockVolumes(entry.VolumeServer)
|
|
|
|
// Surface 1 after: PublicationDiagnostic after failover — still coherent.
|
|
pubAfter, ok := s.ms.PublicationDiagnosticFor("diag-vol-2")
|
|
if !ok {
|
|
t.Fatal("S2: PublicationDiagnosticFor should find diag-vol-2 after failover")
|
|
}
|
|
if !pubAfter.Coherent {
|
|
t.Fatalf("S2: publication should be coherent after failover, got reason=%q", pubAfter.Reason)
|
|
}
|
|
|
|
// Diagnosis conclusion from explicit surfaces only:
|
|
// - Pre-failover: coherent, lookup matches authority
|
|
// - Post-failover: coherent, lookup updated to new primary
|
|
// - Publication switched: post != pre
|
|
if pubAfter.LookupVolumeServer == pubBefore.LookupVolumeServer {
|
|
t.Fatal("S2: LookupVolumeServer unchanged after failover — publication did not switch")
|
|
}
|
|
if pubAfter.LookupIscsiAddr == pubBefore.LookupIscsiAddr {
|
|
t.Fatal("S2: LookupIscsiAddr unchanged after failover")
|
|
}
|
|
|
|
// Post-failover repeated lookup still self-consistent (via diagnostic).
|
|
pubAfter2, _ := s.ms.PublicationDiagnosticFor("diag-vol-2")
|
|
if pubAfter2.LookupVolumeServer != pubAfter.LookupVolumeServer ||
|
|
pubAfter2.LookupIscsiAddr != pubAfter.LookupIscsiAddr {
|
|
t.Fatal("S2: post-failover publication diagnostics inconsistent")
|
|
}
|
|
|
|
t.Logf("P12P3 S2: diagnosed via PublicationDiagnostic — pre(vs=%s, iscsi=%s, coherent=%v) → post(vs=%s, iscsi=%s, coherent=%v)",
|
|
pubBefore.LookupVolumeServer, pubBefore.LookupIscsiAddr, pubBefore.Coherent,
|
|
pubAfter.LookupVolumeServer, pubAfter.LookupIscsiAddr, pubAfter.Coherent)
|
|
}
|
|
|
|
// --- S3: Runtime residue diagnosable via RecoveryDiagnostic ---
|
|
|
|
func TestP12P3_RuntimeResidue_Diagnosable(t *testing.T) {
|
|
s := newSoakSetup(t)
|
|
ctx := context.Background()
|
|
|
|
s.ms.CreateBlockVolume(ctx, &master_pb.CreateBlockVolumeRequest{
|
|
Name: "diag-vol-3", SizeBytes: 1 << 20,
|
|
})
|
|
|
|
entry, _ := s.ms.blockRegistry.Lookup("diag-vol-3")
|
|
s.bs.localServerID = entry.VolumeServer
|
|
s.deliver(entry.VolumeServer)
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Surface: RecoveryDiagnostic shows active task set.
|
|
diagBefore := s.bs.v2Recovery.DiagnosticSnapshot()
|
|
t.Logf("S3 before delete: %d active tasks: %v", len(diagBefore.ActiveTasks), diagBefore.ActiveTasks)
|
|
|
|
// Delete the volume.
|
|
s.ms.DeleteBlockVolume(ctx, &master_pb.DeleteBlockVolumeRequest{Name: "diag-vol-3"})
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Surface after: RecoveryDiagnostic — no tasks for deleted volume.
|
|
diagAfter := s.bs.v2Recovery.DiagnosticSnapshot()
|
|
for _, task := range diagAfter.ActiveTasks {
|
|
if strings.Contains(task, "diag-vol-3") {
|
|
t.Fatalf("S3 residue: task %s active after delete", task)
|
|
}
|
|
}
|
|
|
|
// Diagnosis conclusion from explicit surface only:
|
|
// - RecoveryDiagnostic.ActiveTasks does not contain deleted volume's tasks
|
|
// - Conclusion: clean (no residue) or non-empty but unrelated to deleted volume
|
|
if len(diagAfter.ActiveTasks) == 0 {
|
|
t.Log("P12P3 S3: diagnosed via RecoveryDiagnostic — clean (0 active tasks after delete)")
|
|
} else {
|
|
t.Logf("P12P3 S3: diagnosed via RecoveryDiagnostic — %d active tasks (none for deleted vol)",
|
|
len(diagAfter.ActiveTasks))
|
|
}
|
|
}
|
|
|
|
// --- Blocker ledger: reads and validates the actual file ---
|
|
|
|
func TestP12P3_BlockerLedger_Bounded(t *testing.T) {
|
|
ledgerPath := "../../sw-block/.private/phase/phase-12-p3-blockers.md"
|
|
|
|
data, err := os.ReadFile(ledgerPath)
|
|
if err != nil {
|
|
t.Fatalf("blocker ledger must exist at %s: %v", ledgerPath, err)
|
|
}
|
|
|
|
content := string(data)
|
|
|
|
// Must contain diagnosed items.
|
|
for _, id := range []string{"B1", "B2", "B3"} {
|
|
if !strings.Contains(content, id) {
|
|
t.Fatalf("ledger missing diagnosed item %s", id)
|
|
}
|
|
}
|
|
|
|
// Must contain unresolved items.
|
|
for _, id := range []string{"U1", "U2", "U3"} {
|
|
if !strings.Contains(content, id) {
|
|
t.Fatalf("ledger missing unresolved item %s", id)
|
|
}
|
|
}
|
|
|
|
// Must contain out-of-scope section.
|
|
if !strings.Contains(content, "Out of Scope") {
|
|
t.Fatal("ledger must have 'Out of Scope' section")
|
|
}
|
|
|
|
// Must NOT overclaim perf or rollout.
|
|
lines := strings.Split(content, "\n")
|
|
diagnosedCount := 0
|
|
unresolvedCount := 0
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "| B") {
|
|
diagnosedCount++
|
|
}
|
|
if strings.HasPrefix(strings.TrimSpace(line), "| U") {
|
|
unresolvedCount++
|
|
}
|
|
}
|
|
|
|
total := diagnosedCount + unresolvedCount
|
|
if total == 0 {
|
|
t.Fatal("ledger has no blocker items")
|
|
}
|
|
if total > 20 {
|
|
t.Fatalf("ledger should be finite, got %d items", total)
|
|
}
|
|
|
|
t.Logf("P12P3 blockers: %d diagnosed + %d unresolved = %d total (from actual file, finite)",
|
|
diagnosedCount, unresolvedCount, total)
|
|
}
|