Files
seaweedfs/test/vacuum/vacuum_integration_test.go
T

480 lines
14 KiB
Go

package vacuum
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/operation"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/shell"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
)
type TestCluster struct {
masterCmd *exec.Cmd
volumeServers []*exec.Cmd
}
func (c *TestCluster) Stop() {
for _, cmd := range c.volumeServers {
if cmd != nil && cmd.Process != nil {
cmd.Process.Kill()
cmd.Wait()
}
}
if c.masterCmd != nil && c.masterCmd.Process != nil {
c.masterCmd.Process.Kill()
c.masterCmd.Wait()
}
}
func startCluster(ctx context.Context, dataDir string) (*TestCluster, error) {
weedBinary := findWeedBinary()
if weedBinary == "" {
return nil, fmt.Errorf("weed binary not found - build with 'cd weed && go build' first")
}
cluster := &TestCluster{}
masterDir := filepath.Join(dataDir, "master")
os.MkdirAll(masterDir, 0755)
// Empty security.toml to disable JWT in tests
os.WriteFile(filepath.Join(dataDir, "security.toml"), []byte("# test\n"), 0644)
// Start master
masterCmd := exec.CommandContext(ctx, weedBinary, "master",
"-port", "9333",
"-mdir", masterDir,
"-volumeSizeLimitMB", "10",
"-ip", "127.0.0.1",
)
masterCmd.Dir = dataDir
masterLog, _ := os.Create(filepath.Join(masterDir, "master.log"))
masterCmd.Stdout = masterLog
masterCmd.Stderr = masterLog
if err := masterCmd.Start(); err != nil {
return nil, fmt.Errorf("start master: %v", err)
}
cluster.masterCmd = masterCmd
time.Sleep(2 * time.Second)
// Start 2 volume servers (enough for vacuum testing)
for i := 0; i < 2; i++ {
volumeDir := filepath.Join(dataDir, fmt.Sprintf("volume%d", i))
os.MkdirAll(volumeDir, 0755)
port := fmt.Sprintf("808%d", i)
volumeCmd := exec.CommandContext(ctx, weedBinary, "volume",
"-port", port,
"-dir", volumeDir,
"-max", "10",
"-master", "127.0.0.1:9333",
"-ip", "127.0.0.1",
)
volumeCmd.Dir = dataDir
volumeLog, _ := os.Create(filepath.Join(volumeDir, "volume.log"))
volumeCmd.Stdout = volumeLog
volumeCmd.Stderr = volumeLog
if err := volumeCmd.Start(); err != nil {
cluster.Stop()
return nil, fmt.Errorf("start volume server %d: %v", i, err)
}
cluster.volumeServers = append(cluster.volumeServers, volumeCmd)
}
time.Sleep(5 * time.Second)
return cluster, nil
}
func findWeedBinary() string {
candidates := []string{
"../../weed/weed",
"../weed/weed",
"./weed",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
if abs, err := filepath.Abs(c); err == nil {
return abs
}
return c
}
}
if path, err := exec.LookPath("weed"); err == nil {
return path
}
return ""
}
func waitForServer(address string, timeout time.Duration) error {
start := time.Now()
for time.Since(start) < timeout {
if conn, err := net.DialTimeout("tcp", address, 1*time.Second); err == nil {
conn.Close()
return nil
}
time.Sleep(500 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for server %s", address)
}
func uploadData(masterAddr, collection string, data []byte) (string, needle.VolumeId, error) {
assignResult, err := operation.Assign(context.Background(), func(ctx context.Context) pb.ServerAddress {
return pb.ServerAddress(masterAddr)
}, grpc.WithInsecure(), &operation.VolumeAssignRequest{
Count: 1,
Collection: collection,
})
if err != nil {
return "", 0, fmt.Errorf("assign: %v", err)
}
uploader, err := operation.NewUploader()
if err != nil {
return "", 0, fmt.Errorf("new uploader: %v", err)
}
uploadResult, err, _ := uploader.Upload(context.Background(), bytes.NewReader(data), &operation.UploadOption{
UploadUrl: "http://" + assignResult.Url + "/" + assignResult.Fid,
Filename: "testfile.txt",
MimeType: "text/plain",
})
if err != nil {
return "", 0, fmt.Errorf("upload: %v", err)
}
if uploadResult.Error != "" {
return "", 0, fmt.Errorf("upload error: %s", uploadResult.Error)
}
fid, err := needle.ParseFileIdFromString(assignResult.Fid)
if err != nil {
return "", 0, err
}
return assignResult.Fid, fid.VolumeId, nil
}
func deleteFile(masterAddr string, fid string) error {
results := operation.DeleteFileIds(func(ctx context.Context) pb.ServerAddress {
return pb.ServerAddress(masterAddr)
}, false, grpc.WithInsecure(), []string{fid})
for _, r := range results {
if r.Error != "" {
return fmt.Errorf("delete %s: %s", fid, r.Error)
}
}
return nil
}
func getGarbageRatio(volumeServerAddr string, volumeId uint32) (float64, error) {
var ratio float64
err := operation.WithVolumeServerClient(false, pb.ServerAddress(volumeServerAddr), grpc.WithInsecure(),
func(client volume_server_pb.VolumeServerClient) error {
resp, err := client.VacuumVolumeCheck(context.Background(), &volume_server_pb.VacuumVolumeCheckRequest{
VolumeId: volumeId,
})
if err != nil {
return err
}
ratio = resp.GarbageRatio
return nil
})
return ratio, err
}
// TestVacuumIntegration tests the full vacuum flow:
// upload data → delete some → verify garbage → vacuum → verify cleanup
func TestVacuumIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testDir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cluster, err := startCluster(ctx, testDir)
require.NoError(t, err)
defer cluster.Stop()
require.NoError(t, waitForServer("127.0.0.1:9333", 30*time.Second))
require.NoError(t, waitForServer("127.0.0.1:8080", 30*time.Second))
require.NoError(t, waitForServer("127.0.0.1:8081", 30*time.Second))
masterAddr := "127.0.0.1:9333"
collection := "vactest"
// Upload files large enough that deleting most creates significant garbage.
// With volumeSizeLimitMB=10, we need several MB of garbage to exceed the
// 10% threshold passed to vacuum.
const fileSize = 500 * 1024 // 500 KB per file
const totalFiles = 16
const filesToDelete = 12 // delete 75% → ~6 MB garbage out of ~8 MB
var fids []string
var payloads [][]byte
var fileVolumes []needle.VolumeId
for i := 0; i < totalFiles; i++ {
data := bytes.Repeat([]byte{byte('A' + i%26)}, fileSize)
fid, vid, err := uploadData(masterAddr, collection, data)
require.NoError(t, err, "upload %d", i)
fids = append(fids, fid)
payloads = append(payloads, data)
fileVolumes = append(fileVolumes, vid)
}
// Collect the set of volumes that will contain garbage after the deletes below.
// The master may spread uploads across multiple volumes, so we cannot assume
// a single volume id holds all the garbage.
dirtyVolumesSet := map[needle.VolumeId]struct{}{}
for i := 0; i < filesToDelete; i++ {
dirtyVolumesSet[fileVolumes[i]] = struct{}{}
}
var dirtyVolumes []needle.VolumeId
for v := range dirtyVolumesSet {
dirtyVolumes = append(dirtyVolumes, v)
}
// Sort for deterministic log output and stable iteration order across runs.
sort.Slice(dirtyVolumes, func(i, j int) bool { return dirtyVolumes[i] < dirtyVolumes[j] })
t.Logf("Uploaded %d files (%d KB each) across volumes %v; will delete from volumes %v",
totalFiles, fileSize/1024, fileVolumes, dirtyVolumes)
// Wait for heartbeat to report sizes
time.Sleep(6 * time.Second)
// Delete most files to create garbage well above the threshold
for i := 0; i < filesToDelete; i++ {
err := deleteFile(masterAddr, fids[i])
require.NoError(t, err, "delete %s", fids[i])
}
t.Logf("Deleted %d of %d files to create garbage", filesToDelete, totalFiles)
// Wait for heartbeat to report deletions
time.Sleep(6 * time.Second)
// Verify garbage exists on every volume we deleted from.
// Retry briefly in case heartbeats / deletions have not fully settled.
// We require all dirty volumes to report garbage > threshold so that
// the subsequent vacuum + cleanup check has a well-defined expectation
// for every volume, not just the first one that happens to be ready.
t.Run("verify_garbage_before_vacuum", func(t *testing.T) {
deadline := time.Now().Add(20 * time.Second)
var lastMissing needle.VolumeId
for {
ready := true
for _, vid := range dirtyVolumes {
volumeReady := false
for _, addr := range []string{"127.0.0.1:8080", "127.0.0.1:8081"} {
ratio, err := getGarbageRatio(addr, uint32(vid))
if err != nil {
continue
}
t.Logf("Garbage ratio for volume %d on %s: %.2f%%", vid, addr, ratio*100)
if ratio > 0.1 {
volumeReady = true
break
}
}
if !volumeReady {
ready = false
lastMissing = vid
break
}
}
if ready {
return
}
if time.Now().After(deadline) {
break
}
time.Sleep(1 * time.Second)
}
t.Fatalf("volume %d did not report garbage > 10%% — test data setup failed", lastMissing)
})
// Execute vacuum via shell command
t.Run("run_vacuum", func(t *testing.T) {
options := &shell.ShellOptions{
Masters: stringPtr(masterAddr),
GrpcDialOption: grpc.WithInsecure(),
FilerGroup: stringPtr("default"),
}
commandEnv := shell.NewCommandEnv(options)
// The session context outlives the vacuum command: a deadline here
// closes the master connection mid-command on a slow runner, and the
// leaked lock renewal then spins until the test binary's timeout.
shellCtx, shellCancel := context.WithCancel(context.Background())
defer shellCancel()
go commandEnv.MasterClient.KeepConnectedToMaster(shellCtx)
connectCtx, connectCancel := context.WithTimeout(shellCtx, 30*time.Second)
commandEnv.MasterClient.WaitUntilConnected(connectCtx)
connectCancel()
time.Sleep(2 * time.Second)
// Acquire lock (required by shell commands)
locked, unlock := tryLock(t, commandEnv, 30*time.Second)
require.True(t, locked, "could not acquire shell lock")
defer unlock()
// Find and execute vacuum command
var output bytes.Buffer
var found bool
var err error
for _, cmd := range shell.Commands {
if cmd.Name() == "volume.vacuum" {
err = cmd.Do(
[]string{"-garbageThreshold", "0.1", "-collection", collection},
commandEnv, &output,
)
found = true
break
}
}
require.True(t, found, "volume.vacuum command not found")
t.Logf("Vacuum output: %s", output.String())
require.NoError(t, err, "vacuum command failed")
t.Log("Vacuum completed successfully")
})
// Verify garbage was cleaned on every volume we deleted from.
// Vacuum + heartbeat reporting is asynchronous, so retry until each
// volume reports a cleaned ratio or the deadline expires.
t.Run("verify_cleanup_after_vacuum", func(t *testing.T) {
deadline := time.Now().Add(30 * time.Second)
remaining := map[needle.VolumeId]struct{}{}
for _, vid := range dirtyVolumes {
remaining[vid] = struct{}{}
}
failureReasons := map[needle.VolumeId]string{}
for {
for vid := range remaining {
var volumeFound, cleanupVerified bool
for _, addr := range []string{"127.0.0.1:8080", "127.0.0.1:8081"} {
ratio, err := getGarbageRatio(addr, uint32(vid))
if err != nil {
continue
}
volumeFound = true
t.Logf("Garbage ratio for volume %d after vacuum on %s: %.2f%%", vid, addr, ratio*100)
if ratio < 0.05 {
cleanupVerified = true
break
}
}
switch {
case !volumeFound:
failureReasons[vid] = fmt.Sprintf("no server reported volume %d after vacuum", vid)
case !cleanupVerified:
failureReasons[vid] = fmt.Sprintf("garbage on volume %d was not cleaned up after vacuum", vid)
default:
delete(remaining, vid)
delete(failureReasons, vid)
}
}
if len(remaining) == 0 {
return
}
if time.Now().After(deadline) {
break
}
time.Sleep(1 * time.Second)
}
stillFailing := make([]needle.VolumeId, 0, len(remaining))
for vid := range remaining {
stillFailing = append(stillFailing, vid)
}
sort.Slice(stillFailing, func(i, j int) bool { return stillFailing[i] < stillFailing[j] })
msgs := make([]string, 0, len(stillFailing))
for _, vid := range stillFailing {
msgs = append(msgs, failureReasons[vid])
}
t.Fatalf("cleanup verification failed for %d volume(s): %s",
len(stillFailing), strings.Join(msgs, "; "))
})
// Verify remaining files are still readable with correct contents
t.Run("verify_remaining_data", func(t *testing.T) {
for i := filesToDelete; i < totalFiles; i++ {
fid := fids[i]
expected := payloads[i]
// Read file via HTTP from volume server
client := &http.Client{Timeout: 5 * time.Second}
url := fmt.Sprintf("http://127.0.0.1:8080/%s", fid)
resp, err := client.Get(url)
if err != nil || resp.StatusCode == http.StatusNotFound {
if resp != nil {
resp.Body.Close()
}
url = fmt.Sprintf("http://127.0.0.1:8081/%s", fid)
resp, err = client.Get(url)
}
require.NoError(t, err, "read fid %s", fid)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err, "read body of fid %s", fid)
require.Equal(t, http.StatusOK, resp.StatusCode, "fid %s returned %d", fid, resp.StatusCode)
require.Equal(t, len(expected), len(body), "fid %s size mismatch", fid)
require.True(t, bytes.Equal(expected, body), "fid %s content mismatch", fid)
t.Logf("File %s verified (%d bytes)", fid, len(body))
}
})
}
func stringPtr(s string) *string {
return &s
}
func tryLock(t *testing.T, commandEnv *shell.CommandEnv, timeout time.Duration) (locked bool, unlock func()) {
t.Helper()
type result struct {
err error
}
done := make(chan result, 1)
go func() {
for _, cmd := range shell.Commands {
if cmd.Name() == "lock" {
var out bytes.Buffer
done <- result{err: cmd.Do([]string{}, commandEnv, &out)}
return
}
}
done <- result{err: fmt.Errorf("lock command not found")}
}()
select {
case res := <-done:
if res.err != nil {
t.Logf("lock failed: %v", res.err)
return false, nil
}
return true, func() {
for _, cmd := range shell.Commands {
if cmd.Name() == "unlock" {
var out bytes.Buffer
cmd.Do([]string{}, commandEnv, &out)
return
}
}
}
case <-time.After(timeout):
t.Log("lock timed out")
return false, nil
}
}