mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-05 22:12:04 +02:00
* volume server: ec.decode reads the .ecx from the index dir it was copied to VolumeEcShardsCopy writes the .ecx/.ecj into the receiver's -dir.idx, so with a split data/index dir the decode target has no .ecx beside its shards. VolumeEcShardsToVolume sized the .dat from the right .ecx but built the .idx from the data dir, failing with NotFound after the .dat was already published. It now reads .ecx/.ecj from where the EC volume opened them and writes the .idx beside the .dat, where Go leaves it. The live-entry check and the .dat size also ignored deletions recorded only in the .ecj, which Go folds into the .ecx (RebuildEcxFile) first: a fully deleted volume was decoded instead of reported as having no live entries, and deleted tail needles were copied into the .dat. Both now treat journaled ids as deleted, without rewriting the sealed .ecx. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode keeps the decoded volume writable and reads every .ecj The rebuilt .idx copied a journaled tail needle's .ecx row verbatim after the .dat was cut short before it, so the mount saw a row past EOF and marked the decoded volume read-only. Rows of deleted needles the .dat no longer holds are now dropped, and each journaled needle still in the .dat gets one tombstone instead of one per journal entry. VolumeEcShardsCopy appends journals collected from other holders into the idx dir, but the decode read only the .ecj beside the .ecx, which sits in the data dir when this server generated the shards. It now reads both, once, in bounded chunks via the loader EcVolume uses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: test ec.decode drops a sealed .ecx tail tombstone Covers the other half of the rule added in the previous commit: a tail needle tombstoned in the .ecx itself (Go's RebuildEcxFile) is cut from the .dat, and its row must not reach the rebuilt .idx either. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode runs its file I/O off the async runtime VolumeEcShardsToVolume released the store lock before decoding, but read the .ecx/.ecj, rebuilt the .dat and wrote the .idx inside the async handler, parking a runtime worker for the length of a volume-sized copy. The decode now runs in spawn_blocking on inputs snapshotted under the store lock. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode checks the rebuilt .dat is complete Go stats the decoded .dat before writing the .idx (VerifyDecodedDatFile) and fails the decode when it is shorter than the extent the EC index references, since the caller deletes the shards once the call returns. The Rust handler returned success without that check. The rebuild already fails on a short shard read, so this guards the published file itself. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode drops the decoded volume's bitrot sidecars Go removes <base>.ecsum and <base>.ecsum.v<N> beside the .dat and beside the .ecx once the .idx is written, so a stale checksum sidecar cannot pass for the protection of a later re-encode. The Rust handler left them in place. Removal is best effort, as in Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode compacts the decoded volume Go ends VolumeEcShardsToVolume with an offline CompactVolumeFiles, so the decoded volume holds only live needles. The Rust decode left every needle deleted through the .ecj in the .dat, tombstoned in the .idx, until a later vacuum reclaimed it. Store::compact_volume_files loads the unmounted volume, checks free space the way the vacuum does (the estimate now lives in one helper), and runs the vacuum's compact-by-index and commit. As in Go a failed compaction is logged and the decode still succeeds, so the uncompacted .idx rules stay: the tests that pin them now make the compaction fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * volume server: ec.decode keeps deletes journaled while the .dat is written The decode read the .ecj journals once, before rebuilding the .dat, so a delete that reached the EC volume during the rebuild was left out of the new .idx and the needle came back live. Each journal's read length is now kept, and the bytes appended since are read just before the .idx is written, after waiting out any journal append in flight (appends hold the store write lock), so every delete acknowledged by then is in the .idx. A delete after that point is still lost, as in Go. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Guard overlapping ec decode requests; serialize journal catch-up volume_ec_shards_to_volume runs its decode in spawn_blocking, so a dropped request leaves the job running and a retry would race it on the temporary and final volume files. Claim the vid in a per-server in-flight set until the blocking job finishes, and return Unavailable to an overlapping request. The Go handler has the same exposure and gets the same guard. Journal appends hold the store write lock through their sync-or-truncate, so holding a read lock across the catch-up read guarantees every record it sees is committed: a rolled-back delete can no longer leave a tombstone in the decoded index. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Reconcile the swap when offline compaction commit fails A CommitCompact that fails after the .cpc marker may have renamed .dat but not .idx. cleanup_compact refuses while the marker exists, so the mismatched pair survived until a restart reconciled it — and the decode caller treats the failure as non-fatal. Run reconcileCompactState on commit failure so a decided swap rolls forward and orphan temps are removed before the volume can mount. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Release the decode claim on panic * volume: add ec_decodes_in_flight to the integration-test state literal * volume server: hold the decode tail's lock through compaction The catch_up read released before the rebuilt .idx was written and the volume compacted, so a delete synced to .ecj in that window was durably journaled yet absent from the published index — resurrecting the needle. Rust now holds the store read lock from catch_up through compact, and Go mirrors it by holding the volume's journal lock from the journal- consuming index write through CompactVolumeFiles. * volume server: serialize ec decode's tail per volume, not per store Review follow-ups on the decode path: - Rust: holding the store read lock from journal catch-up through the offline compaction stalled every writer on unrelated volumes for the whole rewrite. The new ec_decode_tail set marks the vid only while its .idx is published and .cpd/.cpx swapped; the two local .ecj append paths (VolumeEcBlobDelete, the distributed delete's local journal) wait on a Notify for that span — Go's per-volume ecjFileAccessLock semantics without the global stall. VolumeMount and the staged-adopt path are also held off while a decode claim is in flight so neither can race the swap. - Rust: the initial journal read ran unlocked, so bytes a rolled-back append later truncated could be folded in as phantom tombstones. The first pass stays unlocked (a slow journal must not stall the store) and a rescan under the quiescing read lock re-reads only committed content; catch_up now rebuilds the id set when a regular journal shrank. - Go: the decode resolved the compaction DiskLocation through FindEcVolume while holding the journal lock, inverting DestroyEcVolume's map->journal order into a deadlock. The lookup now happens first, and DestroyEcVolume/deleteEcVolumeById/DiskLocation.Close destroy outside the map lock. - Go: RebuildEcxFile unlinks .ecj while the volume's ecjFile handle stays open, so later deletes could commit to a detached inode. Both call sites now fold under the journal lock and ReopenDeletionJournal repoints the handle at the live path, working on the volume's resolved .ecx dir (EcIndexBaseFileName) rather than the configured index dir. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: fence EC remounts behind the destroy tombstone DestroyEcVolume, deleteEcVolumeById, and the collection-delete sweep now remove the EcVolume from ecVolumes before destroying it off-lock, so a concurrent remount could re-open shard files that the in-flight destroy then unlinks — registering a detached fd. Each destroy records a per-vid tombstone channel in a new ecVolumesDestroying map before dropping the map entry and closes it when Destroy returns. The tombstone intentionally survives as the vid's destroy generation: loadEcShardWithIdxDir compares it before and after opening the shard, so a destroy that both started and finished inside the open window is still detected. A mismatch drops the just-opened shard (releasing its fd and mount gauge) and retries after the destroy completes; a successful mount clears the stale tombstone. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: rescan the .ecj under the store lock only after a rollback The decode's second journal pass ran a full rescan under the store read lock on every decode, stalling unrelated writers for the length of the scan. Bump a process-wide epoch whenever a failed append truncates its uncommitted tail; an unchanged epoch between the unlocked read and the quiesced pass proves every id folded in was committed, so catch_up() suffices. catch_up() also treats a journal that was read but has since disappeared as shrunk to zero, so its earlier ids cannot linger. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: check the decode tail under the store write lock on delete A blob delete waited for the publishing tail before taking the store write lock, so a decode that claimed the tail while the delete was parked behind the decoder's read lock could still see the journal append land after the rebuilt .idx — an acknowledged delete the mount would miss. Test tail membership under the write lock instead, retrying after the wait; journal_delete_local reports WouldBlock for the same recheck on the distributed path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: claim the vid for mount and staged adoption, per volume VolumeMount and the staged .copying adoption held the ec_decodes_in_flight set lock through slow file renames and mounts, stalling every unrelated volume's decode, mount, and adoption. Take the per-volume claim instead — the same exclusion against a racing decode for this vid, released when the call returns. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: fail the decode when a compaction commit marker survives CompactVolumeFiles' caller logged a compaction error and went on to delete the EC shards. When the commit marker (.cpc) is still on disk the .dat/.idx swap was decided but could not be reconciled, so the mounted pair may be mismatched — report the failure instead so the shards are kept and the caller can retry. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume server: gate the parked-delete test on the held write lock The releaser thread and the spawned delete raced for the store write lock; on a slow runner the delete could acquire it first and commit before the tail was ever claimed, failing !delete.is_finished() on the Windows unit-test job. Spawn the delete only after the thread reports the lock held. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Lu <chris.lu@gmail.com> Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1067 lines
41 KiB
Go
1067 lines
41 KiB
Go
package erasure_coding
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
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
|
|
|
|
// A .ecj smaller than this is never rewritten, however redundant. Below a
|
|
// megabyte the duplication costs nothing and the rewrite is pure churn.
|
|
const ecjCompactMinBytes = 1 << 20
|
|
|
|
// Rewrite only when the journal is at least this many times larger than the
|
|
// set it encodes. A journal holds one entry per delete, so a healthy one is
|
|
// close to 1x; four times the set means most of the file is repeats.
|
|
const ecjCompactRatio = 4
|
|
|
|
// EcjCompactTmpExt names the staging file for a compacted journal, next to the
|
|
// .ecj it replaces. Listed with the other EC index files wherever those are
|
|
// removed, so a tmp left by a crash between write and rename does not outlive
|
|
// its volume.
|
|
const EcjCompactTmpExt = ".ecj.compact.tmp"
|
|
|
|
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
|
|
// ecjHold registers this volume as a holder of its .ecj path for as long
|
|
// as ecjFile may be open; see ecj_registry.go.
|
|
ecjHold *ecjHold
|
|
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: the write offset for appends, and
|
|
// the size mount-time compaction compares against the id set and re-checks
|
|
// on disk before replacing the file. The runtime delete count comes from
|
|
// deletedNeedles, not from this.
|
|
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) {
|
|
return newEcVolumeWith(diskType, dir, dirIdx, collection, vid, realEcjFsOps)
|
|
}
|
|
|
|
// newEcVolumeWith is NewEcVolume with the journal-compaction filesystem steps
|
|
// supplied, so tests can drive a failing load or publish through the real
|
|
// mount.
|
|
func newEcVolumeWith(diskType types.DiskType, dir string, dirIdx string, collection string, vid needle.VolumeId, ecjOps ecjFsOps) (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.
|
|
//
|
|
// Register as a holder first: this waits out a compaction another disk's
|
|
// volume may be running on the same path, so the handle below is on the
|
|
// final inode, and it stops any compaction from replacing the file under
|
|
// this handle.
|
|
ev.ecjHold = acquireEcjHold(indexBaseFileName + ".ecj")
|
|
if ev.ecjFile, err = openEcjFile(indexBaseFileName + ".ecj"); err != nil {
|
|
ev.Close()
|
|
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 {
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: repair torn .ecj tail: %w", vid, truncErr)
|
|
}
|
|
if syncErr := ev.ecjFile.Sync(); syncErr != nil {
|
|
ev.Close()
|
|
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{})
|
|
loadErr := ev.loadDeletedNeedlesFromEcj(ecjOps.readAt)
|
|
if 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
|
|
}
|
|
|
|
// Fold a bloated journal back down to the set it encodes. Last, once every
|
|
// check that can refuse the mount has passed, so a volume the server
|
|
// declines to serve keeps its files as they were.
|
|
if compactErr := ev.compactEcjAfterLoad(loadErr, ecjOps); compactErr != nil {
|
|
ev.Close()
|
|
return nil, fmt.Errorf("ec volume %d: .ecj compaction left no usable journal handle: %w", vid, compactErr)
|
|
}
|
|
|
|
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
|
|
}
|
|
if ev.ecjHold != nil {
|
|
ev.ecjHold.release()
|
|
ev.ecjHold = 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")
|
|
os.Remove(base + EcjCompactTmpExt)
|
|
}
|
|
// 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))
|
|
}
|
|
|
|
// EcIndexBaseFileName returns the base path of the volume's live .ecx/.ecj
|
|
// pair — where NewEcVolume actually found them (ecxActualDir), which can
|
|
// differ from IndexBaseFileName when an .ecx copy exists in both the data
|
|
// and index directories. Anything that must agree with the live journal
|
|
// handle (ecjFile) uses this, not the configured index dir.
|
|
func (ev *EcVolume) EcIndexBaseFileName() string {
|
|
return EcShardFileName(ev.Collection, ev.ecxActualDir, 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(readAt func(f *os.File, b []byte, off int64) (int, error)) 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 := readAt(ev.ecjFile, 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
|
|
}
|
|
|
|
// openEcjFile opens path as the deletion journal handle, creating it if
|
|
// absent. Both the mount and the reopen after compaction go through here, so
|
|
// the handle a compacted volume appends through behaves like the original.
|
|
func openEcjFile(path string) (*os.File, error) {
|
|
return backend.OpenVolumeFile(path, os.O_RDWR|os.O_CREATE)
|
|
}
|
|
|
|
// ecjFsOps are the filesystem steps of a mount's journal compaction: the
|
|
// reads that load the set it compacts from, and the steps that publish the
|
|
// compacted file. Production uses realEcjFsOps; tests substitute failing steps
|
|
// to cover the failure paths through the real mount.
|
|
type ecjFsOps struct {
|
|
readAt func(f *os.File, b []byte, off int64) (int, error)
|
|
rename func(oldpath, newpath string) error
|
|
fsyncDir func(path string) error
|
|
reopen func(path string) (*os.File, error)
|
|
}
|
|
|
|
var realEcjFsOps = ecjFsOps{
|
|
readAt: (*os.File).ReadAt,
|
|
rename: os.Rename,
|
|
fsyncDir: func(p string) error { return util.FsyncDir(filepath.Dir(p)) },
|
|
reopen: openEcjFile,
|
|
}
|
|
|
|
// ecjHandleLostError marks a compaction failure that left the volume without a
|
|
// usable journal handle, so the mount must fail: deletes would error, or land
|
|
// in an inode no longer at the journal's path. Decided where the failure
|
|
// happens rather than inferred afterwards from ecjFile.
|
|
type ecjHandleLostError struct{ err error }
|
|
|
|
func (e *ecjHandleLostError) Error() string { return e.err.Error() }
|
|
func (e *ecjHandleLostError) Unwrap() error { return e.err }
|
|
|
|
// compactEcjAfterLoad compacts the journal unless its load failed. After a
|
|
// failed load the set holds only the part of the journal read before the
|
|
// error, and rewriting the file from it would delete the rest for good.
|
|
func (ev *EcVolume) compactEcjAfterLoad(loadErr error, ops ecjFsOps) error {
|
|
if loadErr != nil {
|
|
glog.Warningf("ec volume %d: not compacting .ecj: its load failed, so the in-memory set may be partial", ev.VolumeId)
|
|
return nil
|
|
}
|
|
return ev.maybeCompactEcj(ops)
|
|
}
|
|
|
|
// maybeCompactEcj rewrites a bloated .ecj from the set just loaded out of it.
|
|
//
|
|
// The journal is semantically a SET of deleted needle ids, written as an
|
|
// append-only log that nothing dedupes. VolumeEcShardsCopy, EC index recovery
|
|
// and ec_decode's merge append a peer's whole journal onto this one, so a
|
|
// volume whose shards are balanced back and forth grows the file
|
|
// geometrically (1.51 TB for ~100 distinct ids in production).
|
|
//
|
|
// Compaction is safe because the set IS the journal's meaning, provided
|
|
// nothing else can write the file between the load and the rename. The
|
|
// ecj_registry reservation and the on-disk re-check establish that: this
|
|
// volume is the only holder of the path, no copy is writing to it, and the file
|
|
// is still the inode and size that was loaded.
|
|
//
|
|
// Returns an error only when the volume is left without a usable journal
|
|
// handle, which must fail the mount. Every other failure leaves the original
|
|
// journal in place and is logged here.
|
|
func (ev *EcVolume) maybeCompactEcj(ops ecjFsOps) error {
|
|
ecjPath := ev.FileName(".ecj")
|
|
tmpPath := EcShardFileName(ev.Collection, ev.ecxActualDir, int(ev.VolumeId)) + EcjCompactTmpExt
|
|
wanted := ev.ecjNeedsCompaction()
|
|
_, tmpStatErr := os.Stat(tmpPath)
|
|
staleTmp := tmpStatErr == nil
|
|
if (!wanted && !staleTmp) || ev.ecjHold == nil {
|
|
return nil
|
|
}
|
|
end, ok := ev.ecjHold.tryBeginCompaction()
|
|
if !ok {
|
|
glog.V(1).Infof("ec volume %d: skipping .ecj compaction: another holder or a copy can reach %s", ev.VolumeId, ecjPath)
|
|
return nil
|
|
}
|
|
defer end()
|
|
// A tmp left by a crash between its write and the rename. Removed under the
|
|
// reservation, so it cannot be another holder's compaction in flight.
|
|
if staleTmp {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
if !wanted {
|
|
return nil
|
|
}
|
|
return ev.compactEcjReserved(ecjPath, tmpPath, ops)
|
|
}
|
|
|
|
// compactEcjReserved is the part of maybeCompactEcj that runs under the path
|
|
// reservation: re-check the file, write the compacted tmp, publish it. Same
|
|
// error contract: an error only when no usable journal handle is left.
|
|
func (ev *EcVolume) compactEcjReserved(ecjPath, tmpPath string, ops ecjFsOps) error {
|
|
unchanged, err := ev.ecjUnchangedSinceLoad(ecjPath)
|
|
if err != nil {
|
|
glog.Warningf("ec volume %d: compact .ecj: stat journal: %v", ev.VolumeId, err)
|
|
return nil
|
|
}
|
|
if !unchanged {
|
|
glog.Warningf("ec volume %d: skipping .ecj compaction: %s changed on disk after it was loaded", ev.VolumeId, ecjPath)
|
|
return nil
|
|
}
|
|
ids := ev.sortedDeletedIds()
|
|
glog.Warningf("ec volume %d: compacting bloated .ecj deletion journal on-disk=%d unique=%d compacted=%d",
|
|
ev.VolumeId, ev.ecjFileSize, len(ids), len(ids)*types.NeedleIdSize)
|
|
if err := writeCompactedEcjTmp(tmpPath, ids); err != nil {
|
|
// A partial tmp would pin its bytes, and the likeliest cause here is
|
|
// ENOSPC, where those bytes are exactly what is scarce.
|
|
_ = os.Remove(tmpPath)
|
|
glog.Warningf("ec volume %d: compact .ecj: write compacted journal: %v", ev.VolumeId, err)
|
|
return nil
|
|
}
|
|
if err := ev.publishCompactedEcj(ecjPath, tmpPath, ops); err != nil {
|
|
var lost *ecjHandleLostError
|
|
if errors.As(err, &lost) {
|
|
return err
|
|
}
|
|
glog.Warningf("ec volume %d: compact .ecj: journal left as it was: %v", ev.VolumeId, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ecjNeedsCompaction reports whether the loaded journal is bloated enough to
|
|
// rewrite: file_bytes >= max(ecjCompactMinBytes, ecjCompactRatio * set_bytes).
|
|
// The 1 MiB floor keeps a small healthy journal from ever being rewritten. It
|
|
// reads only the set's length, so the common no-op mount copies nothing.
|
|
func (ev *EcVolume) ecjNeedsCompaction() bool {
|
|
ev.deletedNeedlesLock.RLock()
|
|
distinct := int64(len(ev.deletedNeedles))
|
|
ev.deletedNeedlesLock.RUnlock()
|
|
compactedLen := distinct * int64(types.NeedleIdSize)
|
|
return ev.ecjFileSize >= ecjCompactMinBytes && ev.ecjFileSize >= compactedLen*ecjCompactRatio
|
|
}
|
|
|
|
// sortedDeletedIds returns the deleted set sorted, so the rewritten file is
|
|
// deterministic and two holders compacting one set write identical bytes.
|
|
func (ev *EcVolume) sortedDeletedIds() []types.NeedleId {
|
|
ev.deletedNeedlesLock.RLock()
|
|
ids := make([]types.NeedleId, 0, len(ev.deletedNeedles))
|
|
for id := range ev.deletedNeedles {
|
|
ids = append(ids, id)
|
|
}
|
|
ev.deletedNeedlesLock.RUnlock()
|
|
slices.Sort(ids)
|
|
return ids
|
|
}
|
|
|
|
// ecjUnchangedSinceLoad reports whether the file at ecjPath is still the one
|
|
// this volume loaded: the same file as the open handle and the size the set
|
|
// was read from. A copy that appended, or replaced the file, after the load
|
|
// fails this, and compacting then would drop what it wrote.
|
|
func (ev *EcVolume) ecjUnchangedSinceLoad(ecjPath string) (bool, error) {
|
|
if ev.ecjFile == nil {
|
|
return false, nil
|
|
}
|
|
held, err := ev.ecjFile.Stat()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
onPath, err := os.Stat(ecjPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return os.SameFile(held, onPath) && held.Size() == onPath.Size() && onPath.Size() == ev.ecjFileSize, nil
|
|
}
|
|
|
|
// writeCompactedEcjTmp writes sorted ids to tmpPath through a buffer and
|
|
// fsyncs it. Opened like every other volume file, so the journal it becomes
|
|
// has the same mode and open flags as one that was never compacted.
|
|
func writeCompactedEcjTmp(tmpPath string, ids []types.NeedleId) error {
|
|
f, err := backend.OpenVolumeFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w := bufio.NewWriterSize(f, ecjLoadChunkBytes)
|
|
var rec [types.NeedleIdSize]byte
|
|
for _, id := range ids {
|
|
types.NeedleIdToBytes(rec[:], id)
|
|
if _, err := w.Write(rec[:]); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
}
|
|
if err := w.Flush(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// publishCompactedEcj replaces the live journal with the compacted tmp file:
|
|
// drop the handle (Windows cannot rename over an open file), rename, fsync the
|
|
// directory, reopen the handle.
|
|
//
|
|
// A failed rename publishes nothing: the tmp is removed and the handle to the
|
|
// original journal restored, and the rename error is returned as is. If that
|
|
// restore fails too, or anything fails after the rename, the volume has no
|
|
// usable handle and the error is an *ecjHandleLostError carrying every error
|
|
// involved.
|
|
func (ev *EcVolume) publishCompactedEcj(ecjPath, tmpPath string, ops ecjFsOps) error {
|
|
if ev.ecjFile != nil {
|
|
_ = ev.ecjFile.Close()
|
|
ev.ecjFile = nil
|
|
}
|
|
if renameErr := ops.rename(tmpPath, ecjPath); renameErr != nil {
|
|
_ = os.Remove(tmpPath)
|
|
reopened, reopenErr := ops.reopen(ecjPath)
|
|
if reopenErr != nil {
|
|
return &ecjHandleLostError{fmt.Errorf("rename %s over %s: %w; reopening the original journal then failed: %w",
|
|
tmpPath, ecjPath, renameErr, reopenErr)}
|
|
}
|
|
ev.ecjFile = reopened
|
|
return renameErr
|
|
}
|
|
lost := func(what string, err error) error {
|
|
return &ecjHandleLostError{fmt.Errorf("replaced %s but could not %s: %w", ecjPath, what, err)}
|
|
}
|
|
// The tmp sync persisted contents, not the directory entry. Without this a
|
|
// power loss can restore the old journal and discard deletes acknowledged
|
|
// against the replacement.
|
|
if err := ops.fsyncDir(ecjPath); err != nil {
|
|
return lost("fsync its directory", err)
|
|
}
|
|
reopened, err := ops.reopen(ecjPath)
|
|
if err != nil {
|
|
return lost("reopen it", err)
|
|
}
|
|
fi, err := reopened.Stat()
|
|
if err != nil {
|
|
_ = reopened.Close()
|
|
return lost("stat it", err)
|
|
}
|
|
ev.ecjFile = reopened
|
|
ev.ecjFileSize = fi.Size()
|
|
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)
|
|
}
|