mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-28 07:50:44 +01:00
* topology: serialize quarantine records with their layout marks A quarantine publishes a (node, vid) record and marks the replica read-only at the layout level; release and heartbeat flag updates must observe that pair as one state. Store-then-mark let a concurrent release delete the record before the mark landed, stranding the surviving replica read-only; mark-then-store let a heartbeat clear the mark before the record was visible, returning a conflicting replica to the writable list. quarantineLock now covers every record<->mark pair: flagDuplicateVolumeId's quarantine, releaseResolvedQuarantine's undo, the isQuarantined-gated marks on registration, and the quarantine-folded read-only flag writes on heartbeat paths. Remote server pushes stay outside the lock; a push that lands after the record was released is undone, and a failed release push re-adds the record for a later retry. The release test now waits for in-flight async flags to settle instead of asserting on a single read. * topology: fold quarantine into RegisterVolume's flag rebuild RegisterVolume rewrites every replica's location read-only flag from what its node last reported, and reported flags deliberately exclude quarantine. Any registration of a shared vid -- a reconnected replica, a new copy, the self-heal and delta-heartbeat re-register paths -- therefore cleared the quarantine mark on every replica in the location, while the gate afterwards only re-marked the registering node. Two call sites had no gate at all. RegisterVolume now takes an isQuarantined predicate and evaluates it under accessLock for every replica it touches, so the mark is never dropped in the first place: a release unmarks after the rebuild, and a quarantine mark re-applies on the next rebuild, with no window between. * topology: keep quarantine when a vacuum commit re-adds a replica SetVolumeAvailable re-adds the replica into the location list; a re-added slot starts with a cleared read-only flag, so a quarantined replica came back flag-free and the next ensureCorrectWritables could return a conflicting volume to the writable list. Pass the quarantine state in and set the flag back under the same accessLock, with the caller holding quarantineLock so release cannot interleave between the check and the mark. * topology: merge quarantine records instead of overwriting them enforceQuarantine wrote back the state copy it loaded before its remote push, so a concurrent writer's enforced=true was lost: the server stayed read-only while the record said unenforced, and a later release skipped the writable push it owed. The write-back now reloads the record under quarantineLock and merges -- enforced wins from either side, reported is taken from the newer pre-enforcement observation only while no push has landed yet. All three compensating writable pushes had the same hole in reverse: a failed un-quarantine left the server read-only with no record to retry from, and release's restore blindly overwrote any record a fresh flag pass had created in between. They now go through restoreQuarantineRecord, which adds the record only when absent and otherwise sets enforced on the current one, preserving its reported flag. * topology: hold quarantineLock over registration's quarantine check-and-mark RegisterVolume reads the quarantine record and marks the replica read-only under accessLock, but the pair ran without quarantineLock. A release could push writable, watch the record get re-read, and then delete it: the replica stayed read-only with no record left to clear, and unchanged writable heartbeats skip SetReplicaReadOnlyFlag so the mark never repaired. Holding quarantineLock around the check-and-mark in all three production callers serializes it against the release clear-and-delete. * topology: serialize remote quarantine pushes behind a record recheck releaseResolvedQuarantine deletes the record under quarantineLock and sends the writable request after unlock. A conflict flagged while that request is in flight creates a fresh record and marks the replica read-only again, and the older writable request can still land last, leaving the server writable under an active quarantine. The reverse flap strands a stale read-only mark the same way. Route every QuarantineVolumeFn call through pushQuarantine, which holds quarantinePushLock across the remote call and re-checks the record under quarantineLock right before sending: a read-only push requires the pair to exist, a writable push requires it to be gone. A skipped push means the decision already moved on, so the final server state always follows the newest record. * topology: serialize quarantine pushes per replica, not globally A single quarantinePushLock held across the remote mark call let one slow volume server (up to the 10s client timeout) delay pushes for unrelated replicas. Keying the lock by (node, vid) keeps pushes to one replica ordered in record-decision order while pushes to others run in parallel. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * topology: restore the layout read-only mark with a released record A failed writable push re-armed the quarantine record but left the replica marked writable in the layout, so PickForWrite could route to a replica the server still holds read-only. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * topology: wait out async duplicate checks in the release-retry test TestFailedReleasePushRestoresQuarantineMark asserts layout marks around a release pass, but the two RegisterVolumeLayout calls each start a background flagDuplicateVolumeId that can still be mid-flight. A late mark could land after the final release and fail the writable assertion even though the code behaved correctly. Track the async passes in a WaitGroup and drain them before the explicit flag call so the rest of the test is deterministic. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * topology: keep quarantine when a replica's layout key is unreadable * topology: keep a failed writable push's restore ordered under the push lock * topology: retry the quarantine release on every duplicate check * ci: isolate Java and S3 test servers on persistent runners * ci: drop the duplicated -ip flags in the lifecycle test server The bind flags were already on the command; the isolation pass added a second pair. * topology: retry a failed quarantine writable push on the next report * topology: retry pending quarantine releases off the leader pulse A failed writable push re-arms the quarantine record with a retry that only enforceQuarantine fires, and that runs only for volumes a heartbeat carries. Once the server has reported the replica read-only it drops out of the delta reports, and restoreQuarantineRecord keeps the stored flag matching the server's so the digests agree and no full list is requested - the pending release then never retries and the idle volume stays read-only forever. Run the same release pass from the leader's pulse loop so a pending retry no longer waits on a report. * topology: exercise the pulse-driven quarantine release retry --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
332 lines
13 KiB
Go
332 lines
13 KiB
Go
package topology
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/sequence"
|
|
"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"
|
|
)
|
|
|
|
// addMaintenanceTestNode links a data node with maxVol free slots under rack.
|
|
func addMaintenanceTestNode(rack *Rack, id string, maxVol int64) *DataNode {
|
|
dn := NewDataNode(id)
|
|
dn.Ip = id
|
|
rack.LinkChildNode(dn)
|
|
dn.getOrCreateDisk("").UpAdjustDiskUsageDelta("", &DiskUsageCounts{maxVolumeCount: maxVol})
|
|
return dn
|
|
}
|
|
|
|
func newMaintenanceTestTopology() (*Topology, *DataCenter) {
|
|
topo := NewTopology("weedfs", sequence.NewMemorySequencer(), 32*1024, 5, false)
|
|
dc := NewDataCenter("dc1")
|
|
topo.LinkChildNode(dc)
|
|
return topo, dc
|
|
}
|
|
|
|
func TestMaintenanceModeReportsNoFreeSlots(t *testing.T) {
|
|
rack := NewRack("rack1")
|
|
dn := addMaintenanceTestNode(rack, "server1", 10)
|
|
option := &VolumeGrowOption{DiskType: types.HardDriveType}
|
|
|
|
if got := dn.AvailableSpaceFor(option); got != 10 {
|
|
t.Fatalf("AvailableSpaceFor = %d, want 10", got)
|
|
}
|
|
if !dn.SetMaintenanceMode(true) {
|
|
t.Fatal("first switch should report a change")
|
|
}
|
|
if dn.SetMaintenanceMode(true) {
|
|
t.Fatal("repeating the same value should not report a change")
|
|
}
|
|
if !dn.InMaintenanceMode() {
|
|
t.Fatal("InMaintenanceMode = false after switching on")
|
|
}
|
|
if got := dn.AvailableSpaceFor(option); got != 0 {
|
|
t.Errorf("AvailableSpaceFor in maintenance = %d, want 0: no new volumes go to a server in maintenance", got)
|
|
}
|
|
if got := dn.AvailableSpaceForReservation(option); got != 0 {
|
|
t.Errorf("AvailableSpaceForReservation in maintenance = %d, want 0", got)
|
|
}
|
|
if _, ok := dn.TryReserveCapacity(types.HardDriveType, 1); ok {
|
|
t.Error("reserved capacity on a server in maintenance")
|
|
}
|
|
if got := rack.AvailableSpaceFor(option); got != 10 {
|
|
t.Errorf("rack AvailableSpaceFor = %d, want 10: the node's capacity still rolls up to its parents", got)
|
|
}
|
|
|
|
if !dn.SetMaintenanceMode(false) {
|
|
t.Fatal("switching off should report a change")
|
|
}
|
|
if got := dn.AvailableSpaceFor(option); got != 10 {
|
|
t.Errorf("AvailableSpaceFor after maintenance = %d, want 10", got)
|
|
}
|
|
}
|
|
|
|
// A server in maintenance mode is the one being evacuated, so volume growth in
|
|
// its rack must land every replica on its siblings even when it has the most
|
|
// free slots by far.
|
|
func TestVolumeGrowthSkipsMaintenanceNodeInSameRack(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack := NewRack("rack1")
|
|
dc.LinkChildNode(rack)
|
|
busy := addMaintenanceTestNode(rack, "busy", 1000)
|
|
busy.SetMaintenanceMode(true)
|
|
addMaintenanceTestNode(rack, "small1", 2)
|
|
addMaintenanceTestNode(rack, "small2", 2)
|
|
|
|
vg := NewDefaultVolumeGrowth()
|
|
rp, _ := super_block.NewReplicaPlacementFromString("001")
|
|
option := &VolumeGrowOption{ReplicaPlacement: rp, DiskType: types.HardDriveType}
|
|
for i := 0; i < 50; i++ {
|
|
servers, _, err := vg.findEmptySlotsForOneVolume(topo, option, false)
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: %v", i, err)
|
|
}
|
|
if len(servers) != 2 {
|
|
t.Fatalf("iteration %d: got %d servers, want 2", i, len(servers))
|
|
}
|
|
for _, s := range servers {
|
|
if s.Id() == busy.Id() {
|
|
t.Fatalf("iteration %d: picked the server in maintenance mode", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pinning the request to the maintenance node must fail rather than
|
|
// create a volume there.
|
|
rp0, _ := super_block.NewReplicaPlacementFromString("000")
|
|
pinned := &VolumeGrowOption{ReplicaPlacement: rp0, DiskType: types.HardDriveType, DataNode: "busy"}
|
|
if _, _, err := vg.findEmptySlotsForOneVolume(topo, pinned, false); err == nil {
|
|
t.Fatal("a volume pinned to a server in maintenance mode must not be created")
|
|
}
|
|
}
|
|
|
|
// The replica on another rack is chosen by walking a random offset through the
|
|
// rack's rolled-up free slots, which still count a node in maintenance. The walk
|
|
// has to land on an eligible sibling every time, not fall off the end.
|
|
func TestVolumeGrowthSkipsMaintenanceNodeInOtherRack(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack1 := NewRack("rack1")
|
|
dc.LinkChildNode(rack1)
|
|
addMaintenanceTestNode(rack1, "r1n1", 100)
|
|
rack2 := NewRack("rack2")
|
|
dc.LinkChildNode(rack2)
|
|
busy := addMaintenanceTestNode(rack2, "r2busy", 1000)
|
|
busy.SetMaintenanceMode(true)
|
|
ok := addMaintenanceTestNode(rack2, "r2ok", 1)
|
|
|
|
vg := NewDefaultVolumeGrowth()
|
|
rp, _ := super_block.NewReplicaPlacementFromString("010")
|
|
option := &VolumeGrowOption{ReplicaPlacement: rp, DiskType: types.HardDriveType}
|
|
for i := 0; i < 50; i++ {
|
|
servers, _, err := vg.findEmptySlotsForOneVolume(topo, option, false)
|
|
if err != nil {
|
|
t.Fatalf("iteration %d: %v", i, err)
|
|
}
|
|
if len(servers) != 2 {
|
|
t.Fatalf("iteration %d: got %d servers, want 2", i, len(servers))
|
|
}
|
|
onSibling := 0
|
|
for _, s := range servers {
|
|
if s.Id() == busy.Id() {
|
|
t.Fatalf("iteration %d: picked the server in maintenance mode", i)
|
|
}
|
|
if s.Id() == ok.Id() {
|
|
onSibling++
|
|
}
|
|
}
|
|
if onSibling != 1 {
|
|
t.Fatalf("iteration %d: the other-rack replica must land on the eligible sibling, got %v", i, servers)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A replica on a server in maintenance mode takes no writes, so its volume
|
|
// leaves the writable list while the mode is on and returns once it is off.
|
|
func TestMaintenanceModeTogglesVolumeWritability(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack := NewRack("rack1")
|
|
dc.LinkChildNode(rack)
|
|
dn1 := addMaintenanceTestNode(rack, "dn1", 10)
|
|
dn2 := addMaintenanceTestNode(rack, "dn2", 10)
|
|
|
|
rp, _ := super_block.NewReplicaPlacementFromString("001")
|
|
shared := storage.VolumeInfo{Id: 1, Size: 100, ReplicaPlacement: rp, Ttl: needle.EMPTY_TTL, Version: needle.GetCurrentVersion()}
|
|
dn1.AddOrUpdateVolume(shared)
|
|
dn2.AddOrUpdateVolume(shared)
|
|
topo.RegisterVolumeLayout(shared, dn1)
|
|
topo.RegisterVolumeLayout(shared, dn2)
|
|
|
|
rp0, _ := super_block.NewReplicaPlacementFromString("000")
|
|
alone := storage.VolumeInfo{Id: 2, Size: 100, ReplicaPlacement: rp0, Ttl: needle.EMPTY_TTL, Version: needle.GetCurrentVersion()}
|
|
dn2.AddOrUpdateVolume(alone)
|
|
topo.RegisterVolumeLayout(alone, dn2)
|
|
|
|
vlShared := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
vlAlone := topo.GetVolumeLayout("", rp0, needle.EMPTY_TTL, types.HardDriveType)
|
|
expectWritables := func(step string, vl *VolumeLayout, want ...needle.VolumeId) {
|
|
t.Helper()
|
|
got := vl.CloneWritableVolumes()
|
|
slices.Sort(got)
|
|
if !slices.Equal(got, want) {
|
|
t.Errorf("%s: writables = %v, want %v", step, got, want)
|
|
}
|
|
}
|
|
expectWritables("initial shared", vlShared, 1)
|
|
expectWritables("initial alone", vlAlone, 2)
|
|
|
|
if !topo.SetDataNodeMaintenanceMode(dn1, true) {
|
|
t.Fatal("switching dn1 on should report a change")
|
|
}
|
|
expectWritables("dn1 in maintenance: a volume with a replica there takes no writes", vlShared)
|
|
expectWritables("dn1 in maintenance: volumes elsewhere are unaffected", vlAlone, 2)
|
|
if locations := vlShared.Lookup(1); len(locations) != 2 {
|
|
t.Errorf("reads must still see every replica, got %v", locations)
|
|
}
|
|
|
|
if topo.SetDataNodeMaintenanceMode(dn1, true) {
|
|
t.Error("repeating the same state should not report a change")
|
|
}
|
|
|
|
if !topo.SetDataNodeMaintenanceMode(dn1, false) {
|
|
t.Fatal("switching dn1 off should report a change")
|
|
}
|
|
expectWritables("dn1 back: the volume is writable again", vlShared, 1)
|
|
|
|
topo.SetDataNodeMaintenanceMode(dn2, true)
|
|
expectWritables("dn2 in maintenance: shared", vlShared)
|
|
expectWritables("dn2 in maintenance: alone", vlAlone)
|
|
}
|
|
|
|
// A vacuum commit or a mark-writable that lands after the server entered
|
|
// maintenance mode must not put the volume back on the writable list. Both
|
|
// used to check only the replica count, and since heartbeats carry only changed
|
|
// volumes, nothing would have taken it off again.
|
|
func TestMaintenanceModeHoldsThroughVacuumAndMarkWritable(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack := NewRack("rack1")
|
|
dc.LinkChildNode(rack)
|
|
dn1 := addMaintenanceTestNode(rack, "dn1", 10)
|
|
dn2 := addMaintenanceTestNode(rack, "dn2", 10)
|
|
|
|
rp, _ := super_block.NewReplicaPlacementFromString("001")
|
|
vi := storage.VolumeInfo{Id: 1, Size: 100, ReplicaPlacement: rp, Ttl: needle.EMPTY_TTL, Version: needle.GetCurrentVersion()}
|
|
dn1.AddOrUpdateVolume(vi)
|
|
dn2.AddOrUpdateVolume(vi)
|
|
topo.RegisterVolumeLayout(vi, dn1)
|
|
topo.RegisterVolumeLayout(vi, dn2)
|
|
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
expectWritableCount := func(step string, want int) {
|
|
t.Helper()
|
|
if got, _ := vl.GetWritableVolumeCount(); got != want {
|
|
t.Errorf("%s: writable count = %d, want %d", step, got, want)
|
|
}
|
|
}
|
|
|
|
// vacuum: taken off the writable list before compaction, dn1 enters
|
|
// maintenance meanwhile, then every replica commits
|
|
vl.DrainAndRemoveFromWritable(1)
|
|
topo.SetDataNodeMaintenanceMode(dn1, true)
|
|
for _, dn := range []*DataNode{dn1, dn2} {
|
|
if vl.SetVolumeAvailable(dn, 1, false, false, false) {
|
|
t.Errorf("vacuum commit on %s made the volume writable with a replica in maintenance", dn.Id())
|
|
}
|
|
}
|
|
expectWritableCount("after vacuum commit", 0)
|
|
topo.SetDataNodeMaintenanceMode(dn1, false)
|
|
expectWritableCount("after maintenance", 1)
|
|
|
|
// mark-writable from a vacuum worker, after dn1 entered maintenance
|
|
vl.SetVolumeReadOnly(dn2, 1)
|
|
topo.SetDataNodeMaintenanceMode(dn1, true)
|
|
if vl.SetVolumeWritable(dn2, 1) {
|
|
t.Error("mark-writable made the volume writable with a replica in maintenance")
|
|
}
|
|
expectWritableCount("after mark-writable", 0)
|
|
topo.SetDataNodeMaintenanceMode(dn1, false)
|
|
expectWritableCount("after maintenance", 1)
|
|
}
|
|
|
|
// A volume server notifies the master the moment it flips a volume between
|
|
// read-only and writable, ahead of the heartbeat that repeats the flag. The
|
|
// master must act on the notification, not on its heartbeat copy: mark-writable
|
|
// takes effect at once, and a maintenance toggle landing between a mark-readonly
|
|
// and its heartbeat must not put the volume back.
|
|
func TestMarkReadOnlyAndWritableAheadOfHeartbeat(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack := NewRack("rack1")
|
|
dc.LinkChildNode(rack)
|
|
dn1 := addMaintenanceTestNode(rack, "dn1", 10)
|
|
dn2 := addMaintenanceTestNode(rack, "dn2", 10)
|
|
|
|
rp, _ := super_block.NewReplicaPlacementFromString("001")
|
|
vi := storage.VolumeInfo{Id: 1, Size: 100, ReplicaPlacement: rp, Ttl: needle.EMPTY_TTL, Version: needle.GetCurrentVersion()}
|
|
dn1.AddOrUpdateVolume(vi)
|
|
dn2.AddOrUpdateVolume(vi)
|
|
topo.RegisterVolumeLayout(vi, dn1)
|
|
topo.RegisterVolumeLayout(vi, dn2)
|
|
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
expectWritableCount := func(step string, want int) {
|
|
t.Helper()
|
|
if got, _ := vl.GetWritableVolumeCount(); got != want {
|
|
t.Errorf("%s: writable count = %d, want %d", step, got, want)
|
|
}
|
|
}
|
|
|
|
// the heartbeat has reported dn2's replica read-only
|
|
readOnly := vi
|
|
readOnly.ReadOnly = true
|
|
dn2.AddOrUpdateVolume(readOnly)
|
|
vl.EnsureCorrectWritables(&readOnly)
|
|
expectWritableCount("heartbeat says read-only", 0)
|
|
|
|
// dn2 marks it writable and notifies, before the next heartbeat
|
|
if !vl.SetVolumeWritable(dn2, 1) {
|
|
t.Error("mark-writable from the replica's own server should take effect at once")
|
|
}
|
|
expectWritableCount("after mark-writable", 1)
|
|
if v, err := dn2.GetVolumesById(1); err != nil || v.ReadOnly {
|
|
t.Errorf("the node's record should say writable, got %+v, %v", v, err)
|
|
}
|
|
|
|
// dn2 marks it read-only and notifies; dn1 leaves maintenance before the
|
|
// heartbeat repeats the flag
|
|
vl.SetVolumeReadOnly(dn2, 1)
|
|
expectWritableCount("after mark-readonly", 0)
|
|
topo.SetDataNodeMaintenanceMode(dn1, true)
|
|
topo.SetDataNodeMaintenanceMode(dn1, false)
|
|
expectWritableCount("re-evaluated before the heartbeat", 0)
|
|
}
|
|
|
|
// SetDataNodeMaintenanceMode walks a snapshot of the node's volumes, so a
|
|
// concurrent disconnect can have removed one from its layout by the time it is
|
|
// re-evaluated. That must be a no-op, not a crash or a resurrected entry.
|
|
func TestMaintenanceModeAfterVolumeLeftLayout(t *testing.T) {
|
|
topo, dc := newMaintenanceTestTopology()
|
|
rack := NewRack("rack1")
|
|
dc.LinkChildNode(rack)
|
|
dn := addMaintenanceTestNode(rack, "dn1", 10)
|
|
|
|
rp, _ := super_block.NewReplicaPlacementFromString("000")
|
|
vi := storage.VolumeInfo{Id: 1, Size: 100, ReplicaPlacement: rp, Ttl: needle.EMPTY_TTL, Version: needle.GetCurrentVersion()}
|
|
dn.AddOrUpdateVolume(vi)
|
|
topo.RegisterVolumeLayout(vi, dn)
|
|
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
vl.UnRegisterVolume(&vi, dn)
|
|
|
|
for _, on := range []bool{true, false} {
|
|
if !topo.SetDataNodeMaintenanceMode(dn, on) {
|
|
t.Fatalf("SetDataNodeMaintenanceMode(%v) should report a change", on)
|
|
}
|
|
if got, _ := vl.GetWritableVolumeCount(); got != 0 {
|
|
t.Errorf("maintenance %v: writable count = %d, want 0", on, got)
|
|
}
|
|
if locations := vl.Lookup(1); locations != nil {
|
|
t.Errorf("maintenance %v: re-evaluating a departed volume re-created its location list: %v", on, locations)
|
|
}
|
|
}
|
|
}
|