Files
seaweedfs/weed/shell/command_volume_tier_compact_test.go
T
Chris LuandDevin 5b4a707c43 shell: keep volume.tier.compact replicas on the same remote object (#11783)
* shell: keep volume.tier.compact replicas on the same remote object

The compact cycle ran on a single replica: it deleted the shared remote
object up front and never touched the siblings, leaving their .vif/.idx
pointing at a removed object. It also remounted the working replica into
a writable local volume mid-operation.

Freeze all replicas first, keep the shared object while siblings still
reference it, replicate the new metadata to every replica, and let the
last tiered replica retire the old object.

* storage: surface remote object delete failures from volume destroy

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* shell: keep tier.compact replicas frozen until every copy lands

A delete between the source unfreeze and a sibling's index copy could
land past the copied snapshot, leaving the replica serving deleted
data. Unfreeze all replicas only after every resync completes; a remote
volume's readonly mark is inert, so the freeze adds no extra cost.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* storage: abort volume destroy when remote delete fails

destroyLocked removed local files even when the backend delete errored,
leaving a registered replica with no .dat/.idx and a leaked remote
object. Run the remote delete before local cleanup and bail out on
failure so the volume stays fully usable and the delete can be retried.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: mirror destroy ordering for remote delete failures

Propagate a failed remote-object delete instead of warning past it, and
reinsert the volume into the disk location registry so a still-intact
replica is not unregistered.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* shell: recover partial tier.compact freezes and report held replicas

A failed freeze left already-marked replicas readonly with nothing
changed, and later stage failures returned without saying the replicas
stay frozen or how to resume. Roll back the partial mark when freezing
fails, note in every mid-flight error that replicas remain readonly and
rerun is the recovery path, and report how many replicas a partial
unfreeze released.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* shell: retire abandoned remote objects during tier.compact resync

A resync overwrote each replica's .vif, orphaning its previous remote
object; an interrupted run could leave replicas on different keys, and
only the last tiered replica's object was deleted. Read every replica's
remote file list up front, count holders per object, and delete-before-
copy whichever replica is an object's last holder, so shared and
stranded objects alike are retired instead of leaked.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* volume: keep collection members registered when destroy fails

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-16 19:45:17 -07:00

343 lines
8.9 KiB
Go

package shell
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/wdclient"
"github.com/stretchr/testify/assert"
)
func buildTestTopologyWithRemoteVolumes() *master_pb.TopologyInfo {
return &master_pb.TopologyInfo{
Id: "topo",
DataCenterInfos: []*master_pb.DataCenterInfo{
{
Id: "dc1",
RackInfos: []*master_pb.RackInfo{
{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{
{
Id: "server1:8080",
GrpcPort: 18080,
DiskInfos: map[string]*master_pb.DiskInfo{
"": {
VolumeInfos: []*master_pb.VolumeInformationMessage{
{
Id: 1,
Collection: "col1",
RemoteStorageName: "s3.default",
RemoteStorageKey: "col1/1.dat",
DeleteCount: 100,
DeletedByteCount: 1000,
Size: 5000,
},
{
Id: 2,
Collection: "col1",
RemoteStorageName: "",
RemoteStorageKey: "",
Size: 3000,
},
{
Id: 3,
Collection: "col2",
RemoteStorageName: "s3.archive",
RemoteStorageKey: "col2/3.dat",
DeleteCount: 50,
DeletedByteCount: 500,
Size: 2000,
},
},
},
},
},
},
},
},
},
{
Id: "dc2",
RackInfos: []*master_pb.RackInfo{
{
Id: "rack1",
DataNodeInfos: []*master_pb.DataNodeInfo{
{
Id: "server2:8080",
GrpcPort: 18080,
DiskInfos: map[string]*master_pb.DiskInfo{
"": {
VolumeInfos: []*master_pb.VolumeInformationMessage{
{
Id: 4,
Collection: "bucket1",
RemoteStorageName: "s3.default",
RemoteStorageKey: "bucket1/4.dat",
Size: 8000,
},
},
},
},
},
},
},
},
},
},
}
}
func TestFindRemoteVolumeInTopology(t *testing.T) {
topo := buildTestTopologyWithRemoteVolumes()
tests := []struct {
name string
vid needle.VolumeId
collection string
wantFound bool
wantDest string
}{
{
name: "find existing remote volume",
vid: 1,
wantFound: true,
wantDest: "s3.default",
},
{
name: "find remote volume with different backend",
vid: 3,
wantFound: true,
wantDest: "s3.archive",
},
{
name: "find remote volume on another server",
vid: 4,
wantFound: true,
wantDest: "s3.default",
},
{
name: "local volume not found as remote",
vid: 2,
wantFound: false,
},
{
name: "non-existent volume not found",
vid: 999,
wantFound: false,
},
{
name: "filter by matching collection exact",
vid: 1,
collection: "^col1$",
wantFound: true,
wantDest: "s3.default",
},
{
name: "filter by matching collection regex",
vid: 1,
collection: "col.*",
wantFound: true,
wantDest: "s3.default",
},
{
name: "filter by non-matching collection",
vid: 1,
collection: "^col2$",
wantFound: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rv, found, err := findRemoteVolumeInTopology(topo, tt.vid, tt.collection)
assert.NoError(t, err)
assert.Equal(t, tt.wantFound, found)
if found {
assert.Equal(t, tt.vid, rv.vid)
assert.Equal(t, tt.wantDest, rv.remoteStorageName)
}
})
}
}
func TestFindRemoteVolumeInTopologyInvalidPattern(t *testing.T) {
topo := buildTestTopologyWithRemoteVolumes()
_, _, err := findRemoteVolumeInTopology(topo, 1, "[invalid")
assert.Error(t, err)
}
func TestCollectRemoteVolumesWithInfo(t *testing.T) {
topo := buildTestTopologyWithRemoteVolumes()
tests := []struct {
name string
collectionPattern string
wantCount int
wantVids []needle.VolumeId
}{
{
name: "empty pattern matches empty collection only",
collectionPattern: "",
wantCount: 0,
},
{
name: "match all collections",
collectionPattern: ".*",
wantCount: 3, // volumes 1, 3, 4
wantVids: []needle.VolumeId{1, 3, 4},
},
{
name: "match specific collection",
collectionPattern: "^col1$",
wantCount: 1,
wantVids: []needle.VolumeId{1},
},
{
name: "match collection prefix",
collectionPattern: "col.*",
wantCount: 2, // volumes 1, 3
wantVids: []needle.VolumeId{1, 3},
},
{
name: "match bucket collection",
collectionPattern: "bucket1",
wantCount: 1,
wantVids: []needle.VolumeId{4},
},
{
name: "no match",
collectionPattern: "^nonexistent$",
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := collectRemoteVolumesWithInfo(topo, tt.collectionPattern)
assert.NoError(t, err)
assert.Equal(t, tt.wantCount, len(result))
if tt.wantVids != nil {
gotVids := make(map[needle.VolumeId]bool)
for _, rv := range result {
gotVids[rv.vid] = true
}
for _, vid := range tt.wantVids {
assert.True(t, gotVids[vid], "expected volume %d in results", vid)
}
}
})
}
}
func TestCollectRemoteVolumesWithInfoCaptures(t *testing.T) {
topo := buildTestTopologyWithRemoteVolumes()
result, err := collectRemoteVolumesWithInfo(topo, "^col1$")
assert.NoError(t, err)
assert.Equal(t, 1, len(result))
rv := result[0]
assert.Equal(t, needle.VolumeId(1), rv.vid)
assert.Equal(t, "col1", rv.collection)
assert.Equal(t, "s3.default", rv.remoteStorageName)
assert.Equal(t, "server1:8080", rv.serverUrl)
assert.NotEmpty(t, rv.serverAddress)
}
func TestCollectRemoteVolumesWithInfoInvalidPattern(t *testing.T) {
topo := buildTestTopologyWithRemoteVolumes()
_, err := collectRemoteVolumesWithInfo(topo, "[invalid")
assert.Error(t, err)
}
func buildTestTopologyWithReplicas() *master_pb.TopologyInfo {
mkDn := func(id string, volumes ...*master_pb.VolumeInformationMessage) *master_pb.DataNodeInfo {
return &master_pb.DataNodeInfo{
Id: id,
GrpcPort: 18080,
DiskInfos: map[string]*master_pb.DiskInfo{
"": {VolumeInfos: volumes},
},
}
}
remote := func(vid uint32) *master_pb.VolumeInformationMessage {
return &master_pb.VolumeInformationMessage{
Id: vid,
Collection: "col1",
RemoteStorageName: "s3.default",
RemoteStorageKey: "col1/vid.dat",
}
}
local := func(vid uint32) *master_pb.VolumeInformationMessage {
return &master_pb.VolumeInformationMessage{Id: vid, Collection: "col1"}
}
return &master_pb.TopologyInfo{
DataCenterInfos: []*master_pb.DataCenterInfo{
{
RackInfos: []*master_pb.RackInfo{
{
DataNodeInfos: []*master_pb.DataNodeInfo{
mkDn("n1:8080", remote(1)),
mkDn("n2:8080", remote(1), local(2)),
mkDn("n3:8080", remote(1)),
},
},
},
},
},
}
}
func TestCollectVolumeTierCompactLocations(t *testing.T) {
topo := buildTestTopologyWithReplicas()
tiered, local := collectVolumeTierCompactLocations(topo, needle.VolumeId(1), "col1")
assert.Equal(t, 3, len(tiered))
assert.Equal(t, 0, len(local))
tiered, local = collectVolumeTierCompactLocations(topo, needle.VolumeId(2), "col1")
assert.Equal(t, 0, len(tiered))
assert.Equal(t, 1, len(local))
// a different collection or volume id matches nothing
tiered, local = collectVolumeTierCompactLocations(topo, needle.VolumeId(1), "other")
assert.Equal(t, 0, len(tiered))
assert.Equal(t, 0, len(local))
}
func TestPlanTierCompactResync(t *testing.T) {
loc := func(url string) wdclient.Location {
return wdclient.Location{Url: url, GrpcPort: 1}
}
urls := func(locs []wdclient.Location) (out []string) {
for _, l := range locs {
out = append(out, l.Url)
}
return
}
// single replica: nothing to resync, nothing to delete (its download
// already removed the object)
order, deleter := planTierCompactResync([]wdclient.Location{loc("a")}, nil)
assert.Empty(t, order)
assert.Empty(t, deleter)
// two tiered replicas: the sibling is the last holder and goes last
order, deleter = planTierCompactResync([]wdclient.Location{loc("a"), loc("b")}, nil)
assert.Empty(t, order)
assert.Equal(t, []string{"b"}, urls(deleter))
// three tiered replicas plus one local copy: middle replicas resync in
// order, the last tiered replica deletes and is resynced last
order, deleter = planTierCompactResync(
[]wdclient.Location{loc("a"), loc("b"), loc("c")},
[]wdclient.Location{loc("d")})
assert.Equal(t, []string{"b", "d"}, urls(order))
assert.Equal(t, []string{"c"}, urls(deleter))
}