Files
seaweedfs/weed/topology/volume_layout_drain_test.go
T
Chris LuandDevin a261f90e18 vacuum: let the sweep release volumes that stay empty and quiet (#11477)
* vacuum: let the sweep release volumes that stay empty and quiet

Vacuuming reclaims bytes but not slots: a fully emptied volume stays
registered to its collection forever, and since growth is gated only on
slot count a store at 99% free disk can still refuse writes to other
collections (#11429). volume.deleteEmpty exists but is manual-only.

With -vacuumDeleteEmptyAfterSeconds (or master.vacuumDeleteEmptyAfterSeconds
under weed server/mini; default 0, off) the automatic sweep now deletes
replica copies that have stayed empty and quiet for that long, the same
rule volume.deleteEmpty applies on demand: remote-backed copies are
skipped, and every delete carries the volume server's onlyEmpty /
onlyGarbage guards so a copy written since the last report is refused
rather than removed. Copies that still hold data or were written
recently stay; only a volume whose every copy is deleted leaves the
sweep's work map, sparing a compaction of bytes that are all deleted.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* vacuum: harden empty-volume sweep against partial and racing deletes

Review follow-up on #11477:

- delete a volume only when every replica copy is a verifiable
  empty-and-quiet candidate; deleting the empty copy of a volume whose
  sibling holds live files would silently cut its replica count
  (greptile P1).
- drain the volume out of the writable list before deleting, the same
  drain the compact pass uses, so PickForWrite stops assigning it and
  pending writes settle (devin).
- bound the VolumeDelete RPC so one stalled server cannot hold the
  vacuum lock indefinitely (greptile P1, reusing allocateVolumeTimeout).

The vid2location panic scenario raised in review does not exist:
VolumeLocationList methods are nil-receiver safe and a missing vid just
fails enoughCopies, so a partially deleted volume skips compaction
instead of crashing the sweep.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* vacuum: unregister deleted empty replicas and prune the sweep list

A successful VolumeDelete only updates the volume server; the master
still tracked the replica and kept it in the sweep's location list for
the compaction pass (coderabbit on #11477). Unregister the replica right
after its delete succeeds and drop it from the sweep copy, so a partially
deleted volume only compacts copies that still exist.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* vacuum: pin deleting volumes out of the writable list across heartbeats

Review follow-up on #11477 (greptile): DrainAndRemoveFromWritable only
removed the volume once; a heartbeat landing between the drain and the
replica deletes re-evaluated writability and re-added it, so a client
write could reach a replica whose siblings were already gone and leave
the volume under-replicated when the last copy refused its onlyEmpty
delete.

MarkDeleting records the vid in deletingVolumes — checked inside
setVolumeWritable so heartbeat, capacity-recovery, and admin re-add
paths all hold it out — and UnmarkDeleting releases it once the sweep
finishes the copy pass. A partially deleted volume's surviving replicas
then return to writable through the normal heartbeat path.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* vacuum: restore writability when a sweep delete survives

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-27 07:05:22 +08:00

299 lines
7.1 KiB
Go

package topology
import (
"testing"
"time"
"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"
)
func TestGetPendingSize(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":1000, "replication":"000"}
],
"limit":10
}
}
}
}
`
_, vl := setupPickTest(t, layout, 10000)
// Initially no pending
if p := vl.GetPendingSize(1); p != 0 {
t.Fatalf("expected 0 pending, got %d", p)
}
// RecordAssign increases pending
vl.RecordAssign(1, 5000)
if p := vl.GetPendingSize(1); p != 5000 {
t.Fatalf("expected 5000 pending, got %d", p)
}
// UpdateVolumeSize (heartbeat) decays pending
vl.UpdateVolumeSize(1, 3000, 0, true)
// effective was 6000, reported 3000 → decay to 3000 + (6000-3000)/2 = 4500
// pending = 4500 - 3000 = 1500
if p := vl.GetPendingSize(1); p != 1500 {
t.Fatalf("expected 1500 pending after decay, got %d", p)
}
}
func TestGetPendingSize_CompactionResets(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":5000, "replication":"000"}
],
"limit":10
}
}
}
}
`
_, vl := setupPickTest(t, layout, 10000)
// Add large pending
vl.RecordAssign(1, 4000)
if p := vl.GetPendingSize(1); p != 4000 {
t.Fatalf("expected 4000 pending, got %d", p)
}
// Compaction happens — size drops from 5000 to 2000, revision changes.
// Without compaction awareness, decay would give: 2000 + (9000-2000)/2 = 5500.
// With compaction awareness, vid2size resets to 2000 (the real size).
vl.UpdateVolumeSize(1, 2000, 1, true) // revision 0 → 1
if p := vl.GetPendingSize(1); p != 0 {
t.Errorf("expected 0 pending after compaction reset, got %d", p)
}
// Verify vid2size is the reported size, not a decayed value
vl.accessLock.RLock()
if vl.sizeTracking[1].effectiveSize != 2000 {
t.Errorf("expected vid2size=2000 after compaction, got %d", vl.sizeTracking[1].effectiveSize)
}
vl.accessLock.RUnlock()
}
func TestDrainAndRemoveFromWritable_NoPending(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":1000, "replication":"000"}
],
"limit":10
}
}
}
}
`
_, vl := setupPickTest(t, layout, 10000)
// No pending — drain should return immediately
start := time.Now()
vl.DrainAndRemoveFromWritable(1)
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Errorf("drain with no pending took %v, expected near-instant", elapsed)
}
// Verify volume is no longer writable
writable, _ := vl.GetWritableVolumeCount()
if writable != 0 {
t.Errorf("expected 0 writable after drain, got %d", writable)
}
}
func TestDrainAndRemoveFromWritable_WithPending(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 pending below threshold
vl.RecordAssign(1, int64(pendingSizeThreshold-1))
start := time.Now()
vl.DrainAndRemoveFromWritable(1)
elapsed := time.Since(start)
// Should return quickly since pending is below threshold
if elapsed > 500*time.Millisecond {
t.Errorf("drain with pending below threshold took %v", elapsed)
}
// Volume removed from writable
writables := vl.CloneWritableVolumes()
for _, vid := range writables {
if vid == 1 {
t.Error("volume 1 should not be writable after drain")
}
}
// Volume 2 still writable
found := false
for _, vid := range writables {
if vid == 2 {
found = true
}
}
if !found {
t.Error("volume 2 should still be writable")
}
}
func TestDrainAndRemoveFromWritable_DecaysViaConcurrentHeartbeat(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":1000, "replication":"000"}
],
"limit":10
}
}
}
}
`
_, vl := setupPickTest(t, layout, 10000)
// Add large pending (well above threshold)
vl.RecordAssign(1, 100*1024*1024) // 100 MB
// Simulate heartbeats in background that will decay the pending.
// Advance lastUpdateTime before each call to bypass the 2s replica dedup.
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
vl.accessLock.Lock()
if st := vl.sizeTracking[1]; st != nil {
st.lastUpdateTime = time.Time{} // reset to allow update
}
vl.accessLock.Unlock()
vl.UpdateVolumeSize(1, 1000+uint64(i+1)*1000, 0, true)
}
}()
start := time.Now()
vl.DrainAndRemoveFromWritable(1)
elapsed := time.Since(start)
// Should have drained within a few seconds (heartbeats every 500ms).
// Use generous margin for slow CI.
if elapsed > 15*time.Second {
t.Errorf("drain took %v, expected faster with concurrent heartbeats", elapsed)
}
<-done
// Verify volume is no longer writable
writable, _ := vl.GetWritableVolumeCount()
if writable != 0 {
t.Errorf("expected 0 writable after drain, got %d", writable)
}
}
func TestSetVolumeReadOnly_PreservesPending(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":1000, "replication":"000"}
],
"limit":10
}
}
}
}
`
topo := setupWithLimit(t, layout, 10000)
rp, _ := super_block.NewReplicaPlacementFromString("000")
vl := topo.GetVolumeLayout("", rp, needle.EMPTY_TTL, types.HardDriveType)
dn := vl.Lookup(1)[0]
// Add some pending
vl.RecordAssign(1, 5000)
// SetVolumeReadOnly should succeed immediately (non-blocking)
result := vl.SetVolumeReadOnly(dn, 1)
if !result {
t.Error("expected SetVolumeReadOnly to return true")
}
// Pending is still there (not drained), but volume is readonly
writable, _ := vl.GetWritableVolumeCount()
if writable != 0 {
t.Errorf("expected 0 writable after readonly, got %d", writable)
}
if p := vl.GetPendingSize(1); p != 5000 {
t.Errorf("expected 5000 pending (not drained), got %d", p)
}
}
// A heartbeat landing mid-delete re-evaluates writability; a marked volume
// must stay out of the writable list until the sweep finishes with it.
func TestMarkDeletingKeepsVolumeUnwritableAcrossHeartbeats(t *testing.T) {
layout := `
{
"dc1":{
"rack1":{
"server1":{
"volumes":[
{"id":1, "size":1000, "replication":"000"}
],
"limit":10
}
}
}
}
`
_, vl := setupPickTest(t, layout, 10000)
if writable, _ := vl.GetWritableVolumeCount(); writable != 1 {
t.Fatalf("volume not writable before mark, got %d", writable)
}
vl.MarkDeleting(1)
vl.EnsureCorrectWritables(&storage.VolumeInfo{Id: 1})
if writable, _ := vl.GetWritableVolumeCount(); writable != 0 {
t.Fatal("marked volume became writable again")
}
vl.UnmarkDeleting(1)
if writable, _ := vl.GetWritableVolumeCount(); writable != 1 {
t.Fatal("unmarked volume did not regain writability without a heartbeat")
}
}