mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-25 01:14:50 +00:00
* volume: load the .ecj deletion journal in chunks, and repair a torn tail Two independent defects in the EC deletion journal's load path. 1. The loader issued one NEEDLE_ID_SIZE-byte positional read per entry. That is fine for a healthy journal -- kilobytes -- and pathological for a large one. A `.ecj` is semantically a SET of deleted needle ids but is written as an append-only log that nothing dedupes, and several paths append a peer's ENTIRE journal onto the local one (VolumeEcShardsCopy with copy_ecj_file, EC index recovery, and ec_decode's deliberate cross-holder merge), so a volume whose shards are repeatedly balanced between two servers grows the file without bound. Observed in production: 1.51 TB and 1.30 TB on the two holders of one 10+4 volume containing ~100 distinct ids. At that size the per-entry loop is ~188e9 syscalls, run synchronously while holding the deleted_needles write lock and before the HTTP port opens. The process sits at 100% of one core with a small RSS -- the set stays tiny because the ids repeat -- reading at a few MiB/s because 8-byte reads defeat readahead, logs nothing after "Adding storage location", and ignores SIGTERM. The master then unregisters every volume it holds and reads of them fail. 4.46 and 4.47 are both affected. Read in 1 MiB chunks and build into a local set, merging once at the end so the write lock is not held for the whole scan. Measured on a 256 MiB journal of 100 distinct ids: 33,554,500 syscalls -> 257, identical resulting set. 2. A torn tail silently corrupted later deletes. The journal handle is in append mode, so writes land at the physical end regardless of alignment. A trailing partial record therefore pushed every later append out of alignment: the loader skipped the partial bytes, but the next mount decoded them together with the leading bytes of the following entry, producing one garbage id and dropping the delete that came after the tear -- after acknowledging it. Truncate to a whole number of records at mount, before anything can append. The repair uses its own read+write (non-append) handle: on Windows, append(true) requests FILE_APPEND_DATA without FILE_WRITE_DATA (and .write(true) is subsumed by .append(true)), so SetEndOfFile through the journal handle fails with ERROR_ACCESS_DENIED. The same trap exists in journal_delete's recovery path, which calls set_len on the append handle to roll back a partial write whose sync failed. It is error-handled rather than fatal, so on Windows that rollback silently does not happen. Untouched here; worth a separate fix. Bounding the journal's growth needs compaction, which is deliberately not in this change: replacing the file under a store that can hold several EcVolume instances for one volume id requires coordinating with the other holders, and that belongs at the store layer. Sent separately. Tests: a journal spanning several read chunks loads every entry; a trailing partial record is ignored rather than panicking; a torn tail is truncated at mount and a delete taken afterwards survives a remount. * volume: roll back a failed .ecj append through a dedicated write handle The append handle lacks FILE_WRITE_DATA on Windows, so the set_len rollback after a failed sync silently did nothing and the journal could drift one record past deleted_needles. Same trap as the torn-tail repair in this file; fix it the same way. Also format the new tests. * volume: mirror chunked .ecj load and torn-tail repair in Go --------- Co-authored-by: chrislusf <chrislusf@users.noreply.github.com> Co-authored-by: Devin <devin@cognition.ai>
766 lines
29 KiB
Go
766 lines
29 KiB
Go
package erasure_coding
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/backend"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/volume_info"
|
|
)
|
|
|
|
var (
|
|
NotFoundError = errors.New("needle not found")
|
|
destroyDelaySeconds int64 = 0
|
|
)
|
|
|
|
// ecjLoadChunkBytes bounds each positional read when seeding deletedNeedles
|
|
// from .ecj. A multiple of NeedleIdSize; 1 MiB is 131072 entries per syscall,
|
|
// which keeps a bloated journal from spending mount in per-entry reads.
|
|
const ecjLoadChunkBytes = 1 << 20
|
|
|
|
type EcVolume struct {
|
|
VolumeId needle.VolumeId
|
|
Collection string
|
|
dir string
|
|
dirIdx string
|
|
ecxActualDir string // directory where .ecx/.ecj were actually found (may differ from dirIdx after fallback)
|
|
ecxFile *os.File
|
|
ecxFileSize int64
|
|
ecxCreatedAt time.Time
|
|
Shards []*EcVolumeShard
|
|
ShardLocations map[ShardId][]pb.ServerAddress
|
|
ShardLocationsRefreshTime time.Time
|
|
// ShardLocationsStale marks the map for a prompt re-check: a read that failed
|
|
// against a cached location has disproved what the map claims, and the normal
|
|
// freshness window is far too long to serve from a map known to be wrong.
|
|
ShardLocationsStale bool
|
|
ShardLocationsLock sync.RWMutex
|
|
Version needle.Version
|
|
ecjFile *os.File
|
|
ecjFileAccessLock sync.Mutex
|
|
diskType types.DiskType
|
|
datFileSize int64
|
|
ExpireAtSec uint64 //ec volume destroy time, calculated from the ec volume was created
|
|
ECContext *ECContext // EC encoding parameters
|
|
|
|
// EncodeTsNs is the encode time (unix nanos) loaded from .vif; reads carry it
|
|
// so a shard from a different encode run is rejected. 0 for pre-upgrade volumes.
|
|
EncodeTsNs int64
|
|
|
|
// ecjFileSize mirrors the on-disk size of the .ecj deletion journal and
|
|
// is maintained under ecjFileAccessLock. It is only used by IO helpers
|
|
// (seek/truncate) — the authoritative runtime delete count comes from
|
|
// deletedNeedles.
|
|
ecjFileSize int64
|
|
|
|
// deletedNeedles is the in-memory set of needle ids that have been
|
|
// deleted since the volume was encoded. .ecx is immutable at runtime —
|
|
// it only stores the sorted (id, offset, size) index written at encode
|
|
// time — and runtime deletes are journaled to .ecj + tracked here.
|
|
// Reads consult this set to mask out deleted needles on top of the
|
|
// sealed .ecx lookup. Heartbeat delete_count is derived from len(set).
|
|
// Seeded from .ecj in NewEcVolume and updated under deletedNeedlesLock.
|
|
deletedNeedlesLock sync.RWMutex
|
|
deletedNeedles map[types.NeedleId]struct{}
|
|
|
|
// Bitrot checksum sidecar for the active generation (optional). bitrot is
|
|
// nil unless bitrotStatus == BitrotOn, and is loaded at mount. Guarded by
|
|
// bitrotLock.
|
|
bitrotLock sync.RWMutex
|
|
bitrot *volume_server_pb.EcBitrotProtection
|
|
bitrotStatus BitrotStatus
|
|
|
|
lastIoError error
|
|
lastIoErrorCount int32
|
|
ioErrorQuarantined bool
|
|
lastIoErrorLock sync.RWMutex
|
|
}
|
|
|
|
func (ev *EcVolume) CheckReadWriteError(err error) {
|
|
if err == nil {
|
|
ev.clearIoError()
|
|
return
|
|
}
|
|
if errors.Is(err, syscall.EIO) {
|
|
ev.noteIoError(err)
|
|
return
|
|
}
|
|
ev.clearIoError()
|
|
}
|
|
|
|
func (ev *EcVolume) noteIoError(err error) {
|
|
ev.lastIoErrorLock.Lock()
|
|
defer ev.lastIoErrorLock.Unlock()
|
|
ev.lastIoError = err
|
|
ev.lastIoErrorCount++
|
|
stats.VolumeServerStorageIoErrorCounter.Inc()
|
|
}
|
|
|
|
func (ev *EcVolume) clearIoError() {
|
|
ev.lastIoErrorLock.Lock()
|
|
defer ev.lastIoErrorLock.Unlock()
|
|
ev.lastIoError = nil
|
|
ev.lastIoErrorCount = 0
|
|
}
|
|
|
|
func (ev *EcVolume) ResetIoErrorState() {
|
|
ev.lastIoErrorLock.Lock()
|
|
defer ev.lastIoErrorLock.Unlock()
|
|
ev.lastIoError = nil
|
|
ev.lastIoErrorCount = 0
|
|
ev.ioErrorQuarantined = false
|
|
}
|
|
|
|
func (ev *EcVolume) MarkIoQuarantined() {
|
|
ev.lastIoErrorLock.Lock()
|
|
defer ev.lastIoErrorLock.Unlock()
|
|
ev.ioErrorQuarantined = true
|
|
}
|
|
|
|
func (ev *EcVolume) GetIoErrorState() (error, int32, bool) {
|
|
ev.lastIoErrorLock.RLock()
|
|
defer ev.lastIoErrorLock.RUnlock()
|
|
return ev.lastIoError, ev.lastIoErrorCount, ev.ioErrorQuarantined
|
|
}
|
|
|
|
// statEcxSize returns the size of an .ecx file, os.ErrNotExist when it is absent
|
|
// (or a directory), so the resolver can prefer a non-empty copy.
|
|
func statEcxSize(path string) (int64, error) {
|
|
info, statErr := os.Stat(path)
|
|
if statErr != nil {
|
|
return 0, statErr
|
|
}
|
|
if info.IsDir() {
|
|
return 0, os.ErrNotExist
|
|
}
|
|
return info.Size(), nil
|
|
}
|
|
|
|
func NewEcVolume(diskType types.DiskType, dir string, dirIdx string, collection string, vid needle.VolumeId) (ev *EcVolume, err error) {
|
|
ev = &EcVolume{dir: dir, dirIdx: dirIdx, Collection: collection, VolumeId: vid, diskType: diskType}
|
|
|
|
dataBaseFileName := EcShardFileName(collection, dir, int(vid))
|
|
indexBaseFileName := EcShardFileName(collection, dirIdx, int(vid))
|
|
|
|
// open ecx file. Wrap errors with %w so callers walking up the stack
|
|
// (notably Store.MountEcShards) can use errors.Is(err, os.ErrNotExist)
|
|
// to decide whether to try the next local disk vs. bail. A 0-byte .ecx
|
|
// is a legitimate index for a volume that had no live needles at encode
|
|
// time (e.g. all needles deleted before WriteSortedFileFromIdx) and
|
|
// must mount successfully here. A 0-byte stub left by a failed copy
|
|
// stream is indistinguishable from that empty case by file size alone;
|
|
// preventing such stubs is the receiver-side cleanup in writeToFile's
|
|
// job, not this open path.
|
|
// Resolve the .ecx, preferring the copy co-located with the shard data on
|
|
// this disk — where a move or reconstruct leaves it — then the caller's
|
|
// index directory. That directory is either the shared -dir.idx dir or a
|
|
// sibling disk that owns the .ecx when this disk holds only a 0-byte stub
|
|
// left by an interrupted copy (#9212). A 0-byte .ecx is a legitimate empty
|
|
// index, so the local copy yields only to a *non-empty* copy elsewhere,
|
|
// never to a mere absence: prefer a non-empty .ecx local-first, then fall
|
|
// back to whichever exists at all.
|
|
localBaseFileName := dataBaseFileName
|
|
sharedBaseFileName := indexBaseFileName
|
|
localSize, localErr := statEcxSize(localBaseFileName + ".ecx")
|
|
sharedSize, sharedErr := int64(0), os.ErrNotExist
|
|
if dirIdx != dir {
|
|
sharedSize, sharedErr = statEcxSize(sharedBaseFileName + ".ecx")
|
|
}
|
|
switch {
|
|
case localErr == nil && localSize > 0:
|
|
indexBaseFileName, ev.ecxActualDir = localBaseFileName, dir
|
|
case sharedErr == nil && sharedSize > 0:
|
|
indexBaseFileName, ev.ecxActualDir = sharedBaseFileName, dirIdx
|
|
glog.V(1).Infof("ecx not local at %s.ecx, using %s.ecx", localBaseFileName, sharedBaseFileName)
|
|
case localErr == nil: // local exists but is a 0-byte empty index
|
|
indexBaseFileName, ev.ecxActualDir = localBaseFileName, dir
|
|
case sharedErr == nil: // only a 0-byte copy in the index dir
|
|
indexBaseFileName, ev.ecxActualDir = sharedBaseFileName, dirIdx
|
|
default:
|
|
return nil, fmt.Errorf("cannot open ec volume index %s.ecx (or %s.ecx): %w", localBaseFileName, sharedBaseFileName, os.ErrNotExist)
|
|
}
|
|
if ev.ecxFile, err = backend.OpenVolumeFile(indexBaseFileName+".ecx", os.O_RDWR); err != nil {
|
|
return nil, fmt.Errorf("cannot open ec volume index %s.ecx: %w", indexBaseFileName, err)
|
|
}
|
|
ecxFi, statErr := ev.ecxFile.Stat()
|
|
if statErr != nil {
|
|
_ = ev.ecxFile.Close()
|
|
return nil, fmt.Errorf("can not stat ec volume index %s.ecx: %w", indexBaseFileName, statErr)
|
|
}
|
|
ev.ecxFileSize = ecxFi.Size()
|
|
ev.ecxCreatedAt = ecxFi.ModTime()
|
|
|
|
// open ecj file and seed the in-memory deleted set from it.
|
|
if ev.ecjFile, err = backend.OpenVolumeFile(indexBaseFileName+".ecj", os.O_RDWR|os.O_CREATE); err != nil {
|
|
return nil, fmt.Errorf("cannot open ec volume journal %s.ecj: %v", indexBaseFileName, err)
|
|
}
|
|
if ecjFi, statErr := ev.ecjFile.Stat(); statErr == nil {
|
|
ev.ecjFileSize = ecjFi.Size()
|
|
} else {
|
|
glog.Warningf("stat ec volume journal %s.ecj: %v", indexBaseFileName, statErr)
|
|
}
|
|
// Truncate a torn tail before the loader runs: appends land at the
|
|
// physical end, so a trailing partial record would misalign every later
|
|
// delete and lose it on the next mount.
|
|
if ragged := ev.ecjFileSize % int64(types.NeedleIdSize); ragged != 0 {
|
|
whole := ev.ecjFileSize - ragged
|
|
glog.Warningf("ec volume %d: truncating torn .ecj tail %d -> %d bytes", vid, ev.ecjFileSize, whole)
|
|
if truncErr := ev.ecjFile.Truncate(whole); truncErr != nil {
|
|
return nil, fmt.Errorf("ec volume %d: repair torn .ecj tail: %w", vid, truncErr)
|
|
}
|
|
if syncErr := ev.ecjFile.Sync(); syncErr != nil {
|
|
return nil, fmt.Errorf("ec volume %d: sync .ecj after tail repair: %w", vid, syncErr)
|
|
}
|
|
ev.ecjFileSize = whole
|
|
}
|
|
ev.deletedNeedles = make(map[types.NeedleId]struct{})
|
|
if loadErr := ev.loadDeletedNeedlesFromEcj(); loadErr != nil {
|
|
glog.Warningf("ec volume %d: load deleted needles from .ecj: %v", vid, loadErr)
|
|
}
|
|
|
|
// read volume info. Prefer .vif at the data dir (where shards live), but
|
|
// fall back to the index dir when the data dir does not have one — the
|
|
// orphan-shard reconciliation in Store loads shards on a disk whose only
|
|
// EC artefacts are .ec?? files, with .ecx / .ecj / .vif on a sibling disk
|
|
// (issue #9212). Without this fallback we'd write a stub .vif on the
|
|
// shard disk and lose the real EC config + datFileSize.
|
|
vifFileName := dataBaseFileName + ".vif"
|
|
if dirIdx != dir {
|
|
if _, statErr := os.Stat(vifFileName); statErr != nil && os.IsNotExist(statErr) {
|
|
altVif := EcShardFileName(collection, dirIdx, int(vid)) + ".vif"
|
|
if _, altStatErr := os.Stat(altVif); altStatErr == nil {
|
|
vifFileName = altVif
|
|
}
|
|
}
|
|
}
|
|
ev.Version = needle.Version3
|
|
// A present-but-unreadable or malformed .vif FAILS the mount: every new
|
|
// encode records a positive uniform block size there, and defaulting to
|
|
// the legacy layout would serve those shards with the wrong offset math.
|
|
// Absent stays legal — legacy volumes predate the sidecar.
|
|
volumeInfo, _, found, vifErr := volume_info.MaybeLoadVolumeInfo(vifFileName)
|
|
if vifErr != nil {
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: load %s: %w", vid, vifFileName, vifErr)
|
|
}
|
|
if found {
|
|
ev.Version = needle.Version(volumeInfo.Version)
|
|
ev.datFileSize = volumeInfo.DatFileSize
|
|
ev.ExpireAtSec = volumeInfo.ExpireAtSec
|
|
|
|
// Initialize EC context from .vif if present; fallback to defaults
|
|
if volumeInfo.EcShardConfig != nil {
|
|
ds := int(volumeInfo.EcShardConfig.DataShards)
|
|
ps := int(volumeInfo.EcShardConfig.ParityShards)
|
|
ev.EncodeTsNs = volumeInfo.EcShardConfig.GetEncodeTsNs()
|
|
|
|
// A config that is PRESENT but records an impossible ratio is not
|
|
// a volume to fall back on: substituting the default 10+4 with the
|
|
// legacy layout would read uniform shards with the wrong offset
|
|
// math and answer with the wrong bytes. Only an ENTIRELY absent
|
|
// config means "this predates the record", which the else-branch
|
|
// below serves with the legacy defaults.
|
|
if !ValidEcShardCounts(volumeInfo.EcShardConfig.DataShards, volumeInfo.EcShardConfig.ParityShards) {
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: %s records invalid shard counts %d+%d",
|
|
vid, vifFileName, volumeInfo.EcShardConfig.DataShards, volumeInfo.EcShardConfig.ParityShards)
|
|
} else if blockErr := ValidateBlockSize(volumeInfo.EcShardConfig.GetBlockSize()); blockErr != nil {
|
|
// A recorded block size that no encoder could have produced maps
|
|
// every read to the wrong shard offset. Refuse the mount rather
|
|
// than serve those bytes or silently pick a layout.
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: %s: %w", vid, vifFileName, blockErr)
|
|
} else {
|
|
ev.ECContext = &ECContext{
|
|
Collection: collection,
|
|
VolumeId: vid,
|
|
DataShards: ds,
|
|
ParityShards: ps,
|
|
BlockSize: volumeInfo.EcShardConfig.GetBlockSize(),
|
|
}
|
|
glog.V(1).Infof("Loaded EC config from VolumeInfo for volume %d: %s", vid, ev.ECContext.String())
|
|
}
|
|
} else {
|
|
// A vif that carries no ecShardConfig answers nothing about the
|
|
// layout — it is no more informative than an absent one, so it
|
|
// must not skip the sidecar. Going straight to the defaults here
|
|
// read a uniform volume with the legacy offset math.
|
|
cfg, sidecarFound, sidecarErr := layoutFromSidecar(dataBaseFileName, indexBaseFileName)
|
|
if sidecarErr != nil {
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: %s records no EC config and the bitrot sidecar cannot establish the layout: %w", vid, vifFileName, sidecarErr)
|
|
}
|
|
if sidecarFound {
|
|
ev.ECContext = &ECContext{
|
|
Collection: collection,
|
|
VolumeId: vid,
|
|
DataShards: int(cfg.GetDataShards()),
|
|
ParityShards: int(cfg.GetParityShards()),
|
|
BlockSize: cfg.GetBlockSize(),
|
|
}
|
|
ev.EncodeTsNs = cfg.GetEncodeTsNs()
|
|
glog.V(0).Infof("ec volume %d: .vif records no EC config; took it from the bitrot sidecar: %s",
|
|
vid, ev.ECContext.String())
|
|
} else {
|
|
ev.ECContext = NewDefaultECContext(collection, vid)
|
|
}
|
|
}
|
|
} else {
|
|
// Don't fabricate a stub .vif here: a version-only stub implies the
|
|
// default 10+4 ratio with DatFileSize=0 and no encode identity, which
|
|
// the custom-ratio resolver and the startup credibility checks must not
|
|
// mistake for an authoritative config. Mount with in-memory defaults and
|
|
// leave the real .vif to the encoder or a recovery tool (the Rust volume
|
|
// server already behaves this way).
|
|
//
|
|
// The bitrot sidecar records the same EC config at encode time, so when
|
|
// it is present it answers the layout question the missing .vif cannot:
|
|
// defaulting a uniform-layout volume to the legacy block sizes maps
|
|
// every read to the wrong shard offset. `weed fix -ecx` reads the
|
|
// sidecar for the same reason.
|
|
ev.ECContext = NewDefaultECContext(collection, vid)
|
|
cfg, sidecarFound, sidecarErr := layoutFromSidecar(dataBaseFileName, indexBaseFileName)
|
|
if sidecarErr != nil {
|
|
// With no .vif the sidecar is the ONLY record of this volume's
|
|
// layout. Present but unusable is not "assume legacy" — that
|
|
// answers reads with the wrong shard offsets, which is worse than
|
|
// not answering at all.
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: no .vif and the bitrot sidecar cannot establish the layout: %w", vid, sidecarErr)
|
|
}
|
|
if sidecarFound {
|
|
ev.ECContext = &ECContext{
|
|
Collection: collection,
|
|
VolumeId: vid,
|
|
DataShards: int(cfg.GetDataShards()),
|
|
ParityShards: int(cfg.GetParityShards()),
|
|
BlockSize: cfg.GetBlockSize(),
|
|
}
|
|
ev.EncodeTsNs = cfg.GetEncodeTsNs()
|
|
glog.V(0).Infof("ec volume %d: .vif missing; took EC config from the bitrot sidecar: %s",
|
|
vid, ev.ECContext.String())
|
|
} else {
|
|
// Only now are the defaults what the volume actually mounted on;
|
|
// logging this after the sidecar answered would send an operator
|
|
// triaging wrong bytes after the legacy layout instead.
|
|
glog.Warningf("vif file not found, using defaults, volumeId:%d, filename:%s", vid, vifFileName)
|
|
}
|
|
}
|
|
|
|
ev.ShardLocations = make(map[ShardId][]pb.ServerAddress)
|
|
|
|
// Load the active-generation bitrot checksum sidecar (optional).
|
|
if err := ev.loadActiveBitrotSidecar(); err != nil {
|
|
ev.Close()
|
|
return nil, err
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (ev *EcVolume) AddEcVolumeShard(ecVolumeShard *EcVolumeShard) (bool, error) {
|
|
for _, s := range ev.Shards {
|
|
if s.ShardId == ecVolumeShard.ShardId {
|
|
return false, nil
|
|
}
|
|
}
|
|
// A 0-byte shard file beside an index with entries is residue of a
|
|
// failed copy or a truncation, not a mountable shard: registering it
|
|
// would advertise a size-0 claim that serves nothing and, since
|
|
// placement pins re-copies to the owning disk, would keep attracting
|
|
// repairs to a file that was never valid. A 0-byte shard beside a
|
|
// 0-byte index is different — that is the legitimate layout of a
|
|
// volume encoded with no live needles, and it must keep mounting.
|
|
// The startup scan already skips 0-byte shard files; this covers the
|
|
// mount RPC path, which opens the file directly.
|
|
if ecVolumeShard.Size() == 0 && ev.ecxFileSize > 0 {
|
|
return false, fmt.Errorf("ec volume %d shard %d: shard file is empty (0 bytes) but the index has %d entries: residue of a failed copy, not a mountable shard",
|
|
ev.VolumeId, ecVolumeShard.ShardId, ev.ecxFileSize/types.NeedleMapEntrySize)
|
|
}
|
|
ev.Shards = append(ev.Shards, ecVolumeShard)
|
|
slices.SortFunc(ev.Shards, func(a, b *EcVolumeShard) int {
|
|
if a.VolumeId != b.VolumeId {
|
|
return int(a.VolumeId - b.VolumeId)
|
|
}
|
|
return int(a.ShardId - b.ShardId)
|
|
})
|
|
return true, nil
|
|
}
|
|
|
|
func (ev *EcVolume) DeleteEcVolumeShard(shardId ShardId) (ecVolumeShard *EcVolumeShard, deleted bool) {
|
|
foundPosition := -1
|
|
for i, s := range ev.Shards {
|
|
if s.ShardId == shardId {
|
|
foundPosition = i
|
|
}
|
|
}
|
|
if foundPosition < 0 {
|
|
return nil, false
|
|
}
|
|
|
|
ecVolumeShard = ev.Shards[foundPosition]
|
|
ecVolumeShard.Unmount()
|
|
ev.Shards = append(ev.Shards[:foundPosition], ev.Shards[foundPosition+1:]...)
|
|
return ecVolumeShard, true
|
|
}
|
|
|
|
func (ev *EcVolume) FindEcVolumeShard(shardId ShardId) (ecVolumeShard *EcVolumeShard, found bool) {
|
|
for _, s := range ev.Shards {
|
|
if s.ShardId == shardId {
|
|
return s, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (ev *EcVolume) Close() {
|
|
for _, s := range ev.Shards {
|
|
s.Close()
|
|
}
|
|
ev.ecjFileAccessLock.Lock()
|
|
if ev.ecjFile != nil {
|
|
_ = ev.ecjFile.Close()
|
|
ev.ecjFile = nil
|
|
}
|
|
ev.ecjFileAccessLock.Unlock()
|
|
if ev.ecxFile != nil {
|
|
_ = ev.ecxFile.Sync()
|
|
// Do NOT nil ecxFile: LocateEcShardNeedle reads it without the
|
|
// ecVolumesLock after the resolving lookup released it, so a concurrent
|
|
// eviction that nils the field would race that read. A closed-but-set fd
|
|
// yields a clean read error (recovered from parity) and no data race.
|
|
_ = ev.ecxFile.Close()
|
|
}
|
|
}
|
|
|
|
// Sync flushes the .ecx and .ecj files to disk without closing them.
|
|
// This ensures that deletions made via DeleteNeedleFromEcx are visible
|
|
// to other processes/file handles that may read these files.
|
|
func (ev *EcVolume) Sync() {
|
|
ev.ecjFileAccessLock.Lock()
|
|
if ev.ecjFile != nil {
|
|
if err := ev.ecjFile.Sync(); err != nil {
|
|
glog.Warningf("failed to sync ecj file for volume %d: %v", ev.VolumeId, err)
|
|
}
|
|
}
|
|
ev.ecjFileAccessLock.Unlock()
|
|
if ev.ecxFile != nil {
|
|
if err := ev.ecxFile.Sync(); err != nil {
|
|
glog.Warningf("failed to sync ecx file for volume %d: %v", ev.VolumeId, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (ev *EcVolume) Destroy() {
|
|
ev.Close()
|
|
|
|
for _, s := range ev.Shards {
|
|
s.Destroy()
|
|
}
|
|
// Sweep the EC-only index files from BOTH the data directory and the shared
|
|
// index directory. A move or reconstruct can leave a copy in whichever
|
|
// directory is not ecxActualDir; removing only the active one leaves a stale
|
|
// index that a later reload could pick up and re-mount as a phantom EC
|
|
// volume. .ecx/.ecj are EC-specific, so removing both copies is safe.
|
|
for _, base := range ev.ecIndexBaseNames() {
|
|
os.Remove(base + ".ecx")
|
|
os.Remove(base + ".ecj")
|
|
}
|
|
// The .vif is shared with a coexisting normal volume (e.g. mid-decode), so
|
|
// only remove the active copy, not both.
|
|
os.Remove(ev.FileName(".vif"))
|
|
// Remove the bitrot checksum sidecar(s) so a later volume reuse cannot load
|
|
// stale protection. Search both the data and index bases.
|
|
RemoveBitrotSidecars(ev.DataBaseFileName())
|
|
if ev.IndexBaseFileName() != ev.DataBaseFileName() {
|
|
RemoveBitrotSidecars(ev.IndexBaseFileName())
|
|
}
|
|
}
|
|
|
|
// ecIndexBaseNames returns the base paths for the volume's EC index files in
|
|
// both the data and index directories, deduplicated when they coincide.
|
|
func (ev *EcVolume) ecIndexBaseNames() []string {
|
|
bases := []string{ev.DataBaseFileName()}
|
|
if ev.IndexBaseFileName() != ev.DataBaseFileName() {
|
|
bases = append(bases, ev.IndexBaseFileName())
|
|
}
|
|
return bases
|
|
}
|
|
|
|
// DiskType returns the disk type the EC volume currently reports under.
|
|
// Defaults to the physical location's disk type; orchestrators can override
|
|
// it via SetDiskType so the volume keeps reporting under the source
|
|
// volume's disk type after encoding (#9423).
|
|
func (ev *EcVolume) DiskType() types.DiskType {
|
|
return ev.diskType
|
|
}
|
|
|
|
// SetDiskType overrides the EC volume's reported disk type and propagates
|
|
// to its mounted shards. Intended for the orchestrator-driven mount path
|
|
// (VolumeEcShardsMount); not persisted across restarts.
|
|
func (ev *EcVolume) SetDiskType(d types.DiskType) {
|
|
ev.diskType = d
|
|
for _, s := range ev.Shards {
|
|
s.DiskType = d
|
|
}
|
|
}
|
|
|
|
func (ev *EcVolume) FileName(ext string) string {
|
|
switch ext {
|
|
case ".ecx", ".ecj":
|
|
return EcShardFileName(ev.Collection, ev.ecxActualDir, int(ev.VolumeId)) + ext
|
|
}
|
|
// .vif
|
|
return ev.DataBaseFileName() + ext
|
|
}
|
|
|
|
func (ev *EcVolume) DataBaseFileName() string {
|
|
return EcShardFileName(ev.Collection, ev.dir, int(ev.VolumeId))
|
|
}
|
|
|
|
func (ev *EcVolume) IndexBaseFileName() string {
|
|
return EcShardFileName(ev.Collection, ev.dirIdx, int(ev.VolumeId))
|
|
}
|
|
|
|
func (ev *EcVolume) ShardSize() uint64 {
|
|
if len(ev.Shards) > 0 {
|
|
return uint64(ev.Shards[0].Size())
|
|
}
|
|
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 {
|
|
size += uint64(shardSize)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func (ev *EcVolume) CreatedAt() time.Time {
|
|
return ev.ecxCreatedAt
|
|
}
|
|
|
|
func (ev *EcVolume) ShardIdList() (shardIds []ShardId) {
|
|
for _, s := range ev.Shards {
|
|
shardIds = append(shardIds, s.ShardId)
|
|
}
|
|
return
|
|
}
|
|
|
|
func (ev *EcVolume) ToVolumeEcShardInformationMessage(diskId uint32) (messages []*master_pb.VolumeEcShardInformationMessage) {
|
|
ecInfoPerVolume := map[needle.VolumeId]*master_pb.VolumeEcShardInformationMessage{}
|
|
|
|
fileCount, deleteCount := ev.FileAndDeleteCount()
|
|
|
|
for _, s := range ev.Shards {
|
|
m, ok := ecInfoPerVolume[s.VolumeId]
|
|
if !ok {
|
|
m = &master_pb.VolumeEcShardInformationMessage{
|
|
Id: uint32(s.VolumeId),
|
|
Collection: s.Collection,
|
|
DiskType: string(ev.diskType),
|
|
ExpireAtSec: ev.ExpireAtSec,
|
|
DiskId: diskId,
|
|
FileCount: fileCount,
|
|
DeleteCount: deleteCount,
|
|
EncodeTsNs: ev.EncodeTsNs,
|
|
}
|
|
ecInfoPerVolume[s.VolumeId] = m
|
|
}
|
|
|
|
// Update EC shard bits and sizes.
|
|
si := ShardsInfoFromVolumeEcShardInformationMessage(m)
|
|
si.Set(NewShardInfo(s.ShardId, ShardSize(s.Size())))
|
|
m.EcIndexBits = uint32(si.Bitmap())
|
|
m.ShardSizes = si.SizesInt64()
|
|
}
|
|
|
|
for _, m := range ecInfoPerVolume {
|
|
messages = append(messages, m)
|
|
}
|
|
return
|
|
}
|
|
|
|
// FileAndDeleteCount returns the current (fileCount, deleteCount) for this
|
|
// EC volume.
|
|
//
|
|
// - fileCount = .ecx size / NeedleMapEntrySize — the total number of
|
|
// needles recorded in the sealed sorted index. Because .ecx is written
|
|
// at encode time and only overwritten during decode/rebuild (which
|
|
// preserves record count), this matches the "cumulative put count"
|
|
// semantics of regular volume FileCount.
|
|
//
|
|
// - deleteCount = len(deletedNeedles) — the number of unique runtime
|
|
// deletes tracked in memory. The set is seeded from .ecj on load and
|
|
// appended to on every successful DeleteNeedleFromEcx. Because a
|
|
// needle delete is applied on exactly one shard holder, the admin
|
|
// aggregation sums deleteCount across nodes to get the volume's true
|
|
// delete total.
|
|
//
|
|
// Both values are O(1) — no index walking.
|
|
func (ev *EcVolume) FileAndDeleteCount() (fileCount, deleteCount uint64) {
|
|
fileCount = uint64(ev.ecxFileSize) / uint64(types.NeedleMapEntrySize)
|
|
ev.deletedNeedlesLock.RLock()
|
|
deleteCount = uint64(len(ev.deletedNeedles))
|
|
ev.deletedNeedlesLock.RUnlock()
|
|
return
|
|
}
|
|
|
|
// IsNeedleDeleted reports whether the given needle id is in the in-memory
|
|
// deleted set. Callers that have already looked the needle up in .ecx
|
|
// should consult this to apply runtime deletion state on top of the
|
|
// sealed index.
|
|
func (ev *EcVolume) IsNeedleDeleted(needleId types.NeedleId) bool {
|
|
ev.deletedNeedlesLock.RLock()
|
|
_, ok := ev.deletedNeedles[needleId]
|
|
ev.deletedNeedlesLock.RUnlock()
|
|
return ok
|
|
}
|
|
|
|
// markNeedleDeletedInMemory inserts a needle id into the deleted set.
|
|
func (ev *EcVolume) markNeedleDeletedInMemory(needleId types.NeedleId) {
|
|
ev.deletedNeedlesLock.Lock()
|
|
ev.deletedNeedles[needleId] = struct{}{}
|
|
ev.deletedNeedlesLock.Unlock()
|
|
}
|
|
|
|
// loadDeletedNeedlesFromEcj walks the .ecj journal and populates the
|
|
// in-memory deleted set. Called once from NewEcVolume under the exclusive
|
|
// ownership of the just-constructed (and not yet shared) EcVolume.
|
|
func (ev *EcVolume) loadDeletedNeedlesFromEcj() error {
|
|
if ev.ecjFile == nil || ev.ecjFileSize < int64(types.NeedleIdSize) {
|
|
return nil
|
|
}
|
|
buf := make([]byte, ecjLoadChunkBytes)
|
|
for off := int64(0); off+int64(types.NeedleIdSize) <= ev.ecjFileSize; {
|
|
want := min(int64(ecjLoadChunkBytes), ev.ecjFileSize-off)
|
|
want -= want % int64(types.NeedleIdSize)
|
|
if want == 0 {
|
|
break
|
|
}
|
|
if _, err := ev.ecjFile.ReadAt(buf[:want], off); err != nil {
|
|
return fmt.Errorf("read ecj at %d: %w", off, err)
|
|
}
|
|
for i := int64(0); i+int64(types.NeedleIdSize) <= want; i += int64(types.NeedleIdSize) {
|
|
ev.deletedNeedles[types.BytesToNeedleId(buf[i:i+types.NeedleIdSize])] = struct{}{}
|
|
}
|
|
off += want
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ev *EcVolume) LocateEcShardNeedle(needleId types.NeedleId, version needle.Version) (offset types.Offset, size types.Size, intervals []Interval, err error) {
|
|
|
|
// find the needle from ecx file
|
|
offset, size, err = ev.FindNeedleFromEcx(needleId)
|
|
if err != nil {
|
|
return types.Offset{}, 0, nil, fmt.Errorf("FindNeedleFromEcx: %w", err)
|
|
}
|
|
|
|
intervals = ev.LocateEcShardNeedleInterval(version, offset.ToActualOffset(), types.Size(needle.GetActualSize(size, version)))
|
|
return
|
|
}
|
|
|
|
func (ev *EcVolume) LocateEcShardNeedleInterval(version needle.Version, offset int64, size types.Size) (intervals []Interval) {
|
|
shard := ev.Shards[0]
|
|
var shardSize int64
|
|
if ev.datFileSize > 0 {
|
|
// Use datFileSize to calculate the shardSize to match the EC encoding logic.
|
|
// This is the authoritative value stored in .vif during EC encoding.
|
|
shardSize = ev.datFileSize / int64(ev.ECContext.DataShards)
|
|
} else {
|
|
// Fallback for old EC volumes without datFileSize in .vif.
|
|
// Subtract 1 to handle the ambiguous case where ecdFileSize is an exact
|
|
// multiple of ErasureCodingLargeBlockSize but the data is actually in small
|
|
// blocks (e.g., datFileSize was just under DataShards*ErasureCodingLargeBlockSize).
|
|
shardSize = shard.ecdFileSize - 1
|
|
}
|
|
// calculate the locations in the ec shards
|
|
intervals = LocateData(ev.ECContext.LargeBlockSize(), ev.ECContext.SmallBlockSize(), shardSize, offset, types.Size(needle.GetActualSize(size, version)))
|
|
|
|
return
|
|
}
|
|
|
|
// IntervalToShardIdAndOffset resolves an interval against this volume's shard
|
|
// block layout.
|
|
func (ev *EcVolume) IntervalToShardIdAndOffset(interval Interval) (ShardId, int64) {
|
|
return interval.ToShardIdAndOffset(ev.ECContext.LargeBlockSize(), ev.ECContext.SmallBlockSize())
|
|
}
|
|
|
|
func (ev *EcVolume) FindNeedleFromEcx(needleId types.NeedleId) (offset types.Offset, size types.Size, err error) {
|
|
offset, size, err = SearchNeedleFromSortedIndex(ev.ecxFile, ev.ecxFileSize, needleId, nil)
|
|
if err != nil {
|
|
ev.CheckReadWriteError(err)
|
|
return
|
|
}
|
|
ev.CheckReadWriteError(nil)
|
|
if ev.IsNeedleDeleted(needleId) {
|
|
size = types.TombstoneFileSize
|
|
}
|
|
return
|
|
}
|
|
|
|
func SearchNeedleFromSortedIndex(ecxFile *os.File, ecxFileSize int64, needleId types.NeedleId, processNeedleFn func(file *os.File, offset int64) error) (offset types.Offset, size types.Size, err error) {
|
|
var key types.NeedleId
|
|
buf := make([]byte, types.NeedleMapEntrySize)
|
|
l, h := int64(0), ecxFileSize/types.NeedleMapEntrySize
|
|
for l < h {
|
|
m := (l + h) / 2
|
|
if n, err := ecxFile.ReadAt(buf, m*types.NeedleMapEntrySize); err != nil {
|
|
if n != types.NeedleMapEntrySize {
|
|
return types.Offset{}, types.TombstoneFileSize, fmt.Errorf("ecx file %d read at %d: %w", ecxFileSize, m*types.NeedleMapEntrySize, err)
|
|
}
|
|
}
|
|
key, offset, size = idx.IdxFileEntry(buf)
|
|
if key == needleId {
|
|
if processNeedleFn != nil {
|
|
err = processNeedleFn(ecxFile, m*types.NeedleMapEntrySize)
|
|
}
|
|
return
|
|
}
|
|
if key < needleId {
|
|
l = m + 1
|
|
} else {
|
|
h = m
|
|
}
|
|
}
|
|
|
|
err = NotFoundError
|
|
return
|
|
}
|
|
|
|
func (ev *EcVolume) IsTimeToDestroy() bool {
|
|
return ev.ExpireAtSec > 0 && time.Now().Unix() > (int64(ev.ExpireAtSec)+destroyDelaySeconds)
|
|
}
|
|
|
|
func (ev *EcVolume) WalkIndex(processNeedleFn func(key types.NeedleId, offset types.Offset, size types.Size) error) error {
|
|
if ev.ecxFile == nil {
|
|
return fmt.Errorf("no ECX file associated with EC volume %v", ev.VolumeId)
|
|
}
|
|
return idx.WalkIndexFile(ev.ecxFile, 0, processNeedleFn)
|
|
}
|