This is an automated email from the ASF dual-hosted git repository.

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 5ab0389b1 fix(io): treat ADLS Gen2 / OneLake directory blobs as 
directories in blobfs WalkDir (#2180)
5ab0389b1 is described below

commit 5ab0389b1ecb1dd1a508e921909d3e2bcffb26b4
Author: Ajitesh Singh <[email protected]>
AuthorDate: Tue Oct 13 05:34:18 2026 -0700

    fix(io): treat ADLS Gen2 / OneLake directory blobs as directories in blobfs 
WalkDir (#2180)
    
    * fix(io): walk hierarchical namespace directories in blobfs WalkDir
    
    On ADLS Gen2 accounts with a hierarchical namespace (such as OneLake),
    every directory is also stored as a 0-byte blob named after it, with no
    trailing slash. gocloud's fs.FS treats any key with an exact object as a
    file, so WalkDir stopped at the walk root and reported it as the only
    file. Orphan cleanup then listed the table location as its only orphan.
    
    WalkDir now walks a small adapter over the bucket. A key with objects
    under it is a directory, and a 0-byte blob named after a child directory
    is hidden from listings. Trailing-slash markers on flat stores behave as
    before, and the walk makes one listing per directory instead of an
    existence check plus a listing.
    
    Fixes #2159
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    * docs(io): shorten walkFS comments and note empty HNS directories
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 io/gocloud/blobfs/blob.go      | 126 ++++++++++++++++++++++++++++++++++++++++-
 io/gocloud/blobfs/blob_test.go |  45 +++++++++++++++
 2 files changed, 170 insertions(+), 1 deletion(-)

diff --git a/io/gocloud/blobfs/blob.go b/io/gocloud/blobfs/blob.go
index 0bdad018b..5988870aa 100644
--- a/io/gocloud/blobfs/blob.go
+++ b/io/gocloud/blobfs/blob.go
@@ -429,6 +429,130 @@ func isDirectoryMarker(walkRootKey string, dirEntry 
fs.DirEntry) bool {
        return ok && obj.Key == directoryMarker(walkRootKey)
 }
 
+// walkFS walks a bucket for fs.WalkDir, treating any key with children as a 
directory,
+// so the 0-byte directory blobs of ADLS hierarchical namespaces (OneLake) 
aren't files.
+type walkFS struct{ bfs *FileIO }
+
+var (
+       _ fs.StatFS    = walkFS{}
+       _ fs.ReadDirFS = walkFS{}
+)
+
+func (w walkFS) Open(name string) (fs.File, error) { return 
w.bfs.Bucket.Open(name) }
+
+func (w walkFS) Stat(name string) (fs.FileInfo, error) {
+       if !fs.ValidPath(name) {
+               return nil, &fs.PathError{Op: "stat", Path: name, Err: 
fs.ErrInvalid}
+       }
+
+       if name == "." {
+               return walkDirEntry{name: "."}, nil
+       }
+
+       // One child is enough to make name a directory, so fetch a single key.
+       children, _, err := w.bfs.ListPage(w.bfs.ctx, blob.FirstPageToken, 1, 
&blob.ListOptions{Prefix: name + "/"})
+       if err != nil {
+               return nil, blobErrToFsErr("stat", name, err)
+       }
+       if len(children) > 0 {
+               return walkDirEntry{name: pathpkg.Base(name)}, nil
+       }
+
+       attrs, err := w.bfs.Attributes(w.bfs.ctx, name)
+       if err != nil {
+               return nil, blobErrToFsErr("stat", name, err)
+       }
+
+       return blobFileInfo{
+               name:    pathpkg.Base(name),
+               size:    attrs.Size,
+               mode:    fs.ModeIrregular,
+               modTime: attrs.ModTime,
+               sys:     attrs,
+       }, nil
+}
+
+func (w walkFS) ReadDir(name string) ([]fs.DirEntry, error) {
+       if !fs.ValidPath(name) {
+               return nil, &fs.PathError{Op: "readdir", Path: name, Err: 
fs.ErrInvalid}
+       }
+
+       prefix := ""
+       if name != "." {
+               prefix = name + "/"
+       }
+
+       var files []*blob.ListObject
+       dirs := map[string]bool{}
+       iter := w.bfs.List(&blob.ListOptions{Prefix: prefix, Delimiter: "/"})
+       for {
+               obj, err := iter.Next(w.bfs.ctx)
+               if err == io.EOF {
+                       break
+               }
+               if err != nil {
+                       return nil, blobErrToFsErr("readdir", name, err)
+               }
+
+               if obj.IsDir {
+                       dirs[strings.TrimSuffix(strings.TrimPrefix(obj.Key, 
prefix), "/")] = true
+               } else {
+                       files = append(files, obj)
+               }
+       }
+
+       entries := make([]fs.DirEntry, 0, len(dirs)+len(files))
+       for child := range dirs {
+               entries = append(entries, walkDirEntry{name: child})
+       }
+       for _, obj := range files {
+               child := strings.TrimPrefix(obj.Key, prefix)
+               if obj.Size == 0 && dirs[child] {
+                       // A hierarchical-namespace blob for a child directory. 
An empty directory's blob
+                       // can't be told apart without hdi_isfolder metadata, 
so it stays a 0-byte file.
+                       continue
+               }
+
+               // Name entries the way gocloud's fs.FS does, so a "dir/" 
marker keeps
+               // the name it had before walkFS.
+               entries = append(entries, walkFileEntry{blobFileInfo{
+                       name:    pathpkg.Base(obj.Key),
+                       size:    obj.Size,
+                       mode:    fs.ModeIrregular,
+                       modTime: obj.ModTime,
+                       sys:     obj,
+               }})
+       }
+
+       if len(entries) == 0 && name != "." {
+               return nil, &fs.PathError{Op: "readdir", Path: name, Err: 
fs.ErrNotExist}
+       }
+
+       slices.SortFunc(entries, func(a, b fs.DirEntry) int { return 
strings.Compare(a.Name(), b.Name()) })
+
+       return entries, nil
+}
+
+// walkDirEntry is a directory found by walkFS. It implements fs.FileInfo and
+// fs.DirEntry.
+type walkDirEntry struct{ name string }
+
+func (d walkDirEntry) Name() string               { return d.name }
+func (d walkDirEntry) Size() int64                { return 0 }
+func (d walkDirEntry) Mode() fs.FileMode          { return fs.ModeDir }
+func (d walkDirEntry) Type() fs.FileMode          { return fs.ModeDir }
+func (d walkDirEntry) ModTime() time.Time         { return time.Time{} }
+func (d walkDirEntry) IsDir() bool                { return true }
+func (d walkDirEntry) Sys() any                   { return nil }
+func (d walkDirEntry) Info() (fs.FileInfo, error) { return d, nil }
+
+// walkFileEntry is an object found by walkFS. Its Info().Sys() is the
+// *blob.ListObject it was listed from.
+type walkFileEntry struct{ blobFileInfo }
+
+func (f walkFileEntry) Type() fs.FileMode          { return f.mode.Type() }
+func (f walkFileEntry) Info() (fs.FileInfo, error) { return f.blobFileInfo, 
nil }
+
 func (bfs *FileIO) WalkDir(root string, fn fs.WalkDirFunc) error {
        location, err := bfs.resolveLocation(root)
        var walkPath string
@@ -442,7 +566,7 @@ func (bfs *FileIO) WalkDir(root string, fn fs.WalkDirFunc) 
error {
                walkPath = location.key
        }
 
-       return fs.WalkDir(bfs.Bucket, walkPath, func(path string, d 
fs.DirEntry, err error) error {
+       return fs.WalkDir(walkFS{bfs}, walkPath, func(path string, d 
fs.DirEntry, err error) error {
                // Skip the marker for the path passed to WalkDir. Otherwise 
walking
                // "warehouse/ns" reports "warehouse/ns/" as a child file.
                if err == nil && isDirectoryMarker(walkPath, d) {
diff --git a/io/gocloud/blobfs/blob_test.go b/io/gocloud/blobfs/blob_test.go
index cad387779..125a2f042 100644
--- a/io/gocloud/blobfs/blob_test.go
+++ b/io/gocloud/blobfs/blob_test.go
@@ -551,6 +551,51 @@ func TestBlobFileIOWalkDirSubPath(t *testing.T) {
        assert.ElementsMatch(t, expected, walked)
 }
 
+func TestBlobFileIOWalkDirHierarchicalNamespace(t *testing.T) {
+       ctx := context.Background()
+
+       bucket := memblob.OpenBucket(nil)
+       defer bucket.Close()
+
+       // ADLS Gen2 hierarchical namespaces store each directory as a 0-byte
+       // blob named after it, with no trailing slash.
+       for _, k := range []string{"tbl", "tbl/data", "tbl/metadata"} {
+               require.NoError(t, bucket.WriteAll(ctx, k, nil, nil))
+       }
+       require.NoError(t, bucket.WriteAll(ctx, "tbl/data/a.parquet", 
[]byte("a"), nil))
+       require.NoError(t, bucket.WriteAll(ctx, 
"tbl/metadata/v1.metadata.json", []byte("b"), nil))
+       require.NoError(t, bucket.WriteAll(ctx, "tbl/empty.txt", nil, nil))
+
+       bfs := testBlobFileIO(ctx, "mybucket", bucket)
+
+       var dirs, files []string
+       err := bfs.WalkDir("s3://mybucket/tbl", func(path string, d 
fs.DirEntry, err error) error {
+               if err != nil {
+                       return err
+               }
+
+               if d.IsDir() {
+                       dirs = append(dirs, path)
+               } else {
+                       files = append(files, path)
+               }
+
+               return nil
+       })
+       require.NoError(t, err)
+
+       assert.Equal(t, []string{
+               "s3://mybucket/tbl",
+               "s3://mybucket/tbl/data",
+               "s3://mybucket/tbl/metadata",
+       }, dirs)
+       assert.Equal(t, []string{
+               "s3://mybucket/tbl/data/a.parquet",
+               "s3://mybucket/tbl/empty.txt",
+               "s3://mybucket/tbl/metadata/v1.metadata.json",
+       }, files)
+}
+
 func TestBlobFileIORawQueryFragmentRoundTrip(t *testing.T) {
        ctx := context.Background()
 

Reply via email to