mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 18:47:41 +01:00
* 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>
517 lines
17 KiB
Go
517 lines
17 KiB
Go
package shell
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle_map"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
)
|
|
|
|
func TestVolumeFsckCanPurgeDirectory(t *testing.T) {
|
|
testCases := []struct {
|
|
dir util.FullPath
|
|
expected bool
|
|
}{
|
|
{"/orphan/dir/deep", true},
|
|
{"/orphan", true},
|
|
{"/", false},
|
|
{"/buckets", false},
|
|
{"/buckets/bucket1", false},
|
|
{"/buckets/bucket1/dir", true},
|
|
}
|
|
for _, tc := range testCases {
|
|
c := &commandVolumeFsck{bucketsPath: "/buckets"}
|
|
if actual := c.canPurgeDirectory(tc.dir); actual != tc.expected {
|
|
t.Errorf("canPurgeDirectory(%s) = %v, expected %v", tc.dir, actual, tc.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVolumeFsckHttpDeleteRecordsParentDirectory(t *testing.T) {
|
|
filer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/locked/") {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer filer.Close()
|
|
|
|
verbose := false
|
|
var output bytes.Buffer
|
|
c := &commandVolumeFsck{
|
|
verbose: &verbose,
|
|
writer: &output,
|
|
purgedDirs: make(map[util.FullPath]struct{}),
|
|
env: &CommandEnv{option: &ShellOptions{FilerAddress: pb.ServerAddress(strings.TrimPrefix(filer.URL, "http://"))}},
|
|
}
|
|
|
|
c.httpDelete("/orphan/dir/deep/file.txt")
|
|
c.httpDelete("/locked/dir/file.txt")
|
|
|
|
if _, found := c.purgedDirs["/orphan/dir/deep"]; !found {
|
|
t.Errorf("expected /orphan/dir/deep to be recorded, got %v", c.purgedDirs)
|
|
}
|
|
if _, found := c.purgedDirs["/locked/dir"]; found {
|
|
t.Errorf("expected a rejected delete not to be recorded, got %v", c.purgedDirs)
|
|
}
|
|
if got := output.String(); got != "DELETE /locked/dir/file.txt failed: 403 Forbidden\n" {
|
|
t.Errorf("delete output = %q, want only the rejected path", got)
|
|
}
|
|
}
|
|
|
|
// stubFilerServer keeps a flat set of full paths and enforces the same delete
|
|
// rules as the filer: a directory with children is kept, and so is one modified
|
|
// after the caller looked it up. A path in writtenAfterLookup is modified by a
|
|
// client right after fsck reads it.
|
|
type stubFilerServer struct {
|
|
filer_pb.UnimplementedSeaweedFilerServer
|
|
entries map[string]*filer_pb.Entry
|
|
writtenAfterLookup map[string]bool
|
|
deleted []string
|
|
}
|
|
|
|
func (s *stubFilerServer) LookupDirectoryEntry(ctx context.Context, req *filer_pb.LookupDirectoryEntryRequest) (*filer_pb.LookupDirectoryEntryResponse, error) {
|
|
fullPath := string(util.NewFullPath(req.Directory, req.Name))
|
|
entry := s.entries[fullPath]
|
|
if entry != nil && s.writtenAfterLookup[fullPath] {
|
|
seen := &filer_pb.Entry{IsDirectory: entry.IsDirectory, Attributes: &filer_pb.FuseAttributes{Mtime: entry.Attributes.GetMtime()}}
|
|
entry.Attributes.Mtime++
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: seen}, nil
|
|
}
|
|
return &filer_pb.LookupDirectoryEntryResponse{Entry: entry}, nil
|
|
}
|
|
|
|
func (s *stubFilerServer) ListEntries(req *filer_pb.ListEntriesRequest, stream filer_pb.SeaweedFiler_ListEntriesServer) error {
|
|
var names []string
|
|
children := make(map[string]*filer_pb.Entry)
|
|
for path, entry := range s.entries {
|
|
parent, name := util.FullPath(path).DirAndName()
|
|
if parent != req.Directory || name == "" {
|
|
continue
|
|
}
|
|
if name < req.StartFromFileName || (name == req.StartFromFileName && !req.InclusiveStartFrom) {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
children[name] = entry
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
stored := children[name]
|
|
entry := &filer_pb.Entry{
|
|
Name: name,
|
|
IsDirectory: stored.IsDirectory,
|
|
Chunks: stored.Chunks,
|
|
Attributes: stored.Attributes,
|
|
HardLinkId: stored.HardLinkId,
|
|
}
|
|
if err := stream.Send(&filer_pb.ListEntriesResponse{Entry: entry}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *stubFilerServer) DeleteEntry(ctx context.Context, req *filer_pb.DeleteEntryRequest) (*filer_pb.DeleteEntryResponse, error) {
|
|
fullPath := string(util.NewFullPath(req.Directory, req.Name))
|
|
entry, found := s.entries[fullPath]
|
|
if !found {
|
|
return &filer_pb.DeleteEntryResponse{Error: filer_pb.ErrNotFound.Error()}, nil
|
|
}
|
|
entryNewer := false
|
|
if req.IfNotModifiedAfterNs > 0 {
|
|
entryNewer = entry.Attributes.GetMtime()*1e9+int64(entry.Attributes.GetMtimeNs()) > req.IfNotModifiedAfterNs
|
|
} else if req.IfNotModifiedAfter > 0 {
|
|
entryNewer = entry.Attributes.GetMtime() > req.IfNotModifiedAfter
|
|
}
|
|
if entryNewer {
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
for path := range s.entries {
|
|
if strings.HasPrefix(path, fullPath+"/") {
|
|
return &filer_pb.DeleteEntryResponse{Error: filer.MsgFailDelNonEmptyFolder + ": " + fullPath}, nil
|
|
}
|
|
}
|
|
delete(s.entries, fullPath)
|
|
s.deleted = append(s.deleted, fullPath)
|
|
return &filer_pb.DeleteEntryResponse{}, nil
|
|
}
|
|
|
|
// startStubFiler serves stub on a random localhost port and returns the shell
|
|
// environment whose filer client reaches it.
|
|
func startStubFiler(t *testing.T, stub *stubFilerServer) *CommandEnv {
|
|
t.Helper()
|
|
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
srv := grpc.NewServer()
|
|
filer_pb.RegisterSeaweedFilerServer(srv, stub)
|
|
go srv.Serve(lis)
|
|
t.Cleanup(srv.Stop)
|
|
return &CommandEnv{option: &ShellOptions{
|
|
FilerAddress: pb.ServerAddress(fmt.Sprintf("127.0.0.1:1.%d", lis.Addr().(*net.TCPAddr).Port)),
|
|
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
}}
|
|
}
|
|
|
|
func TestVolumeFsckPurgeEmptyDirectories(t *testing.T) {
|
|
settled := &filer_pb.FuseAttributes{Mtime: time.Now().Add(-time.Hour).Unix()}
|
|
directory := &filer_pb.Entry{IsDirectory: true, Attributes: settled}
|
|
stub := &stubFilerServer{entries: map[string]*filer_pb.Entry{
|
|
"/orphan": directory,
|
|
"/orphan/dir": directory,
|
|
"/orphan/dir/deep": directory,
|
|
"/orphan/dir/wide": directory,
|
|
"/keep": directory,
|
|
"/keep/file.txt": {},
|
|
"/buckets": directory,
|
|
"/buckets/bucket1": directory,
|
|
"/buckets/bucket1/folder": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: "application/octet-stream", Mtime: settled.Mtime}},
|
|
// no mtime to condition a delete on, so it stays
|
|
"/nomtime": {IsDirectory: true},
|
|
"/nomtime/dir": {IsDirectory: true},
|
|
}}
|
|
|
|
verbose := false
|
|
c := &commandVolumeFsck{
|
|
verbose: &verbose,
|
|
writer: io.Discard,
|
|
bucketsPath: "/buckets",
|
|
purgedDirs: map[util.FullPath]struct{}{
|
|
"/orphan/dir/deep": {},
|
|
"/orphan/dir/wide": {},
|
|
"/keep": {},
|
|
"/buckets/bucket1/folder": {},
|
|
"/nomtime/dir": {},
|
|
},
|
|
env: startStubFiler(t, stub),
|
|
}
|
|
|
|
c.purgeEmptyDirectories()
|
|
|
|
// the two emptied leaves, then the parent they shared, then its own parent
|
|
expected := []string{"/orphan/dir/deep", "/orphan/dir/wide", "/orphan/dir", "/orphan"}
|
|
sort.Strings(expected)
|
|
sort.Strings(stub.deleted)
|
|
if strings.Join(stub.deleted, ",") != strings.Join(expected, ",") {
|
|
t.Errorf("deleted %v, expected %v", stub.deleted, expected)
|
|
}
|
|
}
|
|
|
|
func TestVolumeFsckPurgeEmptyDirectoriesKeepsFreshDirectory(t *testing.T) {
|
|
stub := &stubFilerServer{entries: map[string]*filer_pb.Entry{
|
|
"/fresh": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}},
|
|
"/fresh/dir": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix()}},
|
|
}}
|
|
|
|
verbose := false
|
|
c := &commandVolumeFsck{
|
|
verbose: &verbose,
|
|
writer: io.Discard,
|
|
bucketsPath: "/buckets",
|
|
purgedDirs: map[util.FullPath]struct{}{"/fresh/dir": {}},
|
|
env: startStubFiler(t, stub),
|
|
}
|
|
|
|
c.purgeEmptyDirectories()
|
|
|
|
if len(stub.deleted) > 0 {
|
|
t.Errorf("deleted %v, expected a directory modified within the quiet period to be kept", stub.deleted)
|
|
}
|
|
}
|
|
|
|
func TestVolumeFsckPurgeEmptyDirectoriesKeepsChangedDirectory(t *testing.T) {
|
|
stub := &stubFilerServer{
|
|
entries: map[string]*filer_pb.Entry{
|
|
"/race": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Add(-time.Hour).Unix()}},
|
|
"/race/dir": {IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Add(-time.Hour).Unix()}},
|
|
},
|
|
writtenAfterLookup: map[string]bool{"/race/dir": true},
|
|
}
|
|
|
|
verbose := false
|
|
c := &commandVolumeFsck{
|
|
verbose: &verbose,
|
|
writer: io.Discard,
|
|
bucketsPath: "/buckets",
|
|
purgedDirs: map[util.FullPath]struct{}{"/race/dir": {}},
|
|
env: startStubFiler(t, stub),
|
|
}
|
|
|
|
c.purgeEmptyDirectories()
|
|
|
|
if len(stub.deleted) > 0 {
|
|
t.Errorf("deleted %v, expected a directory written to after the lookup to be kept", stub.deleted)
|
|
}
|
|
}
|
|
|
|
// The purge pool may run at most `workers` deletes at once, and at least one
|
|
// even when the flag is set to zero.
|
|
func TestVolumeFsckPurgeWorkerLimit(t *testing.T) {
|
|
gate := make(chan struct{})
|
|
var inFlight, maxSeen, processed atomic.Int32
|
|
queue, finish := startPurgeWorkers(3, func(util.FullPath) {
|
|
n := inFlight.Add(1)
|
|
for {
|
|
m := maxSeen.Load()
|
|
if n <= m || maxSeen.CompareAndSwap(m, n) {
|
|
break
|
|
}
|
|
}
|
|
<-gate
|
|
inFlight.Add(-1)
|
|
processed.Add(1)
|
|
})
|
|
|
|
for i := 0; i < 10; i++ {
|
|
queue(purgeItem{path: util.FullPath(fmt.Sprintf("/p/%d", i))})
|
|
}
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for inFlight.Load() < 3 && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if got := inFlight.Load(); got != 3 {
|
|
t.Fatalf("in-flight deletes %d, want the 3-worker limit", got)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
if got := maxSeen.Load(); got > 3 {
|
|
t.Fatalf("saw %d concurrent deletes, want at most 3", got)
|
|
}
|
|
close(gate)
|
|
finish()
|
|
if got := processed.Load(); got != 10 {
|
|
t.Fatalf("processed %d deletes, want all 10 drained", got)
|
|
}
|
|
}
|
|
|
|
func TestVolumeFsckPurgeWorkersAtLeastOne(t *testing.T) {
|
|
var processed atomic.Int32
|
|
queue, finish := startPurgeWorkers(0, func(util.FullPath) { processed.Add(1) })
|
|
for i := 0; i < 5; i++ {
|
|
queue(purgeItem{path: util.FullPath(fmt.Sprintf("/p/%d", i))})
|
|
}
|
|
finish()
|
|
if got := processed.Load(); got != 5 {
|
|
t.Fatalf("processed %d deletes, want 5", got)
|
|
}
|
|
}
|
|
|
|
// Deletes of paths sharing a hard link id must never overlap: the filer
|
|
// decrements a shared counter non-atomically, so concurrent deletes of the
|
|
// same id would both read N and write back N-1.
|
|
func TestVolumeFsckPurgeHardLinksSerialized(t *testing.T) {
|
|
var inFlight, maxSeen, processed atomic.Int32
|
|
gate := make(chan struct{})
|
|
queue, finish := startPurgeWorkers(8, func(util.FullPath) {
|
|
n := inFlight.Add(1)
|
|
for {
|
|
m := maxSeen.Load()
|
|
if n <= m || maxSeen.CompareAndSwap(m, n) {
|
|
break
|
|
}
|
|
}
|
|
<-gate
|
|
inFlight.Add(-1)
|
|
processed.Add(1)
|
|
})
|
|
for i := 0; i < 8; i++ {
|
|
queue(purgeItem{path: util.FullPath(fmt.Sprintf("/hl/%d", i)), hardLinkId: []byte{9, 9, 9}})
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
if got := maxSeen.Load(); got > 1 {
|
|
t.Fatalf("saw %d concurrent deletes on one hard link id, want 1", got)
|
|
}
|
|
close(gate)
|
|
finish()
|
|
if got := processed.Load(); got != 8 {
|
|
t.Fatalf("processed %d deletes, want 8", got)
|
|
}
|
|
}
|
|
|
|
// A file outside the selected volumes' bucket can still reference their
|
|
// chunks — a filer can bind a collection to any path — so the filer walk must
|
|
// reach them or their live chunks would be reported as orphans.
|
|
func TestVolumeFsckCollectFilerFileIdsReachesOutsideBucket(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
chunk := func(fileKey uint64) *filer_pb.FileChunk {
|
|
return &filer_pb.FileChunk{Size: 1, Fid: &filer_pb.FileId{VolumeId: 7, FileKey: fileKey, Cookie: 1}}
|
|
}
|
|
directory := &filer_pb.Entry{IsDirectory: true}
|
|
stub := &stubFilerServer{entries: map[string]*filer_pb.Entry{
|
|
"/": directory,
|
|
"/buckets": directory,
|
|
"/buckets/col1": directory,
|
|
"/buckets/col1/object": {Chunks: []*filer_pb.FileChunk{chunk(11)}},
|
|
"/topics": directory,
|
|
"/topics/system-log": {Chunks: []*filer_pb.FileChunk{chunk(22)}},
|
|
}}
|
|
|
|
verbose, findMissing, verify, force := false, false, false, false
|
|
c := &commandVolumeFsck{
|
|
tempFolder: tempDir,
|
|
verbose: &verbose,
|
|
findMissingChunksInFiler: &findMissing,
|
|
verifyNeedle: &verify,
|
|
forcePurging: &force,
|
|
writer: io.Discard,
|
|
bucketsPath: "/buckets",
|
|
env: startStubFiler(t, stub),
|
|
}
|
|
|
|
vinfo := map[string]map[uint32]VInfo{"dn1": {7: {collection: "col1"}}}
|
|
if err := c.collectFilerFileIdAndPaths(vinfo, false, func(purgeItem) {}, 0, 0); err != nil {
|
|
t.Fatalf("collect filer file ids: %v", err)
|
|
}
|
|
|
|
db := needle_map.NewMemDb()
|
|
for _, needleId := range []types.NeedleId{11, 22, 33} {
|
|
if err := db.Set(needleId, types.ToOffset(16), types.Size(1)); err != nil {
|
|
t.Fatalf("seed volume idx: %v", err)
|
|
}
|
|
}
|
|
if err := db.SaveToIdx(getVolumeFileIdFile(tempDir, "dn1", 7)); err != nil {
|
|
t.Fatalf("write volume idx: %v", err)
|
|
}
|
|
db.Close()
|
|
|
|
inUseCount, orphanFileIds, _, err := c.oneVolumeFileIdsSubtractFilerFileIds("dn1", 7, &VInfo{collection: "col1"}, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("subtract filer file ids: %v", err)
|
|
}
|
|
if inUseCount != 2 {
|
|
t.Fatalf("in-use count %d, want 2 (the in-bucket and out-of-bucket references)", inUseCount)
|
|
}
|
|
if len(orphanFileIds) != 1 {
|
|
t.Fatalf("orphans %v, want only the unreferenced needle", orphanFileIds)
|
|
}
|
|
if want := types.NeedleId(33).FileId(7); orphanFileIds[0] != want {
|
|
t.Fatalf("orphan %s, want the unreferenced needle %s", orphanFileIds[0], want)
|
|
}
|
|
}
|
|
|
|
// Orphans found by the per-volume check go through the same purge queue as
|
|
// the missing-volume path, and the fid file keeps each entry's hard link id
|
|
// so same-id deletes still land on one worker.
|
|
func TestVolumeFsckCheckOneVolumeQueuesOrphansWithHardLinkId(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
var verbose bool
|
|
var buf bytes.Buffer
|
|
c := &commandVolumeFsck{tempFolder: tempDir, verbose: &verbose, writer: &buf}
|
|
|
|
volumeId := uint32(7)
|
|
db := needle_map.NewMemDb()
|
|
if err := db.Set(types.NeedleId(11), types.ToOffset(16), types.Size(1)); err != nil {
|
|
t.Fatalf("seed volume idx: %v", err)
|
|
}
|
|
if err := db.SaveToIdx(getVolumeFileIdFile(tempDir, "dn1", volumeId)); err != nil {
|
|
t.Fatalf("write volume idx: %v", err)
|
|
}
|
|
db.Close()
|
|
|
|
f, err := os.Create(getFilerFileIdFile(tempDir, volumeId))
|
|
if err != nil {
|
|
t.Fatalf("create fid file: %v", err)
|
|
}
|
|
writeFid := func(fileKey uint64, cookie uint32, p string, hardLinkId []byte) {
|
|
buffer := make([]byte, readbufferSize)
|
|
util.Uint64toBytes(buffer, fileKey)
|
|
util.Uint32toBytes(buffer[8:], cookie)
|
|
util.Uint32toBytes(buffer[12:], uint32(len(p)))
|
|
if _, err := f.Write(buffer); err != nil {
|
|
t.Fatalf("write fid header: %v", err)
|
|
}
|
|
if _, err := f.Write([]byte(p)); err != nil {
|
|
t.Fatalf("write fid path: %v", err)
|
|
}
|
|
util.Uint32toBytes(buffer[:4], uint32(len(hardLinkId)))
|
|
if _, err := f.Write(buffer[:4]); err != nil {
|
|
t.Fatalf("write fid hard link id size: %v", err)
|
|
}
|
|
if _, err := f.Write(hardLinkId); err != nil {
|
|
t.Fatalf("write fid hard link id: %v", err)
|
|
}
|
|
}
|
|
writeFid(11, 1, "/keep/a", nil)
|
|
writeFid(22, 2, "/orphan/b", []byte{1, 2, 3})
|
|
writeFid(33, 3, "/orphan/c", nil)
|
|
if err := f.Close(); err != nil {
|
|
t.Fatalf("close fid file: %v", err)
|
|
}
|
|
|
|
var queued []purgeItem
|
|
if err := c.oneVolumeFileIdsCheckOneVolume("dn1", volumeId, func(it purgeItem) {
|
|
queued = append(queued, it)
|
|
}); err != nil {
|
|
t.Fatalf("check volume: %v", err)
|
|
}
|
|
if len(queued) != 2 {
|
|
t.Fatalf("queued %d purges, want 2 (the two orphans): %v", len(queued), queued)
|
|
}
|
|
if queued[0].path != "/orphan/b" || queued[1].path != "/orphan/c" {
|
|
t.Fatalf("queued paths %v, want /orphan/b then /orphan/c", queued)
|
|
}
|
|
if !bytes.Equal(queued[0].hardLinkId, []byte{1, 2, 3}) {
|
|
t.Fatalf("queued hardLinkId %v, want [1 2 3]", queued[0].hardLinkId)
|
|
}
|
|
if queued[1].hardLinkId != nil {
|
|
t.Fatalf("queued hardLinkId %v, want nil", queued[1].hardLinkId)
|
|
}
|
|
}
|
|
|
|
// A .fid file cut short inside the hard link id field is corrupt input, not a
|
|
// record without a link id: the reader must fail instead of emitting the entry
|
|
// with a nil id.
|
|
func TestReadFilerFileIdFileRejectsTruncatedHardLinkId(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
var verbose bool
|
|
c := &commandVolumeFsck{tempFolder: tempDir, verbose: &verbose, writer: io.Discard}
|
|
|
|
f, err := os.Create(getFilerFileIdFile(tempDir, 7))
|
|
if err != nil {
|
|
t.Fatalf("create fid file: %v", err)
|
|
}
|
|
buffer := make([]byte, readbufferSize)
|
|
util.Uint64toBytes(buffer, 11)
|
|
util.Uint32toBytes(buffer[8:], 1)
|
|
util.Uint32toBytes(buffer[12:], uint32(len("/orphan/b")))
|
|
if _, err := f.Write(buffer); err != nil {
|
|
t.Fatalf("write fid header: %v", err)
|
|
}
|
|
if _, err := f.Write([]byte("/orphan/b")); err != nil {
|
|
t.Fatalf("write fid path: %v", err)
|
|
}
|
|
if _, err := f.Write([]byte{0, 0}); err != nil {
|
|
t.Fatalf("write partial hard link id size: %v", err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatalf("close fid file: %v", err)
|
|
}
|
|
|
|
err = c.readFilerFileIdFile(7, func(types.NeedleId, util.FullPath, []byte) {
|
|
t.Fatal("callback fired for a truncated record")
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected an error for a truncated hard link id size")
|
|
}
|
|
}
|