Files
seaweedfs/weed/topology/maintenance_mode_test.go
T
Chris LuandDevin 3e5aa8c3b3 topology: mark replica quarantined before recording the pair (#11983)
* 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>
2026-10-24 07:00:45 -07:00

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)
}
}
}