You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
382 lines
13 KiB
382 lines
13 KiB
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/blockapi"
|
|
)
|
|
|
|
// --- evaluateBlockPlacement unit tests ---
|
|
|
|
func TestEvaluateBlockPlacement_SingleCandidate_RF1(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Replicas) != 0 {
|
|
t.Fatalf("expected 0 replicas, got %d", len(result.Replicas))
|
|
}
|
|
if len(result.Rejections) != 0 {
|
|
t.Fatalf("expected 0 rejections, got %d", len(result.Rejections))
|
|
}
|
|
if len(result.Candidates) != 1 || result.Candidates[0] != "vs1:9333" {
|
|
t.Fatalf("expected candidates [vs1:9333], got %v", result.Candidates)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_LeastLoaded(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 5},
|
|
{Address: "vs2:9333", VolumeCount: 2},
|
|
{Address: "vs3:9333", VolumeCount: 8},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs2:9333" {
|
|
t.Fatalf("expected least-loaded vs2:9333 as primary, got %q", result.Primary)
|
|
}
|
|
// Candidates should be sorted: vs2 (2), vs1 (5), vs3 (8)
|
|
expected := []string{"vs2:9333", "vs1:9333", "vs3:9333"}
|
|
if len(result.Candidates) != 3 {
|
|
t.Fatalf("expected 3 candidates, got %d", len(result.Candidates))
|
|
}
|
|
for i, e := range expected {
|
|
if result.Candidates[i] != e {
|
|
t.Fatalf("candidates[%d]: expected %q, got %q", i, e, result.Candidates[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_DeterministicTiebreak(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs3:9333", VolumeCount: 0},
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
{Address: "vs2:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "", 0, blockvol.DurabilityBestEffort)
|
|
// All same count — address tiebreaker: vs1, vs2, vs3
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected vs1:9333 (lowest address), got %q", result.Primary)
|
|
}
|
|
expected := []string{"vs1:9333", "vs2:9333", "vs3:9333"}
|
|
for i, e := range expected {
|
|
if result.Candidates[i] != e {
|
|
t.Fatalf("candidates[%d]: expected %q, got %q", i, e, result.Candidates[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_RF2_PrimaryAndReplica(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 3},
|
|
{Address: "vs2:9333", VolumeCount: 1},
|
|
{Address: "vs3:9333", VolumeCount: 5},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 2, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs2:9333" {
|
|
t.Fatalf("expected primary vs2:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Replicas) != 1 || result.Replicas[0] != "vs1:9333" {
|
|
t.Fatalf("expected replicas [vs1:9333], got %v", result.Replicas)
|
|
}
|
|
if len(result.Errors) != 0 {
|
|
t.Fatalf("unexpected errors: %v", result.Errors)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_RF3_AllSelected(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
{Address: "vs2:9333", VolumeCount: 0},
|
|
{Address: "vs3:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 3, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Replicas) != 2 {
|
|
t.Fatalf("expected 2 replicas, got %d", len(result.Replicas))
|
|
}
|
|
if len(result.Errors) != 0 {
|
|
t.Fatalf("unexpected errors: %v", result.Errors)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_RF_ExceedsServers(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
{Address: "vs2:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 3, "", 0, blockvol.DurabilityBestEffort)
|
|
// Primary should be selected, but only 1 replica possible out of 2 needed
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Replicas) != 1 {
|
|
t.Fatalf("expected 1 partial replica, got %d", len(result.Replicas))
|
|
}
|
|
// Should have rf_not_satisfiable warning
|
|
found := false
|
|
for _, w := range result.Warnings {
|
|
if w == ReasonRFNotSatisfiable {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected warning %q, got %v", ReasonRFNotSatisfiable, result.Warnings)
|
|
}
|
|
}
|
|
|
|
// TestEvaluateBlockPlacement_SingleServer_BestEffort_RF2 verifies that with one server,
|
|
// RF=2, best_effort: plan warns (not errors), matching create behavior which succeeds as single-copy.
|
|
func TestEvaluateBlockPlacement_SingleServer_BestEffort_RF2(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 2, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
// best_effort: zero replicas available should be a warning, not error
|
|
if len(result.Errors) != 0 {
|
|
t.Fatalf("best_effort should not produce errors, got %v", result.Errors)
|
|
}
|
|
found := false
|
|
for _, w := range result.Warnings {
|
|
if w == ReasonRFNotSatisfiable {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected warning %q, got %v", ReasonRFNotSatisfiable, result.Warnings)
|
|
}
|
|
}
|
|
|
|
// TestEvaluateBlockPlacement_SingleServer_SyncAll_RF2 verifies that with one server,
|
|
// RF=2, sync_all: plan errors because sync_all requires replicas.
|
|
func TestEvaluateBlockPlacement_SingleServer_SyncAll_RF2(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 2, "", 0, blockvol.DurabilitySyncAll)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
// sync_all: zero replicas available should be an error
|
|
found := false
|
|
for _, e := range result.Errors {
|
|
if e == ReasonRFNotSatisfiable {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected error %q for sync_all, got errors=%v warnings=%v",
|
|
ReasonRFNotSatisfiable, result.Errors, result.Warnings)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_NoServers(t *testing.T) {
|
|
result := evaluateBlockPlacement(nil, 1, "", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "" {
|
|
t.Fatalf("expected empty primary, got %q", result.Primary)
|
|
}
|
|
found := false
|
|
for _, e := range result.Errors {
|
|
if e == ReasonNoViablePrimary {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected error %q, got %v", ReasonNoViablePrimary, result.Errors)
|
|
}
|
|
if len(result.Candidates) != 0 {
|
|
t.Fatalf("expected empty candidates, got %v", result.Candidates)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_DiskTypeMismatch(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0, DiskType: "ssd"},
|
|
{Address: "vs2:9333", VolumeCount: 0, DiskType: "hdd"},
|
|
{Address: "vs3:9333", VolumeCount: 0, DiskType: "ssd"},
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "ssd", 0, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
// vs2 should be rejected
|
|
if len(result.Rejections) != 1 || result.Rejections[0].Server != "vs2:9333" {
|
|
t.Fatalf("expected vs2:9333 rejected, got %v", result.Rejections)
|
|
}
|
|
if result.Rejections[0].Reason != ReasonDiskTypeMismatch {
|
|
t.Fatalf("expected reason %q, got %q", ReasonDiskTypeMismatch, result.Rejections[0].Reason)
|
|
}
|
|
if len(result.Candidates) != 2 {
|
|
t.Fatalf("expected 2 eligible candidates, got %d", len(result.Candidates))
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_InsufficientSpace(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0, AvailableBytes: 100 << 30}, // 100GB
|
|
{Address: "vs2:9333", VolumeCount: 0, AvailableBytes: 5 << 30}, // 5GB
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "", 10<<30, blockvol.DurabilityBestEffort) // request 10GB
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Rejections) != 1 || result.Rejections[0].Server != "vs2:9333" {
|
|
t.Fatalf("expected vs2:9333 rejected, got %v", result.Rejections)
|
|
}
|
|
if result.Rejections[0].Reason != ReasonInsufficientSpace {
|
|
t.Fatalf("expected reason %q, got %q", ReasonInsufficientSpace, result.Rejections[0].Reason)
|
|
}
|
|
}
|
|
|
|
func TestEvaluateBlockPlacement_UnknownCapacity_Allowed(t *testing.T) {
|
|
candidates := []PlacementCandidateInfo{
|
|
{Address: "vs1:9333", VolumeCount: 0, AvailableBytes: 0}, // unknown
|
|
{Address: "vs2:9333", VolumeCount: 0, AvailableBytes: 0}, // unknown
|
|
}
|
|
result := evaluateBlockPlacement(candidates, 1, "", 10<<30, blockvol.DurabilityBestEffort)
|
|
if result.Primary != "vs1:9333" {
|
|
t.Fatalf("expected primary vs1:9333, got %q", result.Primary)
|
|
}
|
|
if len(result.Rejections) != 0 {
|
|
t.Fatalf("expected no rejections for unknown capacity, got %v", result.Rejections)
|
|
}
|
|
}
|
|
|
|
// --- HTTP handler tests ---
|
|
|
|
func qaPlanMaster(t *testing.T) *MasterServer {
|
|
t.Helper()
|
|
ms := &MasterServer{
|
|
blockRegistry: NewBlockVolumeRegistry(),
|
|
blockAssignmentQueue: NewBlockAssignmentQueue(),
|
|
blockFailover: newBlockFailoverState(),
|
|
}
|
|
ms.blockVSAllocate = func(ctx context.Context, server string, name string, sizeBytes uint64, diskType string, durabilityMode string) (*blockAllocResult, error) {
|
|
return &blockAllocResult{
|
|
Path: fmt.Sprintf("/data/%s.blk", name),
|
|
IQN: fmt.Sprintf("iqn.2024.test:%s", name),
|
|
ISCSIAddr: server + ":3260",
|
|
ReplicaDataAddr: server + ":14260",
|
|
ReplicaCtrlAddr: server + ":14261",
|
|
RebuildListenAddr: server + ":15000",
|
|
}, nil
|
|
}
|
|
ms.blockVSDelete = func(ctx context.Context, server string, name string) error {
|
|
return nil
|
|
}
|
|
ms.blockRegistry.MarkBlockCapable("vs1:9333")
|
|
ms.blockRegistry.MarkBlockCapable("vs2:9333")
|
|
ms.blockRegistry.MarkBlockCapable("vs3:9333")
|
|
return ms
|
|
}
|
|
|
|
func TestBlockVolumePlanHandler_HappyPath(t *testing.T) {
|
|
ms := qaPlanMaster(t)
|
|
body := `{"name":"test-vol","size_bytes":1073741824,"replica_factor":2}`
|
|
req := httptest.NewRequest(http.MethodPost, "/block/volume/plan", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ms.blockVolumePlanHandler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp blockapi.VolumePlanResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Plan.Primary == "" {
|
|
t.Fatal("expected non-empty primary")
|
|
}
|
|
if len(resp.Plan.Candidates) == 0 {
|
|
t.Fatal("expected non-empty candidates")
|
|
}
|
|
if resp.Plan.Candidates == nil {
|
|
t.Fatal("candidates must never be nil")
|
|
}
|
|
if len(resp.Plan.Replicas) != 1 {
|
|
t.Fatalf("expected 1 replica for RF=2, got %d", len(resp.Plan.Replicas))
|
|
}
|
|
if len(resp.Errors) != 0 {
|
|
t.Fatalf("unexpected errors: %v", resp.Errors)
|
|
}
|
|
}
|
|
|
|
func TestBlockVolumePlanHandler_WithPreset(t *testing.T) {
|
|
ms := qaPlanMaster(t)
|
|
body := `{"name":"db-vol","size_bytes":1073741824,"preset":"database"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/block/volume/plan", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ms.blockVolumePlanHandler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp blockapi.VolumePlanResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.ResolvedPolicy.Preset != "database" {
|
|
t.Fatalf("expected preset database, got %q", resp.ResolvedPolicy.Preset)
|
|
}
|
|
if resp.ResolvedPolicy.DurabilityMode != "sync_all" {
|
|
t.Fatalf("expected sync_all, got %q", resp.ResolvedPolicy.DurabilityMode)
|
|
}
|
|
if resp.Plan.Primary == "" {
|
|
t.Fatal("expected non-empty primary")
|
|
}
|
|
}
|
|
|
|
func TestBlockVolumePlanHandler_NoServers(t *testing.T) {
|
|
ms := &MasterServer{
|
|
blockRegistry: NewBlockVolumeRegistry(),
|
|
blockAssignmentQueue: NewBlockAssignmentQueue(),
|
|
}
|
|
body := `{"name":"test-vol","size_bytes":1073741824}`
|
|
req := httptest.NewRequest(http.MethodPost, "/block/volume/plan", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
ms.blockVolumePlanHandler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 even with errors, got %d", w.Code)
|
|
}
|
|
|
|
var resp blockapi.VolumePlanResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(resp.Errors) == 0 {
|
|
t.Fatal("expected errors for no servers")
|
|
}
|
|
found := false
|
|
for _, e := range resp.Errors {
|
|
if e == ReasonNoViablePrimary {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected %q in errors, got %v", ReasonNoViablePrimary, resp.Errors)
|
|
}
|
|
if resp.Plan.Candidates == nil {
|
|
t.Fatal("candidates must never be nil, even on error")
|
|
}
|
|
}
|