feat(ec): EC bitrot CHECKSUM scrub on the Rust volume server (#10154)

* proto: add EC bitrot checksum messages + CHECKSUM scrub mode

Mirror weed/pb/volume_server.proto byte-for-byte (field numbers + types) so the
.ecsum sidecar payload is wire-identical across the Go and Rust binaries:
EcBitrotProtection / EcShardChecksums / ChecksumAlgorithm, VolumeScrubMode.CHECKSUM=4,
and VolumeEcShardsCopyRequest.copy_ecsum_file. No code uses them yet — the .ecsum
format, producer, mount-load, copy, and scrub land in following commits.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(ec): port the .ecsum bitrot checksum module

ec_bitrot.rs mirrors weed/storage/erasure_coding/ec_bitrot.go: the .ecsum sidecar
format (14-byte big-endian ECSU header + CRC32C over a prost-serialized
EcBitrotProtection payload), the per-shard per-block CRC32C producer
(ShardChecksumBuilder), save/load with payload self-integrity, manifest
validation, status resolution, and verify_shard_file_blocks for the CHECKSUM
scrub. A byte-exact test pins the serialized bytes against the Go reference's
identical constant so a format drift in either binary fails loudly.

Producer wiring (encode/vacuum), mount-load, copy, and the mode-4 dispatch land
in following commits.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* test(ec): pin .ecsum sidecar bytes for cross-binary interop

Deterministic EcBitrotProtection -> exact on-disk bytes, asserted against a
canonical constant on BOTH sides (this test and ec_bitrot.rs), so a format drift
in either binary fails its own suite rather than silently desyncing a Go-written
.ecsum from a Rust-written one.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(ec): write the .ecsum bitrot sidecar during EC encode

write_ec_files now feeds each shard's bytes through a per-shard
ShardChecksumBuilder as it writes them, then persists the generation-0 sidecar
(<base>.ecsum) alongside the shards — mirroring weed's WriteEcFiles +
SaveBitrotSidecar. Best-effort: a failed sidecar write leaves the generation
unprotected rather than failing the encode. A test confirms the produced sidecar
validates and its per-block CRCs match every on-disk shard.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(ec): load the .ecsum at mount + EcVolume::checksum_scrub

EcVolume now loads and validates its generation-0 .ecsum sidecar at mount,
caching the parsed protection + BitrotStatus (Off/On/Invalid), and exposes
bitrot_protection() mirroring Go's EcVolume.BitrotProtection(). checksum_scrub()
verifies every locally-held shard's raw bytes against the sidecar block CRCs —
the only path that exercises cold parity shards — reporting mismatched shards
without mutating anything; a wholesale mismatch beyond parity is flagged as a
suspect sidecar rather than mass shard corruption. Mirrors Go's ChecksumScrub.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(scrub): dispatch EC CHECKSUM (mode 4) to checksum_scrub

Accept VolumeScrubMode.CHECKSUM=4 and route it to EcVolume::checksum_scrub,
accumulating blocks scanned + mismatched shards into the scrub response, plus the
CHECKSUM scrub-mode metric label. Read-only bitrot verification over local shards.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(ec): copy the .ecsum sidecar during VolumeEcShardsCopy

Honor copy_ecsum_file: when set, copy the generation-0 .ecsum alongside the
shards so protection travels with them, mirroring Go's non-2PC copy path.
Tolerant of a missing source (empty stream) — the 0-byte file is dropped so
mount sees no sidecar (protection off) rather than a truncated/invalid one.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* feat(ec): remove the .ecsum sidecars when destroying an EC volume

remove_ec_volume_files now clears <base>.ecsum (and any versioned .ecsum.v<N>)
from the data and idx dirs, so a vid reuse can't load a stale sidecar. Mirrors
Go's removeBitrotSidecars.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo

* style(ec): align bitrot comments and test setup for merge-cleanliness

Match the shared bitrot code (write_ec_files, encode_one_batch, checksum_scrub,
the encode sidecar test) to the canonical wording/layout so the volume-server
Rust port stays line-aligned across trees, keeping periodic merges conflict-free.
No behavior change.

Claude-Session: https://claude.ai/code/session_015EE9Sc9EvNp8BCVva4RKdo
This commit is contained in:
Chris Lu authored and GitHub committed 2026-06-30 20:09:31 -07:00
1 parent 9550b830d0
commit b4a99b996d
8 files changed
+1483 -2

No files matched your search

@@ -0,0 +1,60 @@
package erasure_coding
import (
"encoding/hex"
"os"
"path/filepath"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
)
// interopSample is the fully-deterministic sidecar shared with the Rust port's
// ec_bitrot.rs `sample()`. Both binaries must serialize it to identical bytes.
func interopSample() *volume_server_pb.EcBitrotProtection {
return &volume_server_pb.EcBitrotProtection{
Algorithm: volume_server_pb.ChecksumAlgorithm_CHECKSUM_CRC32C,
BlockSize: uint32(DefaultBitrotBlockSize),
EcShardConfig: &volume_server_pb.EcShardConfig{
DataShards: 10,
ParityShards: 4,
},
Shards: []*volume_server_pb.EcShardChecksums{
{ShardId: 0, CoveredSize: 1024 * 1024, BlockCrc32C: packUint32LE([]uint32{0x01020304})},
{ShardId: 1, CoveredSize: 1024 * 1024, BlockCrc32C: packUint32LE([]uint32{0x05060708})},
},
EncodeUuid: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
}
}
// canonicalInteropHex is the exact on-disk bytes of interopSample(). The Rust
// port's ec_bitrot.rs test_byte_exact_go_interop pins the SAME constant, so a
// format change in EITHER binary (new always-serialized field, renumber,
// EcShardConfig default flip, protobuf canonicalization change) fails that
// binary's test instead of silently desyncing a Go-written sidecar from a
// Rust-written one. If you intentionally change the format, regenerate this
// (run with -v to print the hex) and update the Rust constant in lock-step.
const canonicalInteropHex = "45435355000100000039cc1b826a080110808080082204080a10042a0a108080401a04040302012a0c0801108080401a04080706053210000102030405060708090a0b0c0d0e0f"
// TestBitrotSidecarBytes_RustInterop pins the on-disk bytes so a Go-side format
// drift fails here, and the Rust port asserts the same constant (cross-binary
// sidecar interop).
func TestBitrotSidecarBytes_RustInterop(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "v1.ecsum")
if err := SaveBitrotSidecar(path, interopSample()); err != nil {
t.Fatalf("save: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if got := hex.EncodeToString(data); got != canonicalInteropHex {
t.Fatalf("bitrot sidecar bytes drifted from the cross-binary canonical form;\n got=%s\nwant=%s\n(regenerate and update the Rust constant in lock-step)", got, canonicalInteropHex)
}
// Round-trips through the loader.
if _, err := LoadBitrotSidecar(path); err != nil {
t.Fatalf("load: %v", err)
}
}