fix(storage): prune partial EC shards when sibling disk has healthy .dat (#9478) (#9480)

* fix(storage): prune partial EC shards when sibling disk has healthy .dat (#9478)

handleFoundEcxFile only checks for .dat in the same disk location as the
EC shards. In a multi-disk volume server an interrupted encode can leave
.ec?? + .ecx on disk B while the source .dat still lives on disk A: the
per-disk loader sees no .dat next to .ecx, mistakes the leftover for a
distributed-EC layout, and mounts the partial shards. The volume server
then heartbeats both a regular replica and an EC shard for the same vid
and the master keeps both.

Sweep the store after per-disk loading and before the cross-disk
reconcile to delete partial EC files when a healthy .dat for the same
(collection, vid) exists on a sibling disk. Push DeletedEcShardsChan for
every pruned shard so master forgets the new-shard message the per-disk
pass already emitted, instead of waiting for the next periodic heartbeat.

* fix(seaweed-volume): mirror prune of partial EC with sibling .dat (#9478)

Rust port of the same Store-level prune added to weed/storage. The
per-disk EC loader in disk_location.rs only checks for .dat in the same
disk as the EC shards, so an interrupted encode that leaves .ec?? + .ecx
on disk B while the source .dat sits on disk A is mounted as if it were
a distributed-EC layout. The volume server then heartbeats both a
regular replica and an EC shard for the same vid.

Sweep the store after per-disk loading and before the cross-disk
reconcile, dropping in-memory EcVolumes with fewer than DATA_SHARDS_COUNT
shards when a .dat for the same (collection, vid) exists on a sibling
disk, and remove all on-disk EC artefacts for them. The Rust heartbeat
path already diff-emits deletes from the next ec_volumes snapshot, so no
explicit delete-channel push is needed here.

Tests cover both the issue 9478 layout and a distributed-EC layout with
no .dat anywhere on the store, which must be left alone.

* fix(storage): validate sibling .dat size before deleting partial EC (#9478)

The earlier prune deleted partial EC files whenever any .dat for the
same vid existed on a sibling disk — including a zero-byte shell. A
shell is no more useful than the partial shard it would replace, and
the partial shard might still combine with shards on other servers
in a recoverable distributed-EC layout. Wiping it based on a corrupt
sibling .dat is data loss masquerading as cleanup.

Tighten the check: when the EC's .vif recorded a non-zero source size
in datFileSize, require the sibling .dat to be at least that many
bytes; otherwise fall back to "at least a superblock". The .vif value
is what the encoder wrote at the moment the source was sealed, so a
sibling .dat smaller than that is provably truncated. Carry the size
through indexDatOwners alongside the location.

The Rust port had the same gap and an additional bug behind it:
EcVolume::new wasn't reading datFileSize from .vif, so the safety
check always fell back to the superblock floor. Wire datFileSize
through. The existing shard-size calculation in
LocateEcShardNeedleInterval already uses dat_file_size when non-zero,
so populating it also matches Go's behaviour there.

Tests cover the truncated-sibling case in both ports.
This commit is contained in:
Chris Lu
2026-05-13 09:25:10 -07:00
committed by GitHub
parent 3f1eaf9724
commit de28c4df61
8 changed files with 909 additions and 9 deletions
+9
View File
@@ -288,6 +288,15 @@ func (ev *EcVolume) ShardSize() uint64 {
return 0
}
// DatFileSize returns the source .dat file size as recorded in .vif at
// EC encoding time. Zero for old EC volumes whose .vif predates the
// field, or for .vif files we failed to parse. Used by the Store-level
// prune in store_ec_reconcile.go to validate that a sibling-disk .dat
// is plausibly the encoding source before deleting the partial EC.
func (ev *EcVolume) DatFileSize() int64 {
return ev.datFileSize
}
func (ev *EcVolume) Size() (size uint64) {
for _, shard := range ev.Shards {
if shardSize := shard.Size(); shardSize > 0 {
+10
View File
@@ -154,6 +154,16 @@ func NewStore(
}
wg.Wait()
// First, scrub partial EC artefacts left on one disk by an interrupted
// encode while the source .dat still lives on a sibling disk of the
// same store. The per-disk loader cannot see the sibling .dat and so
// loads the partial shards as if they were a distributed-EC layout,
// which makes the volume server heartbeat both a regular replica and
// an EC shard set for the same vid (issue #9478). Running before the
// cross-disk reconcile keeps that pass from later re-loading shards
// we just cleaned up.
s.pruneIncompleteEcWithSiblingDat()
// After every DiskLocation has finished its per-disk EC scan, sweep the
// store for shards that live on a disk without local index files and
// load them by reaching across to a sibling disk's .ecx / .ecj / .vif.
+257
View File
@@ -0,0 +1,257 @@
package storage
import (
"os"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_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/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()
}
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)
// 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")
}
}
+166
View File
@@ -7,10 +7,22 @@ import (
"strings"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
)
// datOwnerInfo records both the disk that holds a .dat for a given
// (collection, vid) and the size on disk. The size is consulted by
// pruneIncompleteEcWithSiblingDat before deleting any EC artefacts:
// a zero-byte or truncated .dat is not a credible fallback, and we'd
// rather leave the partial EC in place than wipe it based on garbage.
type datOwnerInfo struct {
location *DiskLocation
size int64
}
// ecKeyForReconcile keys orphan-shard reconciliation by collection + volume
// id. Per-collection grouping matters because two collections can re-use the
// same volume id, and we must only pair shards with their own .ecx file.
@@ -125,6 +137,160 @@ func (s *Store) indexEcxOwners() map[ecKeyForReconcile]ecxOwnerInfo {
return owners
}
// pruneIncompleteEcWithSiblingDat removes leftover EC artefacts on one
// disk when a healthy .dat for the same (collection, vid) lives on a
// sibling disk of the same store. This is the cross-disk analogue of the
// validateEcVolume cleanup in handleFoundEcxFile: a same-disk .dat next
// to partial shards is already taken as proof that an EC encode was
// interrupted, and the partial shards get removed so the .dat keeps
// serving the volume. Per-disk loaders cannot see sibling disks, so when
// the .dat ends up on disk A and the partial shards on disk B the per-disk
// pass mistakes the leftover for a normal distributed-EC layout (no .dat
// next to .ecx) and mounts the partial shards. The volume server then
// heartbeats both a regular replica and an EC shard for the same vid, the
// master keeps both entries, and reads route through either path
// depending on the client. Issue 9478.
//
// Cleanup is gated on shardCount < DataShardsCount so that a deliberate
// "full local EC, .dat retained" layout split across two disks (.dat on
// disk A, all 10+ shards on disk B) is left alone — the per-disk loader
// already keeps that configuration when everything is on a single disk,
// and pruning it here would be a behaviour regression for operators who
// rely on it. Distributed EC volumes (no .dat on any disk of this server)
// also fall through unchanged because the lookup in the .dat index below
// will simply not find a match.
//
// Before deleting any EC files we also check that the sibling .dat is
// plausibly the encoding source: at least super_block.SuperBlockSize
// bytes long, and — when the EC's .vif recorded a non-zero source size
// in datFileSize — at least that many bytes. A zero-byte shell or a
// truncated .dat does not justify wiping the partial EC, because that
// EC shard may still combine usefully with shards on other servers in
// a recoverable distributed-EC layout.
//
// We push DeletedEcShardsChan for every pruned shard so the master is told
// to forget the registrations the per-disk pass already emitted on
// NewEcShardsChan during startup, instead of waiting for the first
// periodic heartbeat to reconcile.
func (s *Store) pruneIncompleteEcWithSiblingDat() {
if len(s.Locations) < 2 {
return
}
datOwners := s.indexDatOwners()
if len(datOwners) == 0 {
return
}
for diskId, loc := range s.Locations {
// Snapshot under the read lock so we are not iterating
// ecVolumes while the cleanup below takes the write lock.
type victim struct {
collection string
vid needle.VolumeId
messages []*master_pb.VolumeEcShardInformationMessage
datDir string
shardCount int
}
var victims []victim
loc.ecVolumesLock.RLock()
for vid, ev := range loc.ecVolumes {
shardCount := len(ev.Shards)
if shardCount >= erasure_coding.DataShardsCount {
continue
}
key := ecKeyForReconcile{collection: ev.Collection, vid: vid}
owner, hasDat := datOwners[key]
if !hasDat || owner.location == loc {
continue
}
// Decide whether the sibling .dat is a credible source.
// Prefer the size baked into .vif at encode time; fall
// back to "at least a superblock" for old EC volumes
// whose .vif predates the field.
requiredDatSize := ev.DatFileSize()
if requiredDatSize <= 0 {
requiredDatSize = int64(super_block.SuperBlockSize)
}
if owner.size < requiredDatSize {
glog.Warningf("ec volume %d (collection=%q) on %s has only %d shards but sibling .dat on %s is %d bytes (need >= %d); leaving partial EC in place so distributed reconstruction is still possible",
vid, ev.Collection, loc.Directory, shardCount, owner.location.Directory, owner.size, requiredDatSize)
continue
}
victims = append(victims, victim{
collection: ev.Collection,
vid: vid,
messages: ev.ToVolumeEcShardInformationMessage(uint32(diskId)),
datDir: owner.location.Directory,
shardCount: shardCount,
})
}
loc.ecVolumesLock.RUnlock()
for _, v := range victims {
glog.Warningf("ec volume %d (collection=%q) on %s has only %d shards (need %d) while a healthy .dat exists on sibling disk %s; cleaning up leftover EC files (issue 9478)",
v.vid, v.collection, loc.Directory, v.shardCount, erasure_coding.DataShardsCount, v.datDir)
loc.unloadEcVolume(v.vid)
loc.removeEcVolumeFiles(v.collection, v.vid)
for _, msg := range v.messages {
select {
case s.DeletedEcShardsChan <- *msg:
default:
// Channel full during startup is fine — the next
// periodic heartbeat reports the full ecVolumes
// state, which no longer contains these shards.
glog.V(2).Infof("DeletedEcShardsChan full while pruning ec volume %d; relying on periodic heartbeat", v.vid)
}
}
}
}
}
// indexDatOwners returns, for every (collection, vid), the first disk on
// this store that holds a .dat file for it plus the file's size. Used by
// pruneIncompleteEcWithSiblingDat so it can decide whether partial EC
// artefacts on another disk are leftovers of an interrupted encode AND
// whether the sibling .dat is large enough to be a credible fallback.
//
// We record any .dat os.ReadDir can see — including zero-byte shells.
// The mere presence of a .dat means this volume was a regular volume on
// this server at some point, which rules out the "distributed EC, no
// .dat anywhere" reading. Whether that .dat is actually usable is the
// caller's call, made by comparing this size to the EC's recorded
// source size in .vif.
func (s *Store) indexDatOwners() map[ecKeyForReconcile]datOwnerInfo {
owners := make(map[ecKeyForReconcile]datOwnerInfo)
for _, loc := range s.Locations {
entries, err := os.ReadDir(loc.Directory)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".dat") {
continue
}
base := name[:len(name)-len(".dat")]
collection, vid, err := parseCollectionVolumeId(base)
if err != nil {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
key := ecKeyForReconcile{collection: collection, vid: vid}
if _, exists := owners[key]; !exists {
owners[key] = datOwnerInfo{location: loc, size: info.Size()}
}
}
}
return owners
}
// collectOrphanEcShards walks the disk's data directory and returns the
// .ec?? shard files that are present on disk but not yet registered to an
// EcVolume in memory. The map is keyed by (collection, vid) so callers can