mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-26 15:00:48 +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>
787 lines
22 KiB
Go
787 lines
22 KiB
Go
package topology
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// setupWithLimit is like setup() but allows specifying the volumeSizeLimit
|
|
// so that VolumeLayouts are created with the correct limit from the start.
|
|
func setupWithLimit(t testing.TB, topologyLayout string, volumeSizeLimit uint64) *Topology {
|
|
t.Helper()
|
|
var data interface{}
|
|
if err := json.Unmarshal([]byte(topologyLayout), &data); err != nil {
|
|
t.Fatalf("setupWithLimit: json.Unmarshal: %v", err)
|
|
}
|
|
mTopology, ok := data.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("setupWithLimit: expected map[string]interface{}, got %T", data)
|
|
}
|
|
|
|
topo := NewTopology("weedfs", sequence.NewMemorySequencer(), volumeSizeLimit, 5, false)
|
|
for dcKey, dcValue := range mTopology {
|
|
dc := NewDataCenter(dcKey)
|
|
dcMap := dcValue.(map[string]interface{})
|
|
topo.LinkChildNode(dc)
|
|
for rackKey, rackValue := range dcMap {
|
|
dcRack := NewRack(rackKey)
|
|
rackMap := rackValue.(map[string]interface{})
|
|
dc.LinkChildNode(dcRack)
|
|
for serverKey, serverValue := range rackMap {
|
|
server := NewDataNode(serverKey)
|
|
serverMap := serverValue.(map[string]interface{})
|
|
if ip, ok := serverMap["ip"]; ok {
|
|
server.Ip = ip.(string)
|
|
}
|
|
dcRack.LinkChildNode(server)
|
|
for _, v := range serverMap["volumes"].([]interface{}) {
|
|
m := v.(map[string]interface{})
|
|
vi := storage.VolumeInfo{
|
|
Id: needle.VolumeId(int64(m["id"].(float64))),
|
|
Size: uint64(m["size"].(float64)),
|
|
Version: needle.GetCurrentVersion(),
|
|
}
|
|
if mVal, ok := m["fileCount"]; ok {
|
|
vi.FileCount = uint32(mVal.(float64))
|
|
}
|
|
if mVal, ok := m["collection"]; ok {
|
|
vi.Collection = mVal.(string)
|
|
}
|
|
if mVal, ok := m["replication"]; ok {
|
|
rp, _ := super_block.NewReplicaPlacementFromString(mVal.(string))
|
|
vi.ReplicaPlacement = rp
|
|
}
|
|
if vi.ReplicaPlacement != nil {
|
|
vl := topo.GetVolumeLayout(vi.Collection, vi.ReplicaPlacement, needle.EMPTY_TTL, types.HardDriveType)
|
|
vl.RegisterVolume(&vi, server, nil)
|
|
vl.setVolumeWritable(vi.Id)
|
|
}
|
|
server.AddOrUpdateVolume(vi)
|
|
}
|
|
disk := server.getOrCreateDisk("")
|
|
disk.UpAdjustDiskUsageDelta("", &DiskUsageCounts{
|
|
maxVolumeCount: int64(serverMap["limit"].(float64)),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return topo
|
|
}
|
|
|
|
func setupPickTest(t testing.TB, layout string, volumeSizeLimit uint64) (*Topology, *VolumeLayout) {
|
|
t.Helper()
|
|
topo := setupWithLimit(t, layout, volumeSizeLimit)
|
|
rp, _ := super_block.NewReplicaPlacementFromString("000")
|
|
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
return topo, vl
|
|
}
|
|
|
|
func TestPickForWriteWeightedDistribution(t *testing.T) {
|
|
// 3 volumes at 20%, 50%, 80% full (sizes 2000, 5000, 8000 of limit 10000)
|
|
// remaining: 8000, 5000, 2000 => ratios ~53%, 33%, 13%
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":2000, "replication":"000"},
|
|
{"id":2, "size":5000, "replication":"000"},
|
|
{"id":3, "size":8000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
counts := make(map[needle.VolumeId]int)
|
|
option := &VolumeGrowOption{}
|
|
n := 60000
|
|
|
|
for i := 0; i < n; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
counts[vid]++
|
|
}
|
|
|
|
// vid 1 (remaining 8000) > vid 2 (remaining 5000) > vid 3 (remaining 2000)
|
|
if counts[1] <= counts[3] {
|
|
t.Errorf("expected vid 1 picked more than vid 3: vid1=%d, vid3=%d", counts[1], counts[3])
|
|
}
|
|
if counts[2] <= counts[3] {
|
|
t.Errorf("expected vid 2 picked more than vid 3: vid2=%d, vid3=%d", counts[2], counts[3])
|
|
}
|
|
|
|
// Check proportions: expected 8000/5000/2000 out of 15000
|
|
expected := map[needle.VolumeId]float64{
|
|
1: 8000.0 / 15000.0,
|
|
2: 5000.0 / 15000.0,
|
|
3: 2000.0 / 15000.0,
|
|
}
|
|
for vid, expectedPct := range expected {
|
|
actualPct := float64(counts[vid]) / float64(n)
|
|
if math.Abs(actualPct-expectedPct) > 0.03 {
|
|
t.Errorf("vid %d: expected ~%.1f%%, got %.1f%%", vid, expectedPct*100, actualPct*100)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickForWriteWithPendingSize(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":1000, "replication":"000"},
|
|
{"id":2, "size":1000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
// Add large pending to vid 1, making it effectively 9000/10000
|
|
vl.RecordAssign(1, 8000)
|
|
|
|
counts := make(map[needle.VolumeId]int)
|
|
option := &VolumeGrowOption{}
|
|
n := 10000
|
|
|
|
for i := 0; i < n; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
counts[vid]++
|
|
}
|
|
|
|
// vid 2 (remaining ~9000) should be picked much more than vid 1 (remaining ~1000)
|
|
ratio := float64(counts[2]) / float64(counts[1])
|
|
if ratio < 3.0 {
|
|
t.Errorf("vid2/vid1 ratio %.2f expected >= 3.0 (vid1=%d, vid2=%d)", ratio, counts[1], counts[2])
|
|
}
|
|
}
|
|
|
|
// A flat 1MB charge per hintless file id overcharges a small-file workload by
|
|
// orders of magnitude, marking volumes full while they hold a fraction of the
|
|
// limit.
|
|
func TestPickForWriteEstimatesPendingSizeFromVolumeAverage(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":409600, "fileCount":100, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
topo, vl := setupPickTest(t, layout, 30*1024*1024)
|
|
rp, _ := super_block.NewReplicaPlacementFromString("000")
|
|
option := &VolumeGrowOption{ReplicaPlacement: rp}
|
|
|
|
pendingAfterAssign := func(expectedDataSize uint64) uint64 {
|
|
vl.accessLock.RLock()
|
|
before := vl.sizeTracking[1].effectiveSize
|
|
vl.accessLock.RUnlock()
|
|
if _, _, _, _, err := topo.PickForWrite(1, option, vl, expectedDataSize); err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
vl.accessLock.RLock()
|
|
defer vl.accessLock.RUnlock()
|
|
return vl.sizeTracking[1].effectiveSize - before
|
|
}
|
|
|
|
if got := pendingAfterAssign(0); got != 4096 {
|
|
t.Errorf("a hintless assign charged %d, want the volume's 4096-byte average", got)
|
|
}
|
|
if got := pendingAfterAssign(8192); got != 8192 {
|
|
t.Errorf("a hinted assign charged %d, want the 8192-byte hint to win over the average", got)
|
|
}
|
|
}
|
|
|
|
// A volume with no files yet has no average to draw on, so the 1MB fallback
|
|
// still applies.
|
|
func TestPickForWriteEstimateFallsBackWithoutHistory(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":0, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
topo, vl := setupPickTest(t, layout, 30*1024*1024)
|
|
rp, _ := super_block.NewReplicaPlacementFromString("000")
|
|
option := &VolumeGrowOption{ReplicaPlacement: rp}
|
|
|
|
if _, _, _, _, err := topo.PickForWrite(1, option, vl, 0); err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
vl.accessLock.RLock()
|
|
pending := vl.sizeTracking[1].effectiveSize - vl.sizeTracking[1].reportedSize
|
|
vl.accessLock.RUnlock()
|
|
if pending != DefaultNeedleSizeEstimate {
|
|
t.Errorf("a hintless assign on an empty volume charged %d, want the %d fallback", pending, DefaultNeedleSizeEstimate)
|
|
}
|
|
}
|
|
|
|
func TestPickForWriteSingleWritable(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":5000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
option := &VolumeGrowOption{}
|
|
for i := 0; i < 100; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
if vid != 1 {
|
|
t.Fatalf("expected vid 1, got %d", vid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickForWriteAllNearFull(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":9999, "replication":"000"},
|
|
{"id":2, "size":9999, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
option := &VolumeGrowOption{}
|
|
for i := 0; i < 100; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
if vid != 1 && vid != 2 {
|
|
t.Fatalf("expected vid 1 or 2, got %d", vid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPickForWriteConstrainedWeighted(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"ip":"10.0.0.1",
|
|
"volumes":[
|
|
{"id":1, "size":2000, "replication":"000"},
|
|
{"id":2, "size":8000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
},
|
|
"rack2":{
|
|
"server2":{
|
|
"ip":"10.0.0.2",
|
|
"volumes":[
|
|
{"id":3, "size":5000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
counts := make(map[needle.VolumeId]int)
|
|
option := &VolumeGrowOption{DataCenter: "dc1"}
|
|
n := 20000
|
|
|
|
for i := 0; i < n; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
counts[vid]++
|
|
}
|
|
|
|
// vid 1 (remaining 8000) should be picked most, vid 2 (remaining 2000) least
|
|
if counts[1] <= counts[2] {
|
|
t.Errorf("expected vid 1 picked more than vid 2: vid1=%d, vid2=%d", counts[1], counts[2])
|
|
}
|
|
}
|
|
|
|
func TestRecordAssignMarksCrowded(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":8500, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
// Volume at 85% — not crowded yet (threshold is 90%)
|
|
_, crowded := vl.GetWritableVolumeCount()
|
|
if crowded != 0 {
|
|
t.Fatalf("expected 0 crowded, got %d", crowded)
|
|
}
|
|
|
|
// Add pending that pushes past 90%
|
|
vl.RecordAssign(1, 1000)
|
|
|
|
_, crowded = vl.GetWritableVolumeCount()
|
|
if crowded != 1 {
|
|
t.Errorf("expected 1 crowded after pending push past 90%%, got %d", crowded)
|
|
}
|
|
}
|
|
|
|
func TestRecordAssignReachingCapacityRemovesFromWritable(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":5000, "replication":"000"},
|
|
{"id":2, "size":5000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
topo, vl := setupPickTest(t, layout, 10000)
|
|
|
|
writable, _ := vl.GetWritableVolumeCount()
|
|
if writable != 2 {
|
|
t.Fatalf("expected 2 writable volumes initially, got %d", writable)
|
|
}
|
|
// Each volume counts as active initially.
|
|
initialActive := topo.diskUsages.usages[types.HardDriveType].activeVolumeCount
|
|
|
|
// Push vid 1 past the hard limit (5000 + 5000 = 10000 == limit).
|
|
reachedCapacity := vl.RecordAssign(1, 5000)
|
|
if !reachedCapacity {
|
|
t.Fatalf("RecordAssign should return true when effectiveSize reaches limit")
|
|
}
|
|
vl.AdjustActiveVolumeCountForFull(1)
|
|
|
|
writable, _ = vl.GetWritableVolumeCount()
|
|
if writable != 1 {
|
|
t.Errorf("expected 1 writable after eager removal, got %d", writable)
|
|
}
|
|
// activeVolumeCount should be decremented for the data node holding vid 1.
|
|
afterActive := topo.diskUsages.usages[types.HardDriveType].activeVolumeCount
|
|
if afterActive != initialActive-1 {
|
|
t.Errorf("expected activeVolumeCount=%d, got %d", initialActive-1, afterActive)
|
|
}
|
|
|
|
// A second RecordAssign on the already-removed volume should not return
|
|
// true again (no double accounting).
|
|
if vl.RecordAssign(1, 10000) {
|
|
t.Errorf("RecordAssign should not report reachedCapacity twice for the same removal")
|
|
}
|
|
afterSecond := topo.diskUsages.usages[types.HardDriveType].activeVolumeCount
|
|
if afterSecond != afterActive {
|
|
t.Errorf("activeVolumeCount changed on second RecordAssign: before=%d after=%d", afterActive, afterSecond)
|
|
}
|
|
}
|
|
|
|
// advanceSizeTrackingClock backdates a volume's time-sensitive fields by d
|
|
// so heartbeat decay and the recovery delay fire on the next update.
|
|
func advanceSizeTrackingClock(vl *VolumeLayout, vid needle.VolumeId, d time.Duration) {
|
|
vl.accessLock.Lock()
|
|
defer vl.accessLock.Unlock()
|
|
st := vl.sizeTracking[vid]
|
|
if st == nil {
|
|
return
|
|
}
|
|
if !st.lastUpdateTime.IsZero() {
|
|
st.lastUpdateTime = st.lastUpdateTime.Add(-d)
|
|
}
|
|
if !st.fullSince.IsZero() {
|
|
st.fullSince = st.fullSince.Add(-d)
|
|
}
|
|
}
|
|
|
|
func TestUpdateVolumeSizeRecoversEagerlyRemovedVolume(t *testing.T) {
|
|
// Two writable volumes, each at 40% of a 10000-byte limit. Push vid 1
|
|
// past the hard limit via RecordAssign, then heartbeat with an
|
|
// unchanged reported size so decay shrinks effectiveSize below the
|
|
// crowded threshold (90% of limit). After the recovery delay, the
|
|
// volume should be re-added to writables and activeVolumeCount
|
|
// restored.
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":4000, "replication":"000"},
|
|
{"id":2, "size":4000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
topo, vl := setupPickTest(t, layout, 10000)
|
|
|
|
initialActive := topo.diskUsages.usages[types.HardDriveType].activeVolumeCount
|
|
initialWritables, _ := vl.GetWritableVolumeCount()
|
|
|
|
// Push vid 1 past the limit (effective = 4000 + 6000 = 10000).
|
|
if !vl.RecordAssign(1, 6000) {
|
|
t.Fatalf("RecordAssign should return true at the limit")
|
|
}
|
|
vl.AdjustActiveVolumeCountForFull(1)
|
|
|
|
w, _ := vl.GetWritableVolumeCount()
|
|
if w != initialWritables-1 {
|
|
t.Fatalf("expected %d writables after eager removal, got %d", initialWritables-1, w)
|
|
}
|
|
|
|
// Before the recovery delay, a heartbeat that lets decay run should
|
|
// *not* re-add the volume (even though effectiveSize would now be
|
|
// under the threshold).
|
|
advanceSizeTrackingClock(vl, 1, 3*time.Second) // past the 2s dedup window, but before 30s delay
|
|
if vl.UpdateVolumeSize(1, 4000, 0, true) {
|
|
t.Fatalf("recovery should not fire before capacityRecoveryDelay")
|
|
}
|
|
w, _ = vl.GetWritableVolumeCount()
|
|
if w != initialWritables-1 {
|
|
t.Errorf("writable count should not change before delay, got %d", w)
|
|
}
|
|
|
|
// Now skip past the recovery delay. Decay the gap further until
|
|
// effectiveSize drops below the crowded threshold (9000).
|
|
for i := 0; i < 6; i++ {
|
|
advanceSizeTrackingClock(vl, 1, 10*time.Second)
|
|
recovered := vl.UpdateVolumeSize(1, 4000, 0, true)
|
|
if recovered {
|
|
vl.AdjustActiveVolumeCountAfterRecovery(1)
|
|
break
|
|
}
|
|
}
|
|
|
|
w, _ = vl.GetWritableVolumeCount()
|
|
if w != initialWritables {
|
|
t.Errorf("expected recovery to restore %d writables, got %d", initialWritables, w)
|
|
}
|
|
if got := topo.diskUsages.usages[types.HardDriveType].activeVolumeCount; got != initialActive {
|
|
t.Errorf("expected activeVolumeCount restored to %d, got %d", initialActive, got)
|
|
}
|
|
|
|
// fullSince should have been cleared so a subsequent heartbeat doesn't
|
|
// try to recover again.
|
|
advanceSizeTrackingClock(vl, 1, 60*time.Second)
|
|
if vl.UpdateVolumeSize(1, 4000, 0, true) {
|
|
t.Errorf("recovery should not re-fire after the volume is already writable")
|
|
}
|
|
}
|
|
|
|
func TestUpdateVolumeSizeNoRecoveryWhenDiskStillOversized(t *testing.T) {
|
|
// Volume reported at 95% of limit and pushed past limit by RecordAssign.
|
|
// Even after the recovery delay and decay, reportedSize remains >= limit
|
|
// (real on-disk size is over limit), so recovery must not fire.
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":9500, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
if !vl.RecordAssign(1, 500) {
|
|
t.Fatalf("RecordAssign should hit the limit (9500 + 500 = 10000)")
|
|
}
|
|
vl.AdjustActiveVolumeCountForFull(1)
|
|
|
|
// Plenty of time elapsed — but reported stays at 10500 (over limit).
|
|
for i := 0; i < 5; i++ {
|
|
advanceSizeTrackingClock(vl, 1, 10*time.Second)
|
|
if vl.UpdateVolumeSize(1, 10500, 0, true) {
|
|
t.Fatalf("recovery must not fire when reported >= limit")
|
|
}
|
|
}
|
|
w, _ := vl.GetWritableVolumeCount()
|
|
if w != 0 {
|
|
t.Errorf("expected 0 writables (volume legitimately full), got %d", w)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatDecaysPendingSize(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":1000, "replication":"000"},
|
|
{"id":2, "size":1000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
// vid2size starts at 1000 (reported). Add 8000 pending → 9000.
|
|
vl.RecordAssign(1, 8000)
|
|
|
|
vl.accessLock.RLock()
|
|
if vl.sizeTracking[1].effectiveSize != 9000 {
|
|
t.Fatalf("expected vid2size=9000 after RecordAssign, got %d", vl.sizeTracking[1].effectiveSize)
|
|
}
|
|
vl.accessLock.RUnlock()
|
|
|
|
// Helper to simulate a new heartbeat cycle (advance past dedup window)
|
|
advanceCycle := func() {
|
|
vl.accessLock.Lock()
|
|
vl.sizeTracking[1].lastUpdateTime = time.Now().Add(-3 * time.Second)
|
|
vl.accessLock.Unlock()
|
|
}
|
|
|
|
// Heartbeat: volume server reports size=3000 (some writes landed).
|
|
// Old effective=9000, new reported=3000 → excess=6000 → decayed to 3000.
|
|
// So vid2size should become 3000 + 6000/2 = 6000, not just 3000.
|
|
vl.UpdateVolumeSize(1, 3000, 0, true)
|
|
|
|
vl.accessLock.RLock()
|
|
if vl.sizeTracking[1].effectiveSize != 6000 {
|
|
t.Errorf("expected vid2size=6000 after decay (3000 + 6000/2), got %d", vl.sizeTracking[1].effectiveSize)
|
|
}
|
|
vl.accessLock.RUnlock()
|
|
|
|
// Second heartbeat: size=5000. Old effective=6000 → excess=1000 → decay to 500.
|
|
// vid2size should become 5000 + 1000/2 = 5500.
|
|
advanceCycle()
|
|
vl.UpdateVolumeSize(1, 5000, 0, true)
|
|
|
|
vl.accessLock.RLock()
|
|
if vl.sizeTracking[1].effectiveSize != 5500 {
|
|
t.Errorf("expected vid2size=5500 after second decay (5000 + 1000/2), got %d", vl.sizeTracking[1].effectiveSize)
|
|
}
|
|
vl.accessLock.RUnlock()
|
|
|
|
// Third heartbeat: size=5500. Old effective=5500 → no excess.
|
|
// vid2size should be exactly 5500.
|
|
advanceCycle()
|
|
vl.UpdateVolumeSize(1, 5500, 0, true)
|
|
|
|
vl.accessLock.RLock()
|
|
if vl.sizeTracking[1].effectiveSize != 5500 {
|
|
t.Errorf("expected vid2size=5500 (no excess), got %d", vl.sizeTracking[1].effectiveSize)
|
|
}
|
|
vl.accessLock.RUnlock()
|
|
|
|
// vid 2 (remaining 9000) should be picked more than vid 1 (remaining 4500)
|
|
counts := make(map[needle.VolumeId]int)
|
|
option := &VolumeGrowOption{}
|
|
for i := 0; i < 10000; i++ {
|
|
vid, _, _, _, err := vl.PickForWrite(1, option)
|
|
if err != nil {
|
|
t.Fatalf("PickForWrite: %v", err)
|
|
}
|
|
counts[vid]++
|
|
}
|
|
if counts[2] <= counts[1] {
|
|
t.Errorf("vid 2 (remaining 9000) should be picked more than vid 1 (remaining 4500): vid1=%d, vid2=%d", counts[1], counts[2])
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatDecayDedupReplicas(t *testing.T) {
|
|
// Volume 1 replicated on server1 and server2.
|
|
// Both servers report size=3000 in the same heartbeat cycle.
|
|
// Decay should run only once, not once per replica.
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"ip":"10.0.0.1",
|
|
"volumes":[
|
|
{"id":1, "size":1000, "replication":"001"}
|
|
],
|
|
"limit":10
|
|
},
|
|
"server2":{
|
|
"ip":"10.0.0.2",
|
|
"volumes":[
|
|
{"id":1, "size":1000, "replication":"001"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
topo := setupWithLimit(t, layout, 10000)
|
|
rp, _ := super_block.NewReplicaPlacementFromString("001")
|
|
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
|
|
|
|
// Add pending: effective = 1000 + 8000 = 9000
|
|
vl.RecordAssign(1, 8000)
|
|
|
|
vl.accessLock.RLock()
|
|
if vl.sizeTracking[1].effectiveSize != 9000 {
|
|
t.Fatalf("expected vid2size=9000, got %d", vl.sizeTracking[1].effectiveSize)
|
|
}
|
|
vl.accessLock.RUnlock()
|
|
|
|
// Both replicas report size=3000. Decay should happen once: 3000 + (9000-3000)/2 = 6000.
|
|
// Calling UpdateVolumeSize twice simulates two replicas reporting in the same cycle.
|
|
vl.UpdateVolumeSize(1, 3000, 0, true)
|
|
vl.UpdateVolumeSize(1, 3000, 0, true) // second replica, same size — should be a no-op
|
|
|
|
vl.accessLock.RLock()
|
|
got := vl.sizeTracking[1].effectiveSize
|
|
vl.accessLock.RUnlock()
|
|
// Without dedup: would be 3000 + (6000-3000)/2 = 4500 (double decay).
|
|
// With dedup: should be 6000 (single decay).
|
|
if got != 6000 {
|
|
t.Errorf("expected vid2size=6000 (single decay), got %d (double decay would give 4500)", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdateVolumeSize_DecaysEvenWhenReportedSizeUnchanged(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"volumes":[
|
|
{"id":1, "size":1000, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
// Add pending: effective = 1000 + 8000 = 9000
|
|
vl.RecordAssign(1, 8000)
|
|
if p := vl.GetPendingSize(1); p != 8000 {
|
|
t.Fatalf("expected 8000 pending, got %d", p)
|
|
}
|
|
|
|
// First heartbeat: reported size unchanged at 1000 (writes haven't landed).
|
|
// Decay should still run: 1000 + (9000-1000)/2 = 5000.
|
|
vl.UpdateVolumeSize(1, 1000, 0, true)
|
|
if p := vl.GetPendingSize(1); p != 4000 {
|
|
t.Errorf("expected 4000 pending after first decay, got %d", p)
|
|
}
|
|
|
|
// Simulate next heartbeat cycle (>2s later) with same reported size.
|
|
// Need to advance lastUpdateTime — manipulate directly under lock.
|
|
vl.accessLock.Lock()
|
|
vl.sizeTracking[1].lastUpdateTime = time.Now().Add(-3 * time.Second)
|
|
vl.accessLock.Unlock()
|
|
|
|
// Second heartbeat: still 1000. Decay again: 1000 + (5000-1000)/2 = 3000.
|
|
vl.UpdateVolumeSize(1, 1000, 0, true)
|
|
if p := vl.GetPendingSize(1); p != 2000 {
|
|
t.Errorf("expected 2000 pending after second decay, got %d", p)
|
|
}
|
|
}
|
|
|
|
func TestShouldGrowVolumesByDcAndRack_WithPendingSize(t *testing.T) {
|
|
layout := `
|
|
{
|
|
"dc1":{
|
|
"rack1":{
|
|
"server1":{
|
|
"ip":"10.0.0.1",
|
|
"volumes":[
|
|
{"id":1, "size":8500, "replication":"000"}
|
|
],
|
|
"limit":10
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
_, vl := setupPickTest(t, layout, 10000)
|
|
|
|
writables := vl.CloneWritableVolumes()
|
|
if vl.ShouldGrowVolumesByDcAndRack(&writables, "dc1", "rack1") {
|
|
t.Error("should not grow before pending makes volume crowded")
|
|
}
|
|
|
|
// Add pending that pushes effective size past 9000 threshold
|
|
vl.RecordAssign(1, 600)
|
|
|
|
if !vl.ShouldGrowVolumesByDcAndRack(&writables, "dc1", "rack1") {
|
|
t.Error("should grow after pending pushes volume past crowded threshold")
|
|
}
|
|
}
|