Files
seaweedfs/weed/topology/volume_layout_pick_test.go
T
Chris Lu b77431c142 master: stop hintless small-file assigns from marking volumes full (#10944)
* master: estimate a hintless assign's size from the volume's average file size

An assign that carries no dataSize hint charged a flat 1MB per file id
against the volume's effective size. A small-file workload overpays by
orders of magnitude: bulk-writing 4KB files marks volumes holding a few
hundred MB of real data as crowded and then full, so the master grows
unnecessary volumes and, once every volume is spuriously full, fails all
assigns. Estimate from the volume's own average file size instead, and
keep the 1MB fallback only for volumes with no history.

* master: decay pending assign sizes for volumes gone quiet

The decay that corrects pending assign estimates runs only when a
heartbeat reports the volume, and a heartbeat only reports a volume
whose content changed. A volume held out of the writable list takes no
writes, so once inflated estimates mark every volume full, nothing is
ever reported again, nothing decays, and the cluster refuses all writes
until a restart. Run the decay from the master's periodic loop for
volumes no heartbeat has reported within two pulses, feeding the last
reported size back through the same path an unchanged heartbeat would
take.

* master: trim the comments on the assign size estimate

* master: keep the periodic decay out of the replica-dedup window

UpdateVolumeSize ignores a report arriving within two seconds of the last
one, so replicas of the same volume do not each halve the pending
estimate. The periodic decay went through the same path and stamped that
window, so a real heartbeat landing right behind it was dropped along
with its reported size and compact revision. Only a volume whose content
changed is reported at all, so nothing would send that size again and
the master kept a stale one. Let the dedup window belong to volume
server reports alone.

* master: let the decay read the size record under the lock it mutates

The periodic decay picked its volumes under a read lock and replayed
them under a write one, carrying the size it had read across the gap. A
heartbeat landing in between was rolled back: the replay wrote the older
size and compact revision over the fresh ones, and a compaction report
lost that way is never resent, since only a volume whose content changed
is reported. The decay has no size of its own to contribute, so it now
reads the record under the same lock it mutates.

* master: let a heartbeat that beat the decay stand for the cycle

The decay chooses its volumes under a read lock and applies them under a
write one. A heartbeat landing in that gap already did the halving the
cycle owed, so applying the decay on top of it halved twice and forgot
pending bytes the volume has not written yet - the double-halving the
replica-dedup window exists to prevent. Both callers now give way to a
report already handled for this cycle; only a real report still advances
lastUpdateTime, so a quiet volume keeps decaying every pulse.

* master: keep genuinely full volumes out of the decay pass

A volume the disk really did fill keeps its fullSince set for good, so it
was selected every pulse for a decay that cannot help it: UpdateVolumeSize
refuses to recover a volume whose reported size is at the limit, and
replaying a size that cannot move leaves the record as it found it. Full
and quiet is the ordinary resting state of a cluster, so this was most of
the pass, taking the layout write lock away from the heartbeats to do
nothing. On a million tracked volumes with a hundredth of them phantom-full
it costs ten thousand write locks a pulse instead of a million.

* master: put the stale-replay test back on the path it guards

Giving the decay the dedup window left this test short-circuiting there,
so it no longer reached the locked read it was written for and passed
with that read removed. Age the record past the window, which is the only
case where reading it under the lock is what saves the report.
2026-08-25 10:16:48 -07:00

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