Files
seaweedfs/weed/wdclient/vid_map_generation_test.go
T
Chris LuandGitHub 7b8188fc41 wdclient: age vid map entries by generation instead of chaining snapshots (#10506)
* wdclient: age vid map entries by generation instead of chaining snapshots

The vid map kept its history as a linked list of past snapshots, trimmed
in place by storing nil into a node's cache pointer. That cost up to six
full copies of the volume-location map, a recursive walk taking a
different lock per level, and deletes that had to cascade through every
generation. It also had to special-case explicitly-empty entries, or
fallback would resurrect locations a newer snapshot had cleared.

Keep one map instead, and stamp each entry with the generation it was
learned in. resetVidMap bumps the generation and drops entries that were
not relearned within the retained window, which is the same retention
the chain provided: an entry survives DefaultVidMapCacheSize resets.

The first write of a generation replaces an entry rather than merging
into it, so a volume that moved answers with where it is now — the
property a fresh map per reset used to give for free. Entries are
copy-on-write, so locations handed to a caller are no longer shifted
underneath it by a concurrent delete.

The map is never swapped now, so the client-side lock and its stable /
current accessors go away with it.

* wdclient: make vid map entries immutable and drop them once emptied

Review follow-up. Updating an entry in place left the copy-on-write
guarantee resting on callers never holding the entry pointer; install a
new entry instead, so the rule is simply that a stored entry never
changes.

Deleting a volume's last location now drops the entry rather than
keeping an empty one, which a client that never resets would otherwise
hold for every volume it ever saw deleted. Lookups already treat an
empty entry as a miss, so nothing observable changes.

* wdclient: let the newest generation decide between regular and EC locations

GetLocations checked the regular map first whatever its generation, so a
volume that was EC encoded kept answering with the regular copies the
previous master knew until they expired — for as long as the retained
window, since nothing relearns a copy that no longer exists.

The snapshot chain did not have this problem: the newest map was
consulted first and only a volume it knew nothing about fell through to
older ones. Restore that by comparing generations, with regular copies
winning a tie, since a tie means one generation reported both.
2026-07-31 02:16:33 -07:00

331 lines
9.7 KiB
Go

package wdclient
import (
"runtime"
"sync"
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb"
)
func urlsOf(locations []Location) []string {
urls := make([]string, 0, len(locations))
for _, loc := range locations {
urls = append(urls, loc.Url)
}
return urls
}
// A volume that moved while we were talking to the previous master must answer
// with where it is now, not with both servers: the first write of a generation
// replaces the entry instead of merging into it.
func TestAddLocationReplacesEarlierGeneration(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(3)
movedFrom := Location{Url: "10.0.0.1:8080"}
movedTo := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, movedFrom)
vm.reset()
vm.addLocation(vid, movedTo)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != movedTo.Url {
t.Fatalf("expected only %s after the move, got %v", movedTo.Url, urlsOf(locs))
}
if vm.hasVolumeServer(pb.ServerAddress(movedFrom.Url)) {
t.Errorf("server %s should no longer be referenced after the volume moved", movedFrom.Url)
}
if !vm.hasVolumeServer(pb.ServerAddress(movedTo.Url)) {
t.Errorf("server %s should be referenced after the volume moved", movedTo.Url)
}
}
// Replicas reported within one generation accumulate, and a repeated report of
// the same server is not counted twice.
func TestAddLocationMergesWithinGeneration(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(4)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
vm.addLocation(vid, first)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 2 {
t.Fatalf("expected both replicas, got %v", urlsOf(locs))
}
// One delete must be enough to evict the server: the duplicate report of
// `first` must not have taken a second reference.
vm.deleteLocation(vid, first)
if vm.hasVolumeServer(pb.ServerAddress(first.Url)) {
t.Errorf("server %s should be evicted after a single delete", first.Url)
}
}
// An entry survives exactly retainGenerations resets without being relearned.
func TestResetRetainsUntilWindowExpires(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(5)
location := Location{Url: "10.0.0.1:8080"}
vm.addLocation(vid, location)
for i := 0; i < retain; i++ {
vm.reset()
if _, found := vm.GetLocations(vid); !found {
t.Fatalf("location should still be retained after %d reset(s)", i+1)
}
}
vm.reset()
if _, found := vm.GetLocations(vid); found {
t.Errorf("location should be dropped after %d resets", retain+1)
}
if vm.hasVolumeServer(pb.ServerAddress(location.Url)) {
t.Errorf("expiry should release the server reference for %s", location.Url)
}
if len(vm.serverRefCount) != 0 {
t.Errorf("expiry left %d dangling server refcounts", len(vm.serverRefCount))
}
}
// Relearning an entry restarts its retention window.
func TestResetWindowRestartsOnRelearn(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(6)
location := Location{Url: "10.0.0.1:8080"}
for i := 0; i < retain+3; i++ {
vm.addLocation(vid, location)
vm.reset()
}
if _, found := vm.GetLocations(vid); !found {
t.Error("a location relearned every generation must never expire")
}
}
// A delete names one location; it must not make the rest of a stale entry look
// freshly learned and so outlive its retention window.
func TestDeleteLocationDoesNotRefreshGeneration(t *testing.T) {
const retain = 2
vm := newVidMap("", retain)
vid := uint32(7)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
for i := 0; i < retain; i++ {
vm.reset()
}
vm.deleteLocation(vid, first)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != second.Url {
t.Fatalf("expected %s to remain, got found=%v %v", second.Url, found, urlsOf(locs))
}
vm.reset()
if _, found := vm.GetLocations(vid); found {
t.Error("the surviving location was learned in the expired generation and should be dropped")
}
}
// Encoding a volume must stop the regular copies a previous generation knew
// from answering for it, and decoding it must stop its shards from answering.
func TestNewestGenerationWinsAcrossEcTransition(t *testing.T) {
regular := Location{Url: "10.0.0.1:8080"}
ecShard := Location{Url: "10.0.0.2:8080"}
t.Run("encoded", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addLocation(1, regular)
vm.reset()
vm.addEcLocation(1, ecShard)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != ecShard.Url {
t.Fatalf("expected the freshly learned EC shard, got found=%v %v", found, urlsOf(locs))
}
})
t.Run("decoded", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addEcLocation(1, ecShard)
vm.reset()
vm.addLocation(1, regular)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("expected the freshly learned regular copy, got found=%v %v", found, urlsOf(locs))
}
})
t.Run("same generation prefers regular", func(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vm.addEcLocation(1, ecShard)
vm.addLocation(1, regular)
locs, found := vm.GetLocations(1)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("expected the regular copy, got found=%v %v", found, urlsOf(locs))
}
})
}
// Losing the last location drops the entry: a client that never resets should
// not accumulate one empty entry per volume it has ever seen deleted.
func TestDeleteLastLocationDropsEntry(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(12)
location := Location{Url: "10.0.0.1:8080"}
vm.addLocation(vid, location)
vm.deleteLocation(vid, location)
if _, found := vm.GetLocations(vid); found {
t.Error("a volume with no locations left must not resolve")
}
if len(vm.vid2Locations) != 0 {
t.Errorf("expected the emptied entry to be dropped, got %v", vm.vid2Locations)
}
if len(vm.serverRefCount) != 0 {
t.Errorf("server refcounts leaked: %v", vm.serverRefCount)
}
}
// EC locations follow the same rules as regular ones, and back a volume whose
// regular locations are gone.
func TestEcLocationsFollowGenerationRules(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(8)
regular := Location{Url: "10.0.0.1:8080"}
ecShard := Location{Url: "10.0.0.2:8080"}
movedEcShard := Location{Url: "10.0.0.3:8080"}
vm.addLocation(vid, regular)
vm.addEcLocation(vid, ecShard)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != regular.Url {
t.Fatalf("regular locations should win while they exist, got %v", urlsOf(locs))
}
// Regular copy goes away: the EC shards still serve the volume.
vm.deleteLocation(vid, regular)
locs, found = vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != ecShard.Url {
t.Fatalf("expected EC shard location, got found=%v %v", found, urlsOf(locs))
}
// EC shards move under a new master: no merging with the old report.
vm.reset()
vm.addEcLocation(vid, movedEcShard)
locs, found = vm.GetLocations(vid)
if !found || len(locs) != 1 || locs[0].Url != movedEcShard.Url {
t.Fatalf("expected only the moved EC shard, got found=%v %v", found, urlsOf(locs))
}
}
// Locations handed to a caller are never rewritten underneath it.
func TestGetLocationsResultIsStable(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
vid := uint32(9)
first := Location{Url: "10.0.0.1:8080"}
second := Location{Url: "10.0.0.2:8080"}
vm.addLocation(vid, first)
vm.addLocation(vid, second)
locs, found := vm.GetLocations(vid)
if !found || len(locs) != 2 {
t.Fatalf("expected both replicas, got %v", urlsOf(locs))
}
snapshot := append([]Location(nil), locs...)
vm.deleteLocation(vid, first)
vm.addLocation(vid, Location{Url: "10.0.0.3:8080"})
for i := range snapshot {
if locs[i] != snapshot[i] {
t.Errorf("location %d changed under the caller: %v became %v", i, snapshot[i], locs[i])
}
}
}
func TestNewVidMapDefaultsRetention(t *testing.T) {
for _, retain := range []int{0, -1} {
if got := newVidMap("", retain).retainGenerations; got != DefaultVidMapCacheSize {
t.Errorf("newVidMap(%d) retention = %d, want %d", retain, got, DefaultVidMapCacheSize)
}
}
}
// Readers must never see a spurious miss for a volume that stays live, and the
// bookkeeping must survive concurrent writers. Run with -race.
func TestConcurrentResetAndUpdates(t *testing.T) {
vm := newVidMap("", DefaultVidMapCacheSize)
live := Location{Url: "10.0.0.1:8080"}
churn := Location{Url: "10.0.0.2:8080"}
const liveVid, churnVid = 1, 2
vm.addLocation(liveVid, live)
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
if _, found := vm.GetLocations(liveVid); !found {
t.Error("a volume that is relearned every generation must always resolve")
return
}
vm.hasVolumeServer(pb.ServerAddress(live.Url))
vm.GetLocationsClone(churnVid)
runtime.Gosched()
}
}
}()
}
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 200; j++ {
vm.addEcLocation(churnVid, churn)
vm.deleteEcLocation(churnVid, churn)
vm.deleteVid(churnVid)
}
}()
}
for i := 0; i < 300; i++ {
vm.reset()
vm.addLocation(liveVid, live)
}
close(stop)
wg.Wait()
if _, found := vm.GetLocations(liveVid); !found {
t.Fatal("live volume lost after the churn")
}
vm.deleteVid(liveVid)
if len(vm.serverRefCount) != 0 {
t.Errorf("server refcounts leaked: %v", vm.serverRefCount)
}
}