Files
seaweedfs/weed/storage/erasure_coding/ec_volume_test.go
T
Chris LuandGitHub be81b9d5d7 volume: fix EC decode/reconstruct index locality under -dir.idx (#10442)
* volume: fix EC decode/reconstruct index locality under -dir.idx

EC->replicated decode failed under -dir.idx and on multi-disk with "volume not
found on disk". The reconstruct rebuilds the .dat on the data disk but the
on-demand VolumeMount scans only the data directory, matching on .idx/.vif;
with the rebuilt .idx off in the index directory it matched the volume's
leftover EC .vif and skipped the volume as EC metadata.

- Resolve the EC .ecx local-first: prefer the copy co-located with the shards
  over the shared -dir.idx copy, with a non-empty preference so a 0-byte local
  stub still yields to a valid sibling (the cross-disk fallback).
- Co-locate the rebuilt .idx with the .dat at the end of the reconstruct so the
  mount finds it; sweep .ecx/.ecj from both the data and index directories on
  Destroy so a stale copy cannot re-mount as a phantom EC volume.
- Add VolumeConsolidateIndex: once the EC shards are deleted, unmount, move the
  .idx/.sdx from the data disk back to the -dir.idx directory (copy fallback
  across filesystems), and remount. A no-op without -dir.idx.

* volume: tests for EC index locality (local-first .ecx, sweep, consolidate)

- NewEcVolume prefers a non-empty local .ecx over the shared index dir, and a
  0-byte local stub yields to a non-empty shared copy (the #9212 fallback).
- Destroy sweeps .ecx/.ecj from both the data and index directories.
- ConsolidateVolumeIndex moves a co-located index back to the -dir.idx dir and
  keeps the volume mounted; no-op without a separate index dir.
- RenameOrCopyFile moves a file and drops the source.

* volume: relocate the decoded index in place, without a read gap

ConsolidateVolumeIndex previously unmounted the volume, moved the index, and
remounted it. Between the EC-shard delete and the remount the volume had neither
a normal nor an EC form mounted, so a read landing in that window got a
not-found (or was proxied away).

Move the index in place instead: RelocateIndexTo takes the data-file write lock,
closes the needle map and data backend, moves the .idx (and derived .sdx), then
retargets dirIdx and reloads — the same close-swap-load CommitCompact uses. The
volume never leaves the mounted set, so a concurrent read blocks briefly on the
lock rather than failing. The test now writes a needle before consolidating and
reads it back after, proving the in-place reload keeps the volume serving.

* volume: address review — maintenance guard, no orphan on copy failure

- VolumeConsolidateIndex now rejects the request under maintenance mode, like
  VolumeConfigure and the other mutating volume RPCs.
- RenameOrCopyFile rolls the cross-device copy back when the source cannot be
  removed, so a failed move never leaves two divergent copies (the loader would
  keep the data-dir one while the idx-dir orphan goes stale).
- RelocateIndexTo logs a failed reopen-after-failed-move instead of swallowing
  it, since that leaves the volume unusable until the next load.
2026-07-25 23:45:02 -07:00

259 lines
8.6 KiB
Go

package erasure_coding
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/storage/volume_info"
)
func TestPositioning(t *testing.T) {
ecxFile, err := os.OpenFile("./test_files/389.ecx", os.O_RDONLY, 0)
if err != nil {
t.Errorf("failed to open ecx file: %v", err)
}
defer ecxFile.Close()
stat, _ := ecxFile.Stat()
fileSize := stat.Size()
tests := []struct {
needleId string
offset int64
size int
}{
{needleId: "0f0edb92", offset: 31300679656, size: 1167},
{needleId: "0ef7d7f8", offset: 11513014944, size: 66044},
}
for _, test := range tests {
needleId, _ := types.ParseNeedleId(test.needleId)
offset, size, err := SearchNeedleFromSortedIndex(ecxFile, fileSize, needleId, nil)
assert.Equal(t, nil, err, "SearchNeedleFromSortedIndex")
fmt.Printf("offset: %d size: %d\n", offset.ToActualOffset(), size)
}
needleId, _ := types.ParseNeedleId("0f087622")
offset, size, err := SearchNeedleFromSortedIndex(ecxFile, fileSize, needleId, nil)
assert.Equal(t, nil, err, "SearchNeedleFromSortedIndex")
fmt.Printf("offset: %d size: %d\n", offset.ToActualOffset(), size)
var shardEcdFileSize int64 = 1118830592 // 1024*1024*1024*3
intervals := LocateData(ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize, shardEcdFileSize, offset.ToActualOffset(), types.Size(needle.GetActualSize(size, needle.GetCurrentVersion())))
for _, interval := range intervals {
shardId, shardOffset := interval.ToShardIdAndOffset(ErasureCodingLargeBlockSize, ErasureCodingSmallBlockSize)
fmt.Printf("interval: %+v, shardId: %d, shardOffset: %d\n", interval, shardId, shardOffset)
}
}
// TestNewEcVolumeLoadsEncodeTsNs pins that the per-encode identity stamped into
// .vif is loaded onto the EcVolume, so reads can reject a shard from a different
// encode run.
func TestNewEcVolumeLoadsEncodeTsNs(t *testing.T) {
dir := t.TempDir()
const vid = needle.VolumeId(123)
base := EcShardFileName("", dir, int(vid))
// A 0-byte .ecx is a valid index (no live needles) and lets NewEcVolume mount.
if err := os.WriteFile(base+".ecx", nil, 0o644); err != nil {
t.Fatalf("write .ecx: %v", err)
}
const tsNs int64 = 1717000000000000123
vi := &volume_server_pb.VolumeInfo{
Version: uint32(needle.Version3),
EcShardConfig: &volume_server_pb.EcShardConfig{
DataShards: 10,
ParityShards: 4,
EncodeTsNs: tsNs,
},
}
if err := volume_info.SaveVolumeInfo(base+".vif", vi); err != nil {
t.Fatalf("save .vif: %v", err)
}
ev, err := NewEcVolume(types.HardDriveType, dir, dir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
defer ev.Close()
if ev.EncodeTsNs != tsNs {
t.Errorf("EncodeTsNs = %d, want %d", ev.EncodeTsNs, tsNs)
}
}
// TestNewEcVolumeDoesNotWriteStubVif pins that mounting an EC volume whose .vif
// is missing does NOT fabricate a stub .vif. A version-only stub would imply
// the default ratio with DatFileSize=0 and no encode identity, which the
// custom-ratio resolver and startup credibility checks must not trust.
func TestNewEcVolumeDoesNotWriteStubVif(t *testing.T) {
dir := t.TempDir()
const vid = needle.VolumeId(124)
base := EcShardFileName("", dir, int(vid))
if err := os.WriteFile(base+".ecx", nil, 0o644); err != nil {
t.Fatalf("write .ecx: %v", err)
}
ev, err := NewEcVolume(types.HardDriveType, dir, dir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
defer ev.Close()
if _, statErr := os.Stat(base + ".vif"); !os.IsNotExist(statErr) {
t.Fatalf("mounting without a .vif must not create one, stat err=%v", statErr)
}
// Mount still succeeds with the build's default EC ratio in memory.
if ev.ECContext == nil || ev.ECContext.DataShards != int(DataShardsCount) {
t.Fatalf("expected default EC context, got %+v", ev.ECContext)
}
}
// TestNewEcVolumeLoadsCustomRatio pins that a volume's own EC ratio is loaded from
// its .vif into ECContext, so the read/recover/decode paths reconstruct with the
// matrix that produced the shards rather than the build default. (Custom ratios are
// an enterprise feature; in OSS the .vif always records 10+4.)
func TestNewEcVolumeLoadsCustomRatio(t *testing.T) {
dir := t.TempDir()
const vid = needle.VolumeId(125)
base := EcShardFileName("", dir, int(vid))
if err := os.WriteFile(base+".ecx", nil, 0o644); err != nil {
t.Fatalf("write .ecx: %v", err)
}
if err := volume_info.SaveVolumeInfo(base+".vif", &volume_server_pb.VolumeInfo{
Version: uint32(needle.Version3),
EcShardConfig: &volume_server_pb.EcShardConfig{
DataShards: 9,
ParityShards: 3,
},
}); err != nil {
t.Fatalf("save .vif: %v", err)
}
ev, err := NewEcVolume(types.HardDriveType, dir, dir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
defer ev.Close()
if ev.ECContext == nil {
t.Fatalf("ECContext must be set from the .vif")
}
if ev.ECContext.DataShards != 9 || ev.ECContext.ParityShards != 3 {
t.Fatalf("ECContext = %d+%d, want 9+3", ev.ECContext.DataShards, ev.ECContext.ParityShards)
}
}
// ecIndexDirsFixture lays out an EC volume across a data dir and a separate
// index dir (the -dir.idx split), planting a .vif next to the data and .ecx
// files of the given sizes (-1 = absent). It returns the two bases.
func ecIndexDirsFixture(t *testing.T, vid needle.VolumeId, localEcx, sharedEcx int) (dataDir, idxDir, dataBase, idxBase string) {
t.Helper()
root := t.TempDir()
dataDir = filepath.Join(root, "data")
idxDir = filepath.Join(root, "idx")
for _, d := range []string{dataDir, idxDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
dataBase = EcShardFileName("", dataDir, int(vid))
idxBase = EcShardFileName("", idxDir, int(vid))
writeEcx := func(base string, size int) {
if size < 0 {
return
}
if err := os.WriteFile(base+".ecx", make([]byte, size), 0o644); err != nil {
t.Fatalf("write .ecx: %v", err)
}
}
writeEcx(dataBase, localEcx)
writeEcx(idxBase, sharedEcx)
if err := volume_info.SaveVolumeInfo(dataBase+".vif", &volume_server_pb.VolumeInfo{
Version: uint32(needle.Version3),
EcShardConfig: &volume_server_pb.EcShardConfig{DataShards: 10, ParityShards: 4},
}); err != nil {
t.Fatalf("save .vif: %v", err)
}
return
}
// TestNewEcVolumePrefersLocalEcx pins the local-first resolution: with a
// non-empty .ecx co-located with the shard data AND one in the shared index
// dir, NewEcVolume opens the local copy — where a move or reconstruct leaves
// the index.
func TestNewEcVolumePrefersLocalEcx(t *testing.T) {
const vid = needle.VolumeId(200)
dataDir, idxDir, _, _ := ecIndexDirsFixture(t, vid, 16, 16)
ev, err := NewEcVolume(types.HardDriveType, dataDir, idxDir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
defer ev.Close()
if got := filepath.Dir(ev.FileName(".ecx")); got != dataDir {
t.Errorf(".ecx resolved in %q, want the data dir %q (local-first)", got, dataDir)
}
}
// TestNewEcVolumeZeroByteLocalYieldsToSharedEcx pins that a 0-byte local .ecx
// (an interrupted-copy stub is indistinguishable from a legitimately empty
// index by size) yields to a *non-empty* copy in the shared index dir, keeping
// the cross-disk fallback (#9212).
func TestNewEcVolumeZeroByteLocalYieldsToSharedEcx(t *testing.T) {
const vid = needle.VolumeId(201)
dataDir, idxDir, _, _ := ecIndexDirsFixture(t, vid, 0, 16)
ev, err := NewEcVolume(types.HardDriveType, dataDir, idxDir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
defer ev.Close()
if got := filepath.Dir(ev.FileName(".ecx")); got != idxDir {
t.Errorf(".ecx resolved in %q, want the shared idx dir %q (non-empty wins over 0-byte local)", got, idxDir)
}
}
// TestEcVolumeDestroySweepsBothDirs pins that Destroy removes the EC index
// files from both the data and index directories, so a stale copy left by a
// move or reconstruct cannot re-mount as a phantom EC volume.
func TestEcVolumeDestroySweepsBothDirs(t *testing.T) {
const vid = needle.VolumeId(202)
dataDir, idxDir, dataBase, idxBase := ecIndexDirsFixture(t, vid, 16, 16)
// A stale .ecj in each dir too.
for _, base := range []string{dataBase, idxBase} {
if err := os.WriteFile(base+".ecj", nil, 0o644); err != nil {
t.Fatal(err)
}
}
ev, err := NewEcVolume(types.HardDriveType, dataDir, idxDir, "", vid)
if err != nil {
t.Fatalf("NewEcVolume: %v", err)
}
ev.Destroy()
for _, base := range []string{dataBase, idxBase} {
for _, ext := range []string{".ecx", ".ecj"} {
if _, err := os.Stat(base + ext); !os.IsNotExist(err) {
t.Errorf("%s%s survived Destroy (err=%v), want removed from both dirs", base, ext, err)
}
}
}
}