s3: list keys in byte order around directory markers (#11832)

* s3: list keys in byte order around directory markers

* s3: order deferred dir blocks by slash name and hold quota

* s3: fix deferred dir blocks at page and marker boundaries

Review follow-ups on the marker byte-order change:

- A marker inside an emitted "<name>/" block re-descended and re-listed its
  folded contents under delimiter "/" instead of skipping the block.
- A prefix object's "<name>/" common prefix was emitted eagerly at the name
  position, outranking siblings like "a.txt" and repeating across pages; the
  plain key now emits alone at the name position and the block emits the
  common prefix when children exist.
- A recursion starved on quota before listing a deferred subtree returned
  without truncation, dropping keys silently; the block now reports a
  resumable truncation when the subtree still holds entries.
- Clone prefix-object entries with proto.Clone rather than copying the
  protobuf message by value.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* s3: close the hasEntries probe stream on return

* s3: resume delimited listings at the dir block boundary

A deferred directory block emits at its "<name>/" position, but emitDir
left nextMarker at the bare name, which sorts inside the (name, name/)
band and reads as "block pending". Every pass of the listing loop then
re-emitted the same common prefix until maxKeys ran out (999 prefixes,
IsTruncated), and a "<name>/" continuation token trimmed to "<name>" by
adjustMarkerForDelimiter reopened the block and paged forever.

Advance nextMarker to the block boundary the same way the recursive
branch does, and keep a "/" -ending marker intact — it already names the
bound past the block.

---------

Co-authored-by: Devin <devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 05:36:22 -07:00
1 parent da0d423bd9
commit 7ed26deaae
2 files changed
+575 -138

No files matched your search

+230 -128
View File
@@ -19,6 +19,7 @@ import (
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
type OptionalString struct {
@@ -565,6 +566,8 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
// plain key. The traversal never descends into it under a delimiter,
// so the CommonPrefix its nested keys fold into is added here too.
if entry.IsPrefixObject() {
keyOnly := cursor.prefixObjectKeyOnly
cursor.prefixObjectKeyOnly = false
key := fmt.Sprintf("%s/%s", dir, entry.Name)[len(bucketPrefix):]
if strings.HasPrefix(key, originalPrefix) {
if folded := prefixForKey(dir, entry.Name); folded != "" {
@@ -579,8 +582,10 @@ func (s3a *S3ApiServer) listFilerEntries(ctx context.Context, req listObjectsReq
// whole, so a page ending between them cannot resume at the prefix:
// a marker of "<key>/" means the subtree is done, not that it is
// next. The page runs one item over maxKeys instead of dropping it.
if childPrefix := prefixForKey(dir, entry.Name+"/"); childPrefix != "" && s3a.hasChildren(ctx, bucket, key) {
addCommonPrefix(childPrefix)
if !keyOnly {
if childPrefix := prefixForKey(dir, entry.Name+"/"); childPrefix != "" && s3a.hasChildren(ctx, bucket, key) {
addCommonPrefix(childPrefix)
}
}
return
}
@@ -804,6 +809,10 @@ type ListingCursor struct {
// emitDirAsCommonPrefix marks the next directory entry passed to the entry
// callback for reporting as a CommonPrefix rather than a Contents key.
emitDirAsCommonPrefix bool
// prefixObjectKeyOnly limits the next prefix-object entry passed to the entry
// callback to its plain key; its "<name>/" CommonPrefix is deferred to the
// directory's own block position.
prefixObjectKeyOnly bool
}
// excludedMarkerKey returns the key an exclusive marker names that the walk's cutoff no
@@ -950,35 +959,20 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
return
}
// markerParentWalked tracks whether marker now names a directory whose
// subtree this call already walked: after the descent below it is the
// parent directory, and seeing its entry again must not re-list it.
markerParentWalked := false
if strings.Contains(marker, "/") {
subDir, subMarker := toParentAndDescendants(marker)
// println("doListFilerEntries dir", dir+"/"+subDir, "subMarker", subMarker)
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + subDir, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = subErr
return
// A directory's keys sort at "<name>/" among this level's children, not at
// the name itself: siblings like "a-c" and "a.txt" sort before "a/b" while
// "a0" sorts after. Start the window at the first child whose block can
// still hold keys past the marker — the shortest prefix of the marker
// followed by a byte no greater than "/" — and ask for it inclusively, so
// a marker-named directory's subtree is reached at all.
startName := marker
for i := 0; i < len(marker); i++ {
if marker[i] <= '/' {
startName = marker[:i]
break
}
nextMarker = subDir + "/" + subNextMarker
// finished processing this subdirectory
marker = subDir
markerParentWalked = true
}
if cursor.isTruncated {
return
}
// Ask for the marker's own entry: an exclusive window never delivers the
// directory a marker names, and its children sort after the marker key.
// Delimited listings keep exclusive windows — a marker equal to a
// directory there is the resume position of an already-emitted common
// prefix, not a subtree to walk.
if marker != "" && delimiter == "" {
inclusiveStartFrom = true
}
inclusiveStartFrom = startName != ""
// now marker is also a direct child of dir
ctx, cancel := context.WithCancel(ctx)
@@ -988,6 +982,152 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
// each request window inside the page.
pageMarker := marker
// Defer each directory until a child past its "<name>/" block arrives, so
// emission follows S3 byte order rather than the filer's name order.
type pendingDir struct {
e *filer_pb.Entry
matchedPrefixDir bool
}
var pendingDirs []pendingDir
stopped := false
// hasEntries reports whether the filer directory holds at least one entry,
// probed on the request's own client; errors answer conservatively as true.
hasEntries := func(path string) bool {
probeCtx, cancel := context.WithCancel(ctx)
defer cancel()
stream, lerr := client.ListEntries(probeCtx, &filer_pb.ListEntriesRequest{Directory: path, Limit: 1})
if lerr != nil {
return true
}
_, recvErr := stream.Recv()
return recvErr == nil
}
emitDir := func(e *filer_pb.Entry, matchedPrefixDir bool) error {
blockKey := e.Name + "/"
var subMarker string
if strings.HasPrefix(marker, blockKey) {
if delimiter == "/" {
// this block collapses to one common prefix, already emitted
return nil
}
subMarker = marker[len(blockKey):]
} else if blockKey <= marker {
return nil
}
// A prefix object's plain key is emitted at the name position or not at
// all; the block emits only its "<name>/" fold, so the callback sees a
// plain directory.
emitEntry := e
if e.IsPrefixObject() {
emitEntry = proto.Clone(e).(*filer_pb.Entry)
delete(emitEntry.Extended, s3_constants.SeaweedFSPrefixObject)
}
isKeyObject := e.IsDirectoryKeyObject() && !e.IsPrefixObject()
if delimiter != "/" || cursor.prefixEndsOnDelimiter {
// A trailing-slash prefix (e.g. "logs/") names one directory and asks
// whether it exists, so a real but empty directory must be reported for
// that probe.
explicitDirProbe := cursor.prefixEndsOnDelimiter
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
if isKeyObject && blockKey > marker {
// Directory key objects (created via PutObject with trailing "/")
// appear as regular keys in recursive listing mode.
eachEntryFn(dir, emitEntry)
}
// Recurse into subdirectory to list any children, noting whether the
// subtree produced any entries.
childEmitted := false
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + e.Name, marker: subMarker, delimiter: delimiter, bucket: bucket}, cursor, func(d string, ent *filer_pb.Entry) {
childEmitted = true
eachEntryFn(d, ent)
})
if subErr != nil {
return fmt.Errorf("doListFilerEntries2: %w", subErr)
}
nextMarker = e.Name + "/" + subNextMarker
if cursor.maxKeys <= 0 && subNextMarker == "" && hasEntries(dir+"/"+e.Name) {
// The recursion starved on quota before listing the subtree;
// keep a resume position that re-enters it.
cursor.isTruncated = true
}
// A real but empty directory (created out of band via mount, mkdir or
// the filer API, so it carries no MIME) is otherwise invisible to S3
// clients that detect directories by listing it under its own "<dir>/"
// prefix. Answer that explicit probe so tools like hadoop-aws can find
// the directory: a delimited listing reports it as a CommonPrefix --
// a real key in Contents would look like an object a client can read --
// while a recursive listing keeps the directory marker key it used.
// Plain listings are left untouched, so empty directories left behind
// by deleted objects are not shown as keys.
// A directory that still holds version history no longer names anything,
// so it gets no marker either.
if explicitDirProbe && !isKeyObject && !childEmitted && !cursor.isTruncated && e.Attributes != nil &&
!s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+e.Name, cursor) {
if delimiter == "/" {
cursor.emitDirAsCommonPrefix = true
} else {
emitEntry.Attributes.Mime = s3_constants.FolderMimeType
}
eachEntryFn(dir, emitEntry)
}
// println("doListFilerEntries2 dir", dir+"/"+e.Name, "subNextMarker", subNextMarker)
if cursor.isTruncated || matchedPrefixDir {
stopped = true
}
// println("doListFilerEntries2 nextMarker", nextMarker)
} else {
switch {
case isKeyObject && blockKey > marker:
eachEntryFn(dir, emitEntry)
case e.IsPrefixObject():
// The plain key already listed at the name position; the
// deferred block emits the "<name>/" common prefix only when
// children fold under it.
if hasEntries(dir + "/" + e.Name) {
eachEntryFn(dir, emitEntry)
}
case !s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+e.Name, cursor):
eachEntryFn(dir, emitEntry)
}
// Whatever the block emitted sits at its "<name>/" position, so the
// resume marker is the block boundary: the bare name sorts inside the
// (name, name/) band and would reopen the block for a second emission.
nextMarker = e.Name + "/"
}
return nil
}
// pendingDirs is kept ordered by "<name>/" rather than by name: "a-c/" and
// "a./" sort before "a/" even though "a" is the smaller name.
flushDirs := func(upTo string) error {
for len(pendingDirs) > 0 && (upTo == "" || pendingDirs[0].e.Name+"/" < upTo) {
if cursor.maxKeys <= 0 {
cursor.isTruncated = true
return nil
}
d := pendingDirs[0]
pendingDirs = pendingDirs[1:]
if err := emitDir(d.e, d.matchedPrefixDir); err != nil {
return err
}
if stopped || cursor.isTruncated {
return nil
}
}
return nil
}
queueDir := func(pd pendingDir) {
blockKey := pd.e.Name + "/"
i := 0
for i < len(pendingDirs) && pendingDirs[i].e.Name+"/" < blockKey {
i++
}
pendingDirs = append(pendingDirs, pendingDir{})
copy(pendingDirs[i+1:], pendingDirs[i:])
pendingDirs[i] = pd
}
// Entries that emit nothing (empty directories, the .uploads folder, the marker
// echo) consume the request window without consuming maxKeys, so one window may
// end before maxKeys is satisfied. Keep requesting from the last received entry
@@ -997,7 +1137,7 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
Directory: dir,
Prefix: prefix,
Limit: uint32(cursor.maxKeys) + 2, // bucket root directory needs to skip additional s3_constants.MultipartUploadsFolder folder
StartFromFileName: marker,
StartFromFileName: startName,
InclusiveStartFrom: inclusiveStartFrom,
}
@@ -1028,47 +1168,21 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
entriesReceived++
lastEntryName = entry.Name
// listFilerEntries always calls doListFilerEntries with inclusiveStartFrom=false
// (S3 marker semantics are exclusive), but keep the guard explicit to preserve
// behavior if inclusive callers are introduced in the future.
// A versioned object lives in a "<key>.versions" directory, so the marker also
// has to be matched against the object name that directory stands for.
markerName := entry.Name
if entry.IsDirectory {
markerName = strings.TrimSuffix(markerName, s3_constants.VersionsFolder)
// A directory whose block sorts before this entry emits now — every
// key it yields still sorts before this entry's name.
if err = flushDirs(entry.Name); err != nil || stopped || cursor.isTruncated {
return
}
if marker != "" && (entry.Name == marker || markerName == marker) {
// The marker excludes only the key it names. A directory under
// that name sorts its children after the marker, so a recursive
// listing still walks the subtree — from the start. A directory
// key object lists as "<marker>/" — a different key that also
// sorts after — so it is emitted; a prefix object would repeat
// the marker itself and is not. Internal folders keep their
// exclusion, .versions dirs fold into the marker key, and a
// delimited listing keeps skipping — the same marker resumes an
// already-emitted common prefix there.
walkableMarkerDir := delimiter == "" && entry.IsDirectory && entry.Name == marker &&
!markerParentWalked && !strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) &&
entry.Name != s3_constants.MultipartUploadsFolder
if !walkableMarkerDir {
// A directory block still contributes past a marker that lands inside
// its "<name>/" band; only a block entirely behind it is skipped.
if marker != "" {
if entry.IsDirectory {
if entry.Name+"/" <= marker && !strings.HasPrefix(marker, entry.Name+"/") {
continue
}
} else if entry.Name <= marker {
continue
}
if entry.IsDirectoryKeyObject() && !entry.IsPrefixObject() {
eachEntryFn(dir, entry)
if cursor.isTruncated {
return
}
}
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = fmt.Errorf("doListFilerEntries: %w", subErr)
return
}
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
continue
}
// The .versions sibling of the key just emitted still decides that key's
@@ -1105,6 +1219,25 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
if entry.IsDirectory {
// glog.V(4).Infof("List Dir Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
// A marker inside a skipped subtree still descends — the
// internal folder is not listed, its contents may be.
if strings.HasPrefix(marker, entry.Name+"/") {
if delimiter == "/" {
// The whole block already collapsed into its common
// prefix; nothing under it is listed again.
continue
}
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, marker: marker[len(entry.Name)+1:], delimiter: delimiter, bucket: bucket}, cursor, eachEntryFn)
if subErr != nil {
err = fmt.Errorf("doListFilerEntries: %w", subErr)
return
}
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
continue
}
if entry.Name == s3_constants.MultipartUploadsFolder { // FIXME no need to apply to all directories. this extra also affects maxKeys
continue
}
@@ -1119,6 +1252,10 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
}
// Extract object name from .versions directory name
baseObjectName := strings.TrimSuffix(entry.Name, s3_constants.VersionsFolder)
// A marker at or past the folded key already covered it.
if marker != "" && baseObjectName <= marker {
continue
}
// A page resuming from a marker inside the base key's extension
// region ("k.bak" sorts between "k" and "k.versions") means an
// earlier page already listed and settled the base null object;
@@ -1155,76 +1292,32 @@ func (s3a *S3ApiServer) doListFilerEntries(ctx context.Context, client filer_pb.
continue
}
if delimiter != "/" || cursor.prefixEndsOnDelimiter {
// A trailing-slash prefix (e.g. "logs/") names one directory and asks
// whether it exists, so a real but empty directory must be reported for
// that probe.
explicitDirProbe := cursor.prefixEndsOnDelimiter
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
isKeyObject := entry.IsDirectoryKeyObject()
if isKeyObject {
// Directory key objects (created via PutObject with trailing "/")
// must appear as regular keys in recursive listing mode.
eachEntryFn(dir, entry)
}
// Recurse into subdirectory to list any children, noting whether the
// subtree produced any entries.
childEmitted := false
subNextMarker, subErr := s3a.doListFilerEntries(ctx, client, listDirectoryRequest{dir: dir + "/" + entry.Name, delimiter: delimiter, bucket: bucket}, cursor, func(d string, e *filer_pb.Entry) {
childEmitted = true
eachEntryFn(d, e)
})
if subErr != nil {
err = fmt.Errorf("doListFilerEntries2: %w", subErr)
return
}
// A real but empty directory (created out of band via mount, mkdir or
// the filer API, so it carries no MIME) is otherwise invisible to S3
// clients that detect directories by listing it under its own "<dir>/"
// prefix. Answer that explicit probe so tools like hadoop-aws can find
// the directory: a delimited listing reports it as a CommonPrefix --
// a real key in Contents would look like an object a client can read --
// while a recursive listing keeps the directory marker key it used.
// Plain listings are left untouched, so empty directories left behind
// by deleted objects are not shown as keys.
// A directory that still holds version history no longer names anything,
// so it gets no marker either.
if explicitDirProbe && !isKeyObject && !childEmitted && !cursor.isTruncated && entry.Attributes != nil &&
!s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+entry.Name, cursor) {
if delimiter == "/" {
cursor.emitDirAsCommonPrefix = true
} else {
entry.Attributes.Mime = s3_constants.FolderMimeType
}
eachEntryFn(dir, entry)
}
// println("doListFilerEntries2 dir", dir+"/"+entry.Name, "subNextMarker", subNextMarker)
nextMarker = entry.Name + "/" + subNextMarker
if cursor.isTruncated {
return
}
if matchedPrefixDir {
return
}
// println("doListFilerEntries2 nextMarker", nextMarker)
} else if entry.IsDirectoryKeyObject() || !s3a.dirHoldsOnlyHiddenEntries(ctx, client, bucket, dir+"/"+entry.Name, cursor) {
pd := pendingDir{e: entry, matchedPrefixDir: matchedPrefixDir}
// A prefix object lists as the plain key "<name>", which sorts
// at the name, not at the "<name>/" block its children occupy; the
// deferred block emits the common prefix at its own position.
if entry.IsPrefixObject() && entry.Name > marker {
cursor.prefixObjectKeyOnly = true
eachEntryFn(dir, entry)
}
} else {
eachEntryFn(dir, entry)
// glog.V(4).Infof("List File Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
queueDir(pd)
continue
}
eachEntryFn(dir, entry)
// glog.V(4).Infof("List File Entries %s, file: %s, maxKeys %d", dir, entry.Name, cursor.maxKeys)
if cursor.prefixEndsOnDelimiter {
cursor.prefixEndsOnDelimiter = false
}
}
if cursor.isTruncated || entriesReceived < request.Limit {
if cursor.isTruncated || stopped {
return
}
marker = lastEntryName
if entriesReceived < request.Limit {
err = flushDirs("")
return
}
startName = lastEntryName
inclusiveStartFrom = false
}
}
@@ -1482,6 +1575,15 @@ func adjustMarkerForDelimiter(marker, prefix, delimiter string) string {
return marker
}
// A marker ending on "/" already names the bound past that directory's
// block — the walk reads "boo/" as "the boo/ block is done". Trimming it
// to the bare name would sort inside the (name, name/) band instead and
// reopen the block. Other delimiters have no matching directory boundary,
// so their markers still need the trim below.
if delimiter == "/" {
return marker
}
// Remove the trailing delimiter
// This ensures we skip all entries under the prefix but don't skip
// potential directory entries that start with a similar prefix
+345 -10
View File
@@ -4,11 +4,13 @@ import (
"context"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/stretchr/testify/assert"
grpc "google.golang.org/grpc"
@@ -98,18 +100,31 @@ func (c *markerEchoFilerClient) ListEntries(ctx context.Context, in *filer_pb.Li
return &testListEntriesStream{entries: entries}, nil
}
for i, e := range entries {
if e.Name == in.StartFromFileName {
// Emulate buggy backend behavior: return marker again even when exclusive.
if c.returnFollowing {
echoAndFollowing := entries[i:]
return &testListEntriesStream{entries: echoAndFollowing}, nil
}
return &testListEntriesStream{entries: []*filer_pb.Entry{e}}, nil
filtered := make([]*filer_pb.Entry, 0, len(entries))
for _, e := range entries {
if e.Name > in.StartFromFileName || (in.InclusiveStartFrom && e.Name == in.StartFromFileName) {
filtered = append(filtered, e)
}
}
return &testListEntriesStream{entries: nil}, nil
// Emulate buggy backend behavior: echo the start entry again even when the
// request did not ask for it.
if !in.InclusiveStartFrom {
var echo *filer_pb.Entry
for _, e := range entries {
if e.Name == in.StartFromFileName {
echo = e
break
}
}
if echo != nil {
if c.returnFollowing {
filtered = append([]*filer_pb.Entry{echo}, filtered...)
} else {
filtered = []*filer_pb.Entry{echo}
}
}
}
return &testListEntriesStream{entries: filtered}, nil
}
func ensureEntryAttributes(entries []*filer_pb.Entry) {
@@ -1143,3 +1158,323 @@ func TestListObjectsV2_PrefixEndingWithSlash_WithDelimiter(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{"fileA"}, results, "Should only return files under directory '1', not '1000'")
}
// A marker landing between a directory's name and its trailing slash must
// still produce keys inside the directory in byte order: the marker's own
// block band, not just its name, decides what follows it.
func TestDoListFilerEntries_MarkerBetweenDirAndSlash(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b/o": {dir("a"), file("a-c"), file("a.txt"), file("a0"), file("ab")},
"/buckets/b/o/a": {file("b"), dir("c")},
"/buckets/b/o/a/c": {file("d")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b/o", marker: marker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b/")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
// byte order: o/a-c, o/a.txt, o/a/b, o/a/c/d, o/a0, o/ab
assert.Equal(t, []string{
"o/a-c", "o/a.txt", "o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect(""))
assert.Equal(t, []string{
"o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect("a.txt"))
assert.Equal(t, []string{
"o/a/c/d", "o/a0", "o/ab",
}, collect("a/b"))
assert.Equal(t, []string{
"o/ab",
}, collect("a0"))
// a marker that sorts inside the "a" band before its slash still walks a:
assert.Equal(t, []string{
"o/a.txt", "o/a/b", "o/a/c/d", "o/a0", "o/ab",
}, collect("a-c"))
}
// Directory blocks order by "<name>/", not by name: "a-/" sorts before "a/".
func TestDoListFilerEntries_DirBlocksSortBySlashName(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), dir("a-"), file("a.txt")},
"/buckets/b/a": {file("b")},
"/buckets/b/a-": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/a-/x", "/a.txt", "/a/b"}, seen)
}
// A full page must report truncation while deferred directories still hold keys.
func TestDoListFilerEntries_FullPageTruncatesWithPendingDir(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("a-c")},
"/buckets/b/a": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1}
var seen []string
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a-c"}, seen)
assert.True(t, cursor.isTruncated, "deferred directory still holds a key")
assert.Equal(t, "a-c", nextMarker)
// the next page re-enters the deferred block through the marker band
cursor = &ListingCursor{maxKeys: 1000}
seen = nil
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, dir+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/buckets/b/a/x"}, seen)
}
// A directory that is also a plain object lists "a" at the name position and
// does not repeat it when the marker is the object itself.
func TestDoListFilerEntries_PrefixObjectKeepsNamePosition(t *testing.T) {
s3a := &S3ApiServer{}
dirObj := func(name string) *filer_pb.Entry {
e := &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0644)}}
e.MarkPrefixObject()
return e
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirObj("a"), file("a.txt")},
"/buckets/b/a": {file("b")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, dir+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
assert.Equal(t, []string{"/buckets/b/a", "/buckets/b/a.txt", "/buckets/b/a/b"}, collect(""))
// the marker is the object "a" itself: excluded, but "a.txt" and the
// "a/" block still follow
assert.Equal(t, []string{"/buckets/b/a.txt", "/buckets/b/a/b"}, collect("a"))
}
// A marker that lands inside a directory's "<name>/" block — the continuation
// token a delimited page returns for it — must not re-list the block's folded
// contents under delimiter "/".
func TestDoListFilerEntries_DelimiterSkipsResumedDirBlock(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("b")},
"/buckets/b/a": {file("x")},
},
}
collect := func(marker string) []string {
cursor := &ListingCursor{maxKeys: 1000}
var seen []string
_, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
return seen
}
// The "a/" block already collapsed into a common prefix on the earlier
// page, so neither "a/" nor anything under it is emitted again.
assert.Equal(t, []string{"/b"}, collect("a/"))
assert.Equal(t, []string{"/b"}, collect("a/x"))
}
// A delimited listing resumes a directory at its "<name>/" block boundary, not
// the bare name: the name alone sorts inside the (name, name/) band and makes
// the next pass re-emit the same common prefix until the quota runs out. The
// marker also survives a round trip through the request path unchanged —
// adjustMarkerForDelimiter must not trim the trailing slash off it.
func TestDoListFilerEntries_DelimitedDirResumesAtBlockBoundary(t *testing.T) {
s3a := &S3ApiServer{}
dir := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dir("a"), file("b")},
"/buckets/b/a": {file("x")},
},
}
// The outer listFilerEntries loop re-enters with the returned marker until
// a pass comes back empty; every entry must be emitted exactly once.
var seen []string
marker := ""
for pass := 0; pass < 4; pass++ {
cursor := &ListingCursor{maxKeys: 1000}
emitted := 0
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: marker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
emitted++
})
assert.NoError(t, err)
if emitted == 0 {
break
}
marker = adjustMarkerForDelimiter(nextMarker, "", "/")
}
assert.Equal(t, []string{"/a", "/b"}, seen)
}
// A prefix object's "<name>/" common prefix belongs to the block position, not
// the name position: it must not outrank "a.txt" on a full page nor repeat on
// the next one.
func TestDoListFilerEntries_PrefixObjectDefersCommonPrefix(t *testing.T) {
s3a := &S3ApiServer{}
dirObj := func(name string) *filer_pb.Entry {
e := &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{FileMode: uint32(os.ModeDir | 0644)}}
e.MarkPrefixObject()
return e
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirObj("a"), file("a.txt")},
"/buckets/b/a": {file("b")},
},
}
// Page one holds the plain key "a" and "a.txt"; the "a/" block sort after
// both, so the page truncates at "a.txt" without touching it.
cursor := &ListingCursor{maxKeys: 2}
var seen []string
var sawPrefixObject bool
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
sawPrefixObject = sawPrefixObject || entry.IsPrefixObject()
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a", "a.txt"}, seen)
assert.True(t, sawPrefixObject, "the name-position emission is the plain object")
assert.True(t, cursor.isTruncated)
assert.Equal(t, "a.txt", nextMarker)
// Page two emits the deferred block once, as a plain directory fold.
cursor = &ListingCursor{maxKeys: 2}
seen = nil
sawPrefixObject = false
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, delimiter: "/", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
sawPrefixObject = sawPrefixObject || entry.IsPrefixObject()
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a"}, seen)
assert.False(t, sawPrefixObject, "the block emission is the \"<name>/\" fold, not the plain key again")
assert.False(t, cursor.isTruncated)
}
// A directory key object that spends the page's last slot still reports
// truncation so the next page re-enters its subtree.
func TestDoListFilerEntries_DirKeyObjectBoundaryTruncates(t *testing.T) {
s3a := &S3ApiServer{}
dirKeyObj := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, IsDirectory: true, Attributes: &filer_pb.FuseAttributes{Mime: s3_constants.FolderMimeType}}
}
file := func(name string) *filer_pb.Entry {
return &filer_pb.Entry{Name: name, Attributes: &filer_pb.FuseAttributes{}}
}
client := &testFilerClient{
entriesByDir: map[string][]*filer_pb.Entry{
"/buckets/b": {dirKeyObj("a")},
"/buckets/b/a": {file("x")},
},
}
cursor := &ListingCursor{maxKeys: 1}
var seen []string
nextMarker, err := s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"a"}, seen)
assert.True(t, cursor.isTruncated, "the subtree may still hold unlisted keys")
assert.Equal(t, "a/", nextMarker)
// The next page resumes inside the "a/" block and lists the child.
cursor = &ListingCursor{maxKeys: 1000}
seen = nil
_, err = s3a.doListFilerEntries(context.Background(), client, listDirectoryRequest{dir: "/buckets/b", marker: nextMarker, bucket: "b"}, cursor, func(dir string, entry *filer_pb.Entry) {
seen = append(seen, strings.TrimPrefix(dir, "/buckets/b")+"/"+entry.Name)
cursor.maxKeys--
})
assert.NoError(t, err)
assert.Equal(t, []string{"/a/x"}, seen)
assert.False(t, cursor.isTruncated)
}