Files
seaweedfs/weed/storage/store_headroom_test.go
T
0d721fccee fix(volume): keep preallocated growth above -volume.minFreeSpace (#12067)
* fix(volume): keep preallocated growth above -volume.minFreeSpace

When free space fell below -volume.minFreeSpace, every volume went
read-only and the master kept growing new volumes. With preallocation on,
each new volume reserved its full size, driving the disk further below the
floor (15 Oct incident: 66 empty volumes reserved about 1980 GiB).

Two causes, both in weed/storage/store.go:

- MaybeAdjustVolumeMax sized a disk's volume slots from raw free space, so
  space inside the floor was counted as available. It now counts only free
  space above the floor.
- addVolume trusted the once-a-minute low-disk flag. A preallocated volume
  is now refused when the disk is low now, or when its reservation would
  not fit above the floor. Volumes without preallocation, including merges
  that copy existing data, are not affected.

addVolume runs under a store-level lock, so two concurrent creates cannot
both pass the room check against the same free space.

* volume: mirror headroom guard in the rust store

find_free_location only trusted the once-a-probe is_disk_space_low flag,
so a burst of preallocated creates could still run the disk under
-volume.minFreeSpace between probes, and maybe_adjust_volume_max sized
slots from raw free space. Adds MinFreeSpace::is_low/available_space to
match Go and applies both checks the same way. store.write() already
serializes add_volume, so no extra lock is needed.

* volume: read rust headroom tests from a fake disk

The floor arithmetic used a live free-space reading, so unrelated disk
activity could flip a test between pass and fail. Store.disk_stats_of is
now the same seam Go uses (diskStatusOf): the growth-loop and floor tests
run against a controlled disk, mirroring the Go suite one for one.

* volume: hold back create metadata from the headroom check

An exact-fit preallocation left free space exactly at the floor, so the
.vif and .idx writes that follow a create could dip under it and get the
location flagged read-only at the next probe. Reserve room for .vif on
the data filesystem, and for .idx growth only when dir_idx shares it.

* volume: mirror the metadata allowance in the rust store

Same rule as the Go side: charge .vif against the checked filesystem and
.idx only when dir_idx shares it, and grow the exact-fit test's fake
disk by the per-create cost.

* volume: skip the reservation check where create cannot preallocate

* volume: mirror the platform preallocation gate in the rust store

* volume.copy: count the preallocation reservation in the destination space check

The copy path picked a location with room for the copied .dat, then reserved
the master's full volume size limit on it. A volume smaller than the limit
could land on a disk whose free space above -volume.minFreeSpace fit the
copy but not the reservation, driving the disk under the floor the same way
the growth loop did.

Fetch the preallocation setting before choosing the location and require
room for the reservation itself, only where CreateVolumeFile actually
reserves blocks.

* seaweed-volume: store min-free-space-adjusted available space like Go

Go's DiskLocation.AvailableSpace holds free space above the
-volume.minFreeSpace floor; the Rust probe cached raw free instead, so the
VolumeCopy location check could accept a disk already inside the floor.
Store the same adjusted figure and cover it with a test.

* seaweed-volume: reserve the copied .dat like Go's CreateVolumeFile

VolumeCopy called set_len for the reservation, which only grows a sparse
file — no blocks are held, so a disk with room for the copied bytes but
not the full reservation only fails mid-copy. preallocate_file fallocates
KEEP_SIZE on Linux like Go, reserving the blocks up front; off Linux it
is a no-op, matching PREALLOCATE_SUPPORTED.

* seaweed-volume: charge a copy's destination pick for the .dat reservation

Go's VolumeCopy now sizes its free-location check against the master's
volume size limit when the platform preallocates, not just the copied
bytes. The Rust pick only measured the copied bytes, so a destination
could accept a copy whose reservation then dips under the floor. The
config fetch moves ahead of the pick (same order as Go) and feeds both
the space check and the reservation itself.

* volume.copy: charge the picked location's free space under volumeCreateLock

The copy path picked a destination off the once-a-minute AvailableSpace
probe and released the decision long before the .dat reservation landed.
Two copies could pass the same cached headroom and together drive the disk
under -volume.minFreeSpace.

ReserveFreeLocation serializes the pick under volumeCreateLock, verifies
the disk still has live room for the reservation, and charges it against
the location's cached free space until the next probe refreshes it.

* seaweed-volume: mirror the copy pick's live-check and headroom charge

Go's VolumeCopy now picks through Store.ReserveFreeLocation: live disk
stats under the store write lock, then the reservation charged against the
location's cached free space. The Rust plan_copy_destination gains the
same store.reserve_free_location so two copies cannot spend the same
headroom before the first .dat reservation lands.

* volume.copy: saturate the free-space charge against a live-space drop

The two statfs reads bracketing a pick can race a large external write; a
plain subtraction would underflow and leave the location looking roomy
until the next probe.

* volume.copy: release the headroom reservation when a copy does not land

ReserveFreeLocation charges the picked location's cached free space, but
a failed copy never returned it, so the headroom stayed booked until the
next disk-space probe and blocked retries. ReleaseFreeLocation refunds
the charge; the copy's cleanup defer now registers with the reservation
so every failure path (existing-replica delete, .note write, stream send)
releases it, including the post-transfer mount/validate failures covered
by the outer defer.

* seaweed-volume: mirror the copy headroom release on failed copies

release_free_location refunds reserve_free_location's charge. The copy
returns it when the plan's existing-replica delete fails, when the .note
write fails, and in cleanup_failed_copy, so a failed copy stops holding
the headroom until the next disk-space probe.

* volume.copy: keep pending copy headroom out of probe readings

ReleaseFreeLocation refunded the reservation into AvailableSpace even when
a CheckDiskSpace probe had already rebased the cache on live stats — the
probe's reading still shows the in-flight copy's bytes as free, so the
refund double-counted headroom and two copies could spend the same space,
driving the disk under -volume.minFreeSpace.

Track the outstanding charge in DiskLocation.ReservedSpace instead: probes
subtract it so they cannot re-spend it, a failed copy removes only its own
charge, and a landed copy commits it — the charge and the new usage cancel
out. hasRoomForNewVolume respects pending headroom too, so a preallocating
create cannot overrun an in-flight copy.

* seaweed-volume: mirror pending copy headroom across disk probes

reserved_space tracks the in-flight copy's charge separately from the
cached free space: check_disk_space subtracts it, release removes only the
failed copy's charge, a landed copy commits it, and has_room_for_new_volume
respects it — matching the Go side.

* volume.copy: settle a copy's headroom as its bytes land on disk

The pending charge held the copy's full reservation until commit, but
written and fallocated bytes already show up in statfs, so the live-space
check counted them twice. A slow copy could then block other copies and
volume growth despite free space above -volume.minFreeSpace.

Settle the charge instead: commit the bytes CreateVolumeFile actually
reserved (a failed fallocate leaves nothing committed), then settle each
file's write progress as it lands. The failure paths now release only the
unsettled remainder; allocatedSize is exported for the .dat's st_blocks.

* seaweed-volume: mirror copy headroom settling as bytes land

reserved_space keeps only the promised bytes statfs cannot see yet:
preallocate_dat settles whatever blocks the filesystem actually reserved
(0 when fallocate cannot), each copied file's progress settles the rest as
it lands, and a failed copy releases only the unsettled remainder —
matching the Go side.

* volume.copy: release a failed copy's headroom charge only once

Both defers fired on the same in-closure error: the inner released the
unsettled charge and the outer released it again, driving ReservedSpace
negative (an atomic.Uint64 underflow) and inflating AvailableSpace —
the location would then fail every later headroom check, or worse,
pass them with fabricated space. Zero the charge once it is released;
the Rust mirror already releases via swap(0) for the same reason.

* volume.copy: write into the preallocated .dat instead of staging over it

The .dat stream staged into .copying and renamed over the file
CreateVolumeFile had just fallocated, so the copy's transient footprint
was preallocate+dat — more than the reservation the pick just checked —
and the reservation itself was discarded at rename. Writing in place
reuses the fallocated extent, keeps the peak at the reserved maximum,
and leaves the copied volume preallocated like a created one.

* seaweed-volume: mirror the in-place write into the preallocated .dat

The .dat copy opened the just-fallocated file with truncate, freeing its
blocks at open and spending the headroom again as the stream re-grew it.
Append mode preserves them, matching the Go side.

---------

Co-authored-by: chrislusf <chrislusf@gmail.com>
Co-authored-by: user.email <chrislusf@users.noreply.github.com>
2026-10-26 08:03:25 -07:00

364 lines
14 KiB
Go

package storage
import (
"testing"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/stats"
"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/util"
)
const testMiB = uint64(1 << 20)
// fakeDisk stands in for the disk under a location. Preallocating a volume
// takes its size out of free space, as fallocate does on Linux.
type fakeDisk struct {
all uint64
free uint64
}
// newHeadroomStore builds a store with one location whose disk has the given
// free space and whose -volume.minFreeSpace floor is floor. The low-disk flag
// stays false, as it does between two once-a-minute probes.
func newHeadroomStore(t *testing.T, free, floor uint64) (*Store, *DiskLocation, *fakeDisk) {
t.Helper()
dir := t.TempDir()
disk := &fakeDisk{all: 10 * 1024 * testMiB, free: free}
orig := diskStatusOf
diskStatusOf = func(string) *volume_server_pb.DiskStatus {
return &volume_server_pb.DiskStatus{
All: disk.all,
Free: disk.free,
Used: disk.all - disk.free,
PercentFree: float32(disk.free) * 100 / float32(disk.all),
}
}
t.Cleanup(func() { diskStatusOf = orig })
// The fake disk behaves like fallocate on Linux: pretend creates reserve.
origPreallocates := createVolumeFilePreallocates
createVolumeFilePreallocates = true
t.Cleanup(func() { createVolumeFilePreallocates = origPreallocates })
loc := &DiskLocation{
Directory: dir,
IdxDirectory: dir,
DiskType: types.HardDriveType,
MinFreeSpace: util.MinFreeSpace{Type: util.AsBytes, Bytes: floor},
}
loc.volumes = make(map[needle.VolumeId]*Volume)
// Close created volumes before TempDir removes them; Windows cannot
// delete a file that is still open.
t.Cleanup(func() {
for _, v := range loc.volumes {
v.Close()
}
})
s := &Store{
Locations: []*DiskLocation{loc},
NewVolumesChan: make(chan *master_pb.VolumeShortInformationMessage, 100),
}
return s, loc, disk
}
// The master sizes a disk's volume slots from its free space. Free space
// inside the -volume.minFreeSpace floor is not available for new volumes, so
// it must not add slots.
func TestMaybeAdjustVolumeMaxIgnoresFreeSpaceInsideFloor(t *testing.T) {
const floor = 1000 * testMiB
const volumeSize = 30 * testMiB
// 90 MiB above the floor is 3 volumes. The other 1000 MiB is headroom.
s, loc, _ := newHeadroomStore(t, floor+90*testMiB, floor)
s.SetVolumeSizeLimit(volumeSize)
s.SetPreallocate(true)
s.MaybeAdjustVolumeMax()
if loc.MaxVolumeCount != 3 {
t.Errorf("MaxVolumeCount=%d; want 3 (only the 90 MiB above the floor is usable)", loc.MaxVolumeCount)
}
}
// A preallocated volume must not be created when its reservation would take
// the disk under the floor, even if the low-disk flag has not been probed yet.
func TestAddVolumeRefusesPreallocationThatCrossesFloor(t *testing.T) {
const floor = 1000 * testMiB
const preallocate = 8 * testMiB
// 4 MiB above the floor, so an 8 MiB reservation would cross it.
s, _, disk := newHeadroomStore(t, floor+4*testMiB, floor)
rp, _ := super_block.NewReplicaPlacementFromString("000")
err := s.addVolume(1, "", NeedleMapInMemory, rp, needle.EMPTY_TTL, int64(preallocate), needle.GetCurrentVersion(), 0, types.HardDriveType, 0)
if err == nil {
t.Errorf("addVolume succeeded; want refusal (free %d MiB, floor %d MiB, reserve %d MiB)",
disk.free/testMiB, floor/testMiB, preallocate/testMiB)
}
if s.findVolume(1) != nil {
t.Errorf("volume 1 exists after refused addVolume")
}
}
// A volume with no preallocation is still created while free space is above
// the floor, so the guard only holds back growth that would cross it.
func TestAddVolumeAllowsPlainVolumeAboveFloor(t *testing.T) {
const floor = 1000 * testMiB
s, _, _ := newHeadroomStore(t, floor+1*testMiB, floor)
rp, _ := super_block.NewReplicaPlacementFromString("000")
if err := s.addVolume(1, "", NeedleMapInMemory, rp, needle.EMPTY_TTL, 0, needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Errorf("addVolume without preallocation failed above the floor: %v", err)
}
}
// Copying existing data into a new volume (a merge) reserves nothing and must
// still go through while the disk is below its floor.
func TestAddVolumeAllowsMergeVolumeBelowFloor(t *testing.T) {
const floor = 1000 * testMiB
s, _, _ := newHeadroomStore(t, floor-1*testMiB, floor)
rp, _ := super_block.NewReplicaPlacementFromString("000")
if err := s.addVolume(1, "", NeedleMapInMemory, rp, needle.EMPTY_TTL, 0, needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Errorf("merge volume refused below the floor: %v", err)
}
}
// Where CreateVolumeFile cannot preallocate, a create reserves nothing and
// must not be held back by a reservation that will never exist.
func TestAddVolumeAllowsPreallocationWhereUnsupported(t *testing.T) {
const floor = 1000 * testMiB
s, _, _ := newHeadroomStore(t, floor+1*testMiB, floor)
createVolumeFilePreallocates = false
rp, _ := super_block.NewReplicaPlacementFromString("000")
if err := s.addVolume(1, "", NeedleMapInMemory, rp, needle.EMPTY_TTL, 8*int64(testMiB), needle.GetCurrentVersion(), 0, types.HardDriveType, 0); err != nil {
t.Errorf("addVolume refused where preallocation is unsupported: %v", err)
}
}
// Replays the incident in miniature: the master keeps asking for volumes
// while the disk sits just above its floor. Each created volume reserves its
// full size, so the disk must not be driven below the floor.
func TestGrowthLoopStopsAtFloorWhenVolumesPreallocate(t *testing.T) {
const floor = 1000 * testMiB
const preallocate = 1 * testMiB
const attempts = 20
// Room for 4 preallocated volumes above the floor once each create's
// metadata allowance is held back.
const createCost = preallocate + volumeCreateVifBytes + volumeCreateIdxBytes
s, _, disk := newHeadroomStore(t, floor+4*createCost, floor)
rp, _ := super_block.NewReplicaPlacementFromString("000")
created := 0
for i := 1; i <= attempts; i++ {
err := s.addVolume(needle.VolumeId(i), "", NeedleMapInMemory, rp, needle.EMPTY_TTL, int64(preallocate), needle.GetCurrentVersion(), 0, types.HardDriveType, 0)
if err != nil {
continue
}
created++
// fallocate reserves the volume's full size on disk; the metadata
// allowance it was charged goes with it.
disk.free -= createCost
}
if disk.free < floor {
t.Errorf("free space fell to %d MiB, below the %d MiB floor, after %d volumes",
disk.free/testMiB, floor/testMiB, created)
}
// Exactly the room above the floor must be used. Checking only an upper
// bound would also pass when every create fails.
if created != 4 {
t.Errorf("created %d preallocated volumes; want exactly 4 (the room above the floor)", created)
}
}
// A copy's pick and its .dat reservation are separate steps. The picked
// location's cached free space must carry the charge at once, so a second
// copy cannot spend the same headroom before the first reservation lands.
func TestReserveFreeLocationChargesPickedReservation(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
// 40 MiB above the floor: room for one 30 MiB reservation, not two.
s, loc, _ := newHeadroomStore(t, floor+40*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(40 * testMiB)
pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
})
if pick != loc {
t.Fatalf("first pick = %v; want the only location", pick)
}
if got := loc.AvailableSpace.Load(); got != 10*testMiB {
t.Fatalf("AvailableSpace after charge = %d MiB; want 10", got/testMiB)
}
if pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
}); pick != nil {
t.Fatalf("second pick = %v; want nil (the first reservation still holds the headroom)", pick)
}
}
// The cached free space is refreshed only once a minute, so a stale-high
// value must not admit a reservation the live disk cannot fit.
func TestReserveFreeLocationRechecksLiveDisk(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
// Only 5 MiB above the floor, but the probe last saw 100 MiB of headroom.
s, loc, _ := newHeadroomStore(t, floor+5*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(100 * testMiB)
if pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
}); pick != nil {
t.Fatalf("pick = %v; want nil (live disk has no room for the reservation)", pick)
}
}
// A failed copy must return its reservation, or the headroom stays booked
// until the next disk probe and blocks retries.
func TestReleaseFreeLocationRestoresReservation(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
s, loc, _ := newHeadroomStore(t, floor+40*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(40 * testMiB)
pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
})
if pick != loc {
t.Fatalf("first pick = %v; want the only location", pick)
}
s.ReleaseFreeLocation(pick, need)
if pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
}); pick == nil {
t.Fatal("retry pick = nil; want the refunded reservation to admit a copy")
}
}
// A probe between reserve and release must not resurrect the charge: the
// probe's live reading already forgot the failed copy's pending bytes, so
// refunding the cached figure would let two copies spend the same headroom.
func TestReleaseFreeLocationAfterProbeRefundsOnlyOwnCharge(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
s, loc, disk := newHeadroomStore(t, floor+40*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(40 * testMiB)
orig := newDiskStatus
newDiskStatus = func(string, stats.DiskIOProbeConfig) *volume_server_pb.DiskStatus {
return &volume_server_pb.DiskStatus{All: disk.all, Free: disk.free}
}
t.Cleanup(func() { newDiskStatus = orig })
hdd := func(location *DiskLocation) bool { return location.DiskType == types.HardDriveType }
if pick := s.ReserveFreeLocation(need, hdd); pick == nil {
t.Fatal("first pick = nil; want the only location")
}
// Mid-copy probe: the live disk still shows the not-yet-written bytes
// free, but the pending charge must keep them out of the cache.
loc.CheckDiskSpace(stats.DiskIOProbeConfig{})
if got := loc.AvailableSpace.Load(); got != 10*testMiB {
t.Fatalf("AvailableSpace after probe = %d MiB; want 10 (charge survives the probe)", got/testMiB)
}
if pick := s.ReserveFreeLocation(need, hdd); pick != nil {
t.Fatalf("second pick = %v; want nil (the in-flight reservation still holds the headroom)", pick)
}
// The failed copy removes only its own charge; the probe-rebased cache
// must end up holding exactly the live headroom, not the headroom plus
// a resurrected second charge.
s.ReleaseFreeLocation(loc, need)
if got := loc.AvailableSpace.Load(); got != 40*testMiB {
t.Fatalf("AvailableSpace after refund = %d MiB; want 40", got/testMiB)
}
if pick := s.ReserveFreeLocation(need, hdd); pick == nil {
t.Fatal("retry pick = nil; want the live headroom to admit a copy")
}
if pick := s.ReserveFreeLocation(need, hdd); pick != nil {
t.Fatalf("third pick = %v; want nil (only 40 MiB of headroom exists)", pick)
}
}
// A copy that lands must end its pending charge without refunding the cache:
// the cache's charge and the landed bytes cancel out, and the next probe
// reads the disk exactly as it is.
func TestCommitFreeLocationSettlesLandedReservation(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
s, loc, disk := newHeadroomStore(t, floor+40*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(40 * testMiB)
orig := newDiskStatus
newDiskStatus = func(string, stats.DiskIOProbeConfig) *volume_server_pb.DiskStatus {
return &volume_server_pb.DiskStatus{All: disk.all, Free: disk.free}
}
t.Cleanup(func() { newDiskStatus = orig })
pick := s.ReserveFreeLocation(need, func(location *DiskLocation) bool {
return location.DiskType == types.HardDriveType
})
if pick != loc {
t.Fatalf("pick = %v; want the only location", pick)
}
disk.free -= need // the copied bytes are on disk now
s.CommitFreeLocation(pick, need)
if got := loc.ReservedSpace.Load(); got != 0 {
t.Fatalf("ReservedSpace after commit = %d MiB; want 0", got/testMiB)
}
if got := loc.AvailableSpace.Load(); got != 10*testMiB {
t.Fatalf("AvailableSpace after commit = %d MiB; want 10 (no refund)", got/testMiB)
}
loc.CheckDiskSpace(stats.DiskIOProbeConfig{})
if got := loc.AvailableSpace.Load(); got != 10*testMiB {
t.Fatalf("AvailableSpace after post-landing probe = %d MiB; want 10", got/testMiB)
}
}
// A running copy settles its charge as its bytes land on disk. The
// live-space check must then see the written part once — in the disk
// reading — not again inside the pending charge, or a slow copy would block
// reservations the disk has room for.
func TestCommitFreeLocationSettlesAsBytesLand(t *testing.T) {
const floor = 1000 * testMiB
const need = 30 * testMiB
s, loc, disk := newHeadroomStore(t, floor+70*testMiB, floor)
loc.MaxVolumeCount = 10
loc.AvailableSpace.Store(70 * testMiB)
hdd := func(location *DiskLocation) bool { return location.DiskType == types.HardDriveType }
if pick := s.ReserveFreeLocation(need, hdd); pick != loc {
t.Fatalf("first pick = %v; want the only location", pick)
}
// Half the copy's bytes are on disk now; settling that much charge
// keeps the second 30 MiB reservation inside the real headroom.
disk.free -= 15 * testMiB
s.CommitFreeLocation(loc, 15*testMiB)
if pick := s.ReserveFreeLocation(need, hdd); pick == nil {
t.Fatal("second pick = nil; want the settled headroom to admit the copy")
}
if got := loc.ReservedSpace.Load(); got != 45*testMiB {
t.Fatalf("ReservedSpace after second pick = %d MiB; want 45", got/testMiB)
}
if got := loc.AvailableSpace.Load(); got != 10*testMiB {
t.Fatalf("AvailableSpace after second pick = %d MiB; want 10", got/testMiB)
}
}