Files
seaweedfs/weed/topology/volume_digest_test.go
T
Chris Lu 6d08b08f37 heartbeat: carry a volume digest and verify it (#10627)
* pb: carry a volume digest on the heartbeat

The full volume list is the only way a master notices a volume that vanished
without a delta, so it cannot simply be dropped. A digest gives the same
guarantee without the list, and a way back to the list when they disagree.

The digest has explicit presence: a server holding no volumes reports 0, which
has to stay distinguishable from a server that does not compute one at all.

* volume: report a digest of the volumes each heartbeat carries

Digests exactly what goes on the wire: volumes skipped as quarantined, phantom
or expired are absent from both the list and the digest, so the master compares
against the same set the server meant to report.

Runs the master's own hash over the master's own conversion of the message, so
the two ends cannot drift into disagreeing about a field.

* master: check the reported volume digest and ask for the list on a mismatch

Compared after everything the heartbeat carried has been applied, so agreement
means the master is current rather than that nothing changed.

Servers reporting no digest are untouched, and a mismatch on a heartbeat that
already carried the full list is reported rather than answered: there is
nothing further to ask for, so asking again would loop. Nodes reporting one
volume id twice are skipped for the same reason.

* rust: report the heartbeat volume digest

Mirrors the Go volume server. The master compares this against a digest it
computes itself, so the hash has to agree byte for byte across the two
implementations, not merely be a hash of the same fields: report_hash_vectors
pins it against values generated by the Go side, and the ttl and replica
placement narrowing the master applies when it decodes a message is applied
here too rather than assumed away.

A drift there would not corrupt anything, but every volume server on this
implementation would report a digest the master can never match and fall back
to sending its whole volume list forever, which is the cost the digest exists
to avoid.

* master: pin what the digest check does to each kind of report

The upgrade story rests on these: a server that reports no digest is never
asked for anything, so the two sides can be upgraded in either order, and a
disagreement that resending cannot fix is reported rather than re-asked, so it
cannot loop.

* topology: enumerate the digest coverage test from the message

The list of fields was written out by hand, so a field added to
VolumeInformationMessage later would fall outside the digest while the test
went on passing, and a change to it would never reach the master. Walk the
message descriptor instead.

Some fields are narrowed or normalised on the way into VolumeInfo, so the
smallest change to the wire value can land back on the stored one; the test
offers several values per field and asks only that some change is visible.
2026-08-07 14:46:34 -07:00

473 lines
17 KiB
Go

package topology
import (
"testing"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"github.com/seaweedfs/seaweedfs/weed/storage"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"github.com/seaweedfs/seaweedfs/weed/util"
)
func digestTestNode(t *testing.T) (*Topology, *DataNode) {
t.Helper()
topo := NewTopology("digest", nil, 32*1024*1024*1024, 5, false)
dn := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1").
GetOrCreateDataNode("127.0.0.1", 8080, 18080, "", "", map[string]uint32{"": 1000})
return topo, dn
}
func digestTestVolume(id uint32) *master_pb.VolumeInformationMessage {
return &master_pb.VolumeInformationMessage{
Id: id,
Size: 1024 * 1024,
Collection: "c",
FileCount: 10,
DeleteCount: 1,
DeletedByteCount: 128,
ReplicaPlacement: 0,
Version: 3,
CompactRevision: 1,
ModifiedAtSecond: 1700000000,
}
}
func TestVolumeDigestIsStableAcrossRepeatedHeartbeats(t *testing.T) {
topo, dn := digestTestNode(t)
volumes := []*master_pb.VolumeInformationMessage{digestTestVolume(1), digestTestVolume(2), digestTestVolume(3)}
topo.SyncDataNodeRegistration(volumes, dn)
first := dn.VolumeDigest()
if first == 0 {
t.Fatal("expected a non-zero digest for a node holding volumes")
}
for i := 0; i < 3; i++ {
topo.SyncDataNodeRegistration(volumes, dn)
if got := dn.VolumeDigest(); got != first {
t.Fatalf("heartbeat %d changed the digest with no change to report: %d != %d", i, got, first)
}
}
}
func TestVolumeDigestIsIndependentOfReportOrder(t *testing.T) {
topoA, dnA := digestTestNode(t)
topoA.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
digestTestVolume(1), digestTestVolume(2), digestTestVolume(3),
}, dnA)
topoB, dnB := digestTestNode(t)
topoB.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
digestTestVolume(3), digestTestVolume(1), digestTestVolume(2),
}, dnB)
if dnA.VolumeDigest() != dnB.VolumeDigest() {
t.Errorf("digest depends on report order: %d != %d", dnA.VolumeDigest(), dnB.VolumeDigest())
}
}
// Every field of VolumeInformationMessage has to reach the digest: one the hash
// skips is a change the master would never be told about. Enumerated from the
// message rather than listed here, so a field added later cannot quietly fall
// outside the digest while this still passes.
func TestVolumeDigestTracksEveryReportedField(t *testing.T) {
base := digestTestVolume(1)
baseInfo, err := storage.NewVolumeInfo(base)
if err != nil {
t.Fatal(err)
}
fields := base.ProtoReflect().Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
fd := fields.Get(i)
t.Run(string(fd.Name()), func(t *testing.T) {
candidates := distinctValuesFor(t, fd, base.ProtoReflect().Get(fd))
for _, candidate := range candidates {
changed := proto.Clone(base).(*master_pb.VolumeInformationMessage)
changed.ProtoReflect().Set(fd, candidate)
changedInfo, err := storage.NewVolumeInfo(changed)
if err != nil {
t.Fatal(err)
}
if baseInfo.ReportHash() != changedInfo.ReportHash() {
return
}
}
t.Errorf("no change to %s moves the digest, so the master would never be told about one", fd.Name())
})
}
}
// distinctValuesFor offers values that differ from current. Several, because
// some fields are narrowed or normalised on the way into VolumeInfo and the
// smallest change to the wire value can land back on the stored one.
func distinctValuesFor(t *testing.T, fd protoreflect.FieldDescriptor, current protoreflect.Value) []protoreflect.Value {
t.Helper()
switch fd.Kind() {
case protoreflect.BoolKind:
return []protoreflect.Value{protoreflect.ValueOfBool(!current.Bool())}
case protoreflect.Uint32Kind:
return []protoreflect.Value{
protoreflect.ValueOfUint32(uint32(current.Uint()) + 1),
protoreflect.ValueOfUint32(uint32(current.Uint()) + 1<<8),
protoreflect.ValueOfUint32(uint32(current.Uint()) + 1<<16),
}
case protoreflect.Uint64Kind:
return []protoreflect.Value{protoreflect.ValueOfUint64(current.Uint() + 1)}
case protoreflect.Int64Kind:
return []protoreflect.Value{protoreflect.ValueOfInt64(current.Int() + 1)}
case protoreflect.StringKind:
return []protoreflect.Value{protoreflect.ValueOfString(current.String() + "x")}
}
t.Fatalf("field %s has kind %s, which this test does not know how to vary", fd.Name(), fd.Kind())
return nil
}
func TestVolumeDigestFollowsVolumeChanges(t *testing.T) {
topo, dn := digestTestNode(t)
volumes := []*master_pb.VolumeInformationMessage{digestTestVolume(1), digestTestVolume(2)}
topo.SyncDataNodeRegistration(volumes, dn)
original := dn.VolumeDigest()
grown := []*master_pb.VolumeInformationMessage{digestTestVolume(1), digestTestVolume(2)}
grown[1].Size += 4096
topo.SyncDataNodeRegistration(grown, dn)
if dn.VolumeDigest() == original {
t.Error("a volume that grew left the digest unchanged")
}
topo.SyncDataNodeRegistration(volumes, dn)
if dn.VolumeDigest() != original {
t.Error("reverting a volume did not restore the digest")
}
topo.SyncDataNodeRegistration(volumes[:1], dn)
if dn.VolumeDigest() == original {
t.Error("dropping a volume left the digest unchanged")
}
topo.SyncDataNodeRegistration(volumes, dn)
if dn.VolumeDigest() != original {
t.Error("restoring a dropped volume did not restore the digest")
}
}
func TestVolumeDigestEmptiesWithTheNode(t *testing.T) {
topo, dn := digestTestNode(t)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
digestTestVolume(1), digestTestVolume(2),
}, dn)
topo.SyncDataNodeRegistration(nil, dn)
if got := dn.VolumeDigest(); got != 0 {
t.Errorf("expected an empty node to digest to 0, got %d", got)
}
}
func TestVolumeDigestFollowsDeltaRegistration(t *testing.T) {
topo, dn := digestTestNode(t)
full := []*master_pb.VolumeInformationMessage{digestTestVolume(1), digestTestVolume(2)}
topo.SyncDataNodeRegistration(full, dn)
both := dn.VolumeDigest()
topo.IncrementalSyncDataNodeRegistration(nil, []*master_pb.VolumeShortInformationMessage{{Id: 2}}, dn)
if dn.VolumeDigest() == both {
t.Error("unmounting a volume left the digest unchanged")
}
topo.SyncDataNodeRegistration(full, dn)
if dn.VolumeDigest() != both {
t.Error("a full heartbeat did not restore the digest after an unmount")
}
}
// The point of the digest is not to detect that volumes changed -- in any live
// cluster some always have. It is to confirm that after applying the changes a
// heartbeat did carry, the master holds what the volume server holds. So a
// heartbeat reporting only the volumes that moved must still reconcile.
func TestVolumeDigestMatchesAfterApplyingOnlyChangedVolumes(t *testing.T) {
const total = 50
full := make([]*master_pb.VolumeInformationMessage, 0, total)
for i := 1; i <= total; i++ {
v := digestTestVolume(uint32(i))
v.ReadOnly = i > 5 // only the first few are writable, as in a tiered cluster
full = append(full, v)
}
topo, dn := digestTestNode(t)
topo.SyncDataNodeRegistration(full, dn)
// Three writable volumes take writes between two heartbeats.
changed := make([]storage.VolumeInfo, 0, 3)
for _, v := range full[:3] {
v.Size += 4096
v.FileCount++
v.ModifiedAtSecond += 5
vi, err := storage.NewVolumeInfo(v)
if err != nil {
t.Fatal(err)
}
changed = append(changed, vi)
}
// What the volume server would now report for its whole set.
reference, referenceNode := digestTestNode(t)
reference.SyncDataNodeRegistration(full, referenceNode)
want := referenceNode.VolumeDigest()
if dn.VolumeDigest() == want {
t.Fatal("expected the master to be behind before the changes are applied")
}
// The heartbeat carries three volumes, not fifty.
dn.DeltaUpdateVolumes(changed, nil)
if got := dn.VolumeDigest(); got != want {
t.Errorf("digest still disagrees after applying the reported changes: %d != %d", got, want)
}
}
// A volume that disappears without a delta is exactly what the full list exists
// to catch, and is the case the digest has to keep catching.
func TestVolumeDigestCatchesASilentlyLostVolume(t *testing.T) {
full := []*master_pb.VolumeInformationMessage{
digestTestVolume(1), digestTestVolume(2), digestTestVolume(3),
}
topo, dn := digestTestNode(t)
topo.SyncDataNodeRegistration(full, dn)
// The volume server no longer has volume 2 and never got to say so.
reference, referenceNode := digestTestNode(t)
reference.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{full[0], full[2]}, referenceNode)
if dn.VolumeDigest() == referenceNode.VolumeDigest() {
t.Error("a volume lost without a delta went undetected, which is what the full list is for")
}
}
// The disk map and the lookup index are maintained separately, and a disconnect
// racing a reconnect has been seen to drop a volume from the lookup index while
// leaving it on the node. The volume server's report is identical either way, so
// the heartbeat digest cannot see it and the master has to notice on its own.
func TestVolumeIndexDigestSeesLookupDivergence(t *testing.T) {
topo, dn := digestTestNode(t)
v := digestTestVolume(1)
v.Collection = "drr"
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, dn)
if !dn.HasConsistentVolumeIndex() {
t.Fatal("a freshly registered node should have a consistent index")
}
reported := dn.VolumeDigest()
rp, _ := super_block.NewReplicaPlacementFromString("000")
vl := topo.GetVolumeLayout("drr", rp, needle.EMPTY_TTL, types.HardDriveType)
vl.SetVolumeUnavailable(dn, needle.VolumeId(1))
if got := topo.Lookup("drr", needle.VolumeId(1)); got != nil {
t.Fatalf("expected the volume to have become unservable, got %v", got)
}
if _, err := dn.GetVolumesById(needle.VolumeId(1)); err != nil {
t.Fatalf("the volume should still be on the node: %v", err)
}
if dn.VolumeDigest() != reported {
t.Error("the reported digest should not move: the volume server sees no change")
}
if dn.HasConsistentVolumeIndex() {
t.Error("a volume held but not servable left the index digests agreeing, so nothing would repair it")
}
// The full heartbeat self-heal puts it back.
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, dn)
if !dn.HasConsistentVolumeIndex() {
t.Error("the self-heal did not restore index consistency")
}
}
func TestVolumeIndexDigestFollowsNodeLifecycle(t *testing.T) {
topo, dn := digestTestNode(t)
full := []*master_pb.VolumeInformationMessage{digestTestVolume(1), digestTestVolume(2), digestTestVolume(3)}
topo.SyncDataNodeRegistration(full, dn)
if !dn.HasConsistentVolumeIndex() {
t.Fatal("registration left the indexes disagreeing")
}
topo.SyncDataNodeRegistration(full[:2], dn)
if !dn.HasConsistentVolumeIndex() {
t.Error("dropping a volume left the indexes disagreeing")
}
topo.IncrementalSyncDataNodeRegistration(
[]*master_pb.VolumeShortInformationMessage{{Id: 9}}, nil, dn)
if !dn.HasConsistentVolumeIndex() {
t.Error("a mount delta left the indexes disagreeing")
}
topo.IncrementalSyncDataNodeRegistration(
nil, []*master_pb.VolumeShortInformationMessage{{Id: 9}}, dn)
if !dn.HasConsistentVolumeIndex() {
t.Error("an unmount delta left the indexes disagreeing")
}
topo.UnRegisterDataNode(dn)
held, servable := dn.VolumeIndexDigests()
if held != 0 || servable != 0 {
t.Errorf("an unregistered node should hold nothing: held=%d servable=%d", held, servable)
}
}
// A volume id mounted on two disks of one server is reported twice with
// different disk ids, but the master keys volumes by id alone, so it keeps only
// one copy and its digest can never equal the server's. Resending the full list
// cannot fix that, so the node has to be excluded from digest comparison
// entirely rather than resend forever.
func TestVolumeDigestRefusesDuplicateVolumeIds(t *testing.T) {
topo, dn := digestTestNode(t)
first := digestTestVolume(1)
second := digestTestVolume(1)
second.DiskId = 1
second.Size = first.Size * 2
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{first, second}, dn)
if !dn.HasDuplicateVolumeIds() {
t.Fatal("a volume id reported twice went unnoticed, so the digest would be trusted and never reconcile")
}
firstInfo, err := storage.NewVolumeInfo(first)
if err != nil {
t.Fatal(err)
}
secondInfo, err := storage.NewVolumeInfo(second)
if err != nil {
t.Fatal(err)
}
if dn.VolumeDigest() == firstInfo.ReportHash()^secondInfo.ReportHash() {
t.Error("expected the master to be unable to represent both copies; if it now can, the guard is no longer needed")
}
// Once the stale twin is gone the node is comparable again.
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{first}, dn)
if dn.HasDuplicateVolumeIds() {
t.Error("the node stayed marked as duplicated after reporting a clean list")
}
if dn.VolumeDigest() != firstInfo.ReportHash() {
t.Error("digest did not settle on the surviving copy")
}
}
// Two volume servers can hold one address. GetOrCreateDataNode keys on the id a
// server reports and deliberately refuses to merge a new id onto an address an
// older node still claims, while the lookup list keys on address alone -- so
// registering the second server displaces the first from the lookup entry
// without either node being told. The digest has to follow the entry, not the
// node that was passed in.
func addressSharingNodes(t *testing.T) (*Topology, *DataNode, *DataNode) {
t.Helper()
topo := NewTopology("digest", nil, 32*1024*1024*1024, 5, false)
rack := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1")
counts := map[string]uint32{"": 1000}
old := rack.GetOrCreateDataNode("10.1.2.3", 8080, 18080, "", "n1", counts)
fresh := rack.GetOrCreateDataNode("10.1.2.3", 8080, 18080, "", "n2", counts)
if old == fresh {
t.Skip("address reuse no longer produces two nodes")
}
return topo, old, fresh
}
func TestVolumeIndexDigestFollowsDisplacedLookupEntry(t *testing.T) {
topo, old, fresh := addressSharingNodes(t)
v := digestTestVolume(1)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, old)
if !old.HasConsistentVolumeIndex() {
t.Fatal("the first node should be consistent before it is displaced")
}
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, fresh)
servable := map[*DataNode]bool{}
for _, dn := range topo.Lookup("", needle.VolumeId(1)) {
servable[dn] = true
}
if servable[old] || !servable[fresh] {
t.Fatalf("expected the lookup entry to move to the new node, got old=%v fresh=%v",
servable[old], servable[fresh])
}
if old.HasConsistentVolumeIndex() {
t.Error("the displaced node still holds the volume and can no longer serve it, so its index must read as inconsistent")
}
if !fresh.HasConsistentVolumeIndex() {
t.Error("the node the lookup entry now names reads as inconsistent")
}
}
func TestVolumeIndexDigestFollowsRemovedLookupEntry(t *testing.T) {
topo, old, fresh := addressSharingNodes(t)
v := digestTestVolume(1)
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, old)
// fresh shares old's address, so unregistering through it removes old's
// lookup entry. The digest must come off the node that was actually removed.
rp, _ := super_block.NewReplicaPlacementFromString("000")
vl := topo.GetVolumeLayout("c", rp, needle.EMPTY_TTL, types.HardDriveType)
vl.SetVolumeUnavailable(fresh, needle.VolumeId(1))
if got := topo.Lookup("c", needle.VolumeId(1)); got != nil {
t.Fatalf("expected the lookup entry to be gone, got %v", got)
}
if old.HasConsistentVolumeIndex() {
t.Error("the removed node still holds the volume and cannot serve it, so its index must read as inconsistent")
}
if _, servable := fresh.VolumeIndexDigests(); servable != 0 {
t.Error("the node that was merely passed in should never have gained the entry")
}
}
// The two ends must agree on real heartbeat data, not just on hand-built
// messages: the volume server hashes what it is about to send, the master
// hashes what it stored from it.
func TestMasterDigestMatchesWhatAVolumeServerReports(t *testing.T) {
dir := t.TempDir()
loc := storage.NewDiskLocation(dir, 100, util.MinFreeSpace{}, "", types.HardDriveType, nil,
stats.DefaultDiskIOProbeConfig())
for _, vid := range []needle.VolumeId{1, 2, 3} {
v, err := storage.NewVolume(loc.Directory, loc.IdxDirectory, "", vid, storage.NeedleMapInMemory,
&super_block.ReplicaPlacement{}, &needle.TTL{}, 0, needle.GetCurrentVersion(), 0, 0)
if err != nil {
t.Fatal(err)
}
loc.SetVolume(vid, v)
}
reported := make([]*master_pb.VolumeInformationMessage, 0, 3)
var serverDigest uint64
for _, vid := range []needle.VolumeId{1, 2, 3} {
v, _ := loc.FindVolume(vid)
_, m := v.ToVolumeInformationMessage()
if m == nil {
t.Fatalf("volume %d reported nothing", vid)
}
vi, err := storage.NewVolumeInfo(m)
if err != nil {
t.Fatal(err)
}
serverDigest ^= vi.ReportHash()
reported = append(reported, m)
}
topo, dn := digestTestNode(t)
topo.SyncDataNodeRegistration(reported, dn)
if got := dn.VolumeDigest(); got != serverDigest {
t.Errorf("master digest %d does not match the reporting server's %d", got, serverDigest)
}
}