Files
Chris Lu 80dd3b2621 EC bitrot follow-ups: protect destination sidecar on optional copy; cap sidecar block_size (#9763)
* fix(ec_bitrot): cap sidecar block_size in ValidateBitrotManifest

A sidecar loaded from disk (or supplied via a backfill/peer RPC) could carry a
huge power-of-two block_size that passed validation, then force a multi-GiB
scratch-buffer allocation in scrub/verify. Add a shared MaxBitrotBlockSize
(64 MiB) constant, enforce it as an upper bound in isPow2MultipleOf1MiB, and
derive the volume flag cap from the same constant so they cannot drift.

* fix(ec_bitrot): don't destroy a valid destination sidecar on an optional copy

writeToFile opened the destination with O_TRUNC before knowing whether the
source had the file, so an optional copy (ignoreSourceFileNotFound) from a source
that lacks the .ecsum truncated and then removed a valid pre-existing destination
sidecar. Stage the optional copy into a temp sibling and commit it with an atomic
rename only when the source actually delivered the file; a missing source is now
a no-op. Mandatory copies keep their in-place behavior.
2026-05-31 23:42:33 -07:00

559 lines
20 KiB
Go

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 <base>.ecsum (legacy/generation 0) and <base>.ecsum.v<N>:
//
// [ 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 "<base>.ecsum"; vacuum generation
// N uses "<base>.ecsum.v<N>", 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),
},
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),
},
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
}
// 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() {
ev.loadBitrotForGeneration(0)
}
// loadBitrotForGeneration loads and validates the sidecar describing generation
// `generation`, setting ev.bitrot/ev.bitrotStatus. Called at mount. Absent or
// generation/config-mismatched => BitrotOff; self-integrity/manifest failure =>
// BitrotInvalid; usable => BitrotOn.
func (ev *EcVolume) loadBitrotForGeneration(generation uint32) {
ev.bitrotLock.Lock()
defer ev.bitrotLock.Unlock()
ev.bitrot = nil
ev.bitrotStatus = BitrotOff
if ev.ECContext == nil {
return
}
path := findBitrotSidecar(generation, ev.DataBaseFileName(), ev.IndexBaseFileName())
if path == "" {
return
}
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
}
if prot.Generation != generation {
return // not for this generation -> off, not corruption
}
if prot.EcShardConfig == nil ||
int(prot.EcShardConfig.DataShards) != ev.ECContext.DataShards ||
int(prot.EcShardConfig.ParityShards) != ev.ECContext.ParityShards {
return
}
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
}
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)
}
// RemoveBitrotSidecars removes the legacy <base>.ecsum and any versioned
// <base>.ecsum.v<N> 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 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 ""
}