Files
seaweedfs/weed/shell/command_volume_fsck_live_test.go
T
76198d15cf fix(fsck): inspect references to deleted volumes (#12357)
* fix(fsck): inspect references to deleted volumes

* volume.fsck: treat every match-all collection filter as global

ALL_COLLECTIONS and EACH_COLLECTION compile to the same match-all
matcher as an empty filter or "*", but the string check treated them
as a restricted scope: a selected missing volume was rejected and
references to master-absent volumes were reported without being purged.
Use the compiled matcher instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: purge an entry whose manifest is on a missing volume

A top-level chunk on a missing volume already proves the entry is
unreadable, whether or not its manifests resolve. Skipping such entries
kept the very case where the manifest needle sat on the retired volume,
and the remaining unresolved manifest then blocked every later orphan
purge. The missing-needle check on live volumes never had this gate, so
both paths now treat these entries the same. The warning now says only
orphan purging is disabled, which is the only thing the counter gates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: report a failed Filer delete

A rejected purge DELETE printed nothing unless -v was set, so the run
looked successful after "deleting path" while the entry stayed. The
filer answers 204 for an entry that is already gone, so only real
failures print.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: tighten help text, comments and missing-volume tests

The help now wraps like the rest of it and says that a purge removes
the whole entry, including its chunks on other volumes. The live test
header no longer calls the run report-only, and the reverse-scan test
checks the report line for each missing chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: drop the missing-volume design notes

The notes said no destructive change was approved while the change adds
a confirmed purge, still described the dropped manifest gate, and
mostly recorded local runs and a reverse index that is not part of
this change. The operator contract lives in help volume.fsck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: allow a report-only missing-volume scan with -collection

The collection check exists to stop a purge that cannot be scoped, but
it also rejected a report-only run that deletes nothing. Apply it only
with -reallyDeleteFilerEntries, and limit the -confirmMissingVolumeDeletion
description to the -volumeId case it guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: count only a 2xx answer as a purged entry

A redirect the client could not follow left the entry in place but was
recorded as deleted, which also queued its parent for empty-directory
cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* volume.fsck: say that a filtered purge of a missing -volumeId is refused

The help and the early warning both said a purge with -collection only
reports references to unlisted volumes, but a -volumeId naming one is
rejected before the scan. The help now says so, and the warning prints
after that check, so a refused run no longer claims it will report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: MZ01-CHOIMINSU <mz01-juwannam@MZ01-CHOIMINSU.local>
Co-authored-by: Chris Lu <chrislusf@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:57:43 -08:00

361 lines
14 KiB
Go

//go:build integration
package shell
import (
"bytes"
"context"
"fmt"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"testing"
"time"
)
// Opt-in: runs report-only and confirmed-purge scans against real services
// after a volume is deleted, and checks that a stopped server blocks the purge.
func TestVolumeFsckFindsReferenceAfterRealVolumeDeletion(t *testing.T) {
weed := buildWeedForIntegration(t)
base := t.TempDir()
for _, name := range []string{"logs", "master", "volume", "filer"} {
if err := os.MkdirAll(filepath.Join(base, name), 0o755); err != nil {
t.Fatal(err)
}
}
portPairs := freeIntegrationPortPairs(t, 3)
masterHTTP, masterGRPC := portPairs[0][0], portPairs[0][1]
volumeHTTP, volumeGRPC := portPairs[1][0], portPairs[1][1]
filerHTTP, filerGRPC := portPairs[2][0], portPairs[2][1]
masterAddress := fmt.Sprintf("127.0.0.1:%d", masterHTTP)
startIntegrationProcess(t, base, weed, "master",
"master", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
fmt.Sprintf("-port=%d", masterHTTP), fmt.Sprintf("-port.grpc=%d", masterGRPC),
"-mdir="+filepath.Join(base, "master"), "-defaultReplication=000", "-volumeSizeLimitMB=64")
waitIntegrationTCP(t, masterHTTP, filepath.Join(base, "logs", "master.log"))
waitIntegrationTCP(t, masterGRPC, filepath.Join(base, "logs", "master.log"))
volumeProcess := startIntegrationProcess(t, base, weed, "volume",
"volume", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
fmt.Sprintf("-port=%d", volumeHTTP), fmt.Sprintf("-port.grpc=%d", volumeGRPC),
"-master="+masterAddress, "-dir="+filepath.Join(base, "volume"), "-max=10")
waitIntegrationTCP(t, volumeHTTP, filepath.Join(base, "logs", "volume.log"))
waitIntegrationTCP(t, volumeGRPC, filepath.Join(base, "logs", "volume.log"))
waitForMasterVolumeRegistration(t, masterHTTP, volumeHTTP, filepath.Join(base, "logs", "volume.log"))
startIntegrationProcess(t, base, weed, "filer",
"filer", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
fmt.Sprintf("-port=%d", filerHTTP), fmt.Sprintf("-port.grpc=%d", filerGRPC),
"-master="+masterAddress, "-defaultReplicaPlacement=000",
"-defaultStoreDir="+filepath.Join(base, "filer"))
waitIntegrationTCP(t, filerHTTP, filepath.Join(base, "logs", "filer.log"))
waitIntegrationTCP(t, filerGRPC, filepath.Join(base, "logs", "filer.log"))
filerURL := fmt.Sprintf("http://127.0.0.1:%d", filerHTTP)
request, err := http.NewRequest(http.MethodPut, filerURL+"/fsck-probe.txt", strings.NewReader("live integration data"))
if err != nil {
t.Fatal(err)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("upload probe through Filer: %v", err)
}
response.Body.Close()
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusAccepted {
t.Fatalf("Filer PUT status = %d, want 201 or 202", response.StatusCode)
}
filerAddress := fmt.Sprintf("127.0.0.1:%d", filerHTTP)
meta := runIntegrationShell(t, weed, masterAddress, filerAddress, "lock\nfs.meta.cat /fsck-probe.txt\nunlock\n")
fidPattern := regexp.MustCompile(`"fid"\s*:\s*\{\s*"volumeId"\s*:\s*(\d+)`)
match := fidPattern.FindStringSubmatch(meta)
if len(match) != 2 {
t.Fatalf("could not read chunk volume ID from Filer metadata: %s", meta)
}
volumeID := match[1]
// Delete only the physical volume. The Filer entry intentionally remains
// so fsck must discover a dangling reference rather than a missing file.
deleteOutput := runIntegrationShell(t, weed, masterAddress, filerAddress,
fmt.Sprintf("lock\nvolume.delete -node=127.0.0.1:%d -volumeId=%s\nunlock\n", volumeHTTP, volumeID))
recentOutput := runIntegrationShell(t, weed, masterAddress, filerAddress,
fmt.Sprintf("lock\nvolume.fsck -findMissingChunksInFiler -volumeId=%s\nunlock\n", volumeID))
if strings.Contains(recentOutput, "/fsck-probe.txt") {
t.Fatalf("default cutoff unexpectedly reported a just-written file: %q", recentOutput)
}
fsckOutput := runIntegrationShell(t, weed, masterAddress, filerAddress,
fmt.Sprintf("lock\nvolume.fsck -findMissingChunksInFiler -volumeId=%s -cutoffTimeAgo=0\nunlock\n", volumeID))
if !strings.Contains(fsckOutput, "scanning filer references for selected volume(s)") {
t.Fatalf("fsck did not treat the deleted ID as a missing-volume target; delete=%q fsck=%q", deleteOutput, fsckOutput)
}
if !strings.Contains(fsckOutput, "/fsck-probe.txt") || !strings.Contains(fsckOutput, "volume not found") {
t.Fatalf("fsck did not report the stale Filer reference: %q", fsckOutput)
}
scopedPurgeOutput := runIntegrationShell(t, weed, masterAddress, filerAddress,
"lock\nvolume.fsck -findMissingChunksInFiler -reallyDeleteFilerEntries -collection=logs -cutoffTimeAgo=0\nunlock\n")
if !strings.Contains(scopedPurgeOutput, "collection cannot be verified for volumes absent from master") {
t.Fatalf("fsck did not warn that a missing volume cannot be collection-scoped: %q", scopedPurgeOutput)
}
if strings.Contains(scopedPurgeOutput, "deleting path /fsck-probe.txt after volume not found") {
t.Fatalf("collection-filtered fsck queued an unverified missing-volume path: %q", scopedPurgeOutput)
}
// Report-only fsck must not delete the stale Filer entry.
metaAfter := runIntegrationShell(t, weed, masterAddress, filerAddress, "lock\nfs.meta.cat /fsck-probe.txt\nunlock\n")
if !strings.Contains(metaAfter, "fsck-probe.txt") {
t.Fatalf("report-only fsck changed or removed the Filer entry: %q", metaAfter)
}
// An entry whose chunk manifest lived on the retired volume is unreadable too.
filerConn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%d", filerGRPC), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
t.Fatalf("dial Filer gRPC: %v", err)
}
defer filerConn.Close()
filerClient := filer_pb.NewSeaweedFilerClient(filerConn)
volumeIDUint, err := strconv.ParseUint(volumeID, 10, 32)
if err != nil {
t.Fatalf("parse volume ID %q: %v", volumeID, err)
}
manifestChunk := &filer_pb.FileChunk{
Fid: &filer_pb.FileId{VolumeId: uint32(volumeIDUint), FileKey: 987654321, Cookie: 1},
Size: 21,
IsChunkManifest: true,
}
manifestChunk.FileId = manifestChunk.GetFileIdString()
now := time.Now().Unix()
if err := filer_pb.CreateEntry(context.Background(), filerClient, &filer_pb.CreateEntryRequest{
Directory: "/",
Entry: &filer_pb.Entry{
Name: "manifest-probe.txt",
Attributes: &filer_pb.FuseAttributes{FileSize: 21, FileMode: 0o644, Mtime: now, Crtime: now},
Chunks: []*filer_pb.FileChunk{manifestChunk},
},
}); err != nil {
t.Fatalf("create unresolved-manifest probe: %v", err)
}
purgeOutput := runIntegrationShell(t, weed, masterAddress, filerAddress,
fmt.Sprintf("lock\nvolume.fsck -findMissingChunksInFiler -reallyDeleteFilerEntries -confirmMissingVolumeDeletion -volumeId=%s -cutoffTimeAgo=0\nunlock\n", volumeID))
if !strings.Contains(purgeOutput, "ResolveChunkManifest failed") {
t.Fatalf("fsck did not surface the unresolved manifest: %q", purgeOutput)
}
if !strings.Contains(purgeOutput, "deleting path /manifest-probe.txt") {
t.Fatalf("confirmed purge did not queue the entry whose manifest was on the retired volume: %q", purgeOutput)
}
manifestAfterPurge, err := http.Get(filerURL + "/manifest-probe.txt")
if err != nil {
t.Fatalf("read manifest probe after confirmed purge: %v", err)
}
manifestAfterPurge.Body.Close()
if manifestAfterPurge.StatusCode != http.StatusNotFound {
t.Fatalf("confirmed purge manifest probe GET status = %d, want 404", manifestAfterPurge.StatusCode)
}
if !strings.Contains(purgeOutput, "deleting path /fsck-probe.txt after volume not found") {
t.Fatalf("confirmed purge did not queue the direct missing-volume entry: %q", purgeOutput)
}
probeAfterPurge, err := http.Get(filerURL + "/fsck-probe.txt")
if err != nil {
t.Fatalf("read Filer probe after confirmed purge: %v", err)
}
probeAfterPurge.Body.Close()
if probeAfterPurge.StatusCode != http.StatusNotFound {
t.Fatalf("confirmed purge Filer GET status = %d, want 404", probeAfterPurge.StatusCode)
}
// A volume still listed by the master but whose server is unreachable is
// not proof that its references are stale. Purge must fail before touching
// the Filer entry when the volume index cannot be read.
offlineRequest, err := http.NewRequest(http.MethodPut, filerURL+"/offline-probe.txt", strings.NewReader("offline safety probe"))
if err != nil {
t.Fatal(err)
}
offlineResponse, err := http.DefaultClient.Do(offlineRequest)
if err != nil {
t.Fatalf("upload offline safety probe: %v", err)
}
offlineResponse.Body.Close()
if offlineResponse.StatusCode != http.StatusCreated && offlineResponse.StatusCode != http.StatusAccepted {
t.Fatalf("offline safety PUT status = %d", offlineResponse.StatusCode)
}
offlineMeta := runIntegrationShell(t, weed, masterAddress, filerAddress, "lock\nfs.meta.cat /offline-probe.txt\nunlock\n")
offlineMatch := fidPattern.FindStringSubmatch(offlineMeta)
if len(offlineMatch) != 2 {
t.Fatalf("could not read offline probe volume ID: %s", offlineMeta)
}
if err := volumeProcess.Process.Kill(); err != nil {
t.Fatalf("stop volume server: %v", err)
}
waitIntegrationTCPClosed(t, volumeHTTP)
shellCtx, cancelShell := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelShell()
fsckCmd := exec.CommandContext(shellCtx, weed, "shell", "-master="+masterAddress, "-filer="+filerAddress)
fsckCmd.Stdin = strings.NewReader(fmt.Sprintf("lock\nvolume.fsck -findMissingChunksInFiler -reallyDeleteFilerEntries -volumeId=%s -cutoffTimeAgo=0\nunlock\n", offlineMatch[1]))
fsckBytes, fsckErr := fsckCmd.CombinedOutput()
offlineFsckOutput := string(fsckBytes)
if fsckErr == nil {
t.Fatalf("fsck unexpectedly purged metadata for a volume server outage: %q", offlineFsckOutput)
}
if !strings.Contains(offlineFsckOutput, "confirmMissingVolumeDeletion") && !strings.Contains(offlineFsckOutput, "failed to collect file ids") {
t.Fatalf("fsck failed for an unexpected reason: err=%v output=%q", fsckErr, offlineFsckOutput)
}
offlineMetaAfter := runIntegrationShell(t, weed, masterAddress, filerAddress, "lock\nfs.meta.cat /offline-probe.txt\nunlock\n")
if !strings.Contains(offlineMetaAfter, "offline-probe.txt") {
t.Fatalf("offline-volume fsck removed the live Filer entry: %q", offlineMetaAfter)
}
}
func buildWeedForIntegration(t *testing.T) string {
t.Helper()
// Reuse the CI/integration binary when supplied; otherwise build this checkout.
if binary := os.Getenv("WEED_BINARY"); binary != "" {
if _, err := os.Stat(binary); err != nil {
t.Fatalf("WEED_BINARY=%q is unavailable: %v", binary, err)
}
return binary
}
_, source, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve integration test source path")
}
root := filepath.Clean(filepath.Join(filepath.Dir(source), "..", ".."))
binary := filepath.Join(t.TempDir(), "weed")
cmd := exec.Command("go", "build", "-o", binary, "./weed")
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build weed: %v\n%s", err, out)
}
return binary
}
// freeIntegrationPortPairs reserves HTTP ports with the CLI's conventional
// HTTP+10000 gRPC mapping. The low range also keeps the derived ports valid
// for weed shell's master/Filer client configuration.
func freeIntegrationPortPairs(t *testing.T, count int) [][2]int {
t.Helper()
var listeners []net.Listener
defer func() {
for _, listener := range listeners {
listener.Close()
}
}()
pairs := make([][2]int, 0, count)
for httpPort := 20000; httpPort < 30000 && len(pairs) < count; httpPort++ {
grpcPort := httpPort + 10000
httpListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", httpPort))
if err != nil {
continue
}
grpcListener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", grpcPort))
if err != nil {
httpListener.Close()
continue
}
listeners = append(listeners, httpListener, grpcListener)
pairs = append(pairs, [2]int{httpPort, grpcPort})
}
if len(pairs) != count {
t.Fatalf("reserved %d of %d required HTTP/gRPC port pairs", len(pairs), count)
}
return pairs
}
func startIntegrationProcess(t *testing.T, base, weed, name string, args ...string) *exec.Cmd {
t.Helper()
logFile, err := os.Create(filepath.Join(base, "logs", name+".log"))
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(weed, args...)
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
t.Fatalf("start %s: %v", name, err)
}
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
_ = logFile.Close()
})
return cmd
}
// waitForMasterVolumeRegistration prevents racing a successful HTTP listener
// start against the volume server's first heartbeat to the master.
func waitForMasterVolumeRegistration(t *testing.T, masterPort, volumePort int, logPath string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
wantAddress := fmt.Sprintf("127.0.0.1:%d", volumePort)
for time.Now().Before(deadline) {
response, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/dir/status", masterPort))
if err == nil {
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr == nil && bytes.Contains(body, []byte(wantAddress)) {
return
}
}
time.Sleep(100 * time.Millisecond)
}
log, _ := os.ReadFile(logPath)
t.Fatalf("master did not register volume server %s; log:\n%s", wantAddress, bytes.TrimSpace(log))
}
func waitIntegrationTCPClosed(t *testing.T, port int) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
if err != nil {
return
}
conn.Close()
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("service still listens on port %d after process was stopped", port)
}
func waitIntegrationTCP(t *testing.T, port int, logPath string) {
t.Helper()
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond)
if err == nil {
conn.Close()
return
}
time.Sleep(100 * time.Millisecond)
}
log, _ := os.ReadFile(logPath)
t.Fatalf("service did not listen on port %d; log:\n%s", port, bytes.TrimSpace(log))
}
func runIntegrationShell(t *testing.T, weed, master, filer, input string) string {
t.Helper()
cmd := exec.Command(weed, "shell", "-master="+master, "-filer="+filer)
cmd.Stdin = strings.NewReader(input)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("weed shell failed: %v\n%s", err, out)
}
return string(out)
}