package erasure_coding // EC bitrot detection — checksum sidecar. // // A per-volume sidecar file stores a CRC32C (Castagnoli) checksum for every // fixed-size block of every EC shard, so a scrub (and the reconstruction path) // can detect silent disk corruption in any shard — including cold parity shards // that are never read during normal serving. // // The sidecar is OPTIONAL: an absent or generation-mismatched sidecar simply // means "feature off" for that generation, so old binaries, JSON-only Rust // nodes, and rollback deployments ignore it and degrade gracefully. See // EC_BITROT_DETECTION_DESIGN.md. // // On-disk layout of .ecsum (legacy/generation 0) and .ecsum.v: // // [ magic(4) | format_version(2) | payload_len(4) | payload_crc32c(4) ] [ proto payload ] // // The header's payload_crc32c lets a loader detect corruption of the sidecar // itself BEFORE trusting any contents, so a rotted sidecar can never be // mistaken for shard corruption. import ( "crypto/rand" "encoding/binary" "fmt" "io" "math/bits" "os" "path/filepath" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb" "github.com/seaweedfs/seaweedfs/weed/storage/needle" "google.golang.org/protobuf/proto" ) const ( // BitrotSidecarExt is the canonical extension for the checksum sidecar. // Generation 0 (legacy/fresh encode) uses ".ecsum"; vacuum generation // N uses ".ecsum.v", mirroring the .vif/.ecx versioned convention. BitrotSidecarExt = ".ecsum" // DefaultBitrotBlockSize is the default checksum granularity (16 MiB). It is // a power-of-two multiple of ErasureCodingSmallBlockSize (1 MiB) and keeps // the sidecar tiny (~11 KB for a 30 GB volume) while localizing corruption // to a 16 MiB region. DefaultBitrotBlockSize = 16 * 1024 * 1024 // MaxBitrotBlockSize caps the block granularity so a loaded sidecar cannot // force a huge scrub/verify scratch buffer. Power-of-two multiple of 1 MiB. MaxBitrotBlockSize = 64 * 1024 * 1024 bitrotMagic uint32 = 0x45435355 // "ECSU" bitrotFormatVersion uint16 = 1 bitrotHeaderSize = 14 // magic(4)+version(2)+payload_len(4)+payload_crc32c(4) ) // Config knobs (wired to volume-server flags). Defaults: protection on for new // encodes, 16 MiB block granularity. var ( // BitrotProtectionEnabled controls whether encode/vacuum write a sidecar. // When false, generations are produced unprotected (no sidecar), which is a // legitimate "off" state. Detection on already-protected generations is // unaffected. BitrotProtectionEnabled = true // BitrotBlockSize is the checksum block granularity used for new sidecars. // Must remain a power-of-two multiple of 1 MiB. BitrotBlockSize int64 = DefaultBitrotBlockSize ) // BitrotStatus is the resolved protection state of an EC volume's active // generation after loading and validating its sidecar. type BitrotStatus int const ( // BitrotOff: no sidecar, or a sidecar that does not describe the active // generation. The generation is unprotected; this is NOT corruption. BitrotOff BitrotStatus = iota // BitrotOn: a complete, well-formed, generation-matching sidecar is loaded. BitrotOn // BitrotInvalid: a generation-matching sidecar that is malformed, incomplete, // or self-integrity-failed. The generation is unprotected pending repair of // the sidecar, and an integrity alarm should fire. The rebuild path treats // this as fail-closed (see ec_encoder rebuild verify). BitrotInvalid ) func (s BitrotStatus) String() string { switch s { case BitrotOn: return "on" case BitrotInvalid: return "invalid" default: return "off" } } // BitrotSidecarPath returns the sidecar path for a base file name and EC // generation. Generation 0 is the un-suffixed legacy path; generation N>0 is // the versioned path, consistent with how .vif/.ecx are versioned by the 2PC // switch. func BitrotSidecarPath(baseFileName string, generation uint32) string { if generation == 0 { return baseFileName + BitrotSidecarExt } return fmt.Sprintf("%s%s.v%d", baseFileName, BitrotSidecarExt, generation) } // NewEncodeUUID returns a fresh random per-encode identity used to detect a // stale sidecar left behind by an in-place re-encode. func NewEncodeUUID() []byte { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // rand.Read essentially never fails; fall back to a zero UUID rather // than aborting an encode over entropy. Stale detection still has the // other defenses (wholesale-mismatch cap + RS arbiter). glog.Warningf("ec bitrot: failed to read encode uuid entropy: %v", err) } return b } // isPow2MultipleOf1MiB reports whether block_size is a power of two in // [1 MiB, MaxBitrotBlockSize]. func isPow2MultipleOf1MiB(blockSize uint32) bool { return blockSize >= (1<<20) && blockSize <= MaxBitrotBlockSize && bits.OnesCount32(blockSize) == 1 } // shardChecksumBuilder accumulates the per-block CRC32C of a single shard's byte // stream as it is written. It tolerates arbitrary chunk sizes that cross block // boundaries, so it works for both the 256 KiB encode buffers and the 1 MiB // rebuild buffers. type shardChecksumBuilder struct { blockSize int64 cur needle.CRC curLen int64 total int64 blocks []uint32 } func newShardChecksumBuilder(blockSize int64) *shardChecksumBuilder { return &shardChecksumBuilder{blockSize: blockSize} } func (b *shardChecksumBuilder) write(p []byte) { for len(p) > 0 { room := b.blockSize - b.curLen n := int64(len(p)) if n > room { n = room } b.cur = b.cur.Update(p[:n]) b.curLen += n b.total += n p = p[n:] if b.curLen == b.blockSize { b.blocks = append(b.blocks, uint32(b.cur)) b.cur = 0 b.curLen = 0 } } } // finalize flushes any partial last block and returns the covered size and the // packed little-endian uint32 CRC array. func (b *shardChecksumBuilder) finalize() (coveredSize int64, packed []byte) { if b.curLen > 0 { b.blocks = append(b.blocks, uint32(b.cur)) b.cur = 0 b.curLen = 0 } return b.total, packUint32LE(b.blocks) } // buildProtectionFromBuilders assembles a generation-0 EcBitrotProtection from // the per-shard checksum builders produced during an encode pass. Callers that // produce a versioned generation (vacuum) set prot.Generation afterwards. A // fresh encode_uuid is minted on every call so an in-place re-encode is // distinguishable from a stale sidecar. func buildProtectionFromBuilders(ctx *ECContext, builders []*shardChecksumBuilder, blockSize int64) *volume_server_pb.EcBitrotProtection { shards := make([]*volume_server_pb.EcShardChecksums, 0, len(builders)) for id, b := range builders { covered, packed := b.finalize() shards = append(shards, &volume_server_pb.EcShardChecksums{ ShardId: uint32(id), CoveredSize: covered, BlockCrc32C: packed, }) } return &volume_server_pb.EcBitrotProtection{ Algorithm: volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C, BlockSize: uint32(blockSize), Generation: 0, EcShardConfig: &volume_server_pb.EcShardConfig{ DataShards: uint32(ctx.DataShards), ParityShards: uint32(ctx.ParityShards), BlockSize: ctx.BlockSize, }, Shards: shards, EncodeUuid: NewEncodeUUID(), } } func packUint32LE(vals []uint32) []byte { out := make([]byte, len(vals)*4) for i, v := range vals { binary.LittleEndian.PutUint32(out[i*4:], v) } return out } func unpackUint32LE(b []byte) []uint32 { out := make([]uint32, len(b)/4) for i := range out { out[i] = binary.LittleEndian.Uint32(b[i*4:]) } return out } // expectedBlockCount returns ceil(coveredSize/blockSize). func expectedBlockCount(coveredSize, blockSize int64) int { if blockSize <= 0 { return 0 } return int((coveredSize + blockSize - 1) / blockSize) } // SaveBitrotSidecar atomically writes prot to path, wrapped in the on-disk // header with a CRC32C over the serialized payload. func SaveBitrotSidecar(path string, prot *volume_server_pb.EcBitrotProtection) error { payload, err := proto.Marshal(prot) if err != nil { return fmt.Errorf("marshal bitrot sidecar: %w", err) } // The header records payload_len as a uint32 and the allocation below adds // it to a constant. Bound the payload well under any overflow (a real // manifest is a few KB) so neither the length field nor make() can wrap. const maxBitrotPayloadSize = 1 << 30 // 1 GiB, vastly above any real sidecar if len(payload) > maxBitrotPayloadSize { return fmt.Errorf("bitrot sidecar payload too large: %d bytes", len(payload)) } buf := make([]byte, bitrotHeaderSize+len(payload)) binary.BigEndian.PutUint32(buf[0:4], bitrotMagic) binary.BigEndian.PutUint16(buf[4:6], bitrotFormatVersion) binary.BigEndian.PutUint32(buf[6:10], uint32(len(payload))) binary.BigEndian.PutUint32(buf[10:14], uint32(needle.NewCRC(payload))) copy(buf[bitrotHeaderSize:], payload) tmp := path + ".tmp" if err := os.WriteFile(tmp, buf, 0644); err != nil { return fmt.Errorf("write bitrot sidecar tmp %s: %w", tmp, err) } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return fmt.Errorf("rename bitrot sidecar %s: %w", path, err) } return nil } // LoadBitrotSidecar reads and self-integrity-checks a sidecar file. It returns // the parsed message, or an error if the file is missing, truncated, has a bad // magic/version, or fails the payload CRC. A self-integrity failure is a // sidecar-integrity problem (caller maps it to BitrotInvalid), never a shard // corruption signal. func LoadBitrotSidecar(path string) (*volume_server_pb.EcBitrotProtection, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } if len(data) < bitrotHeaderSize { return nil, fmt.Errorf("bitrot sidecar %s too short (%d bytes)", path, len(data)) } if magic := binary.BigEndian.Uint32(data[0:4]); magic != bitrotMagic { return nil, fmt.Errorf("bitrot sidecar %s bad magic %#x", path, magic) } if ver := binary.BigEndian.Uint16(data[4:6]); ver != bitrotFormatVersion { return nil, fmt.Errorf("bitrot sidecar %s unsupported format version %d", path, ver) } payloadLen := binary.BigEndian.Uint32(data[6:10]) wantCRC := binary.BigEndian.Uint32(data[10:14]) payload := data[bitrotHeaderSize:] if int(payloadLen) != len(payload) { return nil, fmt.Errorf("bitrot sidecar %s length mismatch: header %d, actual %d", path, payloadLen, len(payload)) } if got := uint32(needle.NewCRC(payload)); got != wantCRC { return nil, fmt.Errorf("bitrot sidecar %s self-integrity CRC mismatch: header %#x, computed %#x", path, wantCRC, got) } prot := &volume_server_pb.EcBitrotProtection{} if err := proto.Unmarshal(payload, prot); err != nil { return nil, fmt.Errorf("unmarshal bitrot sidecar %s: %w", path, err) } return prot, nil } // ValidateBitrotManifest performs the disk-free manifest/syntax checks that // every loader runs: supported algorithm, valid block size, exactly one entry // per shard id in the active layout (no duplicates, no out-of-range ids), // positive covered_size, and a packed-CRC count consistent with covered_size. // It does NOT compare covered_size against on-disk shard lengths — that is a // per-node physical check done only for locally-held shards. func ValidateBitrotManifest(prot *volume_server_pb.EcBitrotProtection, dataShards, parityShards int) error { if prot.Algorithm != volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C { return fmt.Errorf("unsupported checksum algorithm %v", prot.Algorithm) } bs := int64(prot.BlockSize) if !isPow2MultipleOf1MiB(prot.BlockSize) { return fmt.Errorf("invalid block_size %d (must be a power-of-two multiple of 1 MiB, at most %d)", prot.BlockSize, MaxBitrotBlockSize) } total := dataShards + parityShards if total <= 0 || total > MaxShardCount { return fmt.Errorf("invalid active layout: data=%d parity=%d", dataShards, parityShards) } if len(prot.Shards) != total { return fmt.Errorf("incomplete manifest: %d shard entries, expected %d", len(prot.Shards), total) } seen := make([]bool, MaxShardCount) for _, s := range prot.Shards { if s.ShardId >= uint32(total) { return fmt.Errorf("shard id %d out of range [0,%d)", s.ShardId, total) } if seen[s.ShardId] { return fmt.Errorf("duplicate shard id %d", s.ShardId) } seen[s.ShardId] = true if s.CoveredSize <= 0 { return fmt.Errorf("shard %d has non-positive covered_size %d", s.ShardId, s.CoveredSize) } wantCount := expectedBlockCount(s.CoveredSize, bs) if len(s.BlockCrc32C) != wantCount*4 { return fmt.Errorf("shard %d crc count mismatch: %d bytes, expected %d (covered_size=%d block_size=%d)", s.ShardId, len(s.BlockCrc32C), wantCount*4, s.CoveredSize, prot.BlockSize) } } return nil } // shardChecksums returns the EcShardChecksums entry for a shard id, or nil. func shardChecksums(prot *volume_server_pb.EcBitrotProtection, shardId uint32) *volume_server_pb.EcShardChecksums { for _, s := range prot.Shards { if s.ShardId == shardId { return s } } return nil } // verifyShardFileBlocks reads a shard file at path in block_size chunks and // compares each block's CRC32C against the manifest entry. It returns the list // of mismatching block indices (empty == clean) and a fatal error only for I/O // problems or a covered_size/length mismatch (which is itself corruption of the // shard). It does not interpret the result — the caller (scrub / rebuild) // arbitrates shard-vs-sidecar via Reed-Solomon before acting. func verifyShardFileBlocks(path string, entry *volume_server_pb.EcShardChecksums, blockSize int64) (mismatched []int, err error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() fi, err := f.Stat() if err != nil { return nil, err } if fi.Size() != entry.CoveredSize { // Length drift (truncation or unexpected trailing bytes) is shard // corruption: report every block as mismatched so the caller treats the // shard as bad. want := unpackUint32LE(entry.BlockCrc32C) all := make([]int, len(want)) for i := range all { all[i] = i } return all, nil } want := unpackUint32LE(entry.BlockCrc32C) buf := make([]byte, blockSize) var offset int64 for i := 0; i < len(want); i++ { toRead := blockSize if rem := entry.CoveredSize - offset; rem < toRead { toRead = rem } // os.File.ReadAt fills the slice or returns an error; a full read that // lands exactly on EOF may still report io.EOF, which is not corruption. // A short read (n < toRead) means the shard is truncated and the EOF is // propagated as an error. n, rerr := f.ReadAt(buf[:toRead], offset) if rerr == io.EOF && int64(n) == toRead { rerr = nil } if rerr != nil { return nil, rerr } if uint32(needle.NewCRC(buf[:n])) != want[i] { mismatched = append(mismatched, i) } offset += int64(n) } return mismatched, nil } // ComputeProtectionFromShards builds a complete EcBitrotProtection by reading // the on-disk bytes of every shard for a generation — the trust-on-first-use // backfill primitive for EC volumes that were encoded before this feature. It // requires EVERY shard to be reachable (locally or in additionalDirs) and // returns an error otherwise, so a partial sidecar is never produced. This is // the per-holder building block a coordinator assembles across servers; on a // server that holds (or can reach) all shards it yields a complete manifest // directly. Caveat: it blesses whatever bytes exist now and cannot detect // pre-baseline corruption. func ComputeProtectionFromShards(baseFileName string, ctx *ECContext, generation uint32, additionalDirs []string) (*volume_server_pb.EcBitrotProtection, error) { shards := make([]*volume_server_pb.EcShardChecksums, 0, ctx.Total()) for id := 0; id < ctx.Total(); id++ { path := findShardFile(baseFileName, ctx.ToExt(id), additionalDirs) if path == "" { return nil, fmt.Errorf("bitrot backfill: shard %d missing for %s; refusing to write a partial sidecar", id, baseFileName) } covered, packed, err := computeShardFileCRCs(path, BitrotBlockSize) if err != nil { return nil, fmt.Errorf("bitrot backfill: read shard %d (%s): %w", id, path, err) } shards = append(shards, &volume_server_pb.EcShardChecksums{ ShardId: uint32(id), CoveredSize: covered, BlockCrc32C: packed, }) } return &volume_server_pb.EcBitrotProtection{ Algorithm: volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C, BlockSize: uint32(BitrotBlockSize), Generation: generation, EcShardConfig: &volume_server_pb.EcShardConfig{ DataShards: uint32(ctx.DataShards), ParityShards: uint32(ctx.ParityShards), BlockSize: ctx.BlockSize, }, Shards: shards, EncodeUuid: NewEncodeUUID(), }, nil } // computeShardFileCRCs reads a shard file sequentially and returns its size and // packed per-block CRC32C array. func computeShardFileCRCs(path string, blockSize int64) (coveredSize int64, packed []byte, err error) { f, err := os.Open(path) if err != nil { return 0, nil, err } defer f.Close() b := newShardChecksumBuilder(blockSize) buf := make([]byte, blockSize) for { n, rerr := f.Read(buf) if n > 0 { b.write(buf[:n]) } if rerr == io.EOF { break } if rerr != nil { return 0, nil, rerr } if n == 0 { break } } coveredSize, packed = b.finalize() return coveredSize, packed, nil } // BitrotProtection returns the active-generation bitrot protection and its // status. The returned message is read-only; callers must not mutate it. func (ev *EcVolume) BitrotProtection() (*volume_server_pb.EcBitrotProtection, BitrotStatus) { ev.bitrotLock.RLock() defer ev.bitrotLock.RUnlock() return ev.bitrot, ev.bitrotStatus } // ReloadBitrotSidecar re-resolves the checksum sidecar for a volume that is // already mounted — a shard delivery can bring the manifest with it, and the // receive path only writes the file, so without this the in-memory volume // keeps the protection state it resolved at mount (off) until a remount. func (ev *EcVolume) ReloadBitrotSidecar(additionalDirs ...string) { if err := ev.loadActiveBitrotSidecar(additionalDirs...); err != nil { glog.Warningf("ec volume %d: reload bitrot sidecar: %v", ev.VolumeId, err) } } // loadActiveBitrotSidecar loads the generation-0 checksum sidecar into the // volume. Best-effort: any failure leaves protection off/invalid without // failing the mount. OSS only produces generation-0 (fresh-encode) sidecars. func (ev *EcVolume) loadActiveBitrotSidecar(additionalDirs ...string) error { return ev.loadBitrotForGeneration(0, additionalDirs...) } // loadBitrotForGeneration loads and validates the sidecar describing generation // `generation`, setting ev.bitrot/ev.bitrotStatus. Called at mount. Absent or // generation-mismatched => BitrotOff; self-integrity/manifest failure => // BitrotInvalid; usable => BitrotOn. // // A sidecar written FOR THIS generation that disagrees with the volume's shard // geometry is different in kind from those: both files record the layout the // generation was encoded with, so a disagreement means one of them is wrong and // reads through the other would land at the wrong shard offsets. That returns // an error and fails the mount instead of quietly dropping to unprotected // reads. func (ev *EcVolume) loadBitrotForGeneration(generation uint32, additionalDirs ...string) error { ev.bitrotLock.Lock() defer ev.bitrotLock.Unlock() ev.bitrot = nil ev.bitrotStatus = BitrotOff if ev.ECContext == nil { return nil } path := findBitrotSidecar(generation, ev.DataBaseFileName(), ev.IndexBaseFileName(), additionalDirs...) if path == "" { return nil } prot, err := LoadBitrotSidecar(path) if err != nil { glog.Warningf("ec volume %d: bitrot sidecar %s self-integrity failed: %v", ev.VolumeId, path, err) ev.bitrotStatus = BitrotInvalid return nil } if prot.Generation != generation { return nil // not for this generation -> off, not corruption } if prot.EcShardConfig == nil { return nil // records no geometry -> nothing to contradict } if int(prot.EcShardConfig.DataShards) != ev.ECContext.DataShards || int(prot.EcShardConfig.ParityShards) != ev.ECContext.ParityShards || prot.EcShardConfig.BlockSize != ev.ECContext.BlockSize { return fmt.Errorf("ec volume %d generation %d: %s records layout %d+%d block %d but the volume is mounted as %d+%d block %d; refusing to serve one of the two layouts", ev.VolumeId, generation, path, prot.EcShardConfig.DataShards, prot.EcShardConfig.ParityShards, prot.EcShardConfig.BlockSize, ev.ECContext.DataShards, ev.ECContext.ParityShards, ev.ECContext.BlockSize) } if err := ValidateBitrotManifest(prot, ev.ECContext.DataShards, ev.ECContext.ParityShards); err != nil { glog.Warningf("ec volume %d: bitrot sidecar %s manifest invalid: %v", ev.VolumeId, path, err) ev.bitrotStatus = BitrotInvalid return nil } ev.bitrot = prot ev.bitrotStatus = BitrotOn glog.V(1).Infof("ec volume %d: loaded bitrot protection generation %d (%d shards, block_size %d)", ev.VolumeId, generation, len(prot.Shards), prot.BlockSize) return nil } // layoutFromSidecar resolves a volume's EC layout from its generation-0 // checksum sidecar, searching the data base and then the index base. A split // -dir/-dir.idx layout keeps the sidecar with the index, so probing the data // base alone reports "absent" for a volume that has the record right there — // and absent is the one answer that selects the legacy 10+4 layout. // // The three answers stay distinct: (nil, false, nil) means genuinely absent // and the caller may fall back to the defaults; a non-nil error means the // record exists but cannot establish the layout, which must fail rather than // default; otherwise the config is usable. func layoutFromSidecar(dataBaseFileName, indexBaseFileName string) (cfg *volume_server_pb.EcShardConfig, found bool, err error) { path := findBitrotSidecar(0, dataBaseFileName, indexBaseFileName) if path == "" { return nil, false, nil } cfg, err = EcShardConfigFromSidecarPath(path) return cfg, true, err } // EcShardConfigFromSidecar reads the generation-0 bitrot sidecar's record of a // volume's EC config. The three answers are distinct on purpose: absent means // the volume may genuinely predate the sidecar, which is the only case a caller // may answer with the legacy layout; present-but-unusable means the one // surviving record of the geometry is corrupt, and guessing from there returns // wrong bytes rather than no bytes. func EcShardConfigFromSidecar(baseFileName string) (cfg *volume_server_pb.EcShardConfig, found bool, err error) { path := BitrotSidecarPath(baseFileName, 0) if _, statErr := os.Stat(path); statErr != nil { if os.IsNotExist(statErr) { return nil, false, nil } return nil, false, fmt.Errorf("stat %s: %w", path, statErr) } cfg, err = EcShardConfigFromSidecarPath(path) return cfg, true, err } // EcShardConfigFromSidecarPath is EcShardConfigFromSidecar for a sidecar whose // path the caller already resolved — the rebuild finds it across a multi-disk // server's directories, not only next to the base name. func EcShardConfigFromSidecarPath(path string) (*volume_server_pb.EcShardConfig, error) { prot, loadErr := LoadBitrotSidecar(path) if loadErr != nil { return nil, fmt.Errorf("read %s: %w", path, loadErr) } // The un-suffixed sidecar describes generation 0 and nothing else; one // stamped for another generation is not a record of these shards. if prot.GetGeneration() != 0 { return nil, fmt.Errorf("%s records generation %d, not generation 0", path, prot.GetGeneration()) } cfg := prot.GetEcShardConfig() if cfg == nil { return nil, fmt.Errorf("%s records no EC config", path) } if !ValidEcShardCounts(cfg.GetDataShards(), cfg.GetParityShards()) { return nil, fmt.Errorf("%s records invalid shard counts %d+%d", path, cfg.GetDataShards(), cfg.GetParityShards()) } if bsErr := ValidateBlockSize(cfg.GetBlockSize()); bsErr != nil { return nil, fmt.Errorf("%s: %w", path, bsErr) } return cfg, nil } // RemoveBitrotSidecars removes the legacy .ecsum and any versioned // .ecsum.v sidecars for a base file name. Best-effort. func RemoveBitrotSidecars(base string) { os.Remove(base + BitrotSidecarExt) if matches, _ := filepath.Glob(base + BitrotSidecarExt + ".v*"); matches != nil { for _, m := range matches { os.Remove(m) } } } // FindBitrotSidecar is findBitrotSidecar for callers outside this package. It // answers "does this volume already have a manifest, and where" — a question // that cannot be settled from one base name, because a split -dir/-dir.idx // layout keeps the sidecar with the index and a multi-disk server may keep it // on a sibling. Returns "" when no candidate exists. func FindBitrotSidecar(generation uint32, dataBase, indexBase string, additionalDirs ...string) string { return findBitrotSidecar(generation, dataBase, indexBase, additionalDirs...) } // findBitrotSidecar resolves the sidecar path for a generation, searching the // data base, the index base, and any additional directories — mirroring how // shard/.vif lookups handle split data/idx layouts and per-disk mirrors. func findBitrotSidecar(generation uint32, dataBase, indexBase string, additionalDirs ...string) string { candidates := []string{ BitrotSidecarPath(dataBase, generation), BitrotSidecarPath(indexBase, generation), } base := filepath.Base(dataBase) for _, dir := range additionalDirs { candidates = append(candidates, BitrotSidecarPath(filepath.Join(dir, base), generation)) } for _, c := range candidates { if c == "" { continue } if _, err := os.Stat(c); err == nil { return c } } return "" }