Skip to content

Commit e0e4de5

Browse files
Nostalgianostalumecodex
authored
fix(server/s3): paginate recursive object listings (#2968)
fix(s3): paginate recursive object listings - stop recursive traversal after filling the requested S3 page - preserve lexicographic marker ordering and request cancellation - cover bounded traversal, continuation, prefixes, and cache safety Co-authored-by: nostalume <nostalucent@gmail.com> Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
1 parent 39fcaf4 commit e0e4de5

5 files changed

Lines changed: 413 additions & 86 deletions

File tree

‎server/s3/backend.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ var (
3636
// s3Backend implements the gofacess3.Backend interface to make an S3
3737
// backend for gofakes3
3838
type s3Backend struct {
39-
meta *sync.Map
39+
meta *sync.Map
40+
listDir func(context.Context, string) ([]model.Obj, error)
4041
}
4142

4243
// newBackend creates a new SimpleBucketBackend.
4344
func newBackend() gofakes3.Backend {
4445
return &s3Backend{
45-
meta: new(sync.Map),
46+
meta: new(sync.Map),
47+
listDir: getDirEntries,
4648
}
4749
}
4850

@@ -84,18 +86,17 @@ func (b *s3Backend) ListBucket(ctx context.Context, bucketName string, prefix *g
8486
prefix.HasDelimiter = false
8587
}
8688

87-
response := gofakes3.NewObjectList()
8889
path, remaining := prefixParser(prefix)
8990

90-
err = b.entryListR(bucketPath, path, remaining, prefix.HasDelimiter, response)
91+
response, err := b.listPage(ctx, bucketPath, path, remaining, prefix.HasDelimiter, page)
9192
if err == gofakes3.ErrNoSuchKey {
9293
// AWS just returns an empty list
9394
response = gofakes3.NewObjectList()
9495
} else if err != nil {
9596
return nil, err
9697
}
9798

98-
return b.pager(response, page)
99+
return response, nil
99100
}
100101

101102
// HeadObject returns the fileinfo for the given object name.

‎server/s3/list.go‎

Lines changed: 119 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,118 @@
33
package s3
44

55
import (
6+
"context"
67
"path"
8+
"slices"
9+
"sort"
710
"strings"
811
"time"
912

1013
"github.com/itsHenry35/gofakes3"
1114
log "github.com/sirupsen/logrus"
1215
)
1316

14-
func (b *s3Backend) entryListR(bucket, fdPath, name string, addPrefix bool, response *gofakes3.ObjectList) error {
15-
fp := path.Join(bucket, fdPath)
17+
// S3 ListObjects responses contain at most 1,000 keys.
18+
const maxListPageKeys int64 = 1000
19+
20+
type objectPage struct {
21+
result *gofakes3.ObjectList
22+
marker string
23+
maxKeys int64
24+
count int64
25+
lastKey string
26+
}
27+
28+
func newObjectPage(page gofakes3.ListBucketPage) *objectPage {
29+
maxKeys := page.MaxKeys
30+
if maxKeys <= 0 || maxKeys > maxListPageKeys {
31+
maxKeys = maxListPageKeys
32+
}
33+
marker := ""
34+
if page.HasMarker {
35+
marker = page.Marker
36+
}
37+
return &objectPage{
38+
result: gofakes3.NewObjectList(),
39+
marker: marker,
40+
maxKeys: maxKeys,
41+
}
42+
}
43+
44+
func (p *objectPage) addContent(item *gofakes3.Content) bool {
45+
if item.Key <= p.marker {
46+
return true
47+
}
48+
if p.count >= p.maxKeys {
49+
p.result.IsTruncated = true
50+
return false
51+
}
52+
p.result.Add(item)
53+
p.count++
54+
p.lastKey = item.Key
55+
return true
56+
}
57+
58+
func (p *objectPage) addPrefix(prefix string) bool {
59+
if prefix <= p.marker {
60+
return true
61+
}
62+
if p.count >= p.maxKeys {
63+
p.result.IsTruncated = true
64+
return false
65+
}
66+
p.result.AddPrefix(prefix)
67+
p.count++
68+
p.lastKey = prefix
69+
return true
70+
}
1671

17-
dirEntries, err := getDirEntries(fp)
72+
func (p *objectPage) finish() *gofakes3.ObjectList {
73+
if p.result.IsTruncated {
74+
p.result.NextMarker = p.lastKey
75+
}
76+
return p.result
77+
}
78+
79+
func (b *s3Backend) listPage(
80+
ctx context.Context,
81+
bucket, fdPath, name string,
82+
addPrefix bool,
83+
page gofakes3.ListBucketPage,
84+
) (*gofakes3.ObjectList, error) {
85+
result := newObjectPage(page)
86+
_, err := b.walkPage(ctx, bucket, fdPath, name, addPrefix, result)
87+
if err != nil {
88+
return nil, err
89+
}
90+
return result.finish(), nil
91+
}
92+
93+
// walkPage returns false after finding an entry beyond the requested page.
94+
func (b *s3Backend) walkPage(
95+
ctx context.Context,
96+
bucket, fdPath, name string,
97+
addPrefix bool,
98+
page *objectPage,
99+
) (bool, error) {
100+
if err := ctx.Err(); err != nil {
101+
return false, err
102+
}
103+
104+
fp := path.Join(bucket, fdPath)
105+
dirEntries, err := b.listDir(ctx, fp)
18106
if err != nil {
19-
return err
107+
return false, err
108+
}
109+
if err := ctx.Err(); err != nil {
110+
return false, err
20111
}
21112

22113
// workaround as s3 can't have empty files in directories, useful in deletions
23114
if len(dirEntries) == 0 {
115+
if !strings.HasPrefix(emptyObjectName, name) {
116+
return true, nil
117+
}
24118
item := &gofakes3.Content{
25119
// Key: gofakes3.URLEncode(path.Join(fdPath, emptyObjectName)),
26120
Key: path.Join(fdPath, emptyObjectName),
@@ -29,11 +123,15 @@ func (b *s3Backend) entryListR(bucket, fdPath, name string, addPrefix bool, resp
29123
Size: 0,
30124
StorageClass: gofakes3.StorageStandard,
31125
}
32-
response.Add(item)
33126
log.Debugf("Adding empty object %s to response", item.Key)
34-
return nil
127+
return page.addContent(item), nil
35128
}
36129

130+
dirEntries = slices.Clone(dirEntries)
131+
sort.Slice(dirEntries, func(i, j int) bool {
132+
return dirEntries[i].GetName() < dirEntries[j].GetName()
133+
})
134+
37135
for _, entry := range dirEntries {
38136
object := entry.GetName()
39137

@@ -47,12 +145,19 @@ func (b *s3Backend) entryListR(bucket, fdPath, name string, addPrefix bool, resp
47145
if entry.IsDir() {
48146
if addPrefix {
49147
// response.AddPrefix(gofakes3.URLEncode(objectPath))
50-
response.AddPrefix(objectPath)
148+
if !page.addPrefix(objectPath) {
149+
return false, nil
150+
}
151+
continue
152+
}
153+
subtreePrefix := objectPath + "/"
154+
// A marker beyond this subtree lets us avoid an upstream directory read.
155+
if subtreePrefix <= page.marker && !strings.HasPrefix(page.marker, subtreePrefix) {
51156
continue
52157
}
53-
err := b.entryListR(bucket, path.Join(fdPath, object), "", false, response)
54-
if err != nil {
55-
return err
158+
keepGoing, err := b.walkPage(ctx, bucket, objectPath, "", false, page)
159+
if err != nil || !keepGoing {
160+
return keepGoing, err
56161
}
57162
} else {
58163
item := &gofakes3.Content{
@@ -63,8 +168,10 @@ func (b *s3Backend) entryListR(bucket, fdPath, name string, addPrefix bool, resp
63168
Size: entry.GetSize(),
64169
StorageClass: gofakes3.StorageStandard,
65170
}
66-
response.Add(item)
171+
if !page.addContent(item) {
172+
return false, nil
173+
}
67174
}
68175
}
69-
return nil
176+
return true, nil
70177
}

0 commit comments

Comments
 (0)