mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 17:10:40 +02:00
480 lines
14 KiB
Go
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
|
|
}
|
|
}
|