mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-08 15:41:15 +02:00
* 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.
337 lines
13 KiB
Go
337 lines
13 KiB
Go
package storage
|
|
|
|
import (
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"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.
|
|
type ecKeyForReconcile struct {
|
|
collection string
|
|
vid needle.VolumeId
|
|
}
|
|
|
|
// ecxOwnerInfo records both the disk that owns the .ecx and the actual
|
|
// directory it lives in (IdxDirectory or Directory). The directory matters
|
|
// because indexEcxOwners scans both — when .ecx lives in Directory (the
|
|
// legacy "written before -dir.idx was set" layout that removeEcVolumeFiles
|
|
// in disk_location_ec.go also keeps cleaning up), passing the owner's
|
|
// IdxDirectory to NewEcVolume would ENOENT both the primary and the
|
|
// same-disk fallback path, which uses the orphan disk's data dir, not the
|
|
// owner's. Tracking the actual scan dir lets reconcile point loaders at
|
|
// the directory the .ecx is really in.
|
|
type ecxOwnerInfo struct {
|
|
location *DiskLocation
|
|
idxDir string
|
|
}
|
|
|
|
// reconcileEcShardsAcrossDisks loads EC shards that the per-disk scan in
|
|
// loadAllEcShards skipped because the disk holding the .ec?? files does not
|
|
// also hold the matching .ecx / .ecj / .vif index files. The index files
|
|
// are located on a different disk of the same volume server (issue #9212).
|
|
//
|
|
// Per-disk loadAllEcShards correctly leaves these orphan shards on disk —
|
|
// it does not have visibility into other DiskLocations on the same store —
|
|
// so the cross-disk fan-out must happen here, after every disk's initial
|
|
// pass has completed. We register each shard against its physical disk's
|
|
// ecVolumes map (so heartbeat reporting carries the right DiskId per
|
|
// shard), but point the EcVolume at the sibling disk's index files so it
|
|
// can serve reads and route deletes through a real .ecx / .ecj.
|
|
func (s *Store) reconcileEcShardsAcrossDisks() {
|
|
if len(s.Locations) < 2 {
|
|
return
|
|
}
|
|
|
|
ecxOwners := s.indexEcxOwners()
|
|
if len(ecxOwners) == 0 {
|
|
return
|
|
}
|
|
|
|
for _, loc := range s.Locations {
|
|
orphans := loc.collectOrphanEcShards()
|
|
if len(orphans) == 0 {
|
|
continue
|
|
}
|
|
for key, shards := range orphans {
|
|
owner, ok := ecxOwners[key]
|
|
if !ok {
|
|
glog.Warningf("ec volume %d (collection=%q) has shards on %s without a matching .ecx anywhere on this volume server; shards %v will stay unloaded until the missing .ecx is restored",
|
|
key.vid, key.collection, loc.Directory, shards)
|
|
continue
|
|
}
|
|
if owner.location == loc {
|
|
// .ecx is on this same disk, but loadAllEcShards still
|
|
// did not load these shards — handleFoundEcxFile already
|
|
// logged the underlying failure. Don't try again here.
|
|
continue
|
|
}
|
|
glog.V(0).Infof("ec volume %d (collection=%q): loading orphan shards %v on %s using index files from %s (issue #9212)",
|
|
key.vid, key.collection, shards, loc.Directory, owner.idxDir)
|
|
if err := loc.loadEcShardsWithIdxDir(shards, key.collection, key.vid, owner.idxDir, loc.ecShardNotifyHandler); err != nil {
|
|
glog.Errorf("ec volume %d on %s: cross-disk shard load failed: %v", key.vid, loc.Directory, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// indexEcxOwners returns the disk and the actual directory that owns the
|
|
// .ecx file for each (collection, vid) on this store. .ecx normally lives
|
|
// in IdxDirectory but may have been written into the data directory before
|
|
// -dir.idx was set, so we check both — and we record which one matched so
|
|
// downstream loaders point NewEcVolume at the directory that really has
|
|
// the file. The first owner found wins; duplicates across disks are
|
|
// unusual but tolerated.
|
|
func (s *Store) indexEcxOwners() map[ecKeyForReconcile]ecxOwnerInfo {
|
|
owners := make(map[ecKeyForReconcile]ecxOwnerInfo)
|
|
for _, loc := range s.Locations {
|
|
seen := make(map[string]bool, 2)
|
|
for _, scan := range []string{loc.IdxDirectory, loc.Directory} {
|
|
if scan == "" || seen[scan] {
|
|
continue
|
|
}
|
|
seen[scan] = true
|
|
entries, err := os.ReadDir(scan)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if !strings.HasSuffix(name, ".ecx") {
|
|
continue
|
|
}
|
|
base := name[:len(name)-len(".ecx")]
|
|
collection, vid, err := parseCollectionVolumeId(base)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
key := ecKeyForReconcile{collection: collection, vid: vid}
|
|
if _, exists := owners[key]; !exists {
|
|
owners[key] = ecxOwnerInfo{location: loc, idxDir: scan}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
// match each group against the .ecx-owning disk in one lookup.
|
|
//
|
|
// Zero-byte shard files are ignored — loadAllEcShards already treats them
|
|
// as cleanup-worthy noise and we want the same shape here.
|
|
func (l *DiskLocation) collectOrphanEcShards() map[ecKeyForReconcile][]string {
|
|
entries, err := os.ReadDir(l.Directory)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
orphans := make(map[ecKeyForReconcile][]string)
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
ext := path.Ext(name)
|
|
if !re.MatchString(ext) {
|
|
continue
|
|
}
|
|
info, err := entry.Info()
|
|
if err != nil || info.Size() == 0 {
|
|
continue
|
|
}
|
|
shardId, err := strconv.ParseInt(ext[3:], 10, 64)
|
|
if err != nil || shardId < 0 || shardId > 255 {
|
|
continue
|
|
}
|
|
base := name[:len(name)-len(ext)]
|
|
collection, vid, err := parseCollectionVolumeId(base)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, loaded := l.FindEcShard(vid, erasure_coding.ShardId(shardId)); loaded {
|
|
continue
|
|
}
|
|
key := ecKeyForReconcile{collection: collection, vid: vid}
|
|
orphans[key] = append(orphans[key], name)
|
|
}
|
|
return orphans
|
|
}
|