Files
seaweedfs/weed/ec/ec_balance_no_space_test.go
T
Chris Lu e6cf190596 ec: re-plan balance moves when a target reports no space left (#11759)
* ecbalancer: add Node.SetNoReceive to exclude a node as a move target

A node that has refused a shard copy at runtime (e.g. 'no space left' on the actual disk, issue #11753) can no longer be trusted as a destination for the remainder of a balance run, but it may still hold shards that must move away. SetNoReceive excludes the node from target selection and from receiving-side capacity accounting, while leaving it eligible as a move source.

* ec: re-plan balance moves when a target reports no space left

Moves are planned from a heartbeat snapshot, but concurrent copies let the volume server's real disk state diverge from it, so a planned target can refuse the copy with 'no space left' mid-run and abort the whole balance (issue #11753). Track the refusing targets during execution, mark them no-receive, re-collect the topology, and plan the remaining moves again. The loop terminates because a marked node can never be picked as a target again.

* volume server: return ResourceExhausted when no disk can take an EC shard

A failed EcShardsCopy carried code=Unknown even though the cause is exhausted local capacity; ResourceExhausted describes the refusal and lets clients distinguish it from internal errors. The message text is kept so message-based matching still works (the Rust volume server reports the same text under Internal).

* ec: replay applied moves onto the re-collected snapshot and surface failures

Addresses review on the re-plan loop: a fresh topology read can still describe an applied move while heartbeats lag, so journal each applied move and replay it onto the next snapshot; retry only when every failure was a 'no space left' refusal (an unrelated failure aborts instead of being hidden); and exclude refused nodes' slots from the free-space precheck so a balance with no remaining receiver fails instead of reporting success for an empty plan.

* ec: carry per-disk state when replaying applied moves

A move that landed on an explicit TargetDisk loses that attribution when replayed onto a stale snapshot, leaving the disk's free slots and shard count unchanged; the next plan could pick the same disk, hit another refusal, and over-exclude the node. Replay the shard onto the target disk's entry and move the disk-level counters, and restore the source disk's counters on removal.

* ecbalancer: add Node.SetDiskNoReceive to exclude a single disk as a target

A copy with an explicit disk_id can be refused while the node's other disks still have room; a disk_id 0 refusal already means the server searched every disk, so the scopes differ. disk.noReceive removes that disk from receiving capacity and disk selection while leaving the node and its sibling disks eligible.

* ec: record explicit-disk refusals as disk-scoped no-receive

recordMoveResult now distinguishes the two refusal scopes: TargetDisk>0 marks only (node, disk), while TargetDisk==0 marks the whole node since the server's auto-pick found no usable disk. The free-slot precheck subtracts refused disk slots, and the replan log names refused destinations as node:diskN.

* ecbalancer: skip no-receive disks in pickBestDiskOnNode

The balance phases pick the destination disk through pickBestDiskOnNode, which ranked disks by score only; a refused disk with a low shard count would be re-picked, fail again, and abort the run since no new destination was rejected.

* volume_move: export IsNoSpaceLeftError for move callers

Both the ec.balance replanner and the worker ec_balance task need to recognize the volume server's no-space refusal; keep one matcher next to the move RPCs instead of a private copy in weed/ec.

* worker: retry pinned-disk EC moves with a server-picked disk on no space

ec_balance detection always pins TargetDisk, so a full disk fails the job even when a sibling disk has room. On a no-space refusal to the pinned disk, retry the move once with TargetDisk 0 so the volume server picks a disk on the same node; the planner's node choice stands.
2026-10-15 18:47:07 -07:00

360 lines
12 KiB
Go

package ec
import (
"context"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/ecbalancer"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// ecBalanceFakeServer answers the EC-shard RPC subset a balance run drives:
// copy (rejected when full), mount, inventory, unmount, delete. The disk state
// a real server tracks lives in shards; what the planner saw is rebuilt from it
// through FetchTopology, so a landed move is visible to the next plan.
type ecBalanceFakeServer struct {
volume_server_pb.UnimplementedVolumeServerServer
mu sync.Mutex
shards map[uint32]map[uint32]bool // vid -> shardId -> present
full bool // VolumeEcShardsCopy answers "no space left"
copyCalls int
grpcAddr string
stop func()
}
func (f *ecBalanceFakeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_server_pb.VolumeEcShardsCopyRequest) (*volume_server_pb.VolumeEcShardsCopyResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.copyCalls++
if f.full {
return nil, fmt.Errorf("no space left")
}
return &volume_server_pb.VolumeEcShardsCopyResponse{}, nil
}
func (f *ecBalanceFakeServer) VolumeEcShardsMount(ctx context.Context, req *volume_server_pb.VolumeEcShardsMountRequest) (*volume_server_pb.VolumeEcShardsMountResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sid := range req.ShardIds {
f.shards[req.VolumeId][sid] = true
}
return &volume_server_pb.VolumeEcShardsMountResponse{}, nil
}
func (f *ecBalanceFakeServer) VolumeEcShardsInfo(ctx context.Context, req *volume_server_pb.VolumeEcShardsInfoRequest) (*volume_server_pb.VolumeEcShardsInfoResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
resp := &volume_server_pb.VolumeEcShardsInfoResponse{}
for sid := range f.shards[req.VolumeId] {
resp.EcShardInfos = append(resp.EcShardInfos, &volume_server_pb.EcShardInfo{
VolumeId: req.VolumeId,
ShardId: sid,
Size: 1,
})
}
return resp, nil
}
func (f *ecBalanceFakeServer) VolumeEcShardsUnmount(ctx context.Context, req *volume_server_pb.VolumeEcShardsUnmountRequest) (*volume_server_pb.VolumeEcShardsUnmountResponse, error) {
return &volume_server_pb.VolumeEcShardsUnmountResponse{}, nil
}
func (f *ecBalanceFakeServer) VolumeEcShardsDelete(ctx context.Context, req *volume_server_pb.VolumeEcShardsDeleteRequest) (*volume_server_pb.VolumeEcShardsDeleteResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sid := range req.ShardIds {
delete(f.shards[req.VolumeId], sid)
}
return &volume_server_pb.VolumeEcShardsDeleteResponse{}, nil
}
// nodeId returns the "host:httpPort.grpcPort" node id form the cluster uses, so
// ToGrpcAddress resolves to this server's listener.
func (f *ecBalanceFakeServer) nodeId() string {
host, port, err := net.SplitHostPort(f.grpcAddr)
if err != nil {
return f.grpcAddr
}
return fmt.Sprintf("%s:1.%s", host, port)
}
func (f *ecBalanceFakeServer) shardCount(vid uint32) int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.shards[vid])
}
func startEcBalanceFakeServer(t *testing.T, vid uint32, present ...uint32) *ecBalanceFakeServer {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
f := &ecBalanceFakeServer{
shards: map[uint32]map[uint32]bool{vid: {}},
grpcAddr: listener.Addr().String(),
}
for _, sid := range present {
f.shards[vid][sid] = true
}
server := grpc.NewServer()
volume_server_pb.RegisterVolumeServerServer(server, f)
go func() { _ = server.Serve(listener) }()
f.stop = server.Stop
t.Cleanup(server.Stop)
return f
}
// fetchEcBalanceTopology builds a FetchTopology that reports the fake servers'
// live shard inventory.
func fetchEcBalanceTopology(servers []*ecBalanceFakeServer) func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
return func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
var nodes []*master_pb.DataNodeInfo
for _, f := range servers {
di := &master_pb.DiskInfo{Type: string(types.HardDriveType), MaxVolumeCount: 100}
f.mu.Lock()
for v, set := range f.shards {
if len(set) == 0 {
continue
}
si := erasure_coding.NewShardsInfo()
for sid := range set {
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(sid), 1))
}
di.EcShardInfos = append(di.EcShardInfos, &master_pb.VolumeEcShardInformationMessage{
Id: v,
Collection: "c1",
EcIndexBits: si.Bitmap(),
ShardSizes: si.SizesInt64(),
DiskType: string(types.HardDriveType),
})
}
f.mu.Unlock()
nodes = append(nodes, &master_pb.DataNodeInfo{
Id: f.nodeId(),
DiskInfos: map[string]*master_pb.DiskInfo{string(types.HardDriveType): di},
})
}
return &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{{
Id: "r1",
DataNodeInfos: nodes,
}},
}},
}, 0, nil
}
}
// TestEcBalanceReplanAfterNoSpaceLeft reproduces seaweedfs/seaweedfs#11753:
// the planner works from heartbeat free-slot counts that can disagree with the
// volume server's own check, so a planned target can reject the copy with
// "no space left" mid-run. The balance must re-plan without that node as a
// target rather than fail the whole run.
func TestEcBalanceReplanAfterNoSpaceLeft(t *testing.T) {
const vid = uint32(1)
var allShards []uint32
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
allShards = append(allShards, uint32(sid))
}
full := startEcBalanceFakeServer(t, vid)
accept := startEcBalanceFakeServer(t, vid)
source := startEcBalanceFakeServer(t, vid, allShards...)
full.full = true
env := &Env{
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
FetchTopology: fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full, accept}),
}
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
if err != nil {
t.Fatalf("balance should re-plan around the full target, got: %v", err)
}
if full.copyCalls == 0 {
t.Fatalf("expected the full server to have rejected at least one copy")
}
if got := full.shardCount(vid); got != 0 {
t.Fatalf("full server should receive no shards, holds %d", got)
}
total := source.shardCount(vid) + full.shardCount(vid) + accept.shardCount(vid)
if total != len(allShards) {
t.Fatalf("shards lost or duplicated: total %d, want %d", total, len(allShards))
}
if source.shardCount(vid) == len(allShards) {
t.Fatalf("no shards were moved off the source")
}
}
// TestEcBalanceReplanAfterNoSpaceLeftStaleSnapshot serves the pre-move
// topology on every collection after the first, simulating a master heartbeat
// that lags the landed moves. The re-plan must replay its completed moves onto
// the stale snapshot instead of re-moving shards off the deleted source.
func TestEcBalanceReplanAfterNoSpaceLeftStaleSnapshot(t *testing.T) {
const vid = uint32(1)
var allShards []uint32
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
allShards = append(allShards, uint32(sid))
}
full := startEcBalanceFakeServer(t, vid)
accept := startEcBalanceFakeServer(t, vid)
source := startEcBalanceFakeServer(t, vid, allShards...)
full.full = true
live := fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full, accept})
var stale *master_pb.TopologyInfo
calls := 0
fetchTopology := func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error) {
topo, size, err := live(delay)
if err != nil {
return nil, 0, err
}
calls++
if stale == nil {
stale = topo
}
if calls > 1 {
return stale, size, nil
}
return topo, size, nil
}
env := &Env{
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
FetchTopology: fetchTopology,
}
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
if err != nil {
t.Fatalf("balance should re-plan around the full target, got: %v", err)
}
if calls < 2 {
t.Fatalf("expected a re-plan, topology collected %d time(s)", calls)
}
// Each shard lands on the accepting server exactly once; a stale re-plan
// would copy already-moved shards again.
if got, want := accept.copyCalls, accept.shardCount(vid); got != want {
t.Fatalf("accepting server saw %d copies for %d held shards: completed moves were re-planned", got, want)
}
total := source.shardCount(vid) + full.shardCount(vid) + accept.shardCount(vid)
if total != len(allShards) {
t.Fatalf("shards lost or duplicated: total %d, want %d", total, len(allShards))
}
}
// TestEcBalanceNoReceivingTargetFails surfaces the refusal when every
// destination that could take a shard has rejected a copy, rather than
// reporting success for a plan that moved nothing.
func TestEcBalanceNoReceivingTargetFails(t *testing.T) {
const vid = uint32(1)
var allShards []uint32
for sid := 0; sid < erasure_coding.DataShardsCount+erasure_coding.ParityShardsCount; sid++ {
allShards = append(allShards, uint32(sid))
}
full := startEcBalanceFakeServer(t, vid)
source := startEcBalanceFakeServer(t, vid, allShards...)
full.full = true
env := &Env{
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
FetchTopology: fetchEcBalanceTopology([]*ecBalanceFakeServer{source, full}),
}
err := EcBalance(env, []string{"c1"}, "", nil, types.HardDriveType, 3, 0, true, nil, nil, nil)
if err == nil {
t.Fatalf("balance with no receiving target should fail")
}
if !strings.Contains(err.Error(), "no space left") {
t.Fatalf("expected the refusal to surface, got: %v", err)
}
}
// TestApplyCompletedMovesReplaysDiskState checks that replaying a landed move
// onto a stale snapshot carries the shard's disk attribution and the per-disk
// slot accounting, so a re-plan does not treat the just-filled disk as empty.
func TestApplyCompletedMovesReplaysDiskState(t *testing.T) {
const vid = uint32(1)
source := newEcNode("dc1", "r1", "dn1", 99)
source.Info.DiskInfos["hdd"] = &master_pb.DiskInfo{
Type: "hdd",
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{
Id: vid,
Collection: "c1",
DiskId: 1,
DiskType: "hdd",
EcIndexBits: uint32(erasure_coding.ShardBits(0).Set(5)),
}},
}
si := erasure_coding.NewShardsInfo()
si.Set(erasure_coding.NewShardInfo(5, 1))
source.Disks = map[uint32]*EcDisk{
1: {DiskId: 1, DiskType: "hdd", FreeEcSlots: 99, EcShardCount: 1, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{1: si}},
}
target := newEcNode("dc1", "r1", "dn2", 100)
target.Disks = map[uint32]*EcDisk{
1: {DiskId: 1, DiskType: "hdd", FreeEcSlots: 10, EcShardCount: 0, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
2: {DiskId: 2, DiskType: "hdd", FreeEcSlots: 10, EcShardCount: 0, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
}
nodes := []*EcNode{source, target}
move := ecbalancer.Move{
VolumeID: vid,
ShardID: 5,
Collection: "c1",
SourceNode: "dn1",
TargetNode: "dn2",
TargetDisk: 2,
Phase: "within_rack",
}
applyCompletedMoves(nodes, []ecbalancer.Move{move}, types.HardDriveType)
if ecNodeHasShard(source, needle.VolumeId(vid), 5) {
t.Fatalf("source still holds shard 5")
}
if d := source.Disks[1]; d.EcShardCount != 0 || d.FreeEcSlots != 100 {
t.Fatalf("source disk counts not restored: %+v", d)
}
if !ecNodeHasShard(target, needle.VolumeId(vid), 5) {
t.Fatalf("target missing shard 5")
}
if d := target.Disks[2]; d.EcShardCount != 1 || d.FreeEcSlots != 9 {
t.Fatalf("target disk 2 not charged: %+v", d)
}
if d := target.Disks[1]; d.EcShardCount != 0 {
t.Fatalf("shard attributed to wrong disk: %+v", d)
}
if target.FreeEcSlot != 99 {
t.Fatalf("target free slots = %d, want 99", target.FreeEcSlot)
}
// Replaying again (or onto a fresher snapshot) must not double-count.
applyCompletedMoves(nodes, []ecbalancer.Move{move}, types.HardDriveType)
if d := target.Disks[2]; d.EcShardCount != 1 || d.FreeEcSlots != 9 {
t.Fatalf("double-applied move: %+v", d)
}
if target.FreeEcSlot != 99 {
t.Fatalf("target free slots = %d, want 99", target.FreeEcSlot)
}
}