fix(s3): preserve explicit directory markers during empty folder cleanup (#8831)
* fix(s3): preserve explicit directory markers during empty folder cleanup PR #8292 switched empty-folder cleanup from per-folder implicit checks to bucket-level policy, inadvertently dropping the check that preserved explicitly created directories (e.g., PUT /bucket/folder/). This caused user-created folders to be deleted when their last file was removed. Add IsDirectoryKeyObject check in executeCleanup to skip folders that have a MIME type set, matching the canonical pattern used throughout the S3 listing and delete handlers. * fix: handle ErrNotFound in IsDirectoryKeyObject for race safety Entry may be deleted between the emptiness check and the directory marker lookup. Treat not-found as false rather than propagating the error, avoiding unnecessary error logging in the cleanup path. * refactor: consolidate directory marker tests and tidy error handling - Combine two separate test functions into a table-driven test - Nest ErrNotFound check inside the err != nil block
This commit is contained in:
@@ -27,6 +27,7 @@ type FilerOperations interface {
|
|||||||
CountDirectoryEntries(ctx context.Context, dirPath util.FullPath, limit int) (count int, err error)
|
CountDirectoryEntries(ctx context.Context, dirPath util.FullPath, limit int) (count int, err error)
|
||||||
DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, ifNotModifiedAfter int64) error
|
DeleteEntryMetaAndData(ctx context.Context, p util.FullPath, isRecursive, ignoreRecursiveError, shouldDeleteChunks, isFromOtherCluster bool, signatures []int32, ifNotModifiedAfter int64) error
|
||||||
GetEntryAttributes(ctx context.Context, p util.FullPath) (attributes map[string][]byte, err error)
|
GetEntryAttributes(ctx context.Context, p util.FullPath) (attributes map[string][]byte, err error)
|
||||||
|
IsDirectoryKeyObject(ctx context.Context, p util.FullPath) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderState tracks the state of a folder for empty folder cleanup
|
// folderState tracks the state of a folder for empty folder cleanup
|
||||||
@@ -312,6 +313,16 @@ func (efc *EmptyFolderCleaner) executeCleanup(folder string, triggeredBy string)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip explicitly created directory markers (e.g., PUT /bucket/folder/)
|
||||||
|
// These have a MIME type set and should be preserved even when empty
|
||||||
|
if isKeyObj, err := efc.filer.IsDirectoryKeyObject(ctx, util.FullPath(folder)); err != nil {
|
||||||
|
glog.V(2).Infof("EmptyFolderCleaner: error checking directory key object %s: %v", folder, err)
|
||||||
|
return
|
||||||
|
} else if isKeyObj {
|
||||||
|
glog.V(3).Infof("EmptyFolderCleaner: skipping %s (triggered by %s), explicit directory marker", folder, triggeredBy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the empty folder
|
// Delete the empty folder
|
||||||
glog.Infof("EmptyFolderCleaner: deleting empty folder %s (triggered by %s)", folder, triggeredBy)
|
glog.Infof("EmptyFolderCleaner: deleting empty folder %s (triggered by %s)", folder, triggeredBy)
|
||||||
if err := efc.deleteFolder(ctx, folder); err != nil {
|
if err := efc.deleteFolder(ctx, folder); err != nil {
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type mockFilerOps struct {
|
type mockFilerOps struct {
|
||||||
countFn func(path util.FullPath) (int, error)
|
countFn func(path util.FullPath) (int, error)
|
||||||
deleteFn func(path util.FullPath) error
|
deleteFn func(path util.FullPath) error
|
||||||
attrsFn func(path util.FullPath) (map[string][]byte, error)
|
attrsFn func(path util.FullPath) (map[string][]byte, error)
|
||||||
|
isDirKeyObjFn func(path util.FullPath) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockFilerOps) CountDirectoryEntries(_ context.Context, dirPath util.FullPath, _ int) (int, error) {
|
func (m *mockFilerOps) CountDirectoryEntries(_ context.Context, dirPath util.FullPath, _ int) (int, error) {
|
||||||
@@ -38,6 +39,13 @@ func (m *mockFilerOps) GetEntryAttributes(_ context.Context, p util.FullPath) (m
|
|||||||
return m.attrsFn(p)
|
return m.attrsFn(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockFilerOps) IsDirectoryKeyObject(_ context.Context, p util.FullPath) (bool, error) {
|
||||||
|
if m.isDirKeyObjFn == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return m.isDirKeyObjFn(p)
|
||||||
|
}
|
||||||
|
|
||||||
func Test_isUnderPath(t *testing.T) {
|
func Test_isUnderPath(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -733,3 +741,70 @@ func TestEmptyFolderCleaner_executeCleanup_bucketPolicyDisabledSkips(t *testing.
|
|||||||
t.Fatalf("expected folder %s to be skipped, got deletions %v", folder, deleted)
|
t.Fatalf("expected folder %s to be skipped, got deletions %v", folder, deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmptyFolderCleaner_executeCleanup_directoryMarker(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
isDirKeyObj bool
|
||||||
|
expectDeletion bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "skips explicit directory marker",
|
||||||
|
isDirKeyObj: true,
|
||||||
|
expectDeletion: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deletes implicit empty folder",
|
||||||
|
isDirKeyObj: false,
|
||||||
|
expectDeletion: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
lockRing := lock_manager.NewLockRing(5 * time.Second)
|
||||||
|
lockRing.SetSnapshot([]pb.ServerAddress{"filer1:8888"})
|
||||||
|
|
||||||
|
var deleted []string
|
||||||
|
mock := &mockFilerOps{
|
||||||
|
countFn: func(_ util.FullPath) (int, error) {
|
||||||
|
return 0, nil
|
||||||
|
},
|
||||||
|
deleteFn: func(path util.FullPath) error {
|
||||||
|
deleted = append(deleted, string(path))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
isDirKeyObjFn: func(path util.FullPath) (bool, error) {
|
||||||
|
return tc.isDirKeyObj, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaner := &EmptyFolderCleaner{
|
||||||
|
filer: mock,
|
||||||
|
lockRing: lockRing,
|
||||||
|
host: "filer1:8888",
|
||||||
|
bucketPath: "/buckets",
|
||||||
|
enabled: true,
|
||||||
|
folderCounts: make(map[string]*folderState),
|
||||||
|
cleanupQueue: NewCleanupQueue(1000, time.Minute),
|
||||||
|
maxCountCheck: 1000,
|
||||||
|
cacheExpiry: time.Minute,
|
||||||
|
processorSleep: time.Second,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
folder := "/buckets/test/folder"
|
||||||
|
cleaner.executeCleanup(folder, "triggered_item")
|
||||||
|
|
||||||
|
if tc.expectDeletion {
|
||||||
|
if len(deleted) != 1 || deleted[0] != folder {
|
||||||
|
t.Fatalf("expected implicit empty folder %s to be deleted, got deletions %v", folder, deleted)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(deleted) != 0 {
|
||||||
|
t.Fatalf("expected explicit directory marker %s to be preserved, got deletions %v", folder, deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -559,3 +559,17 @@ func (f *Filer) GetEntryAttributes(ctx context.Context, p util.FullPath) (map[st
|
|||||||
}
|
}
|
||||||
return entry.Extended, nil
|
return entry.Extended, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Filer) IsDirectoryKeyObject(ctx context.Context, p util.FullPath) (bool, error) {
|
||||||
|
entry, err := f.FindEntry(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, filer_pb.ErrNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if entry == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return entry.IsDirectory() && entry.Mime != "", nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user