Files
seaweedfs/sw-block/runtime/masterv2/master_test.go
T
pingqiuandClaude Opus 4.6 b8c6944e3f feat: V2 MVP milestone — masterv2 + volumev2 + in-process failover
V2 runtime packages:
- sw-block/runtime/masterv2: identity authority (desired state,
  heartbeat handling, promotion arbitration via SelectPromotionCandidate)
- sw-block/runtime/volumev2: per-volume micro-cluster shell (node,
  orchestrator, control session, iSCSI frontend, takeover gate,
  failover session + driver, replica summary reconstruction)
- sw-block/runtime/purev2: RF1 execution shell (engine + store +
  dispatcher + local boundary observations)
- sw-block/runtime/protocolv2: three-channel separation
  (heartbeat/assignment/query + replica summary)

V2 binaries:
- sw-block/cmd/v2singleblock: single-node RF1 block server
- sw-block/cmd/purev2rf1: minimal RF1 runtime binary

Milestone capabilities:
- RF1 write/read/sync with engine-driven mode projection
- masterv2 ↔ volumev2 heartbeat convergence + assignment reissue
- Promotion query with fresh CommittedLSN/WALHeadLSN evidence
- Replica summary for bounded takeover reconstruction
- Primary-loss reconstruction from peer summaries (fail-closed gate)
- In-process failover driver with session observability
- Local boundary observations feed engine (Committed/Durable/Checkpoint)

Design docs:
- v2-two-loop-protocol.md: identity vs data-control separation
- v2-automata-ownership-map.md: event/command ownership split
- v2-loop1-surface-draft.md: heartbeat/query/assignment field spec
- v2-volumev2-single-node-mvp.md: target layering
- v2-kernel-closure-review.md: per-volume micro-cluster principle
- v2-pure-runtime-rf1-bootstrap.md, v2-capability-map.md,
  v2-proof-and-retest-pyramid.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:08:02 -07:00

139 lines
3.3 KiB
Go

package masterv2
import "testing"
func TestSelectPromotionCandidate_PrefersHighestCommittedLSN(t *testing.T) {
master := New(Config{})
selected, err := master.SelectPromotionCandidate([]PromotionQueryResponse{
{
VolumeName: "vol-a",
NodeID: "node-b",
CommittedLSN: 12,
WALHeadLSN: 20,
Eligible: true,
},
{
VolumeName: "vol-a",
NodeID: "node-a",
CommittedLSN: 15,
WALHeadLSN: 16,
Eligible: true,
},
})
if err != nil {
t.Fatalf("select candidate: %v", err)
}
if selected.NodeID != "node-a" {
t.Fatalf("selected node=%q, want node-a", selected.NodeID)
}
}
func TestSelectPromotionCandidate_UsesWalHeadAsTiebreaker(t *testing.T) {
master := New(Config{})
selected, err := master.SelectPromotionCandidate([]PromotionQueryResponse{
{
VolumeName: "vol-a",
NodeID: "node-a",
CommittedLSN: 15,
WALHeadLSN: 16,
Eligible: true,
},
{
VolumeName: "vol-a",
NodeID: "node-b",
CommittedLSN: 15,
WALHeadLSN: 19,
Eligible: true,
},
})
if err != nil {
t.Fatalf("select candidate: %v", err)
}
if selected.NodeID != "node-b" {
t.Fatalf("selected node=%q, want node-b", selected.NodeID)
}
}
func TestSelectPromotionCandidate_RejectsWhenNoEligibleCandidates(t *testing.T) {
master := New(Config{})
_, err := master.SelectPromotionCandidate([]PromotionQueryResponse{
{NodeID: "node-a", Eligible: false, Reason: "needs_rebuild"},
{NodeID: "node-b", Eligible: false, Reason: "epoch_mismatch"},
})
if err == nil {
t.Fatal("expected error when no eligible candidates")
}
}
func TestAuthorizePromotion_ReassignsPrimaryAndAdvancesEpoch(t *testing.T) {
master := New(Config{})
if err := master.DeclarePrimary(VolumeSpec{
Name: "vol-a",
Path: "/tmp/vol-a.blk",
PrimaryNodeID: "node-a",
}); err != nil {
t.Fatalf("declare primary: %v", err)
}
assign, err := master.AuthorizePromotion("vol-a", []PromotionQueryResponse{
{
VolumeName: "vol-a",
NodeID: "node-b",
CommittedLSN: 18,
WALHeadLSN: 19,
Eligible: true,
},
{
VolumeName: "vol-a",
NodeID: "node-c",
CommittedLSN: 17,
WALHeadLSN: 20,
Eligible: true,
},
})
if err != nil {
t.Fatalf("authorize promotion: %v", err)
}
if assign.NodeID != "node-b" {
t.Fatalf("assignment node=%q, want node-b", assign.NodeID)
}
if assign.Epoch != 2 {
t.Fatalf("assignment epoch=%d, want 2", assign.Epoch)
}
view, ok := master.Volume("vol-a")
if !ok {
t.Fatal("master view missing")
}
if view.PrimaryNodeID != "node-b" {
t.Fatalf("view primary=%q, want node-b", view.PrimaryNodeID)
}
if view.DesiredEpoch != 2 {
t.Fatalf("view desired epoch=%d, want 2", view.DesiredEpoch)
}
}
func TestAuthorizePromotion_RejectsMismatchedVolume(t *testing.T) {
master := New(Config{})
if err := master.DeclarePrimary(VolumeSpec{
Name: "vol-a",
Path: "/tmp/vol-a.blk",
PrimaryNodeID: "node-a",
}); err != nil {
t.Fatalf("declare primary: %v", err)
}
_, err := master.AuthorizePromotion("vol-a", []PromotionQueryResponse{
{
VolumeName: "vol-b",
NodeID: "node-b",
CommittedLSN: 18,
WALHeadLSN: 19,
Eligible: true,
},
})
if err == nil {
t.Fatal("expected mismatched volume error")
}
}