package storage import ( "os" "testing" "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/needle" "github.com/seaweedfs/seaweedfs/weed/storage/types" "github.com/seaweedfs/seaweedfs/weed/storage/volume_info" "github.com/seaweedfs/seaweedfs/weed/util" ) // TestIssue9478_PartialEcOnSiblingDiskOfHealthyDat reproduces // https://github.com/seaweedfs/seaweedfs/issues/9478: // // - A single volume server has two disks. Disk A holds a healthy // volume_x.dat (with .idx + .vif). Disk B holds the leftovers of an // interrupted EC encode for the SAME volume: a single .ec?? shard plus // .ecx + .ecj + .vif, with no .dat next to them. // // The current per-disk EC loader (handleFoundEcxFile / validateEcVolume) only // checks for .dat in the same DiskLocation as the EC shards. Because Disk B // has no .dat, it concludes "this must be a distributed EC volume" and // happily loads the lone partial shard. The .dat on Disk A also loads as a // regular replica. The volume server then heartbeats BOTH a regular volume // and an EC shard for the same vid, which is the inconsistent state the // issue is describing. // // The expected behaviour is to recognise that a healthy .dat exists on a // sibling disk on the same store and clean up the partial EC files on // Disk B, exactly as we already do today when .dat and the partial EC files // sit on the same disk. func TestIssue9478_PartialEcOnSiblingDiskOfHealthyDat(t *testing.T) { collection := "" vid := needle.VolumeId(122) root := t.TempDir() datDir := root + "/sdd" ecDir := root + "/sdf" if err := os.MkdirAll(datDir, 0o755); err != nil { t.Fatalf("mkdir datDir: %v", err) } if err := os.MkdirAll(ecDir, 0o755); err != nil { t.Fatalf("mkdir ecDir: %v", err) } // Disk A (sdd): a healthy-looking volume_x.dat + .idx + .vif. We don't // run NewVolume here, only check what loadAllEcShards on Disk B does in // the presence of a same-server .dat. A truncated 10 MiB .dat is enough // for the EC validator to compute an expected shard size from. datFileSize := int64(10 * 1024 * 1024) datBase := erasure_coding.EcShardFileName(collection, datDir, int(vid)) if f, err := os.Create(datBase + ".dat"); err == nil { if err := f.Truncate(datFileSize); err != nil { t.Fatalf("truncate dat: %v", err) } f.Close() } else { t.Fatalf("create dat: %v", err) } if f, err := os.Create(datBase + ".idx"); err == nil { f.Close() } if f, err := os.Create(datBase + ".vif"); err == nil { f.Close() } // Disk B (sdf): only one EC shard plus .ecx + .ecj + .vif, no .dat. // This mirrors the issue 9478 listing for volume_server_4 / sdf. ecBase := erasure_coding.EcShardFileName(collection, ecDir, int(vid)) expectedShardSize := calculateExpectedShardSize(datFileSize, erasure_coding.DataShardsCount) if f, err := os.Create(ecBase + erasure_coding.ToExt(1)); err == nil { if err := f.Truncate(expectedShardSize); err != nil { t.Fatalf("truncate shard: %v", err) } f.Close() } else { t.Fatalf("create shard: %v", err) } if f, err := os.Create(ecBase + ".ecx"); err == nil { f.WriteString("dummy ecx") f.Close() } if f, err := os.Create(ecBase + ".ecj"); err == nil { f.Close() } // A credible EC .vif records the encode-time source size and ratio. The // prune now deletes a partial leftover only when the sibling .dat matches // that recorded size byte-for-byte (a real encoded volume records it), so // the test reflects production rather than a loose "any .dat > superblock" // gate. if err := volume_info.SaveVolumeInfo(ecBase+".vif", &volume_server_pb.VolumeInfo{ Version: uint32(needle.Version3), DatFileSize: datFileSize, EcShardConfig: &volume_server_pb.EcShardConfig{DataShards: 10, ParityShards: 4}, }); err != nil { t.Fatalf("save ec .vif: %v", err) } minFreeSpace := util.MinFreeSpace{Type: util.AsPercent, Percent: 1, Raw: "1"} makeDisk := func(dir string) *DiskLocation { dl := &DiskLocation{ Directory: dir, DirectoryUuid: "test-uuid-" + dir, IdxDirectory: dir, DiskType: types.HddType, MinFreeSpace: minFreeSpace, } dl.volumes = make(map[needle.VolumeId]*Volume) dl.ecVolumes = make(map[needle.VolumeId]*erasure_coding.EcVolume) return dl } datLoc := makeDisk(datDir) ecLoc := makeDisk(ecDir) // Stand the disks up the same way NewStore does: per-disk EC scan // first, then the Store-level passes. The per-disk pass alone cannot // see the .dat on the sibling disk, so it mounts the partial shards; // the Store-level prune is what brings the cluster back to a // consistent state. store := &Store{ Locations: []*DiskLocation{datLoc, ecLoc}, NewEcShardsChan: make(chan *master_pb.VolumeEcShardInformationMessage, 16), DeletedEcShardsChan: make(chan *master_pb.VolumeEcShardInformationMessage, 16), } ecLoc.ecShardNotifyHandler = func(collection string, vid needle.VolumeId, shardId erasure_coding.ShardId, ecVolume *erasure_coding.EcVolume) { store.NewEcShardsChan <- &master_pb.VolumeEcShardInformationMessage{ Id: uint32(vid), Collection: collection, } } if err := ecLoc.loadAllEcShards(ecLoc.ecShardNotifyHandler); err != nil { t.Logf("loadAllEcShards on ecDir returned: %v", err) } t.Cleanup(func() { closeEcVolumes(ecLoc) }) preShardCount := ecLoc.EcShardCount() preNewMessages := len(store.NewEcShardsChan) t.Logf("after per-disk EC load on sdf: inMemoryShards=%d newEcShardMessages=%d", preShardCount, preNewMessages) if preShardCount == 0 { t.Fatalf("test setup is no longer reproducing the bug: per-disk EC load did not mount the partial shard") } store.pruneIncompleteEcWithSiblingDat() store.reconcileEcShardsAcrossDisks() leftoverShard := util.FileExists(ecBase + erasure_coding.ToExt(1)) leftoverEcx := util.FileExists(ecBase + ".ecx") leftoverEcj := util.FileExists(ecBase + ".ecj") loaded := ecLoc.EcShardCount() deleteMessages := len(store.DeletedEcShardsChan) t.Logf("after Store-level cleanup: shardFileLeft=%v ecxLeft=%v ecjLeft=%v inMemoryShards=%d deletedEcShardMessages=%d", leftoverShard, leftoverEcx, leftoverEcj, loaded, deleteMessages) if loaded != 0 { t.Fatalf("partial EC shard was still mounted (%d shards) after the Store-level prune; a healthy .dat exists on a sibling disk and the leftover should have been cleaned up", loaded) } if leftoverShard || leftoverEcx || leftoverEcj { t.Fatalf("partial EC files survived the Store-level prune (shard=%v ecx=%v ecj=%v); a healthy .dat exists on a sibling disk and the leftover should have been removed", leftoverShard, leftoverEcx, leftoverEcj) } if deleteMessages == 0 { t.Errorf("prune did not push DeletedEcShardsChan; master would have to wait for the next periodic heartbeat to forget the partial shard") } // The .dat on the sibling disk must survive — pruning the partial EC // must never touch the healthy replica we are falling back to. if !util.FileExists(datBase + ".dat") { t.Fatalf("healthy .dat on sibling disk was removed by the prune; only the partial EC files should be cleaned up") } } // TestIssue9478_ZeroByteSiblingDatKeepsPartialEc is the safety guard for // pruneIncompleteEcWithSiblingDat: when the sibling .dat is zero bytes // (or smaller than what .vif recorded as the encoding source), we'd // rather keep the partial EC than delete it based on garbage. The // partial shard may still combine with shards on other servers in a // recoverable distributed-EC layout. func TestIssue9478_ZeroByteSiblingDatKeepsPartialEc(t *testing.T) { collection := "" vid := needle.VolumeId(122) root := t.TempDir() datDir := root + "/sdd" ecDir := root + "/sdf" if err := os.MkdirAll(datDir, 0o755); err != nil { t.Fatalf("mkdir datDir: %v", err) } if err := os.MkdirAll(ecDir, 0o755); err != nil { t.Fatalf("mkdir ecDir: %v", err) } // Sibling .dat is a zero-byte shell — the kind volume servers 3 // and 5 in the issue 9478 report have. datBase := erasure_coding.EcShardFileName(collection, datDir, int(vid)) if f, err := os.Create(datBase + ".dat"); err == nil { f.Close() } else { t.Fatalf("create zero-byte dat: %v", err) } ecBase := erasure_coding.EcShardFileName(collection, ecDir, int(vid)) datFileSizeForShard := int64(10 * 1024 * 1024) expectedShardSize := calculateExpectedShardSize(datFileSizeForShard, erasure_coding.DataShardsCount) if f, err := os.Create(ecBase + erasure_coding.ToExt(1)); err == nil { if err := f.Truncate(expectedShardSize); err != nil { t.Fatalf("truncate shard: %v", err) } f.Close() } if f, err := os.Create(ecBase + ".ecx"); err == nil { f.WriteString("dummy ecx") f.Close() } if f, err := os.Create(ecBase + ".ecj"); err == nil { f.Close() } if f, err := os.Create(ecBase + ".vif"); err == nil { f.Close() } minFreeSpace := util.MinFreeSpace{Type: util.AsPercent, Percent: 1, Raw: "1"} makeDisk := func(dir string) *DiskLocation { dl := &DiskLocation{ Directory: dir, DirectoryUuid: "test-uuid-" + dir, IdxDirectory: dir, DiskType: types.HddType, MinFreeSpace: minFreeSpace, } dl.volumes = make(map[needle.VolumeId]*Volume) dl.ecVolumes = make(map[needle.VolumeId]*erasure_coding.EcVolume) return dl } datLoc := makeDisk(datDir) ecLoc := makeDisk(ecDir) store := &Store{ Locations: []*DiskLocation{datLoc, ecLoc}, NewEcShardsChan: make(chan *master_pb.VolumeEcShardInformationMessage, 16), DeletedEcShardsChan: make(chan *master_pb.VolumeEcShardInformationMessage, 16), } if err := ecLoc.loadAllEcShards(nil); err != nil { t.Logf("loadAllEcShards on ecDir returned: %v", err) } t.Cleanup(func() { closeEcVolumes(ecLoc) }) if ecLoc.EcShardCount() == 0 { t.Fatalf("test setup is not reproducing the layout: per-disk EC load did not mount the partial shard") } store.pruneIncompleteEcWithSiblingDat() if ecLoc.EcShardCount() == 0 { t.Fatalf("prune deleted partial EC based on a zero-byte sibling .dat; the shard should have been left in place so distributed reconstruction is still possible") } if !util.FileExists(ecBase + erasure_coding.ToExt(1)) { t.Fatalf("prune deleted the partial shard file based on a zero-byte sibling .dat") } if len(store.DeletedEcShardsChan) != 0 { t.Errorf("prune pushed a DeletedEcShardsChan message despite skipping cleanup; nothing was actually deleted") } }