Files
seaweedfs/weed/storage/erasure_coding/verification_test.go
T
Chris LuandGitHub 34f9b91d69 fix(storage): never let an empty .dat delete healthy distributed EC shards (#9930)
* fix(storage): never let an empty .dat delete healthy distributed EC shards

A leftover empty .dat stub (a phantom from the pre-fix loader; zero
needles) next to a distributed EC volume's local shards made startup
classify the volume as an interrupted local encode: validateEcVolume
requires >= dataShards local shards when a .dat is present, fails with
the 1-2 shards a distributed volume keeps per disk, and the cleanup
deletes those shards -- the only copies of that part of the volume.
Repeated across restart waves this destroys enough shards cluster-wide
to make the volume unrecoverable.

Go:
- loadExistingVolume: hoist the empty-stub sweep above the EC presence
  checks. Previously the .vif-next-to-.ecx guard returned before the
  sweep ever ran, so exactly the dangerous layout (stub + .ecx + local
  shards) kept its stub and then lost its shards in loadAllEcShards.
- validateEcVolume / checkDatFileExists: treat a .dat <= a superblock
  (zero needles) as absent. An empty .dat cannot be the encode source,
  so it must never gate shard deletion; this also covers stubs without
  a .vif, which the sweep cannot prove are EC leftovers.

Rust mirror (seaweed-volume): the same gate in validate_ec_volume and
check_dat_file_exists (the Rust sweep already ran before validation);
the volume-load skip keeps a plain existence check so fresh,
needle-less volumes still load.

Regression tests in Go and Rust reproduce the production layout (a
zero-byte .dat beside .ecx/.ecj and two shards of a 10+4 volume, with
and without a .vif) and fail without the fix with the shards deleted.

* fix(ec): gate source volume deletion on a recoverable shard set

After EC encode, the shell command and the (plugin) worker task refused
to delete the source volume unless every shard was present, and aborted
otherwise -- leaving the source .dat next to live shards, exactly the
mixed state the startup cleanup mishandles.

Replace the full-set requirement with a recoverability gate shared by
both callers (RequireRecoverableShardSet): deleting a non-empty source
.dat requires at least dataShards distinct shards cluster-wide. Below
that the source is kept and the encode fails as before. A degraded but
recoverable set (>= dataShards, < total) now proceeds with a warning
instead of aborting: the missing shards can be rebuilt from the
survivors, while keeping the source would preserve the dangerous mixed
state. Empty stub replicas are still swept unguarded (OnlyEmpty) -- an
empty .dat has nothing to lose.

dataShards/totalShards stay parameters so enterprise custom EC ratios
share the helper verbatim.

* test(ec): use recoverable shard verification gate
2026-06-11 20:26:20 -07:00

143 lines
4.5 KiB
Go

package erasure_coding
import (
"strings"
"testing"
)
func TestRequireRecoverableShardSet_AllPresent(t *testing.T) {
var bits ShardBits
for id := 0; id < TotalShardsCount; id++ {
bits = bits.Set(ShardId(id))
}
degraded, err := RequireRecoverableShardSet(42, bits, DataShardsCount, TotalShardsCount)
if err != nil {
t.Fatalf("unexpected error for full set: %v", err)
}
if degraded {
t.Error("full set must not be reported as degraded")
}
}
func TestRequireRecoverableShardSet_DegradedButRecoverable(t *testing.T) {
// 12 of 14 shards: enough to reconstruct (>= 10), so the source may be
// deleted, but the caller is told to warn and schedule a rebuild.
var bits ShardBits
for id := 0; id < TotalShardsCount; id++ {
if id == 3 || id == 7 {
continue
}
bits = bits.Set(ShardId(id))
}
degraded, err := RequireRecoverableShardSet(42, bits, DataShardsCount, TotalShardsCount)
if err != nil {
t.Fatalf("recoverable set must not error: %v", err)
}
if !degraded {
t.Error("missing shards must be reported as degraded")
}
}
func TestRequireRecoverableShardSet_BelowDataShards(t *testing.T) {
// 9 of 14 shards: one short of reconstructable; the source must be kept.
var bits ShardBits
for id := 0; id < DataShardsCount-1; id++ {
bits = bits.Set(ShardId(id))
}
_, err := RequireRecoverableShardSet(42, bits, DataShardsCount, TotalShardsCount)
if err == nil {
t.Fatal("expected error for unrecoverable set, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "volume 42") {
t.Errorf("error should name the volume id: %s", msg)
}
if !strings.Contains(msg, "9/14") {
t.Errorf("error should report 9/14 shards present: %s", msg)
}
if !strings.Contains(msg, "[9 10 11 12 13]") {
t.Errorf("error should list the missing ids: %s", msg)
}
}
func TestRequireRecoverableShardSet_EmptyBitmap(t *testing.T) {
_, err := RequireRecoverableShardSet(1, 0, DataShardsCount, TotalShardsCount)
if err == nil {
t.Fatal("expected error for empty bitmap")
}
if !strings.Contains(err.Error(), "0/14") {
t.Errorf("error should report 0/14 shards: %s", err.Error())
}
}
func TestRequireRecoverableShardSet_CustomRatio(t *testing.T) {
// 6+3 ratio: total=9, all present
var bits ShardBits
for id := 0; id < 9; id++ {
bits = bits.Set(ShardId(id))
}
if degraded, err := RequireRecoverableShardSet(7, bits, 6, 9); err != nil || degraded {
t.Fatalf("full 6+3 set: degraded=%v err=%v", degraded, err)
}
// 6+3, missing shard 5: 8 >= 6 remain, recoverable but degraded
bits = bits.Clear(5)
if degraded, err := RequireRecoverableShardSet(7, bits, 6, 9); err != nil || !degraded {
t.Fatalf("8/9 shards of 6+3: degraded=%v err=%v", degraded, err)
}
// 6+3, only 5 shards left: below dataShards, must error
bits = ShardBits(0)
for id := 0; id < 5; id++ {
bits = bits.Set(ShardId(id))
}
_, err := RequireRecoverableShardSet(7, bits, 6, 9)
if err == nil {
t.Fatal("expected error with 5/9 shards in 6+3 ratio")
}
if !strings.Contains(err.Error(), "5/9") {
t.Errorf("error should report 5/9: %s", err.Error())
}
}
func TestRequireRecoverableShardSet_RejectsInvalidParams(t *testing.T) {
if _, err := RequireRecoverableShardSet(1, 0, DataShardsCount, 0); err == nil {
t.Error("expected error for totalShards=0")
}
if _, err := RequireRecoverableShardSet(1, 0, DataShardsCount, MaxShardCount+1); err == nil {
t.Errorf("expected error for totalShards > MaxShardCount")
}
if _, err := RequireRecoverableShardSet(1, 0, 0, TotalShardsCount); err == nil {
t.Error("expected error for dataShards=0")
}
if _, err := RequireRecoverableShardSet(1, 0, TotalShardsCount+1, TotalShardsCount); err == nil {
t.Error("expected error for dataShards > totalShards")
}
}
func TestSummarizeShardInventory_Deterministic(t *testing.T) {
perServer := map[string]ServerShardInventory{
"10.0.0.2:8080": {Bits: ShardBits(0).Set(4).Set(5).Set(6)},
"10.0.0.1:8080": {Bits: ShardBits(0).Set(0).Set(1).Set(2).Set(3)},
}
got := SummarizeShardInventory(perServer)
want := "10.0.0.1:8080=[0 1 2 3] 10.0.0.2:8080=[4 5 6]"
if got != want {
t.Errorf("summary mismatch\n got: %q\n want: %q", got, want)
}
}
func TestSummarizeShardInventory_IncludesError(t *testing.T) {
perServer := map[string]ServerShardInventory{
"10.0.0.1:8080": {Bits: ShardBits(0).Set(0).Set(1), QueryError: errStr("dial timeout")},
}
got := SummarizeShardInventory(perServer)
if !strings.Contains(got, "ERR:dial timeout") {
t.Errorf("expected error tag in summary, got %q", got)
}
}
type errStr string
func (e errStr) Error() string { return string(e) }