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 } }