EC bitrot detection: per-shard checksum sidecars (#9761)

* ec: add EC bitrot checksum protobuf

EcBitrotProtection/EcShardChecksums/ChecksumAlgorithm sidecar messages,
copy_ecsum_file and unsafe_ignore_sidecar fields, and a CHECKSUM scrub mode.

* ec: bitrot checksum sidecar format, validation, and per-volume load

Per-shard CRC32C block checksums in an optional <base>.ecsum sidecar with a
self-integrity header; validation, rolling builder, backfill primitive, and
EcVolume load on mount + removal on destroy.

* ec: capture per-shard checksums at encode; verify-and-exclude on rebuild

WriteEcFilesWithContext returns the protection computed inline during encoding.
generateMissingEcFiles verifies present inputs against the sidecar, excludes
corrupt ones, regenerates in place, and re-verifies; fail-closed unless
unsafe_ignore_sidecar, removing all generated outputs on failure.

* ec: read-only checksum scrub with Reed-Solomon arbiter

ChecksumScrub verifies each local shard against the sidecar and reconstructs
flagged shards from the clean shards so stale-sidecar false positives are not
reported. Wired to the gRPC CHECKSUM mode and ec.scrub -mode checksum.

* ec: server-side bitrot sidecar write, copy, cleanup, and opportunistic backfill

Write .ecsum at fresh encode; propagate it with copy_ecsum_file (tolerant);
remove it on full delete and decode; rebuild honors unsafe_ignore_sidecar and
opportunistically backfills a sidecar when all shards are reachable.

* ec: volume server bitrot config flags

-ec.bitrotChecksum (default on) and -ec.bitrotBlockSizeMB (default 16).

* fix(ec_bitrot): bound -ec.bitrotBlockSizeMB before the int64 multiply

Validate the MiB value is in [1, 1024] before multiplying by 1 MiB, so a huge
flag value cannot overflow int64 and slip past the power-of-two check, and a
block size cannot collapse a sidecar to a few oversized blocks.

* fix(ec_bitrot): distribute the .ecsum sidecar from the worker encode path

The worker EC encode wrote the generation-0 sidecar locally but never added it
to shardFiles, so DistributeEcShards never shipped it and the distributed
holders came up unprotected. Append it to shardFiles and map the ecsum shard
type to its extension in the sender so it travels with the shards.

* fix(ec_bitrot): remove orphaned sidecars when the generation is gone

Gate sidecar removal on existingShardCount==0 alone rather than also requiring a
stray .ecx. A sidecar whose shards have all been deleted is orphaned and must be
removed even when no .ecx remains, or it leaks. .ecx/.ecj/.vif removal stays
gated on hasEcxFile as before.

* fix(ec_bitrot): do not fold checksum blocks scanned into TotalFiles

ChecksumScrub's first return is blocks scanned, not files. Discard it so the
scrub response's TotalFiles (a needle/file count) is not inflated by the block
count for CHECKSUM mode.

* test(ec_bitrot): clean up generated .ecsum sidecars in removeGeneratedFiles

* fix(ec_bitrot): reject an oversized sidecar payload before the uint32 cast

The header stores payload_len as a uint32; bound the payload before the
conversion so a pathological manifest cannot truncate the length field and
corrupt the sidecar. A real manifest is a few KB, so this never trips.

* fix(ec_bitrot): cap -ec.bitrotBlockSizeMB at 64 MiB

The block size becomes the per-shard scratch buffer the scrub/backfill path
allocates, so an over-large value (e.g. 1 GiB) is a memory hazard per concurrent
scrub worker. Lower the upper bound from 1024 to 64 MiB.

* fix(ec_bitrot): add -ecUnsafeIgnoreSidecar to weed tool fix -ecx

The -ecx recovery path reconstructs missing shards via RebuildEcFilesWithContext,
which fails closed on a malformed/stale .ecsum. Without an override flag an
operator could not complete the rebuild without manually deleting the sidecar.
Expose -ecUnsafeIgnoreSidecar (default false) and thread it through.

* fix(ec_bitrot): bound sidecar payload with a direct int constant; drop readFull

Guard len(payload) against a plain int constant (1 GiB) before the allocation
instead of a uint64 MaxUint32 compare, so the allocation-size value is provably
bounded (clears the CodeQL overflow alert) and the math import is no longer
needed. Inline os.File.ReadAt with io.EOF handling in verifyShardFileBlocks and
remove the now-redundant readFull helper (os.File.ReadAt fills the slice or
errors).

* test(ec_bitrot): use slices.Contains instead of a hand-rolled containsU32

* refactor(ec): fold the EcFiles WithContext variants into the base functions

RebuildEcFiles now takes the *ECContext directly (nil => derive from .vif as
before) and WriteEcFiles takes it too (nil => default), removing the parallel
RebuildEcFilesWithContext / WriteEcFilesWithContext names. Callers that had an
explicit context drop the WithContext suffix; the default-context callers pass
nil. No behavior change.

* refactor(ec): pass BackgroundECContext instead of nil to Write/RebuildEcFiles

Add a non-nil BackgroundECContext placeholder (analogous to context.Background())
and have callers with no specific layout pass it instead of a nil *ECContext.
WriteEcFiles resolves a zero/background context to the default ratio and
RebuildEcFiles resolves it from the .vif, so behavior is unchanged.

* fix(ec_bitrot): make BackgroundECContext a func; RebuildEcFiles fails closed on bad .vif

- BackgroundECContext is now a function returning a fresh *ECContext, so callers
  cannot mutate a shared singleton or race on it (and it mirrors context.Background,
  which is also a function).
- RebuildEcFiles now propagates the MaybeLoadVolumeInfo error: a present-but-
  unreadable .vif fails closed instead of silently rebuilding with the default
  ratio (which would corrupt a custom-ratio volume). Pass an explicit ctx to override.
This commit is contained in:
Chris Lu
2026-05-31 18:52:44 -07:00
committed by GitHub
parent fdfeb4063c
commit 9658f309d2
22 changed files with 2184 additions and 457 deletions
+10 -9
View File
@@ -37,14 +37,15 @@ var cmdFix = &Command{
}
var (
fixVolumeCollection = cmdFix.Flag.String("collection", "", "an optional volume collection name, if specified only it will be processed")
fixVolumeId = cmdFix.Flag.Int64("volumeId", 0, "an optional volume id, if not 0 (default) only it will be processed")
fixIncludeDeleted = cmdFix.Flag.Bool("includeDeleted", true, "include deleted entries in the index file")
fixIgnoreError = cmdFix.Flag.Bool("ignoreError", false, "an optional, if true will be processed despite errors")
fixRemoteFile = cmdFix.Flag.Bool("remoteFile", false, "an optional, if true will not try to load the local .dat file, but only the remote file")
fixGenerateEcx = cmdFix.Flag.Bool("ecx", false, "regenerate a lost EC index (.ecx) — and the .vif when missing — from the local .ec## shards (missing shards are reconstructed from parity when enough survive). Run with the volume server stopped.")
fixEcDataShards = cmdFix.Flag.Int("ecDataShards", 0, "EC data shard count for -ecx (0 = read from .vif, otherwise default 10)")
fixEcParityShards = cmdFix.Flag.Int("ecParityShards", 0, "EC parity shard count for -ecx (0 = read from .vif, infer from shard count, otherwise default 4)")
fixVolumeCollection = cmdFix.Flag.String("collection", "", "an optional volume collection name, if specified only it will be processed")
fixVolumeId = cmdFix.Flag.Int64("volumeId", 0, "an optional volume id, if not 0 (default) only it will be processed")
fixIncludeDeleted = cmdFix.Flag.Bool("includeDeleted", true, "include deleted entries in the index file")
fixIgnoreError = cmdFix.Flag.Bool("ignoreError", false, "an optional, if true will be processed despite errors")
fixRemoteFile = cmdFix.Flag.Bool("remoteFile", false, "an optional, if true will not try to load the local .dat file, but only the remote file")
fixGenerateEcx = cmdFix.Flag.Bool("ecx", false, "regenerate a lost EC index (.ecx) — and the .vif when missing — from the local .ec## shards (missing shards are reconstructed from parity when enough survive). Run with the volume server stopped.")
fixEcDataShards = cmdFix.Flag.Int("ecDataShards", 0, "EC data shard count for -ecx (0 = read from .vif, otherwise default 10)")
fixEcParityShards = cmdFix.Flag.Int("ecParityShards", 0, "EC parity shard count for -ecx (0 = read from .vif, infer from shard count, otherwise default 4)")
fixEcUnsafeIgnoreSidecar = cmdFix.Flag.Bool("ecUnsafeIgnoreSidecar", false, "for -ecx: proceed even when the EC bitrot checksum sidecar (.ecsum) is malformed or stale, instead of failing closed; the reconstructed shards are not verified against it")
)
type VolumeFileScanner4Fix struct {
@@ -381,7 +382,7 @@ func doFixEcxFromShards(basePath, baseFileName, collection string, volumeId int6
if !dataComplete {
ctx := &erasure_coding.ECContext{DataShards: dataShards, ParityShards: parityShards}
glog.Infof("volume %d: %d/%d shards present; reconstructing missing shards (%s) before index rebuild", volumeId, presentCount, dataShards+parityShards, ctx.String())
if _, err := erasure_coding.RebuildEcFilesWithContext(base, ctx); err != nil {
if _, err := erasure_coding.RebuildEcFiles(base, ctx, *fixEcUnsafeIgnoreSidecar); err != nil {
fail(fmt.Errorf("volume %d: reconstruct missing shards from %d survivors: %w", volumeId, presentCount, err))
return
}
+1 -1
View File
@@ -92,7 +92,7 @@ func buildAndEncodeTestEcVolume(t *testing.T, dir, baseName string) (base string
idxFile.Close()
nm.Close()
if err := erasure_coding.WriteEcFiles(base); err != nil {
if _, err := erasure_coding.WriteEcFiles(base, erasure_coding.BackgroundECContext()); err != nil {
t.Fatalf("WriteEcFiles: %v", err)
}
if err := erasure_coding.WriteSortedFileFromIdx(base, ".ecx"); err != nil {
+24
View File
@@ -23,6 +23,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/server/constants"
stats_collect "github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
"github.com/seaweedfs/seaweedfs/weed/util/grace"
@@ -139,6 +140,9 @@ var (
volumeWhiteListOption = cmdVolume.Flag.String("whiteList", "", "comma separated Ip addresses having write permission. No limit if empty.")
minFreeSpacePercent = cmdVolume.Flag.String("minFreeSpacePercent", "1", "minimum free disk space (default to 1%). Low disk space will mark all volumes as ReadOnly (deprecated, use minFreeSpace instead).")
minFreeSpace = cmdVolume.Flag.String("minFreeSpace", "", "min free disk space (value<=100 as percentage like 1, other as human readable bytes, like 10GiB). Low disk space will mark all volumes as ReadOnly.")
ecBitrotChecksum = cmdVolume.Flag.Bool("ec.bitrotChecksum", true, "write a bitrot checksum sidecar (.ecsum) when generating EC shards, enabling silent-corruption detection on scrub/rebuild")
ecBitrotBlockSizeMB = cmdVolume.Flag.Int("ec.bitrotBlockSizeMB", 16, "EC bitrot checksum block granularity in MiB; must be a power of two and at least 1")
)
func runVolume(cmd *Command, args []string) bool {
@@ -171,6 +175,26 @@ func runVolume(cmd *Command, args []string) bool {
*v.mastersString = *v.mserverString
}
// Apply EC bitrot checksum settings.
erasure_coding.BitrotProtectionEnabled = *ecBitrotChecksum
// Validate the block size before multiplying so an absurd MiB value cannot
// overflow int64 and slip a bogus size past the power-of-two check. The
// block size also becomes the per-shard scratch buffer the scrub/backfill
// path allocates, so the upper bound caps that allocation (64 MiB per
// concurrent scrub worker) and keeps a typo from taking the server down.
const maxBitrotBlockSizeMB = 64
if mb := *ecBitrotBlockSizeMB; mb >= 1 && mb <= maxBitrotBlockSizeMB {
if blockSize := int64(mb) * 1024 * 1024; blockSize&(blockSize-1) == 0 {
erasure_coding.BitrotBlockSize = blockSize
} else if mb != 16 {
glog.Warningf("ignoring invalid -ec.bitrotBlockSizeMB=%d (must be a power of two); using %d MiB",
mb, erasure_coding.BitrotBlockSize/(1024*1024))
}
} else if *ecBitrotBlockSizeMB != 16 {
glog.Warningf("ignoring out-of-range -ec.bitrotBlockSizeMB=%d (must be a power of two in [1, %d] MiB); using %d MiB",
*ecBitrotBlockSizeMB, maxBitrotBlockSizeMB, erasure_coding.BitrotBlockSize/(1024*1024))
}
minFreeSpaces := util.MustParseMinFreeSpace(*minFreeSpace, *minFreeSpacePercent)
v.masters = pb.ServerAddresses(*v.mastersString).ToAddresses()
v.startVolumeServer(*volumeFolders, *maxVolumeCounts, *volumeWhiteListOption, minFreeSpaces)
+27
View File
@@ -428,6 +428,7 @@ message VolumeEcShardsGenerateResponse {
message VolumeEcShardsRebuildRequest {
uint32 volume_id = 1;
string collection = 2;
bool unsafe_ignore_sidecar = 3; // bypass the bitrot-sidecar fail-closed guard (operator override; distinct from ec.rebuild -force)
}
message VolumeEcShardsRebuildResponse {
repeated uint32 rebuilt_shard_ids = 1;
@@ -442,6 +443,7 @@ message VolumeEcShardsCopyRequest {
bool copy_ecj_file = 6;
bool copy_vif_file = 7;
uint32 disk_id = 8; // Target disk ID for storing EC shards
bool copy_ecsum_file = 9; // copy the bitrot checksum sidecar (.ecsum) when present; tolerant of a missing source (no-op), since this non-2PC path has no Prepare backstop
}
message VolumeEcShardsCopyResponse {
}
@@ -578,6 +580,30 @@ message EcShardConfig {
uint32 data_shards = 1; // Number of data shards (e.g., 10)
uint32 parity_shards = 2; // Number of parity shards (e.g., 4)
}
// EcBitrotProtection is the entire content of a bitrot checksum sidecar
// (<base>.ecsum for the legacy generation, <base>.ecsum.v<N> for vacuum
// generation N). On disk it is wrapped in a fixed header carrying a CRC32C
// over this serialized payload (see weed/storage/erasure_coding/ec_bitrot.go).
message EcBitrotProtection {
ChecksumAlgorithm algorithm = 1; // CRC32C (Castagnoli)
uint32 block_size = 2; // bytes per checksum block; default 16777216 (16 MiB), a power-of-two multiple of 1 MiB
uint32 generation = 3; // EC vacuum generation these checksums describe (0 = legacy/fresh); must match the sidecar filename version
EcShardConfig ec_shard_config = 4; // data/parity shard counts at encode time
repeated EcShardChecksums shards = 5; // one entry per shard id in the active layout
bytes encode_uuid = 6; // random per-encode identity, for stale-sidecar detection across in-place re-encodes
}
message EcShardChecksums {
uint32 shard_id = 1; // 0..MaxShardCount-1 (custom EC ratios go up to 32)
int64 covered_size = 2; // shard byte length these checksums cover (must equal the on-disk shard length)
bytes block_crc32c = 3; // packed little-endian uint32[] = ceil(covered_size/block_size) entries
}
enum ChecksumAlgorithm {
CHECKSUM_NONE = 0;
CHECKSUM_CRC32C = 1;
}
message OldVersionVolumeInfo {
repeated RemoteFile files = 1;
uint32 version = 2;
@@ -655,6 +681,7 @@ enum VolumeScrubMode {
INDEX = 1;
FULL = 2;
LOCAL = 3;
CHECKSUM = 4; // EC only: verify each local shard's raw bytes against the bitrot checksum sidecar
}
message ScrubVolumeRequest {
File diff suppressed because it is too large Load Diff
+100 -16
View File
@@ -7,6 +7,7 @@ import (
"math"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
@@ -84,6 +85,7 @@ func (vs *VolumeServer) VolumeEcShardsGenerate(ctx context.Context, req *volume_
os.Remove(baseFileName + ecCtx.ToExt(i))
}
os.Remove(v.IndexFileName() + ".ecx")
os.Remove(erasure_coding.BitrotSidecarPath(baseFileName, 0))
}()
// IMPORTANT: Generate .ecx BEFORE EC shards to prevent a race condition.
@@ -106,8 +108,19 @@ func (vs *VolumeServer) VolumeEcShardsGenerate(ctx context.Context, req *volume_
datSize, _, _ := v.FileStat()
// write .ec00 ~ .ec[TotalShards-1] files using context
if err := erasure_coding.WriteEcFilesWithContext(baseFileName, ecCtx); err != nil {
return nil, fmt.Errorf("WriteEcFilesWithContext %s: %v", baseFileName, err)
ecBitrot, err := erasure_coding.WriteEcFiles(baseFileName, ecCtx)
if err != nil {
return nil, fmt.Errorf("WriteEcFiles %s: %v", baseFileName, err)
}
// Persist the generation-0 bitrot checksum sidecar (<base>.ecsum) alongside
// the shards so it travels with them during distribution (copy_ecsum_file).
// The source loading its own canonical sidecar is correct — it holds all
// shards and a complete manifest. Best-effort: a failed sidecar write leaves
// the generation unprotected rather than failing the encode.
if erasure_coding.BitrotProtectionEnabled && ecBitrot != nil {
if serr := erasure_coding.SaveBitrotSidecar(erasure_coding.BitrotSidecarPath(baseFileName, 0), ecBitrot); serr != nil {
glog.Warningf("failed to write EC bitrot sidecar for volume %d: %v", req.VolumeId, serr)
}
}
// write .vif files
@@ -203,9 +216,11 @@ func (vs *VolumeServer) VolumeEcShardsRebuild(ctx context.Context, req *volume_s
additionalDirs = append(additionalDirs, otherLocation.Directory)
}
// Rebuild missing EC files, searching all disk locations for input shards
// Rebuild missing EC files, searching all disk locations for input shards.
// Present input shards are verified against the bitrot sidecar (when present)
// and corrupt ones are regenerated; unsafe_ignore_sidecar bypasses the guard.
dataBaseFileName := path.Join(rebuildDataDir, baseFileName)
if generatedShardIds, err := erasure_coding.RebuildEcFiles(dataBaseFileName, additionalDirs...); err != nil {
if generatedShardIds, err := erasure_coding.RebuildEcFiles(dataBaseFileName, erasure_coding.BackgroundECContext(), req.UnsafeIgnoreSidecar, additionalDirs...); err != nil {
return nil, fmt.Errorf("RebuildEcFiles %s: %v", dataBaseFileName, err)
} else {
rebuiltShardIds = generatedShardIds
@@ -219,6 +234,30 @@ func (vs *VolumeServer) VolumeEcShardsRebuild(ctx context.Context, req *volume_s
return nil, fmt.Errorf("RebuildEcxFile %s: %v", indexBaseFileName, err)
}
// Opportunistic bitrot backfill: if protection is enabled, no sidecar exists
// yet (a volume encoded before this feature), and this rebuilder can reach
// every shard, compute and write a generation-0 sidecar. The TOFU baseline
// blesses current bytes; ComputeProtectionFromShards refuses a partial
// manifest, so a multi-server rebuild that cannot reach all shards just skips.
if erasure_coding.BitrotProtectionEnabled {
sidecarPath := erasure_coding.BitrotSidecarPath(dataBaseFileName, 0)
if _, statErr := os.Stat(sidecarPath); os.IsNotExist(statErr) {
ctx := erasure_coding.NewDefaultECContext("", 0)
if vi, _, found, _ := volume_info.MaybeLoadVolumeInfo(dataBaseFileName + ".vif"); found && vi.EcShardConfig != nil {
if ds, ps := int(vi.EcShardConfig.DataShards), int(vi.EcShardConfig.ParityShards); ds > 0 && ps > 0 && ds+ps <= erasure_coding.MaxShardCount {
ctx = &erasure_coding.ECContext{DataShards: ds, ParityShards: ps}
}
}
if prot, berr := erasure_coding.ComputeProtectionFromShards(dataBaseFileName, ctx, 0, additionalDirs); berr != nil {
glog.V(2).Infof("bitrot backfill skipped for %s: %v", dataBaseFileName, berr)
} else if werr := erasure_coding.SaveBitrotSidecar(sidecarPath, prot); werr != nil {
glog.Warningf("bitrot backfill: write sidecar for %s: %v", dataBaseFileName, werr)
} else {
glog.V(0).Infof("bitrot backfill: wrote sidecar for %s after rebuild", dataBaseFileName)
}
}
}
return &volume_server_pb.VolumeEcShardsRebuildResponse{
RebuiltShardIds: rebuiltShardIds,
}, nil
@@ -330,6 +369,17 @@ func (vs *VolumeServer) VolumeEcShardsCopy(ctx context.Context, req *volume_serv
return err
}
}
if req.CopyEcsumFile {
// Propagate the generation-0 bitrot checksum sidecar when the source
// has one. This non-2PC copy path (balance / fresh-encode / rebuild
// distribution) has no Prepare backstop, and fresh-encode sidecar
// writes are best-effort, so a missing source sidecar is a no-op
// (ignore-not-found): the holder is simply unprotected.
if _, err := vs.doCopyFile(client, true, req.Collection, req.VolumeId, math.MaxUint32, math.MaxInt64, dataBaseFileName, erasure_coding.BitrotSidecarExt, false, true, nil); err != nil {
return fmt.Errorf("VolumeEcShardsCopy volume %d: copy %s sidecar: %w", req.VolumeId, erasure_coding.BitrotSidecarExt, err)
}
}
return nil
})
if err != nil {
@@ -393,27 +443,54 @@ func deleteEcShardIdsForEachLocation(bName string, location *storage.DiskLocatio
return err
}
if hasEcxFile && existingShardCount == 0 {
// Remove .ecx/.ecj from both idx and data directories
// since they may be in either location depending on when -dir.idx was configured
if err := os.Remove(indexBaseFilename + ".ecx"); err != nil && !os.IsNotExist(err) {
return err
}
os.Remove(indexBaseFilename + ".ecj")
if existingShardCount == 0 {
// The whole EC generation is gone on this disk.
// Remove the bitrot checksum sidecar(s) (.ecsum and any .ecsum.v<N>)
// from both dirs. This is gated on the shards being gone, NOT on a
// stray .ecx still being present: a sidecar whose shards have all been
// deleted is orphaned and must go even when no .ecx remains, or it
// leaks. The per-shard-id delete that ec.rebuild uses for
// copied-survivor cleanup leaves shards behind, so this guard does not
// fire there.
removeBitrotSidecars(dataBaseFilename)
if location.IdxDirectory != location.Directory {
os.Remove(dataBaseFilename + ".ecx")
os.Remove(dataBaseFilename + ".ecj")
removeBitrotSidecars(indexBaseFilename)
}
if !hasIdxFile {
// .vif is used for ec volumes and normal volumes
os.Remove(dataBaseFilename + ".vif")
if hasEcxFile {
// Remove .ecx/.ecj from both idx and data directories
// since they may be in either location depending on when -dir.idx was configured
if err := os.Remove(indexBaseFilename + ".ecx"); err != nil && !os.IsNotExist(err) {
return err
}
os.Remove(indexBaseFilename + ".ecj")
if location.IdxDirectory != location.Directory {
os.Remove(dataBaseFilename + ".ecx")
os.Remove(dataBaseFilename + ".ecj")
}
if !hasIdxFile {
// .vif is used for ec volumes and normal volumes
os.Remove(dataBaseFilename + ".vif")
}
}
}
return nil
}
// removeBitrotSidecars removes the legacy <base>.ecsum and any versioned
// <base>.ecsum.v<N> sidecars. Best-effort; logs nothing on absence.
func removeBitrotSidecars(baseFilename string) {
os.Remove(baseFilename + erasure_coding.BitrotSidecarExt)
if matches, _ := filepath.Glob(baseFilename + erasure_coding.BitrotSidecarExt + ".v*"); matches != nil {
for _, m := range matches {
os.Remove(m)
}
}
}
func checkEcVolumeStatus(bName string, location *storage.DiskLocation) (hasEcxFile bool, hasIdxFile bool, existingShardCount int, err error) {
// check whether to delete the .ecx and .ecj file also
fileInfos, err := os.ReadDir(location.Directory)
@@ -681,6 +758,13 @@ func (vs *VolumeServer) VolumeEcShardsToVolume(ctx context.Context, req *volume_
return nil, fmt.Errorf("WriteIdxFileFromEcIndex %s: %v", v.IndexBaseFileName(), err)
}
// The EC generation is gone; drop its bitrot sidecar(s) so a later EC
// re-encode cannot mistake a stale .ecsum for protection.
removeBitrotSidecars(dataBaseFileName)
if indexBaseFileName != dataBaseFileName {
removeBitrotSidecars(indexBaseFileName)
}
var volumeLocation *storage.DiskLocation
for _, location := range vs.store.Locations {
if candidate, found := location.FindEcVolume(needle.VolumeId(req.VolumeId)); found && candidate == v {
+6
View File
@@ -125,6 +125,12 @@ func (vs *VolumeServer) ScrubEcVolume(ctx context.Context, req *volume_server_pb
files, shardInfos, serrs = v.ScrubLocal()
case volume_server_pb.VolumeScrubMode_FULL:
files, shardInfos, serrs = vs.store.ScrubEcVolume(v.VolumeId)
case volume_server_pb.VolumeScrubMode_CHECKSUM:
// Verify each local shard's raw bytes against the bitrot sidecar,
// exercising cold parity shards. Read-only. ChecksumScrub's first
// return is blocks scanned, not files — discard it so TotalFiles
// (a needle/file count) isn't inflated by the block count.
_, shardInfos, serrs = v.ChecksumScrub()
default:
return nil, fmt.Errorf("unsupported EC volume scrub mode %d", m)
}
+1
View File
@@ -350,6 +350,7 @@ func oneServerCopyAndMountEcShardsFromSource(grpcDialOption grpc.DialOption,
CopyEcxFile: true,
CopyEcjFile: true,
CopyVifFile: true,
CopyEcsumFile: true, // propagate the bitrot sidecar with the shards (no-op if the source has none)
SourceDataNode: string(existingLocation),
DiskId: destDiskId,
})
+3 -1
View File
@@ -49,7 +49,7 @@ func (c *commandEcVolumeScrub) Do(args []string, commandEnv *CommandEnv, writer
volScrubCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
nodesStr := volScrubCommand.String("node", "", "comma-separated list of volume server <host>:<port> (optional)")
volumeIDsStr := volScrubCommand.String("volumeId", "", "comma-separated EC volume IDs to process (optional)")
mode := volScrubCommand.String("mode", "local", "scrubbing mode (index/local/full)")
mode := volScrubCommand.String("mode", "local", "scrubbing mode (index/local/full/checksum)")
maxParallelization := volScrubCommand.Int("maxParallelization", DefaultMaxParallelization, "run up to X tasks in parallel, whenever possible")
if err = volScrubCommand.Parse(args); err != nil {
@@ -96,6 +96,8 @@ func (c *commandEcVolumeScrub) Do(args []string, commandEnv *CommandEnv, writer
c.mode = volume_server_pb.VolumeScrubMode_LOCAL
case "FULL":
c.mode = volume_server_pb.VolumeScrubMode_FULL
case "CHECKSUM":
c.mode = volume_server_pb.VolumeScrubMode_CHECKSUM
default:
return fmt.Errorf("unsupported scrubbing mode %q", *mode)
}
@@ -86,7 +86,7 @@ func TestCalculateExpectedShardSizeWithRealEncoding(t *testing.T) {
expectedShardSize := calculateExpectedShardSize(tt.datFileSize, erasure_coding.DataShardsCount)
// Run actual EC encoding
err = erasure_coding.WriteEcFiles(baseFileName)
_, err = erasure_coding.WriteEcFiles(baseFileName, erasure_coding.BackgroundECContext())
if err != nil {
t.Fatalf("Failed to encode EC files: %v", err)
}
@@ -167,7 +167,7 @@ func TestCalculateExpectedShardSizeEdgeCases(t *testing.T) {
expectedShardSize := calculateExpectedShardSize(tt.datFileSize, erasure_coding.DataShardsCount)
// Run actual EC encoding
err = erasure_coding.WriteEcFiles(baseFileName)
_, err = erasure_coding.WriteEcFiles(baseFileName, erasure_coding.BackgroundECContext())
if err != nil {
t.Fatalf("Failed to encode EC files: %v", err)
}
+554
View File
@@ -0,0 +1,554 @@
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
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 multiple of
// 1 MiB (i.e. a power of two that is at least 1 MiB).
func isPow2MultipleOf1MiB(blockSize uint32) bool {
return blockSize >= (1<<20) && 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)", prot.BlockSize)
}
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 ""
}
@@ -0,0 +1,362 @@
package erasure_coding
import (
"math/rand"
"os"
"path/filepath"
"slices"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
)
func TestBitrotSidecarRoundTripAndSelfIntegrity(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "vol.ecsum")
prot := &volume_server_pb.EcBitrotProtection{
Algorithm: volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C,
BlockSize: DefaultBitrotBlockSize,
Generation: 0,
EcShardConfig: &volume_server_pb.EcShardConfig{DataShards: 10, ParityShards: 4},
Shards: []*volume_server_pb.EcShardChecksums{
{ShardId: 0, CoveredSize: 5, BlockCrc32C: packUint32LE([]uint32{123})},
},
EncodeUuid: NewEncodeUUID(),
}
if err := SaveBitrotSidecar(path, prot); err != nil {
t.Fatalf("save: %v", err)
}
got, err := LoadBitrotSidecar(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.BlockSize != prot.BlockSize || len(got.Shards) != 1 || got.Shards[0].CoveredSize != 5 {
t.Fatalf("roundtrip mismatch: %+v", got)
}
// Corrupt a payload byte and confirm the self-integrity check trips.
data, _ := os.ReadFile(path)
data[bitrotHeaderSize+2] ^= 0xff
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
if _, err := LoadBitrotSidecar(path); err == nil {
t.Fatalf("expected self-integrity failure after payload corruption")
}
}
func TestShardChecksumBuilderCrossesBoundaries(t *testing.T) {
blockSize := int64(1 << 20)
data := make([]byte, 0, 3*blockSize+12345)
r := rand.New(rand.NewSource(1))
data = data[:cap(data)]
r.Read(data)
// Feed in odd-sized chunks that cross block boundaries.
b := newShardChecksumBuilder(blockSize)
for off := 0; off < len(data); off += 7777 {
end := off + 7777
if end > len(data) {
end = len(data)
}
b.write(data[off:end])
}
covered, packed := b.finalize()
if covered != int64(len(data)) {
t.Fatalf("covered=%d want=%d", covered, len(data))
}
got := unpackUint32LE(packed)
// Compare to a direct per-block CRC.
want := []uint32{}
for off := int64(0); off < int64(len(data)); off += blockSize {
end := off + blockSize
if end > int64(len(data)) {
end = int64(len(data))
}
want = append(want, uint32(needle.NewCRC(data[off:end])))
}
if len(got) != len(want) {
t.Fatalf("block count got=%d want=%d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("block %d crc mismatch got=%x want=%x", i, got[i], want[i])
}
}
}
func TestValidateBitrotManifest(t *testing.T) {
mk := func(mut func(*volume_server_pb.EcBitrotProtection)) *volume_server_pb.EcBitrotProtection {
p := &volume_server_pb.EcBitrotProtection{
Algorithm: volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C,
BlockSize: DefaultBitrotBlockSize,
EcShardConfig: &volume_server_pb.EcShardConfig{DataShards: 2, ParityShards: 1},
}
for id := 0; id < 3; id++ {
p.Shards = append(p.Shards, &volume_server_pb.EcShardChecksums{
ShardId: uint32(id), CoveredSize: 5, BlockCrc32C: packUint32LE([]uint32{1}),
})
}
if mut != nil {
mut(p)
}
return p
}
if err := ValidateBitrotManifest(mk(nil), 2, 1); err != nil {
t.Fatalf("valid manifest rejected: %v", err)
}
bad := map[string]func(*volume_server_pb.EcBitrotProtection){
"missing shard": func(p *volume_server_pb.EcBitrotProtection) { p.Shards = p.Shards[:2] },
"out of range id": func(p *volume_server_pb.EcBitrotProtection) { p.Shards[2].ShardId = 9 },
"duplicate id": func(p *volume_server_pb.EcBitrotProtection) { p.Shards[2].ShardId = 0 },
"zero covered": func(p *volume_server_pb.EcBitrotProtection) { p.Shards[0].CoveredSize = 0 },
"bad crc count": func(p *volume_server_pb.EcBitrotProtection) { p.Shards[0].BlockCrc32C = []byte{1, 2, 3} },
"bad block size": func(p *volume_server_pb.EcBitrotProtection) { p.BlockSize = 3 << 20 }, // not power of two
"bad algorithm": func(p *volume_server_pb.EcBitrotProtection) {
p.Algorithm = volume_server_pb.ChecksumAlgorithm_CHECKSUM_NONE
},
}
for name, mut := range bad {
if err := ValidateBitrotManifest(mk(mut), 2, 1); err == nil {
t.Errorf("%s: expected validation error, got nil", name)
}
}
}
// writeRandomDat creates a <base>.dat of n bytes for encoding.
func writeRandomDat(t *testing.T, base string, n int) {
t.Helper()
data := make([]byte, n)
rand.New(rand.NewSource(42)).Read(data)
if err := os.WriteFile(base+".dat", data, 0644); err != nil {
t.Fatal(err)
}
}
func TestEncodeProducesVerifiableSidecar(t *testing.T) {
old := BitrotBlockSize
BitrotBlockSize = 1 << 20
t.Cleanup(func() { BitrotBlockSize = old })
dir := t.TempDir()
base := filepath.Join(dir, "7")
writeRandomDat(t, base, 6*1024*1024) // ~6 MiB
ctx := &ECContext{DataShards: 10, ParityShards: 4}
prot, err := WriteEcFiles(base, ctx)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := SaveBitrotSidecar(BitrotSidecarPath(base, 0), prot); err != nil {
t.Fatalf("save sidecar: %v", err)
}
if err := ValidateBitrotManifest(prot, 10, 4); err != nil {
t.Fatalf("encode produced invalid manifest: %v", err)
}
// Every shard file verifies clean against the sidecar right after encode.
for id := 0; id < ctx.Total(); id++ {
entry := shardChecksums(prot, uint32(id))
mism, verr := verifyShardFileBlocks(base+ctx.ToExt(id), entry, int64(prot.BlockSize))
if verr != nil {
t.Fatalf("verify shard %d: %v", id, verr)
}
if len(mism) != 0 {
t.Fatalf("shard %d unexpectedly mismatched at encode time: %v", id, mism)
}
}
// Corrupt one byte in shard 12 (a parity shard) and confirm detection.
corruptOneByte(t, base+ctx.ToExt(12))
entry := shardChecksums(prot, 12)
mism, verr := verifyShardFileBlocks(base+ctx.ToExt(12), entry, int64(prot.BlockSize))
if verr != nil {
t.Fatalf("verify corrupt shard: %v", verr)
}
if len(mism) == 0 {
t.Fatalf("expected to detect corruption in parity shard 12")
}
}
func TestRebuildExcludesCorruptPresentShard(t *testing.T) {
old := BitrotBlockSize
BitrotBlockSize = 1 << 20
t.Cleanup(func() { BitrotBlockSize = old })
dir := t.TempDir()
base := filepath.Join(dir, "9")
writeRandomDat(t, base, 6*1024*1024)
ctx := &ECContext{DataShards: 10, ParityShards: 4}
prot, err := WriteEcFiles(base, ctx)
if err != nil {
t.Fatalf("encode: %v", err)
}
if err := SaveBitrotSidecar(BitrotSidecarPath(base, 0), prot); err != nil {
t.Fatalf("save sidecar: %v", err)
}
// Snapshot a good copy of shard 3, then delete shard 0 (missing) and corrupt
// shard 3 (present-but-corrupt). Rebuild must regenerate both correctly.
good3, _ := os.ReadFile(base + ctx.ToExt(3))
if err := os.Remove(base + ctx.ToExt(0)); err != nil {
t.Fatal(err)
}
corruptOneByte(t, base+ctx.ToExt(3))
generated, err := RebuildEcFiles(base, ctx, false)
if err != nil {
t.Fatalf("rebuild: %v", err)
}
if !slices.Contains(generated, 0) || !slices.Contains(generated, 3) {
t.Fatalf("expected shards 0 and 3 regenerated, got %v", generated)
}
// Shard 3 should now byte-match its original good content, and every shard
// verifies clean against the sidecar.
now3, _ := os.ReadFile(base + ctx.ToExt(3))
if string(now3) != string(good3) {
t.Fatalf("rebuilt shard 3 does not match original good content")
}
for id := 0; id < ctx.Total(); id++ {
entry := shardChecksums(prot, uint32(id))
mism, verr := verifyShardFileBlocks(base+ctx.ToExt(id), entry, int64(prot.BlockSize))
if verr != nil || len(mism) != 0 {
t.Fatalf("post-rebuild shard %d not clean: mism=%v err=%v", id, mism, verr)
}
}
}
func TestComputeProtectionFromShardsBackfill(t *testing.T) {
old := BitrotBlockSize
BitrotBlockSize = 1 << 20
t.Cleanup(func() { BitrotBlockSize = old })
dir := t.TempDir()
base := filepath.Join(dir, "11")
writeRandomDat(t, base, 6*1024*1024)
ctx := &ECContext{DataShards: 10, ParityShards: 4}
if _, err := WriteEcFiles(base, ctx); err != nil {
t.Fatalf("encode: %v", err)
}
// Backfill from the on-disk shards (no encode pass) yields a complete,
// valid manifest that every shard verifies clean against.
prot, err := ComputeProtectionFromShards(base, ctx, 0, nil)
if err != nil {
t.Fatalf("backfill: %v", err)
}
if err := ValidateBitrotManifest(prot, 10, 4); err != nil {
t.Fatalf("backfill manifest invalid: %v", err)
}
for id := 0; id < ctx.Total(); id++ {
entry := shardChecksums(prot, uint32(id))
mism, verr := verifyShardFileBlocks(base+ctx.ToExt(id), entry, int64(prot.BlockSize))
if verr != nil || len(mism) != 0 {
t.Fatalf("backfilled shard %d not clean: mism=%v err=%v", id, mism, verr)
}
}
// A missing shard must abort backfill (never write a partial manifest).
if err := os.Remove(base + ctx.ToExt(5)); err != nil {
t.Fatal(err)
}
if _, err := ComputeProtectionFromShards(base, ctx, 0, nil); err == nil {
t.Fatalf("expected backfill error with a missing shard")
}
}
func TestRebuildFailClosedCleansUpGeneratedShard(t *testing.T) {
old := BitrotBlockSize
BitrotBlockSize = 1 << 20
t.Cleanup(func() { BitrotBlockSize = old })
dir := t.TempDir()
base := filepath.Join(dir, "13")
writeRandomDat(t, base, 6*1024*1024)
ctx := &ECContext{DataShards: 10, ParityShards: 4}
prot, err := WriteEcFiles(base, ctx)
if err != nil {
t.Fatalf("encode: %v", err)
}
// Tamper one block CRC of shard 0 but re-save so the header self-integrity and
// manifest stay valid: a well-formed but wrong ("stale") sidecar that loads as ON.
for _, s := range prot.Shards {
if s.ShardId == 0 && len(s.BlockCrc32C) >= 1 {
s.BlockCrc32C[0] ^= 0xff
}
}
if err := SaveBitrotSidecar(BitrotSidecarPath(base, 0), prot); err != nil {
t.Fatalf("save sidecar: %v", err)
}
// Delete shard 0 so the rebuild regenerates it; the regenerated (correct)
// bytes will not match the tampered sidecar entry -> post-verify fails closed.
shard0 := base + ctx.ToExt(0)
if err := os.Remove(shard0); err != nil {
t.Fatal(err)
}
if _, rerr := RebuildEcFiles(base, ctx, false); rerr == nil {
t.Fatalf("expected rebuild to fail closed on the stale sidecar")
}
// The genuinely-missing shard 0 must NOT be published: fail-closed cleanup
// returns it to missing.
if _, err := os.Stat(shard0); !os.IsNotExist(err) {
t.Fatalf("shard 0 should have been cleaned up after a failed rebuild, err=%v", err)
}
// With the unsafe override, the rebuild proceeds and regenerates shard 0.
if _, rerr := RebuildEcFiles(base, ctx, true); rerr != nil {
t.Fatalf("rebuild with unsafeIgnoreSidecar should succeed: %v", rerr)
}
if _, err := os.Stat(shard0); err != nil {
t.Fatalf("shard 0 should exist after unsafe rebuild: %v", err)
}
}
func TestRemoveBitrotSidecars(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "5")
// Legacy + two versioned sidecars, plus an unrelated file that must survive.
for _, p := range []string{base + ".ecsum", base + ".ecsum.v1", base + ".ecsum.v7"} {
if err := os.WriteFile(p, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
keep := base + ".ec00"
if err := os.WriteFile(keep, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
RemoveBitrotSidecars(base)
for _, p := range []string{base + ".ecsum", base + ".ecsum.v1", base + ".ecsum.v7"} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("expected %s removed, err=%v", p, err)
}
}
if _, err := os.Stat(keep); err != nil {
t.Errorf("unrelated file %s should survive: %v", keep, err)
}
}
func corruptOneByte(t *testing.T, path string) {
t.Helper()
f, err := os.OpenFile(path, os.O_RDWR, 0644)
if err != nil {
t.Fatal(err)
}
defer f.Close()
var b [1]byte
if _, err := f.ReadAt(b[:], 0); err != nil {
t.Fatal(err)
}
b[0] ^= 0xff
if _, err := f.WriteAt(b[:], 0); err != nil {
t.Fatal(err)
}
}
@@ -18,7 +18,7 @@ import (
// existed in VolumeEcShardsGenerate before the fix in this PR.
//
// Previously, the order was:
// 1. WriteEcFilesWithContext(baseFileName, ecCtx) — EC shards from .dat
// 1. WriteEcFiles(baseFileName, ecCtx) — EC shards from .dat
// 2. WriteSortedFileFromIdx(v.IndexFileName(), ".ecx") — .ecx from .idx
//
// If a write appended data to .dat/.idx between steps 1 and 2, the .ecx would
@@ -48,7 +48,7 @@ func TestEcConsistency_WritesBetweenEncodeAndEcx(t *testing.T) {
})
// Phase 2: EC encode — generates .ec00-.ec13 from current .dat
err = generateEcFiles(baseFileName, int(smallBlockSize), largeBlockSize, smallBlockSize, ctx)
_, err = generateEcFiles(baseFileName, int(smallBlockSize), largeBlockSize, smallBlockSize, ctx)
require.NoError(t, err, "EC encoding")
// Phase 3: SIMULATE a write between EC encoding and .ecx generation
@@ -145,7 +145,7 @@ func TestEcConsistency_ExactLargeRowEncoding(t *testing.T) {
require.NoError(t, err)
// EC encode
err = generateEcFiles(baseFileName, int(smallBlockSize), largeBlockSize, smallBlockSize, ctx)
_, err = generateEcFiles(baseFileName, int(smallBlockSize), largeBlockSize, smallBlockSize, ctx)
require.NoError(t, err)
// Check shard sizes — each shard should be exactly largeBlockSize
+11
View File
@@ -30,6 +30,17 @@ func NewDefaultECContext(collection string, volumeId needle.VolumeId) *ECContext
}
}
// BackgroundECContext returns a non-nil placeholder EC context, analogous to
// context.Background(): pass it to WriteEcFiles / RebuildEcFiles when the caller
// has no specific layout, rather than a nil context. Its zero Total() is the
// "unset" signal — WriteEcFiles resolves it to the default ratio and
// RebuildEcFiles resolves it from the volume's .vif (falling back to default),
// so the placeholder itself never reaches the encoder. A fresh value is returned
// each call so callers cannot mutate a shared default.
func BackgroundECContext() *ECContext {
return &ECContext{}
}
// CreateEncoder creates a Reed-Solomon encoder for this context
func (ctx *ECContext) CreateEncoder() (reedsolomon.Encoder, error) {
return reedsolomon.New(ctx.DataShards, ctx.ParityShards)
+235 -52
View File
@@ -9,6 +9,7 @@ import (
"github.com/klauspost/reedsolomon"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
"github.com/seaweedfs/seaweedfs/weed/storage/needle_map"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
@@ -57,74 +58,89 @@ func WriteSortedFileFromIdx(baseFileName string, ext string) (e error) {
return nil
}
// WriteEcFiles generates .ec00 ~ .ec13 files using default EC context
func WriteEcFiles(baseFileName string) error {
ctx := NewDefaultECContext("", 0)
return WriteEcFilesWithContext(baseFileName, ctx)
}
// WriteEcFilesWithContext generates EC files using the provided context
func WriteEcFilesWithContext(baseFileName string, ctx *ECContext) error {
// WriteEcFiles generates .ec00 ~ .ec13 files from baseFileName.dat. Pass
// BackgroundECContext for the default ratio, or an explicit ctx for a configured
// (e.g. custom-ratio) layout. It returns the bitrot protection (per-shard block
// CRC32C) computed during the single encode pass; the caller persists it as a
// <base>.ecsum sidecar.
func WriteEcFiles(baseFileName string, ctx *ECContext) (*volume_server_pb.EcBitrotProtection, error) {
if ctx == nil || ctx.Total() == 0 {
ctx = NewDefaultECContext("", 0)
}
return generateEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
}
// RebuildEcFiles rebuilds missing EC shard files.
// RebuildEcFiles rebuilds missing EC shard files. Pass BackgroundECContext to
// resolve the layout from the volume's .vif (falling back to the default ratio),
// or an explicit ctx when the caller already knows the shard layout.
// additionalDirs are extra directories to search for existing shard files,
// which handles multi-disk servers where shards may be spread across disks.
func RebuildEcFiles(baseFileName string, additionalDirs ...string) ([]uint32, error) {
// Attempt to load EC config from .vif file to preserve original configuration
var ctx *ECContext
if volumeInfo, _, found, _ := volume_info.MaybeLoadVolumeInfo(baseFileName + ".vif"); found && volumeInfo.EcShardConfig != nil {
ds := int(volumeInfo.EcShardConfig.DataShards)
ps := int(volumeInfo.EcShardConfig.ParityShards)
// When a bitrot checksum sidecar is present for the (generation-0) volume,
// present input shards are verified against it and corrupt ones are excluded
// from Reed-Solomon and regenerated; unsafeIgnoreSidecar bypasses that guard.
func RebuildEcFiles(baseFileName string, ctx *ECContext, unsafeIgnoreSidecar bool, additionalDirs ...string) ([]uint32, error) {
if ctx == nil || ctx.Total() == 0 {
// Resolve the layout from the .vif to preserve the original configuration.
volumeInfo, _, foundVif, vifErr := volume_info.MaybeLoadVolumeInfo(baseFileName + ".vif")
if vifErr != nil {
// The .vif exists but cannot be read or parsed. Fail closed rather
// than silently falling back to the default ratio, which would
// rebuild a custom-ratio volume with the wrong layout. Pass an
// explicit ctx to override.
return nil, fmt.Errorf("RebuildEcFiles %s: cannot load .vif: %w", baseFileName, vifErr)
}
if foundVif && volumeInfo.EcShardConfig != nil {
ds := int(volumeInfo.EcShardConfig.DataShards)
ps := int(volumeInfo.EcShardConfig.ParityShards)
// Validate EC config before using it
if ds > 0 && ps > 0 && ds+ps <= MaxShardCount {
ctx = &ECContext{
DataShards: ds,
ParityShards: ps,
// Validate EC config before using it
if ds > 0 && ps > 0 && ds+ps <= MaxShardCount {
ctx = &ECContext{
DataShards: ds,
ParityShards: ps,
}
glog.V(0).Infof("Rebuilding EC files for %s with config from .vif: %s", baseFileName, ctx.String())
} else {
glog.Warningf("Invalid EC config in .vif for %s (data=%d, parity=%d), using default", baseFileName, ds, ps)
ctx = NewDefaultECContext("", 0)
}
glog.V(0).Infof("Rebuilding EC files for %s with config from .vif: %s", baseFileName, ctx.String())
} else {
glog.Warningf("Invalid EC config in .vif for %s (data=%d, parity=%d), using default", baseFileName, ds, ps)
glog.V(0).Infof("Rebuilding EC files for %s with default config", baseFileName)
ctx = NewDefaultECContext("", 0)
}
} else {
glog.V(0).Infof("Rebuilding EC files for %s with default config", baseFileName)
ctx = NewDefaultECContext("", 0)
}
return RebuildEcFilesWithContext(baseFileName, ctx, additionalDirs...)
}
// RebuildEcFilesWithContext rebuilds missing EC files using the provided context.
// additionalDirs are extra directories to search for existing shard files.
func RebuildEcFilesWithContext(baseFileName string, ctx *ECContext, additionalDirs ...string) ([]uint32, error) {
return generateMissingEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx, additionalDirs)
return generateMissingEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx, unsafeIgnoreSidecar, additionalDirs)
}
func ToExt(ecIndex int) string {
return fmt.Sprintf(".ec%02d", ecIndex)
}
func generateEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext) error {
func generateEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext) (*volume_server_pb.EcBitrotProtection, error) {
file, err := os.OpenFile(baseFileName+".dat", os.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("failed to open dat file: %w", err)
return nil, fmt.Errorf("failed to open dat file: %w", err)
}
defer file.Close()
fi, err := file.Stat()
if err != nil {
return fmt.Errorf("failed to stat dat file: %w", err)
return nil, fmt.Errorf("failed to stat dat file: %w", err)
}
// One rolling-CRC builder per shard; fed as each shard's bytes are written.
builders := make([]*shardChecksumBuilder, ctx.Total())
for i := range builders {
builders[i] = newShardChecksumBuilder(BitrotBlockSize)
}
glog.V(0).Infof("encodeDatFile %s.dat size:%d with EC context %s", baseFileName, fi.Size(), ctx.String())
err = encodeDatFile(fi.Size(), baseFileName, bufferSize, largeBlockSize, file, smallBlockSize, ctx)
err = encodeDatFile(fi.Size(), baseFileName, bufferSize, largeBlockSize, file, smallBlockSize, ctx, builders)
if err != nil {
return fmt.Errorf("encodeDatFile: %w", err)
return nil, fmt.Errorf("encodeDatFile: %w", err)
}
return nil
return buildProtectionFromBuilders(ctx, builders, BitrotBlockSize), nil
}
// findShardFile looks for a shard file at baseFileName+ext, then in additionalDirs.
@@ -143,12 +159,12 @@ func findShardFile(baseFileName string, ext string, additionalDirs []string) str
return ""
}
func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext, additionalDirs []string) (generatedShardIds []uint32, err error) {
func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize int64, smallBlockSize int64, ctx *ECContext, unsafeIgnoreSidecar bool, additionalDirs []string) (generatedShardIds []uint32, err error) {
// Pass 1: discover which shards exist and which are missing,
// opening input files but NOT creating output files yet.
shardHasData := make([]bool, ctx.Total())
shardPaths := make([]string, ctx.Total()) // non-empty for present shards
shardPaths := make([]string, ctx.Total()) // non-empty for present shards (also the in-place output for a reclassified-corrupt shard)
inputFiles := make([]*os.File, ctx.Total())
presentCount := 0
for shardId := 0; shardId < ctx.Total(); shardId++ {
@@ -168,6 +184,67 @@ func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize
}
}
// Bitrot verify-and-exclude: when a generation-0 checksum sidecar is present
// and valid, verify each present input shard against it and reclassify
// corrupt ones as missing so Reed-Solomon regenerates them instead of
// silently consuming corrupt bytes. corruptOwned marks shards whose
// (corrupt) original file must be replaced in place at its discovered path.
corruptOwned := make([]bool, ctx.Total())
prot, status := loadRebuildSidecar(baseFileName, ctx, additionalDirs)
switch status {
case BitrotInvalid:
if !unsafeIgnoreSidecar {
return nil, fmt.Errorf("bitrot sidecar for %s is malformed/unverifiable; refusing to rebuild (pass unsafeIgnoreSidecar to override)", baseFileName)
}
glog.Warningf("bitrot sidecar for %s is malformed/unverifiable; proceeding because unsafeIgnoreSidecar is set", baseFileName)
case BitrotOn:
corrupt := make([]int, 0, ctx.Total())
for shardId := 0; shardId < ctx.Total(); shardId++ {
if !shardHasData[shardId] {
continue
}
entry := shardChecksums(prot, uint32(shardId))
if entry == nil {
continue
}
mismatched, verr := verifyShardFileBlocks(shardPaths[shardId], entry, int64(prot.BlockSize))
if verr != nil {
// A read error means we cannot trust this shard as a Reed-Solomon
// input. Exclude it (treat as corrupt) rather than silently
// feeding possibly-corrupt bytes into reconstruction.
glog.Warningf("bitrot: failed to verify present shard %d for %s: %v; excluding it", shardId, baseFileName, verr)
corrupt = append(corrupt, shardId)
continue
}
if len(mismatched) > 0 {
corrupt = append(corrupt, shardId)
}
}
if len(corrupt) > 0 {
// Wholesale-mismatch guard (RS-arbiter conservative form): localized
// bitrot touches a few shards; a stale/wrong sidecar mismatches more
// than parity_shards. In that case refuse rather than excluding good
// shards en masse.
if len(corrupt) > ctx.ParityShards && !unsafeIgnoreSidecar {
return nil, fmt.Errorf("bitrot sidecar suspect for %s: %d/%d present shards mismatch (> parity %d); refusing to rebuild (pass unsafeIgnoreSidecar to override)",
baseFileName, len(corrupt), presentCount, ctx.ParityShards)
}
if presentCount-len(corrupt) < ctx.DataShards && !unsafeIgnoreSidecar {
return nil, fmt.Errorf("bitrot: only %d verified-good shards for %s, need %d data shards; sidecar may be stale (pass unsafeIgnoreSidecar to override)",
presentCount-len(corrupt), baseFileName, ctx.DataShards)
}
if !unsafeIgnoreSidecar {
for _, shardId := range corrupt {
glog.Warningf("bitrot: present shard %d for %s fails checksum; excluding from rebuild inputs and regenerating", shardId, baseFileName)
shardHasData[shardId] = false
corruptOwned[shardId] = true
generatedShardIds = append(generatedShardIds, uint32(shardId))
presentCount--
}
}
}
}
// Pre-check: bail out before creating any output files.
if presentCount < ctx.DataShards {
return nil, fmt.Errorf("not enough shards to rebuild %s: found %d shards, need at least %d (data shards), missing shards: %v",
@@ -177,29 +254,131 @@ func generateMissingEcFiles(baseFileName string, bufferSize int, largeBlockSize
glog.V(0).Infof("rebuilding %s: %d shards present, %d missing %v, config %s",
baseFileName, presentCount, len(generatedShardIds), generatedShardIds, ctx.String())
// Pass 2: create output files for missing shards now that we know
// reconstruction is possible.
// Pass 2: create output files for missing shards. A genuinely-absent shard
// is written at baseFileName+ext; a reclassified-corrupt shard is written to
// a temp file beside its discovered location and atomically renamed over the
// corrupt original after the rebuild (and checksum) succeed, so we never
// leave a duplicate shard id or a half-written file.
outputFiles := make([]*os.File, ctx.Total())
writePaths := make([]string, ctx.Total())
finalPaths := make([]string, ctx.Total())
for shardId := 0; shardId < ctx.Total(); shardId++ {
if shardHasData[shardId] {
continue
}
outputFileName := baseFileName + ctx.ToExt(shardId)
outputFiles[shardId], err = os.OpenFile(outputFileName, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
finalPath := baseFileName + ctx.ToExt(shardId)
writePath := finalPath
if corruptOwned[shardId] && shardPaths[shardId] != "" {
finalPath = shardPaths[shardId]
writePath = shardPaths[shardId] + ".rebuilding"
}
outputFiles[shardId], err = os.OpenFile(writePath, os.O_TRUNC|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return nil, err
}
defer outputFiles[shardId].Close()
writePaths[shardId] = writePath
finalPaths[shardId] = finalPath
}
err = rebuildEcFiles(shardHasData, inputFiles, outputFiles, ctx)
if err != nil {
if err = rebuildEcFiles(shardHasData, inputFiles, outputFiles, ctx); err != nil {
return nil, fmt.Errorf("rebuildEcFiles: %w", err)
}
// Verify regenerated shards against the sidecar. Reed-Solomon is
// deterministic, so a regenerated shard that does NOT match the sidecar
// means the sidecar is wrong/stale (not the shard) — fail closed rather than
// publishing bytes we cannot trust. On ANY verification failure (sync, read
// error, or mismatch) remove every generated output so the rebuild publishes
// nothing: a genuinely-missing shard returns to missing; a reclassified-
// corrupt shard keeps its untouched original.
if status == BitrotOn && !unsafeIgnoreSidecar {
for shardId := 0; shardId < ctx.Total(); shardId++ {
if writePaths[shardId] == "" {
continue
}
entry := shardChecksums(prot, uint32(shardId))
if entry == nil {
continue
}
if err = outputFiles[shardId].Sync(); err != nil {
cleanupRebuildOutputs(outputFiles, writePaths)
return nil, fmt.Errorf("sync regenerated shard %d: %w", shardId, err)
}
mismatched, verr := verifyShardFileBlocks(writePaths[shardId], entry, int64(prot.BlockSize))
if verr != nil {
cleanupRebuildOutputs(outputFiles, writePaths)
return nil, fmt.Errorf("bitrot: verify regenerated shard %d for %s: %w", shardId, baseFileName, verr)
}
if len(mismatched) > 0 {
cleanupRebuildOutputs(outputFiles, writePaths)
return nil, fmt.Errorf("bitrot: regenerated shard %d for %s does not match sidecar (%d blocks differ); sidecar likely stale — aborting (pass unsafeIgnoreSidecar to override)",
shardId, baseFileName, len(mismatched))
}
}
}
// Atomically move reclassified-corrupt rebuilds over their originals.
for shardId := 0; shardId < ctx.Total(); shardId++ {
if writePaths[shardId] != "" && writePaths[shardId] != finalPaths[shardId] {
outputFiles[shardId].Close()
if rerr := os.Rename(writePaths[shardId], finalPaths[shardId]); rerr != nil {
return nil, fmt.Errorf("bitrot: replace corrupt shard %d (%s -> %s): %w", shardId, writePaths[shardId], finalPaths[shardId], rerr)
}
}
}
return
}
func encodeData(file *os.File, enc reedsolomon.Encoder, startOffset, blockSize int64, buffers [][]byte, outputs []*os.File, ctx *ECContext) error {
// cleanupRebuildOutputs removes every generated output on a failed fail-closed
// rebuild: temp replacements AND genuinely-missing shards written directly at
// their final path, so no unverified bytes are published. A reclassified-corrupt
// shard's untouched original (at finalPath, distinct from its temp writePath) is
// left in place; a genuinely-missing shard (writePath == finalPath) returns to
// missing.
func cleanupRebuildOutputs(outputFiles []*os.File, writePaths []string) {
for i := range writePaths {
if writePaths[i] == "" {
continue
}
if outputFiles[i] != nil {
outputFiles[i].Close()
}
os.Remove(writePaths[i])
}
}
// loadRebuildSidecar loads and validates the generation-0 checksum sidecar for a
// rebuild. RebuildEcFiles operates on the un-suffixed (generation 0) shard
// names, so only the legacy sidecar is relevant here. Returns BitrotOff when
// absent or describing a different generation/config, BitrotInvalid on a
// self-integrity/manifest failure, BitrotOn when usable.
func loadRebuildSidecar(baseFileName string, ctx *ECContext, additionalDirs []string) (*volume_server_pb.EcBitrotProtection, BitrotStatus) {
path := findBitrotSidecar(0, baseFileName, baseFileName, additionalDirs...)
if path == "" {
return nil, BitrotOff
}
prot, err := LoadBitrotSidecar(path)
if err != nil {
glog.Warningf("bitrot: sidecar %s self-integrity failed: %v", path, err)
return nil, BitrotInvalid
}
if prot.Generation != 0 {
return nil, BitrotOff
}
if prot.EcShardConfig == nil ||
int(prot.EcShardConfig.DataShards) != ctx.DataShards ||
int(prot.EcShardConfig.ParityShards) != ctx.ParityShards {
return nil, BitrotOff
}
if err := ValidateBitrotManifest(prot, ctx.DataShards, ctx.ParityShards); err != nil {
glog.Warningf("bitrot: sidecar %s manifest invalid: %v", path, err)
return nil, BitrotInvalid
}
return prot, BitrotOn
}
func encodeData(file *os.File, enc reedsolomon.Encoder, startOffset, blockSize int64, buffers [][]byte, outputs []*os.File, ctx *ECContext, builders []*shardChecksumBuilder) error {
bufferSize := int64(len(buffers[0]))
if bufferSize == 0 {
@@ -212,7 +391,7 @@ func encodeData(file *os.File, enc reedsolomon.Encoder, startOffset, blockSize i
}
for b := int64(0); b < batchCount; b++ {
err := encodeDataOneBatch(file, enc, startOffset+b*bufferSize, blockSize, buffers, outputs, ctx)
err := encodeDataOneBatch(file, enc, startOffset+b*bufferSize, blockSize, buffers, outputs, ctx, builders)
if err != nil {
return err
}
@@ -245,7 +424,7 @@ func closeEcFiles(files []*os.File) {
}
}
func encodeDataOneBatch(file *os.File, enc reedsolomon.Encoder, startOffset, blockSize int64, buffers [][]byte, outputs []*os.File, ctx *ECContext) error {
func encodeDataOneBatch(file *os.File, enc reedsolomon.Encoder, startOffset, blockSize int64, buffers [][]byte, outputs []*os.File, ctx *ECContext, builders []*shardChecksumBuilder) error {
// read data into buffers
for i := 0; i < ctx.DataShards; i++ {
@@ -272,12 +451,16 @@ func encodeDataOneBatch(file *os.File, enc reedsolomon.Encoder, startOffset, blo
if err != nil {
return err
}
// Accumulate this shard's block CRC over exactly the bytes written.
if builders != nil && builders[i] != nil {
builders[i].write(buffers[i])
}
}
return nil
}
func encodeDatFile(remainingSize int64, baseFileName string, bufferSize int, largeBlockSize int64, file *os.File, smallBlockSize int64, ctx *ECContext) error {
func encodeDatFile(remainingSize int64, baseFileName string, bufferSize int, largeBlockSize int64, file *os.File, smallBlockSize int64, ctx *ECContext, builders []*shardChecksumBuilder) error {
var processedSize int64
@@ -302,7 +485,7 @@ func encodeDatFile(remainingSize int64, baseFileName string, bufferSize int, lar
smallRowSize := smallBlockSize * int64(ctx.DataShards)
for remainingSize >= largeRowSize {
err = encodeData(file, enc, processedSize, largeBlockSize, buffers, outputs, ctx)
err = encodeData(file, enc, processedSize, largeBlockSize, buffers, outputs, ctx, builders)
if err != nil {
return fmt.Errorf("failed to encode large chunk data: %w", err)
}
@@ -310,7 +493,7 @@ func encodeDatFile(remainingSize int64, baseFileName string, bufferSize int, lar
processedSize += largeRowSize
}
for remainingSize > 0 {
err = encodeData(file, enc, processedSize, smallBlockSize, buffers, outputs, ctx)
err = encodeData(file, enc, processedSize, smallBlockSize, buffers, outputs, ctx, builders)
if err != nil {
return fmt.Errorf("failed to encode small chunk data: %w", err)
}
@@ -81,7 +81,7 @@ func testEcRead(t *testing.T, large, small, datSize int64) {
ctx := NewDefaultECContext("", 0)
// 2. EC encode with test block sizes
err = generateEcFiles(baseFileName, int(small), large, small, ctx)
_, err = generateEcFiles(baseFileName, int(small), large, small, ctx)
require.NoError(t, err, "EC encoding")
// 3. Open EC shard files for reading
@@ -213,7 +213,7 @@ func TestEcOffByOneBug_Issue8947(t *testing.T) {
require.NoError(t, err)
ctx := NewDefaultECContext("", 0)
err = generateEcFiles(baseFileName, int(small), large, small, ctx)
_, err = generateEcFiles(baseFileName, int(small), large, small, ctx)
require.NoError(t, err, "EC encoding")
ecFiles, err := openEcFiles(baseFileName, true, ctx)
@@ -316,7 +316,7 @@ func testDecodeDat(t *testing.T, datSize int64) {
ctx := NewDefaultECContext("", 0)
// 2. EC encode with PRODUCTION block sizes
err = generateEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
_, err = generateEcFiles(baseFileName, 256*1024, ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, ctx)
require.NoError(t, err, "EC encoding")
// 3. Decode via WriteDatFile
+2 -1
View File
@@ -27,7 +27,7 @@ func TestEncodingDecoding(t *testing.T) {
// Create default EC context for testing
ctx := NewDefaultECContext("", 0)
err := generateEcFiles(baseFileName, bufferSize, largeBlockSize, smallBlockSize, ctx)
_, err := generateEcFiles(baseFileName, bufferSize, largeBlockSize, smallBlockSize, ctx)
if err != nil {
t.Logf("generateEcFiles: %v", err)
}
@@ -194,6 +194,7 @@ func removeGeneratedFiles(baseFileName string, ctx *ECContext) {
os.Remove(fname)
}
os.Remove(baseFileName + ".ecx")
RemoveBitrotSidecars(baseFileName)
}
func TestLocateData(t *testing.T) {
+16
View File
@@ -59,6 +59,13 @@ type EcVolume struct {
// 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
}
func NewEcVolume(diskType types.DiskType, dir string, dirIdx string, collection string, vid needle.VolumeId) (ev *EcVolume, err error) {
@@ -168,6 +175,9 @@ func NewEcVolume(diskType types.DiskType, dir string, dirIdx string, collection
ev.ShardLocations = make(map[ShardId][]pb.ServerAddress)
// Load the active-generation bitrot checksum sidecar (optional).
ev.loadActiveBitrotSidecar()
return
}
@@ -257,6 +267,12 @@ func (ev *EcVolume) Destroy() {
os.Remove(ev.FileName(".ecx"))
os.Remove(ev.FileName(".ecj"))
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())
}
}
// DiskType returns the disk type the EC volume currently reports under.
@@ -1,6 +1,7 @@
package erasure_coding
import (
"bytes"
"fmt"
"slices"
@@ -22,6 +23,190 @@ func (ev *EcVolume) ScrubIndex() (int64, []error) {
return idx.CheckIndexFile(ev.ecxFile, ev.ecxFileSize, ev.Version)
}
// ChecksumScrub verifies every locally-held EC shard's raw bytes against the
// active-generation bitrot checksum sidecar. It is read-only: it detects and
// reports corruption but never mutates or quarantines anything (the apply path
// drives quarantine + rebuild). It is the only path that exercises cold parity
// shards, which are never read during normal serving.
//
// Returns the number of blocks scanned, the broken shards (sidecar mismatch or
// length drift), and errors. A wholesale mismatch (more than parity_shards
// shards differing) is reported as a suspect/stale sidecar — an integrity error
// — rather than flagging the shards as corrupt, so a bad sidecar can never be
// mistaken for mass bitrot.
func (ecv *EcVolume) ChecksumScrub() (blocksScanned int64, brokenShards []*volume_server_pb.EcShardInfo, errs []error) {
prot, status := ecv.BitrotProtection()
switch status {
case BitrotOff:
// Unprotected generation: nothing to verify. Not an error.
return 0, nil, nil
case BitrotInvalid:
return 0, nil, []error{fmt.Errorf("ec volume %d: bitrot sidecar is malformed/unverifiable (sidecar integrity)", ecv.VolumeId)}
}
blockSize := int64(prot.BlockSize)
var broken []*EcVolumeShard
for _, shard := range ecv.Shards {
entry := shardChecksums(prot, uint32(shard.ShardId))
if entry == nil {
errs = append(errs, fmt.Errorf("ec volume %d: no checksum entry for local shard %d", ecv.VolumeId, shard.ShardId))
continue
}
if shard.Size() != entry.CoveredSize {
errs = append(errs, fmt.Errorf("ec volume %d shard %d: size %d != covered_size %d (truncated or extended)",
ecv.VolumeId, shard.ShardId, shard.Size(), entry.CoveredSize))
broken = append(broken, shard)
continue
}
want := unpackUint32LE(entry.BlockCrc32C)
buf := make([]byte, blockSize)
var offset int64
shardBad := false
for i := 0; i < len(want); i++ {
toRead := blockSize
if rem := entry.CoveredSize - offset; rem < toRead {
toRead = rem
}
n, rerr := shard.ReadAt(buf[:toRead], offset)
if rerr != nil || int64(n) != toRead {
errs = append(errs, fmt.Errorf("ec volume %d shard %d: read block %d at %d: %v (got %d/%d)",
ecv.VolumeId, shard.ShardId, i, offset, rerr, n, toRead))
shardBad = true
break
}
if uint32(needle.NewCRC(buf[:n])) != want[i] {
errs = append(errs, fmt.Errorf("ec volume %d shard %d: checksum mismatch at block %d (offset %d)",
ecv.VolumeId, shard.ShardId, i, offset))
shardBad = true
}
offset += int64(n)
blocksScanned++
}
if shardBad {
broken = append(broken, shard)
}
}
// Wholesale-mismatch guard: localized bitrot touches a few shards; a
// stale/wrong sidecar mismatches more than parity_shards. Classify the
// latter as sidecar integrity, never as mass shard corruption.
if ecv.ECContext != nil && len(broken) > ecv.ECContext.ParityShards {
return blocksScanned, nil, []error{fmt.Errorf("ec volume %d: %d/%d local shards mismatch (> parity %d): suspect stale sidecar, not flagging shard corruption",
ecv.VolumeId, len(broken), len(ecv.Shards), ecv.ECContext.ParityShards)}
}
// Reed-Solomon arbitration: a sidecar mismatch could mean the shard rotted
// OR the sidecar block is stale. When enough OTHER shards are local to
// reconstruct, reconstruct each flagged shard from the verified-clean shards
// and compare to its on-disk bytes. Only shards RS *also* disagrees with are
// truly corrupt; the rest are stale-sidecar false positives we must not
// quarantine. This is the arbiter that makes auto-repair safe.
if ecv.ECContext != nil && len(broken) > 0 {
brokenSet := make(map[ShardId]bool, len(broken))
for _, s := range broken {
brokenSet[s.ShardId] = true
}
cleanCount := 0
for _, s := range ecv.Shards {
if !brokenSet[s.ShardId] {
cleanCount++
}
}
if cleanCount >= ecv.ECContext.DataShards {
confirmed := make([]*EcVolumeShard, 0, len(broken))
for _, s := range broken {
corrupt, aerr := ecv.rsConfirmsShardCorrupt(s.ShardId, brokenSet, blockSize)
if aerr != nil {
// Could not arbitrate this shard; stay conservative and keep it.
errs = append(errs, fmt.Errorf("ec volume %d shard %d: RS arbitration failed: %v", ecv.VolumeId, s.ShardId, aerr))
confirmed = append(confirmed, s)
continue
}
if corrupt {
confirmed = append(confirmed, s)
} else {
errs = append(errs, fmt.Errorf("ec volume %d shard %d: sidecar mismatch but Reed-Solomon confirms the bytes are correct: stale sidecar, not flagging shard",
ecv.VolumeId, s.ShardId))
}
}
broken = confirmed
}
}
for _, s := range broken {
brokenShards = append(brokenShards, s.ToEcShardInfo())
}
slices.SortFunc(brokenShards, func(a, b *volume_server_pb.EcShardInfo) int {
return int(a.ShardId) - int(b.ShardId)
})
return blocksScanned, brokenShards, errs
}
// rsConfirmsShardCorrupt reconstructs targetId from the verified-clean local
// shards (all local shards except those in brokenSet) and compares the result
// to targetId's on-disk bytes block-by-block. It returns true only if
// Reed-Solomon disagrees with the disk (the shard is genuinely corrupt); a full
// match means the shard is fine and its sidecar block is stale. The caller
// guarantees at least DataShards clean shards are local before invoking this.
func (ecv *EcVolume) rsConfirmsShardCorrupt(targetId ShardId, brokenSet map[ShardId]bool, blockSize int64) (bool, error) {
enc, err := ecv.ECContext.CreateEncoder()
if err != nil {
return false, err
}
total := ecv.ECContext.Total()
local := make(map[ShardId]*EcVolumeShard, len(ecv.Shards))
for _, s := range ecv.Shards {
local[s.ShardId] = s
}
target := local[targetId]
if target == nil {
return false, fmt.Errorf("target shard not local")
}
size := target.Size()
buffers := make([][]byte, total)
diskBuf := make([]byte, blockSize)
var offset int64
for offset < size {
n := blockSize
if rem := size - offset; rem < n {
n = rem
}
// Fill clean local shards; nil for the target and any broken/absent shard
// so Reconstruct regenerates the target from the trusted inputs.
for id := 0; id < total; id++ {
sid := ShardId(id)
if sid == targetId || brokenSet[sid] {
buffers[id] = nil
continue
}
s := local[sid]
if s == nil {
buffers[id] = nil
continue
}
buf := make([]byte, n)
got, rerr := s.ReadAt(buf, offset)
if rerr != nil || int64(got) != n {
return false, fmt.Errorf("read clean shard %d at %d: %v (got %d/%d)", sid, offset, rerr, got, n)
}
buffers[id] = buf
}
if err := enc.Reconstruct(buffers); err != nil {
return false, fmt.Errorf("reconstruct: %w", err)
}
got, rerr := target.ReadAt(diskBuf[:n], offset)
if rerr != nil || int64(got) != n {
return false, fmt.Errorf("read target shard %d at %d: %v", targetId, offset, rerr)
}
if !bytes.Equal(buffers[targetId][:n], diskBuf[:n]) {
return true, nil // Reed-Solomon disagrees with disk: genuinely corrupt.
}
offset += n
}
return false, nil // every block matches: shard is fine, sidecar block is stale.
}
// ScrubLocal checks the integrity of local shards for a EC volume. Notably, it cannot verify CRC on needles.
// Returns a count of processed file entries, slice of found broken shards, and slice of found errors.
func (ecv *EcVolume) ScrubLocal() (int64, []*volume_server_pb.EcShardInfo, []error) {
@@ -135,6 +135,9 @@ func DistributeEcShards(volumeID uint32, collection string, targets []*worker_pb
if _, hasVif := shardFiles["vif"]; hasVif {
assignedShards = append(assignedShards, "vif")
}
if _, hasEcsum := shardFiles["ecsum"]; hasEcsum {
assignedShards = append(assignedShards, "ecsum")
}
}
if shardDisks[target.Node] == nil {
@@ -322,6 +325,9 @@ func sendShardFileToDestination(volumeID uint32, collection string, dialOption g
} else if shardType == "vif" {
ext = ".vif"
shardId = 0
} else if shardType == "ecsum" {
ext = BitrotSidecarExt
shardId = 0
} else if strings.HasPrefix(shardType, "ec") && len(shardType) == 4 {
ext = "." + shardType
fmt.Sscanf(shardType[2:], "%d", &shardId)
+4 -3
View File
@@ -299,7 +299,7 @@ func TestRemoteTier_ECEncode_RequiresLocalDat(t *testing.T) {
tierUpVolume(t, dir, vid, b)
baseFileName := filepath.Join(dir, fmt.Sprintf("%d", uint32(vid)))
err := erasure_coding.WriteEcFiles(baseFileName)
_, err := erasure_coding.WriteEcFiles(baseFileName, erasure_coding.BackgroundECContext())
require.Error(t, err, "EC encoder must not run with .dat missing — caller is expected to download first")
require.Contains(t, err.Error(), ".dat")
}
@@ -321,7 +321,8 @@ func TestRemoteTier_ECEncodeDecode_AfterDownload(t *testing.T) {
require.NoError(t, err)
require.NoError(t, erasure_coding.WriteSortedFileFromIdx(baseFileName, ".ecx"))
require.NoError(t, erasure_coding.WriteEcFiles(baseFileName))
_, ecErr := erasure_coding.WriteEcFiles(baseFileName, erasure_coding.BackgroundECContext())
require.NoError(t, ecErr)
for i := 0; i < erasure_coding.TotalShardsCount; i++ {
shardPath := fmt.Sprintf("%s.ec%02d", baseFileName, i)
@@ -335,7 +336,7 @@ func TestRemoteTier_ECEncodeDecode_AfterDownload(t *testing.T) {
shardPath := fmt.Sprintf("%s.ec%02d", baseFileName, i)
require.NoError(t, os.Remove(shardPath))
}
rebuilt, err := erasure_coding.RebuildEcFiles(baseFileName)
rebuilt, err := erasure_coding.RebuildEcFiles(baseFileName, erasure_coding.BackgroundECContext(), false)
require.NoError(t, err)
require.NotEmpty(t, rebuilt, "rebuild should report which parity shards were regenerated")
for i := erasure_coding.DataShardsCount; i < erasure_coding.TotalShardsCount; i++ {
+24 -1
View File
@@ -558,9 +558,18 @@ func (t *ErasureCodingTask) generateEcShardsLocally(localFiles map[string]string
}
// Generate EC shard files (.ec00 ~ .ec13)
if err := erasure_coding.WriteEcFiles(baseName); err != nil {
ecBitrot, err := erasure_coding.WriteEcFiles(baseName, erasure_coding.BackgroundECContext())
if err != nil {
return nil, fmt.Errorf("failed to generate EC shard files: %v", err)
}
// Persist the bitrot checksum sidecar (generation 0) alongside the shards so
// it travels with them during distribution. Best-effort: a failed sidecar
// write leaves the generation unprotected rather than failing the encode.
if erasure_coding.BitrotProtectionEnabled && ecBitrot != nil {
if serr := erasure_coding.SaveBitrotSidecar(erasure_coding.BitrotSidecarPath(baseName, 0), ecBitrot); serr != nil {
glog.Warningf("failed to write EC bitrot sidecar for %s: %v", baseName, serr)
}
}
// Collect generated shard file paths and log details
var generatedShards []string
@@ -625,6 +634,20 @@ func (t *ErasureCodingTask) generateEcShardsLocally(localFiles map[string]string
}
}
// Add the generation-0 bitrot checksum sidecar so it is distributed with
// the shards (DistributeEcShards only ships files present in shardFiles).
// Best-effort like the sidecar write above: if it is absent the holders
// are simply unprotected rather than failing the encode.
ecsumFile := erasure_coding.BitrotSidecarPath(baseName, 0)
if info, err := os.Stat(ecsumFile); err == nil {
shardFiles["ecsum"] = ecsumFile
t.GetLogger().WithFields(map[string]interface{}{
"file_type": "ecsum",
"file_path": ecsumFile,
"size_bytes": info.Size(),
}).Info("EC bitrot checksum sidecar generated")
}
// Log summary of generation
t.GetLogger().WithFields(map[string]interface{}{
"total_files": len(shardFiles),