mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-25 06:20:42 +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>
236 lines
9.4 KiB
Go
236 lines
9.4 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"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"
|
|
"github.com/seaweedfs/seaweedfs/weed/topology"
|
|
)
|
|
|
|
func changedTestCluster(t *testing.T) (*topology.Topology, *topology.DataNode) {
|
|
t.Helper()
|
|
topo := topology.NewTopology("test", sequence.NewMemorySequencer(), 32*1024*1024*1024, 5, false)
|
|
dn := topo.GetOrCreateDataCenter("dc1").GetOrCreateRack("rack1").
|
|
GetOrCreateDataNode("127.0.0.1", 8080, 18080, "", "", map[string]uint32{"": 100})
|
|
return topo, dn
|
|
}
|
|
|
|
func changedTestVolume(id uint32, size uint64) *master_pb.VolumeInformationMessage {
|
|
return &master_pb.VolumeInformationMessage{
|
|
Id: id, Size: size, Collection: "c", Version: 3, FileCount: 1,
|
|
}
|
|
}
|
|
|
|
// Applying only the volumes a heartbeat named has to leave the master holding
|
|
// what the server holds, or the digest it is checked against means nothing.
|
|
func TestChangedVolumesBringTheMasterCurrent(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 1024), changedTestVolume(2, 1024), changedTestVolume(3, 1024),
|
|
}, dn)
|
|
|
|
grown := changedTestVolume(2, 8192)
|
|
topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{grown}, dn)
|
|
|
|
reference, referenceNode := changedTestCluster(t)
|
|
reference.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 1024), grown, changedTestVolume(3, 1024),
|
|
}, referenceNode)
|
|
|
|
if dn.VolumeDigest() != referenceNode.VolumeDigest() {
|
|
t.Errorf("after applying the change the master digests %d, the server reports %d",
|
|
dn.VolumeDigest(), referenceNode.VolumeDigest())
|
|
}
|
|
}
|
|
|
|
// Silence about a volume in a changed-only heartbeat says nothing about whether
|
|
// the server still has it, unlike a full report.
|
|
func TestChangedVolumesDoNotRemoveUnmentionedVolumes(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 1024), changedTestVolume(2, 1024),
|
|
}, dn)
|
|
|
|
topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{changedTestVolume(1, 4096)}, dn)
|
|
|
|
if _, err := dn.GetVolumesById(needle.VolumeId(2)); err != nil {
|
|
t.Errorf("a volume the heartbeat did not mention was dropped: %v", err)
|
|
}
|
|
if !dn.HasConsistentVolumeIndex() {
|
|
t.Error("applying changes left the lookup index disagreeing with the disks")
|
|
}
|
|
}
|
|
|
|
// A volume the master has never seen can arrive as a change.
|
|
func TestChangedVolumesRegisterUnknownVolumes(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{changedTestVolume(1, 1024)}, dn)
|
|
|
|
topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{changedTestVolume(9, 1024)}, dn)
|
|
|
|
if locations := topo.Lookup("c", needle.VolumeId(9)); len(locations) != 1 {
|
|
t.Errorf("a volume first seen as a change is not servable: %v", locations)
|
|
}
|
|
if !dn.HasConsistentVolumeIndex() {
|
|
t.Error("applying changes left the lookup index disagreeing with the disks")
|
|
}
|
|
}
|
|
|
|
// Volumes grow constantly, and a growth moves no location. Telling every
|
|
// connected client about each one would flood bounded broadcast queues and push
|
|
// out the topology updates that do matter.
|
|
func TestChangedVolumesAnnounceOnlyArrivals(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 1024), changedTestVolume(2, 1024),
|
|
}, dn)
|
|
|
|
grown := topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 8192), changedTestVolume(2, 9216),
|
|
}, dn)
|
|
if len(grown) != 0 {
|
|
t.Errorf("volumes that only grew were announced as new locations: %v", grown)
|
|
}
|
|
|
|
arrived := topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 16384), changedTestVolume(7, 1024),
|
|
}, dn)
|
|
if len(arrived) != 1 || arrived[0].Id != needle.VolumeId(7) {
|
|
t.Errorf("expected only the volume that arrived, got %v", arrived)
|
|
}
|
|
}
|
|
|
|
// A node dropping out tells clients its volumes went. If the lookup index then
|
|
// loses one while the disk map keeps it, repairing the index has to announce it
|
|
// as well: nothing else will, and the clients that heard it go would never hear
|
|
// otherwise.
|
|
func TestRepairedLookupEntryIsAnnounced(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
apply func(*topology.Topology, *topology.DataNode, *master_pb.VolumeInformationMessage) []storage.VolumeInfo
|
|
}{
|
|
{"ViaChanges", func(topo *topology.Topology, dn *topology.DataNode, v *master_pb.VolumeInformationMessage) []storage.VolumeInfo {
|
|
return topo.ApplyVolumeChanges([]*master_pb.VolumeInformationMessage{v}, dn)
|
|
}},
|
|
{"ViaFullList", func(topo *topology.Topology, dn *topology.DataNode, v *master_pb.VolumeInformationMessage) []storage.VolumeInfo {
|
|
announced, _, _ := topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{v}, dn)
|
|
return announced
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
volume := changedTestVolume(1, 1024)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{volume}, dn)
|
|
|
|
rp, _ := super_block.NewReplicaPlacementFromString("000")
|
|
vl := topo.GetVolumeLayout("c", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
vl.SetVolumeUnavailable(dn, needle.VolumeId(1))
|
|
if locations := topo.Lookup("c", needle.VolumeId(1)); locations != nil {
|
|
t.Fatalf("expected the volume to be unservable, got %v", locations)
|
|
}
|
|
|
|
announced := tc.apply(topo, dn, changedTestVolume(1, 1024))
|
|
|
|
if locations := topo.Lookup("c", needle.VolumeId(1)); len(locations) != 1 {
|
|
t.Fatalf("the repair did not make the volume servable again: %v", locations)
|
|
}
|
|
if len(announced) != 1 || announced[0].Id != needle.VolumeId(1) {
|
|
t.Errorf("the repair was not announced to clients, so those told it went stay stale: %v", announced)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Deleting a collection throws its layouts away wholesale. The lookup bits
|
|
// they held must go with them: leaked bits keep the node's held and servable
|
|
// digests apart forever, and the master then asks for the full volume list on
|
|
// every heartbeat for the rest of the process's life.
|
|
func TestDeletedCollectionReleasesTheLookupIndex(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{
|
|
changedTestVolume(1, 1024), changedTestVolume(2, 1024),
|
|
}, dn)
|
|
if !dn.HasConsistentVolumeIndex() {
|
|
t.Fatal("expected a freshly synced node to be consistent")
|
|
}
|
|
|
|
topo.DeleteCollection("c")
|
|
// The node still holds the volumes until a heartbeat names their
|
|
// departure; this is that heartbeat.
|
|
topo.IncrementalSyncDataNodeRegistration(nil, []*master_pb.VolumeShortInformationMessage{
|
|
{Id: 1, Collection: "c", Version: 3},
|
|
{Id: 2, Collection: "c", Version: 3},
|
|
}, dn)
|
|
|
|
if !dn.HasConsistentVolumeIndex() {
|
|
t.Fatal("the deleted collection's lookup entries were not released")
|
|
}
|
|
}
|
|
|
|
// A full list races the growth that runs while it is in flight: collected
|
|
// before the grow finished, it cannot name the volumes the grow registered.
|
|
// Erasing them strands their collection without writable volumes until a
|
|
// later report happens to re-add them.
|
|
func TestStaleFullListDoesNotEraseAFreshGrow(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
full := []*master_pb.VolumeInformationMessage{changedTestVolume(1, 1024)}
|
|
topo.SyncDataNodeRegistration(full, dn)
|
|
|
|
// what volume growth registers, after the list above was collected
|
|
vi, err := storage.NewVolumeInfo(changedTestVolume(2, 8))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dn.AddProvisionalVolume(vi)
|
|
topo.RegisterVolumeLayout(vi, dn)
|
|
|
|
// the stale list arrives
|
|
topo.SyncDataNodeRegistration(full, dn)
|
|
if _, err := dn.GetVolumesById(needle.VolumeId(2)); err != nil {
|
|
t.Fatal("a stale full list erased a freshly grown volume")
|
|
}
|
|
|
|
// once a report names it, a list without it means it is really gone
|
|
confirmed := append(append([]*master_pb.VolumeInformationMessage{}, full...), changedTestVolume(2, 8))
|
|
topo.SyncDataNodeRegistration(confirmed, dn)
|
|
topo.SyncDataNodeRegistration(full, dn)
|
|
if _, err := dn.GetVolumesById(needle.VolumeId(2)); err == nil {
|
|
t.Fatal("a confirmed volume survived a list that dropped it")
|
|
}
|
|
}
|
|
|
|
// A registration can resolve a collection's layout just before the collection
|
|
// is deleted. Bits it sets afterwards would never be released, and the node's
|
|
// held and servable digests would disagree forever.
|
|
func TestRegistrationRacingCollectionDeleteDoesNotLeak(t *testing.T) {
|
|
topo, dn := changedTestCluster(t)
|
|
topo.SyncDataNodeRegistration([]*master_pb.VolumeInformationMessage{changedTestVolume(1, 1024)}, dn)
|
|
|
|
vi, err := storage.NewVolumeInfo(changedTestVolume(1, 1024))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stale := topo.GetVolumeLayout("c", vi.ReplicaPlacement, vi.Ttl, types.ToDiskType(vi.DiskType))
|
|
|
|
topo.DeleteCollection("c")
|
|
|
|
// the interleaved registration must refuse the dropped layout
|
|
if stale.RegisterVolume(&vi, dn, nil) {
|
|
t.Fatal("registered into a layout dropped with its collection")
|
|
}
|
|
|
|
// the node prunes its copy when the departure is named
|
|
topo.IncrementalSyncDataNodeRegistration(nil, []*master_pb.VolumeShortInformationMessage{
|
|
{Id: 1, Collection: "c", Version: 3},
|
|
}, dn)
|
|
|
|
if !dn.HasConsistentVolumeIndex() {
|
|
t.Fatal("the racing registration leaked lookup ownership")
|
|
}
|
|
}
|