package erasure_coding import ( "context" "fmt" "path/filepath" "regexp" "strconv" "strings" "testing" "time" "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" "github.com/seaweedfs/seaweedfs/weed/shell" "github.com/seaweedfs/seaweedfs/weed/storage/needle" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" ) // TestMultiDiskECBalanceNoShardLoss is the end-to-end regression for issue 9593. // It runs a real cluster of multi-disk volume servers (3 servers x 4 disks), // EC-encodes a volume, then runs ec.balance, asserting hard invariants the older // integration tests only logged: // // - after encode the full set of 14 EC shards exists, // - ec.balance never loses a shard (still 14 distinct shards afterwards), // - shards end up spread across more than one disk per node, and // - cluster.status counts physical disks (not one per node) and matches the // real on-disk distribution. func TestMultiDiskECBalanceNoShardLoss(t *testing.T) { if testing.Short() { t.Skip("Skipping multi-disk EC integration test in short mode") } testDir := t.TempDir() ctx, cancel := context.WithTimeout(context.Background(), 240*time.Second) defer cancel() cluster, err := startMultiDiskCluster(ctx, testDir) require.NoError(t, err) defer cluster.Stop() require.NoError(t, waitForServer("127.0.0.1:9334", 30*time.Second)) for i := 0; i < 3; i++ { require.NoError(t, waitForServer(fmt.Sprintf("127.0.0.1:809%d", i), 30*time.Second)) } t.Log("waiting for multi-disk volume servers to register...") time.Sleep(10 * time.Second) commandEnv := shell.NewCommandEnv(&shell.ShellOptions{ Masters: stringPtr("127.0.0.1:9334"), GrpcDialOption: grpc.WithInsecure(), FilerGroup: stringPtr("default"), }) connectToMasterAndSync(ctx, t, commandEnv) // Upload enough small files that the volume holds real data to encode. var volumeId needle.VolumeId for retry := 0; retry < 5; retry++ { volumeId, err = uploadTestDataToMaster([]byte(strings.Repeat("multidisk-ec-9593 ", 64)), "127.0.0.1:9334") if err == nil { break } time.Sleep(3 * time.Second) } require.NoError(t, err, "failed to upload test data") for i := 0; i < 40; i++ { if _, e := uploadTestDataToMaster([]byte(strings.Repeat("filler ", 128)), "127.0.0.1:9334"); e != nil { break } } t.Logf("using volume %d", volumeId) time.Sleep(3 * time.Second) // Populate every server's disks with volumes so the encode can see and target // each physical disk. The master only enumerates disks that already hold a // volume or EC shard — an empty disk leaves no trace in the topology (heartbeats // aggregate capacity per disk type, not per physical disk). ec.encode therefore // spreads a volume's shards only across the disks the master already knows hold // data on each node; if a node's data sits on a single disk, all its shards land // there and ec.balance cannot redistribute them (it has no within-node // cross-disk move). So spreading must be set up before encoding. // // volume.grow only tops up toward a writable target and stops on the first // allocation error, so a single -count grow can create far fewer volumes than // asked and leave a node on one disk. Grow repeatedly on the nodes that have not // spread yet (the volume server places each new volume on its least-loaded disk) // until the master's topology shows every node holding volumes on at least two // physical disks. This makes the multi-disk layout — and thus the post-encode // disk spread — deterministic instead of racing volume-growth and heartbeat. require.Eventually(t, func() bool { spread := nodeVolumeDiskCounts(t, commandEnv) if len(spread) == 3 && allAtLeast(spread, 2) { return true } for i := 0; i < 3; i++ { server := fmt.Sprintf("127.0.0.1:809%d", i) if spread[server] < 2 { captureCommandOutput(t, shell.Commands[findCommandIndex("volume.grow")], []string{"-collection", "test", "-dataNode", server, "-count", "4"}, commandEnv) } } return false }, 60*time.Second, 2*time.Second, "volumes never spread across >=2 disks on all 3 nodes") locked, unlock := tryLockWithTimeout(t, commandEnv, 15*time.Second) require.True(t, locked, "could not acquire shell lock") defer unlock() // EC-encode the volume. out, err := captureCommandOutput(t, shell.Commands[findCommandIndex("ec.encode")], []string{"-volumeId", fmt.Sprintf("%d", volumeId), "-collection", "test", "-force"}, commandEnv) t.Logf("ec.encode output:\n%s", out) require.NoError(t, err, "ec.encode failed") // All 14 shards must exist after encoding. require.Eventually(t, func() bool { return len(collectDistinctShardIDs(testDir, uint32(volumeId))) == erasureShardCount }, 30*time.Second, time.Second, "expected all %d EC shards after encode, got %v", erasureShardCount, collectDistinctShardIDs(testDir, uint32(volumeId))) beforeBalance := collectDistinctShardIDs(testDir, uint32(volumeId)) t.Logf("after encode: %d distinct shards on %d disks", len(beforeBalance), disksWithShards(testDir, uint32(volumeId))) // Run ec.balance. out, err = captureCommandOutput(t, shell.Commands[findCommandIndex("ec.balance")], []string{"-collection", "test", "-force"}, commandEnv) t.Logf("ec.balance output:\n%s", out) require.NoError(t, err, "ec.balance failed") time.Sleep(3 * time.Second) // The core regression: ec.balance must not lose any shard. afterBalance := collectDistinctShardIDs(testDir, uint32(volumeId)) require.Equal(t, erasureShardCount, len(afterBalance), "ec.balance lost shards on multi-disk nodes: had %v, now %v", sortedKeysOf(beforeBalance), sortedKeysOf(afterBalance)) // Shards must be spread across more than one physical disk per node overall. usedDisks := disksWithShards(testDir, uint32(volumeId)) assert.Greater(t, usedDisks, 3, "EC shards should span more than one disk per node (got %d disks across 3 nodes)", usedDisks) // cluster.status must count physical disks, not collapse to one per node: it // must report at least the disks actually holding this volume's shards (which // is already >3 across the 3 nodes). Before the fix it reported 3 (node count). require.Eventually(t, func() bool { n, ok := clusterStatusDiskCount(t, commandEnv) return ok && n >= usedDisks }, 30*time.Second, 2*time.Second, "cluster.status never reported the >=%d physical disks holding shards (multi-disk count)", usedDisks) n, _ := clusterStatusDiskCount(t, commandEnv) t.Logf("cluster.status reports %d physical disks (>= %d holding this volume's shards)", n, usedDisks) } const erasureShardCount = 14 // 10 data + 4 parity // collectDistinctShardIDs returns the set of EC shard ids present for a volume // across every disk of every server in the multi-disk test layout. func collectDistinctShardIDs(testDir string, volumeId uint32) map[int]bool { ids := map[int]bool{} for server := 0; server < 3; server++ { for disk := 0; disk < 4; disk++ { diskDir := filepath.Join(testDir, fmt.Sprintf("server%d_disk%d", server, disk)) files, err := listECShardFiles(diskDir, volumeId) if err != nil { continue } for _, f := range files { i := strings.LastIndex(f, ".ec") if i < 0 { continue } if n, err := strconv.Atoi(f[i+3:]); err == nil && n >= 0 && n < erasureShardCount { ids[n] = true } } } } return ids } // disksWithShards counts how many physical disks hold at least one shard. func disksWithShards(testDir string, volumeId uint32) int { n := 0 for _, disks := range countShardsPerDisk(testDir, volumeId) { for _, c := range disks { if c > 0 { n++ } } } return n } // nodeVolumeDiskCounts returns, per volume server id, how many distinct physical // disks hold at least one volume according to the master's topology. The master // only enumerates disks that already hold a volume or EC shard (heartbeats // aggregate capacity per disk type, not per physical disk), so this reports the // disks ec.encode can actually spread a volume's shards across on each node. func nodeVolumeDiskCounts(t *testing.T, commandEnv *shell.CommandEnv) map[string]int { t.Helper() var resp *master_pb.VolumeListResponse err := commandEnv.MasterClient.WithClient(context.Background(), false, func(client master_pb.SeaweedClient) error { var e error resp, e = client.VolumeList(context.Background(), &master_pb.VolumeListRequest{}) return e }) counts := map[string]int{} if err != nil || resp.GetTopologyInfo() == nil { return counts } for _, dc := range resp.GetTopologyInfo().GetDataCenterInfos() { for _, r := range dc.GetRackInfos() { for _, dn := range r.GetDataNodeInfos() { disks := map[uint32]bool{} for _, di := range dn.GetDiskInfos() { for _, vi := range di.GetVolumeInfos() { disks[vi.GetDiskId()] = true } } counts[dn.Id] = len(disks) } } } return counts } func allAtLeast(counts map[string]int, min int) bool { for _, c := range counts { if c < min { return false } } return true } var diskCountRe = regexp.MustCompile(`(\d+)\s+disks?`) // clusterStatusDiskCount runs cluster.status and parses the reported disk count. func clusterStatusDiskCount(t *testing.T, commandEnv *shell.CommandEnv) (int, bool) { t.Helper() out, err := captureCommandOutput(t, shell.Commands[findCommandIndex("cluster.status")], []string{}, commandEnv) if err != nil { return 0, false } m := diskCountRe.FindStringSubmatch(out) if m == nil { return 0, false } n, err := strconv.Atoi(m[1]) return n, err == nil } func sortedKeysOf(m map[int]bool) []int { out := make([]int, 0, len(m)) for k := range m { out = append(out, k) } for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j-1] > out[j]; j-- { out[j-1], out[j] = out[j], out[j-1] } } return out }