Skip to content

Commit 1c8a12f

Browse files
erinpentecostErin Pentecost
andauthored
Recursively delete empty parent folders (#94)
* Recursively delete empty parent folders * Address race condition and escape for store dir * Address some PR comments Co-authored-by: Erin Pentecost <erin.pentecost@lytics.com>
1 parent cb860a0 commit 1c8a12f

3 files changed

Lines changed: 154 additions & 14 deletions

File tree

localfs/emptydir_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package localfs
2+
3+
import (
4+
"os"
5+
"path"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestDirectoryCleanup(t *testing.T) {
12+
testDir := t.TempDir()
13+
14+
makeDummyFile := func(t *testing.T, filePath string) string {
15+
fullPath := path.Join(testDir, filePath)
16+
dir := path.Dir(fullPath)
17+
require.NotEmpty(t, dir)
18+
err := os.MkdirAll(dir, 0755)
19+
require.NoError(t, err)
20+
err = os.WriteFile(fullPath, []byte("don't delete this folder"), 0755)
21+
require.NoError(t, err)
22+
return fullPath
23+
}
24+
25+
fileExists := func(t *testing.T, filePath string) bool {
26+
_, err := os.Stat(filePath)
27+
if err == nil {
28+
return true
29+
}
30+
if os.IsNotExist(err) {
31+
return false
32+
}
33+
require.FailNow(t, "failed to get status of file %s", filePath)
34+
return false
35+
}
36+
37+
require.False(t, fileExists(t, "/heythisdoesntexist/overhere"))
38+
39+
// /testDir
40+
// a/
41+
// dummyfile3
42+
// b/
43+
// c/
44+
// dummyfile1
45+
// dummyfile2
46+
// d/
47+
// dummyfile4
48+
49+
d1 := makeDummyFile(t, "a/b/c/dummyfile1")
50+
d2 := makeDummyFile(t, "a/b/c/dummyfile2")
51+
d3 := makeDummyFile(t, "a/dummyfile3")
52+
d4 := makeDummyFile(t, "a/d/dummyfile4")
53+
54+
l := &LocalStore{storepath: testDir}
55+
56+
t.Run("delete-nonempty-dir", func(t *testing.T) {
57+
err := l.deleteParentDirs(path.Join(testDir, "a/d"))
58+
require.NoError(t, err)
59+
require.True(t, fileExists(t, d1))
60+
require.True(t, fileExists(t, d2))
61+
require.True(t, fileExists(t, d3))
62+
require.True(t, fileExists(t, d4))
63+
})
64+
65+
t.Run("delete-nonempty-nested-child-dir", func(t *testing.T) {
66+
err := l.deleteParentDirs(path.Join(testDir, "a/b/c"))
67+
require.NoError(t, err)
68+
require.True(t, fileExists(t, d1))
69+
require.True(t, fileExists(t, d2))
70+
require.True(t, fileExists(t, d3))
71+
require.True(t, fileExists(t, d4))
72+
})
73+
74+
t.Run("delete-nonempty-nested-parent-dir", func(t *testing.T) {
75+
err := l.deleteParentDirs(path.Join(testDir, "a/b"))
76+
require.NoError(t, err)
77+
require.True(t, fileExists(t, d1))
78+
require.True(t, fileExists(t, d2))
79+
require.True(t, fileExists(t, d3))
80+
require.True(t, fileExists(t, d4))
81+
})
82+
83+
require.NoError(t, os.Remove(d4))
84+
85+
t.Run("delete-empty-dir", func(t *testing.T) {
86+
err := l.deleteParentDirs(d4)
87+
require.NoError(t, err)
88+
require.True(t, fileExists(t, d1))
89+
require.True(t, fileExists(t, d2))
90+
require.True(t, fileExists(t, d3))
91+
require.False(t, fileExists(t, d4))
92+
require.False(t, fileExists(t, path.Join(testDir, "a/d")))
93+
})
94+
95+
require.NoError(t, os.Remove(d1))
96+
require.NoError(t, os.Remove(d2))
97+
98+
t.Run("delete-empty-nested-dir", func(t *testing.T) {
99+
err := l.deleteParentDirs(d2)
100+
require.NoError(t, err)
101+
require.False(t, fileExists(t, d1))
102+
require.False(t, fileExists(t, d2))
103+
require.False(t, fileExists(t, path.Join(testDir, "a/b/c")))
104+
require.False(t, fileExists(t, path.Join(testDir, "a/b")))
105+
require.True(t, fileExists(t, d3))
106+
require.False(t, fileExists(t, d4))
107+
require.False(t, fileExists(t, path.Join(testDir, "a/d")))
108+
})
109+
110+
t.Run("delete-missing-dir", func(t *testing.T) {
111+
err := l.deleteParentDirs(path.Join(testDir, "doesntexist/what"))
112+
require.NoError(t, err)
113+
})
114+
115+
require.True(t, fileExists(t, testDir))
116+
}

localfs/store.go

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import (
55
"errors"
66
"fmt"
77
"io"
8+
"io/fs"
89
"io/ioutil"
910
"os"
1011
"path"
1112
"path/filepath"
1213
"strings"
14+
"syscall"
1315
"time"
1416

1517
"github.com/araddon/gou"
@@ -280,31 +282,48 @@ func (l *LocalStore) Get(ctx context.Context, o string) (cloudstorage.Object, er
280282
func (l *LocalStore) Delete(ctx context.Context, obj string) error {
281283
fo := path.Join(l.storepath, obj)
282284
if err := os.Remove(fo); err != nil {
283-
return fmt.Errorf("removing dir=%s: %w", fo, err)
285+
return fmt.Errorf("removing file=%s: %w", fo, err)
284286
}
285287
mf := fo + ".metadata"
286288
if cloudstorage.Exists(mf) {
287289
if err := os.Remove(mf); err != nil {
288-
return fmt.Errorf("removing dir=%s: %w", mf, err)
290+
return fmt.Errorf("removing file=%s: %w", mf, err)
289291
}
290292
}
291293

292294
// When the last item in a folder is deleted, the folder
293295
// should also be deleted. This matches the behavior in GCS.
294-
dir, err := os.Open(l.storepath)
295-
if err != nil {
296-
return fmt.Errorf("failed to open store dir=%s err=%w", l.storepath, err)
297-
}
298-
if _, err = dir.Readdirnames(1); errors.Is(err, io.EOF) {
299-
dir.Close()
300-
// it's empty, so remove it.
301-
if err := os.Remove(l.storepath); err != nil {
302-
return fmt.Errorf("failed to remove store dir=%s err=%w", l.storepath, err)
296+
return l.deleteParentDirs(fo)
297+
}
298+
299+
// deleteParentDirs deletes all the parent dirs of some filepath
300+
// if those dirs are empty.
301+
func (l *LocalStore) deleteParentDirs(filePath string) error {
302+
303+
for dirName := path.Dir(filePath); len(dirName) > 0; dirName = path.Dir(dirName) {
304+
if dirName == l.storepath {
305+
// top level, stop deleting
306+
return nil
303307
}
304-
} else {
305-
dir.Close()
308+
err := os.Remove(dirName)
309+
if errors.Is(err, os.ErrNotExist) {
310+
// it's already deleted; nothing to do.
311+
return nil
312+
}
313+
// There is no equivalent os.ErrNotEmpty in this version of go.
314+
var perr *fs.PathError
315+
if ok := errors.As(err, &perr); ok {
316+
if sysErr, ok := perr.Err.(syscall.Errno); ok && sysErr == syscall.ENOTEMPTY {
317+
// not empty; quit.
318+
return nil
319+
}
320+
}
321+
// unknown error, return it.
322+
if err != nil {
323+
return fmt.Errorf("failed to remove store dir=%s err=%w", dirName, err)
324+
}
325+
// we deleted an empty folder, so continue
306326
}
307-
308327
return nil
309328
}
310329

testutils/testutils.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ func BasicRW(t TestingT, store cloudstorage.Store) {
213213
assert.Equal(t, cloudstorage.ErrObjectNotFound, err)
214214
assert.Equal(t, nil, obj)
215215

216+
// Store should be empty again
217+
all, err = store.List(context.Background(), cloudstorage.NewQueryAll())
218+
assert.NoError(t, err)
219+
assert.NotNil(t, all)
220+
assert.Empty(t, all.Objects)
216221
}
217222

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

0 commit comments

Comments
 (0)