Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recursively delete empty parent folders #94

Merged
merged 3 commits into from
Jul 19, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions localfs/emptydir_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package localfs

import (
"os"
"path"
"testing"

"github.com/stretchr/testify/require"
)

func TestDirectoryCleanup(t *testing.T) {
testDir, err := os.MkdirTemp("/tmp", "dirtest")
require.NoError(t, err)
defer os.RemoveAll(testDir)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NIT] t.TempDir() does all this for us IIRC:

Suggested change
testDir, err := os.MkdirTemp("/tmp", "dirtest")
require.NoError(t, err)
defer os.RemoveAll(testDir)
testDir := t.TempDir()


makeDummyFile := func(filePath string) string {
fullPath := path.Join(testDir, filePath)
dir := path.Dir(fullPath)
require.NotEmpty(t, dir)
err := os.MkdirAll(dir, 0755)
require.NoError(t, err)
err = os.WriteFile(fullPath, []byte("don't delete this folder"), 0755)
require.NoError(t, err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're using subtests below I believe you need to accept t testing.TB as an argument, otherwise you'll reference the wrong *testing.T.

return fullPath
}

fileExists := func(filePath string) bool {
_, err := os.Stat(filePath)
if err == nil {
return true
}
if os.IsNotExist(err) {
return false
}
require.FailNow(t, "failed to get status of file %s", filePath)
return false
}

require.False(t, fileExists("/heythisdoesntexist/overhere"))

// /testDir
// a/
// dummyfile3
// b/
// c/
// dummyfile1
// dummyfile2
// d/
// dummyfile4

d1 := makeDummyFile("a/b/c/dummyfile1")
d2 := makeDummyFile("a/b/c/dummyfile2")
d3 := makeDummyFile("a/dummyfile3")
d4 := makeDummyFile("a/d/dummyfile4")

l := &LocalStore{}

t.Run("delete-nonempty-dir", func(t *testing.T) {
err = l.deleteParentDirs(path.Join(testDir, "a/d"))
require.NoError(t, err)
require.True(t, fileExists(d1))
require.True(t, fileExists(d2))
require.True(t, fileExists(d3))
require.True(t, fileExists(d4))
})

t.Run("delete-nonempty-nested-child-dir", func(t *testing.T) {
err = l.deleteParentDirs(path.Join(testDir, "a/b/c"))
require.NoError(t, err)
require.True(t, fileExists(d1))
require.True(t, fileExists(d2))
require.True(t, fileExists(d3))
require.True(t, fileExists(d4))
})

t.Run("delete-nonempty-nested-parent-dir", func(t *testing.T) {
err = l.deleteParentDirs(path.Join(testDir, "a/b"))
require.NoError(t, err)
require.True(t, fileExists(d1))
require.True(t, fileExists(d2))
require.True(t, fileExists(d3))
require.True(t, fileExists(d4))
})

require.NoError(t, os.Remove(d4))

t.Run("delete-empty-dir", func(t *testing.T) {
err = l.deleteParentDirs(d4)
require.NoError(t, err)
require.True(t, fileExists(d1))
require.True(t, fileExists(d2))
require.True(t, fileExists(d3))
require.False(t, fileExists(d4))
require.False(t, fileExists(path.Join(testDir, "a/d")))
})

require.NoError(t, os.Remove(d1))
require.NoError(t, os.Remove(d2))

t.Run("delete-empty-nested-dir", func(t *testing.T) {
err = l.deleteParentDirs(d2)
require.NoError(t, err)
require.False(t, fileExists(d1))
require.False(t, fileExists(d2))
require.False(t, fileExists(path.Join(testDir, "a/b/c")))
require.False(t, fileExists(path.Join(testDir, "a/b")))
require.True(t, fileExists(d3))
require.False(t, fileExists(d4))
require.False(t, fileExists(path.Join(testDir, "a/d")))
})
}
43 changes: 29 additions & 14 deletions localfs/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,31 +280,46 @@ func (l *LocalStore) Get(ctx context.Context, o string) (cloudstorage.Object, er
func (l *LocalStore) Delete(ctx context.Context, obj string) error {
fo := path.Join(l.storepath, obj)
if err := os.Remove(fo); err != nil {
return fmt.Errorf("removing dir=%s: %w", fo, err)
return fmt.Errorf("removing file=%s: %w", fo, err)
}
mf := fo + ".metadata"
if cloudstorage.Exists(mf) {
if err := os.Remove(mf); err != nil {
return fmt.Errorf("removing dir=%s: %w", mf, err)
return fmt.Errorf("removing file=%s: %w", mf, err)
}
}

// When the last item in a folder is deleted, the folder
// should also be deleted. This matches the behavior in GCS.
dir, err := os.Open(l.storepath)
if err != nil {
return fmt.Errorf("failed to open store dir=%s err=%w", l.storepath, err)
}
if _, err = dir.Readdirnames(1); errors.Is(err, io.EOF) {
dir.Close()
// it's empty, so remove it.
if err := os.Remove(l.storepath); err != nil {
return fmt.Errorf("failed to remove store dir=%s err=%w", l.storepath, err)
return l.deleteParentDirs(fo)
}

// deleteParentDirs deletes all the parent dirs of some filepath
// if those dirs are empty.
func (l *LocalStore) deleteParentDirs(filePath string) error {

for dirName := path.Dir(filePath); len(dirName) > 0; dirName = path.Dir(dirName) {
dir, err := os.Open(dirName)
if os.IsNotExist(err) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block has race conditions between checking existence and emptiness, which is unnecessary as os.Remove() errors on non-empty dirs.

Just try to os.Remove() every parent dir, ignoring the dir already not existing or not being empty (ceasing looping if you hit a non-empty error)

// it's already deleted; nothing to do.
return nil
}
if err != nil {
return fmt.Errorf("failed to open store dir=%s err=%w", dirName, err)
}
files, err := dir.Readdirnames(1)
if len(files) == 0 && errors.Is(err, io.EOF) {
dir.Close()
// it's empty, so remove it.
if err := os.Remove(dirName); err != nil {
return fmt.Errorf("failed to remove store dir=%s err=%w", dirName, err)
}
} else {
dir.Close()
// it's not empty.
return nil
}
} else {
dir.Close()
}

return nil
}

Expand Down
5 changes: 5 additions & 0 deletions testutils/testutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,11 @@ func BasicRW(t TestingT, store cloudstorage.Store) {
assert.Equal(t, cloudstorage.ErrObjectNotFound, err)
assert.Equal(t, nil, obj)

// Store should be empty again
all, err = store.List(context.Background(), cloudstorage.NewQueryAll())
assert.NoError(t, err)
assert.NotNil(t, all)
assert.Empty(t, all.Objects)
}

func createFile(t TestingT, store cloudstorage.Store, name, data string) cloudstorage.Object {
Expand Down