tanmayrauth commented on code in PR #1350:
URL: https://github.com/apache/iceberg-go/pull/1350#discussion_r3509019419


##########
io/gocloud/blob.go:
##########
@@ -256,10 +319,178 @@ func (bfs *blobFileIO) DeleteFiles(ctx context.Context, 
paths []string) ([]strin
        return deleted, errs
 }
 
+// MkdirAll mimics creating a directory by creating a zero-length object for 
each component of the path
+func (bfs *BlobFileIO) MkdirAll(path string) error {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+       }
+
+       key = strings.Trim(key, "/")
+       if key == "" || key == "." {
+               return nil
+       }
+
+       parts := strings.Split(key, "/")
+       for idx := range parts {
+               marker := strings.Join(parts[:idx+1], "/") + "/"
+               if err := bfs.WriteAll(bfs.ctx, marker, nil, nil); err != nil {
+                       return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+               }
+       }
+
+       return nil
+}
+
+// ReadFile reads the contents of the file at the given path and returns it as 
a byte slice.
+func (bfs *BlobFileIO) ReadFile(path string) ([]byte, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "ReadFile", Path: path, Err: err}
+       }
+
+       data, err := bfs.ReadAll(bfs.ctx, key)
+       if err != nil {
+               return nil, blobErrToFsErr("ReadFile", path, err)
+       }
+
+       return data, nil
+}
+
+// Stat interprets the input path as a directory or file and returns the 
corresponding FileInfo.
+// If the path does not exist, it returns fs.ErrNotExist
+func (bfs *BlobFileIO) Stat(path string) (fs.FileInfo, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "Stat", Path: path, Err: err}
+       }
+
+       attrs, err := bfs.Attributes(bfs.ctx, key)
+       // if there is no error and we have attributes, we can return a 
FileInfo for object
+       if err == nil {
+               return blobFileInfo{
+                       name:    filepath.Base(key),
+                       size:    attrs.Size,
+                       mode:    fs.ModeIrregular,
+                       modTime: attrs.ModTime,
+                       sys:     attrs,
+               }, nil
+       }
+
+       if gcerrors.Code(err) != gcerrors.NotFound {
+               return nil, blobErrToFsErr("Stat", path, err)
+       }
+
+       marker := directoryMarker(key)
+       // if the marker exists, we can return a FileInfo for the directory
+       if marker != "" {
+               if attrs, markerErr := bfs.Attributes(bfs.ctx, marker); 
markerErr == nil {
+                       return blobFileInfo{
+                               name:    directoryName(key),
+                               mode:    fs.ModeDir,
+                               modTime: attrs.ModTime,
+                               sys:     attrs,
+                       }, nil
+               } else if gcerrors.Code(markerErr) != gcerrors.NotFound {
+                       return nil, blobErrToFsErr("Stat", path, markerErr)
+               }
+       }
+
+       prefix := marker
+       if prefix == "" {
+               prefix = key
+       }
+
+       iter := bfs.List(&blob.ListOptions{Prefix: prefix})
+       if obj, listErr := iter.Next(bfs.ctx); listErr == nil {
+               return blobFileInfo{
+                       name: directoryName(key),
+                       mode: fs.ModeDir,
+                       sys:  obj,
+               }, nil
+       } else if listErr != io.EOF {
+               return nil, blobErrToFsErr("Stat", path, listErr)
+       }
+
+       return nil, &fs.PathError{Op: "Stat", Path: path, Err: fs.ErrNotExist}
+}
+
+// Rename renames a file or directory from oldpath to newpath, replacing 
newpath if it already exists.
+func (bfs *BlobFileIO) Rename(oldpath, newpath string) error {
+       oldKey, err := bfs.preprocess(oldpath)
+       if err != nil {
+               return &fs.PathError{Op: "Rename", Path: oldpath, Err: err}
+       }
+
+       newKey, err := bfs.preprocess(newpath)
+       if err != nil {
+               return &fs.PathError{Op: "Rename", Path: newpath, Err: err}
+       }
+
+       if err := bfs.Copy(bfs.ctx, newKey, oldKey, nil); err != nil {
+               return blobErrToFsErr("Rename", oldpath, err)
+       }
+
+       if err := bfs.Delete(bfs.ctx, oldKey); err != nil && gcerrors.Code(err) 
!= gcerrors.NotFound {
+               return blobErrToFsErr("Rename", oldpath, err)
+       }
+
+       return nil
+}
+
+// RenameNoReplace renames a file or directory from oldpath to newpath, 
returning an error if newpath already exists.
+func (bfs *BlobFileIO) RenameNoReplace(oldpath, newpath string) error {

Review Comment:
   Just noting for the record since this is the commit primitive: this is 
Stat-then-Copy+Delete, so two concurrent writers can both pass the Stat check 
and both Copy — the losing commit is silently dropped rather than rejected. 
That's correctly gated behind allow-unsafe-commits with a fail-closed default, 
so no change needed here. One precision worth capturing in the follow-up 
tracking issue:  the hazard is concurrent lost-update, not single-writer crash 
corruption (S3 CopyObject is atomic at the destination). The real fix is a 
conditional-write primitive (S3 If-None-Match:*, GCS if-generation-match:0). Is 
that follow-up issue tracked anywhere yet?



##########
io/gocloud/blob.go:
##########
@@ -256,10 +319,178 @@ func (bfs *blobFileIO) DeleteFiles(ctx context.Context, 
paths []string) ([]strin
        return deleted, errs
 }
 
+// MkdirAll mimics creating a directory by creating a zero-length object for 
each component of the path
+func (bfs *BlobFileIO) MkdirAll(path string) error {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+       }
+
+       key = strings.Trim(key, "/")
+       if key == "" || key == "." {
+               return nil
+       }
+
+       parts := strings.Split(key, "/")
+       for idx := range parts {
+               marker := strings.Join(parts[:idx+1], "/") + "/"
+               if err := bfs.WriteAll(bfs.ctx, marker, nil, nil); err != nil {
+                       return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+               }
+       }
+
+       return nil
+}
+
+// ReadFile reads the contents of the file at the given path and returns it as 
a byte slice.
+func (bfs *BlobFileIO) ReadFile(path string) ([]byte, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "ReadFile", Path: path, Err: err}
+       }
+
+       data, err := bfs.ReadAll(bfs.ctx, key)
+       if err != nil {
+               return nil, blobErrToFsErr("ReadFile", path, err)
+       }
+
+       return data, nil
+}
+
+// Stat interprets the input path as a directory or file and returns the 
corresponding FileInfo.
+// If the path does not exist, it returns fs.ErrNotExist
+func (bfs *BlobFileIO) Stat(path string) (fs.FileInfo, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "Stat", Path: path, Err: err}
+       }
+
+       attrs, err := bfs.Attributes(bfs.ctx, key)
+       // if there is no error and we have attributes, we can return a 
FileInfo for object
+       if err == nil {
+               return blobFileInfo{
+                       name:    filepath.Base(key),
+                       size:    attrs.Size,
+                       mode:    fs.ModeIrregular,
+                       modTime: attrs.ModTime,
+                       sys:     attrs,
+               }, nil
+       }
+
+       if gcerrors.Code(err) != gcerrors.NotFound {
+               return nil, blobErrToFsErr("Stat", path, err)
+       }
+
+       marker := directoryMarker(key)
+       // if the marker exists, we can return a FileInfo for the directory
+       if marker != "" {
+               if attrs, markerErr := bfs.Attributes(bfs.ctx, marker); 
markerErr == nil {
+                       return blobFileInfo{
+                               name:    directoryName(key),
+                               mode:    fs.ModeDir,
+                               modTime: attrs.ModTime,
+                               sys:     attrs,
+                       }, nil
+               } else if gcerrors.Code(markerErr) != gcerrors.NotFound {
+                       return nil, blobErrToFsErr("Stat", path, markerErr)
+               }
+       }
+
+       prefix := marker
+       if prefix == "" {
+               prefix = key
+       }
+
+       iter := bfs.List(&blob.ListOptions{Prefix: prefix})
+       if obj, listErr := iter.Next(bfs.ctx); listErr == nil {
+               return blobFileInfo{
+                       name: directoryName(key),
+                       mode: fs.ModeDir,
+                       sys:  obj,
+               }, nil
+       } else if listErr != io.EOF {
+               return nil, blobErrToFsErr("Stat", path, listErr)
+       }
+
+       return nil, &fs.PathError{Op: "Stat", Path: path, Err: fs.ErrNotExist}
+}
+
+// Rename renames a file or directory from oldpath to newpath, replacing 
newpath if it already exists.

Review Comment:
     Doc says "renames a file or directory", but the body is a single 
Copy(newKey, oldKey) — with the marker layout, a directory's children (and its 
own marker) wouldn't move. Current callers only rename single files so it's 
fine in practice; can we tighten the comment to single-object rename to avoid 
surprising a future caller?



##########
io/gocloud/blob.go:
##########
@@ -102,13 +104,65 @@ type blobFileIO struct {
        newRangeReader func(ctx context.Context, key string, offset, length 
int64) (io.ReadCloser, error)
 }
 
-var _ icebergio.ListableIO = (*blobFileIO)(nil)
+var _ icebergio.ListableIO = (*BlobFileIO)(nil)
 
-func (bfs *blobFileIO) preprocess(path string) (string, error) {
+type blobFileInfo struct {
+       name    string
+       size    int64
+       mode    fs.FileMode
+       modTime time.Time
+       sys     any
+}
+
+// blobFileInfo implements the fs.FileInfo interface for a blob object
+var _ fs.FileInfo = (*blobFileInfo)(nil)
+
+func (f blobFileInfo) Name() string       { return f.name }
+func (f blobFileInfo) Size() int64        { return f.size }
+func (f blobFileInfo) Mode() fs.FileMode  { return f.mode }
+func (f blobFileInfo) ModTime() time.Time { return f.modTime }
+func (f blobFileInfo) IsDir() bool        { return f.mode.IsDir() }
+func (f blobFileInfo) Sys() any           { return f.sys }
+
+// preprocess returns the object key from an input path
+func (bfs *BlobFileIO) preprocess(path string) (string, error) {
        return bfs.keyExtractor(path)
 }
 
-func (bfs *blobFileIO) Open(path string) (icebergio.File, error) {
+// blobErrToFsErr converts a gocloud blob error to an error from the fs 
package; this is
+// necessary for comply with certain iceberg-go/io interface functions that 
expect fs.ErrNotExist
+// for missing files, rather than the gocloud error. If there is no 
corresponding fs error, or the
+// mapped error wouldn't be needed, the original error is returned.
+func blobErrToFsErr(op, name string, err error) error {

Review Comment:
   Small doc mismatch: the comment says the original error is returned when 
there's no fs mapping, but this always wraps in *fs.PathError. Behavior's fine 
(errors.Is still works through the wrap) — just the comment.



##########
catalog/hadoop/hadoop_minio_integration_test.go:
##########
@@ -0,0 +1,181 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build integration
+
+package hadoop_test
+
+import (
+       "context"
+       "fmt"
+       "strings"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/catalog"
+       "github.com/apache/iceberg-go/catalog/hadoop"
+       icebergio "github.com/apache/iceberg-go/io"
+       _ "github.com/apache/iceberg-go/io/gocloud"
+       "github.com/apache/iceberg-go/table"
+       "github.com/google/uuid"
+       "github.com/stretchr/testify/suite"
+)
+
+type HadoopMinIOIntegrationSuite struct {
+       suite.Suite
+
+       ctx       context.Context
+       cat       *hadoop.Catalog
+       props     iceberg.Properties
+       warehouse string
+}
+
+func (s *HadoopMinIOIntegrationSuite) SetupTest() {
+       s.ctx = context.Background()
+       s.warehouse = fmt.Sprintf("s3a://warehouse/hadoop-minio/%s", 
uuid.NewString())
+       s.props = iceberg.Properties{
+               "allow-unsafe-commits":      "true",
+               icebergio.S3Region:          "local",
+               icebergio.S3AccessKeyID:     "admin",
+               icebergio.S3SecretAccessKey: "password",
+               // Endpoint is provided by the env var AWS_S3_ENDPOINT
+               // from `make integration-env` or recipe.Start.
+       }
+
+       cat, err := hadoop.NewCatalog("hadoop_minio_test", s.warehouse, s.props)
+       s.Require().NoError(err)
+       s.cat = cat
+}
+
+func (s *HadoopMinIOIntegrationSuite) testSchema() *iceberg.Schema {
+       return iceberg.NewSchema(
+               1,
+               iceberg.NestedField{ID: 1, Name: "id", Type: 
iceberg.PrimitiveTypes.Int64, Required: true},
+               iceberg.NestedField{ID: 2, Name: "name", Type: 
iceberg.PrimitiveTypes.String, Required: false},
+       )
+}
+
+func (s *HadoopMinIOIntegrationSuite) TearDownTest() {
+       fs, err := icebergio.LoadFS(s.ctx, s.props, s.warehouse)
+       if err != nil {
+               return
+       }
+
+       if remover, ok := fs.(icebergio.RemoveAllIO); ok {
+               _ = remover.RemoveAll(s.warehouse)
+       }
+}
+
+func (s *HadoopMinIOIntegrationSuite) TestNamespaceTableRoundTrip() {
+       ns := table.Identifier{"minio_ns"}
+       ident := table.Identifier{"minio_ns", "tbl"}
+
+       s.Require().NoError(s.cat.CreateNamespace(s.ctx, ns, nil))
+
+       exists, err := s.cat.CheckNamespaceExists(s.ctx, ns)
+       s.Require().NoError(err)
+       s.True(exists)
+
+       tbl, err := s.cat.CreateTable(s.ctx, ident, s.testSchema())
+       s.Require().NoError(err)
+       s.NotNil(tbl)
+       s.True(strings.HasPrefix(tbl.MetadataLocation(), 
s.warehouse+"/minio_ns/tbl/metadata/v1.metadata.json"))
+
+       loaded, err := s.cat.LoadTable(s.ctx, ident)
+       s.Require().NoError(err)
+       s.Equal(tbl.MetadataLocation(), loaded.MetadataLocation())
+       s.Equal(2, len(loaded.Schema().Fields()))
+
+       var tables []table.Identifier
+       for found, err := range s.cat.ListTables(s.ctx, ns) {
+               s.Require().NoError(err)
+               tables = append(tables, found)
+       }
+       s.Equal([]table.Identifier{ident}, tables)
+
+       s.Require().NoError(s.cat.DropTable(s.ctx, ident))
+
+       tableExists, err := s.cat.CheckTableExists(s.ctx, ident)
+       s.Require().NoError(err)
+       s.False(tableExists)
+}
+
+func (s *HadoopMinIOIntegrationSuite) TestListTablesEmptyNamespace() {
+       ns := table.Identifier{"empty_ns"}
+       s.Require().NoError(s.cat.CreateNamespace(s.ctx, ns, nil))
+
+       var tables []table.Identifier
+       for found, err := range s.cat.ListTables(s.ctx, ns) {
+               s.Require().NoError(err)
+               tables = append(tables, found)
+       }
+       s.Empty(tables)
+}
+
+func (s *HadoopMinIOIntegrationSuite) TestListTablesMissingNamespace() {
+       for _, err := range s.cat.ListTables(s.ctx, 
table.Identifier{"missing_ns"}) {
+               s.ErrorIs(err, catalog.ErrNoSuchNamespace)
+
+               break
+       }
+}
+
+func (s *HadoopMinIOIntegrationSuite) 
TestListTablesOnlyDirectNamespaceTables() {
+       parent := table.Identifier{"parent_ns"}
+       child := table.Identifier{"parent_ns", "child_ns"}
+       directTable := table.Identifier{"parent_ns", "direct_tbl"}
+       childTable := table.Identifier{"parent_ns", "child_ns", "nested_tbl"}
+
+       s.Require().NoError(s.cat.CreateNamespace(s.ctx, parent, nil))
+       s.Require().NoError(s.cat.CreateNamespace(s.ctx, child, nil))
+       _, err := s.cat.CreateTable(s.ctx, directTable, s.testSchema())
+       s.Require().NoError(err)
+       _, err = s.cat.CreateTable(s.ctx, childTable, s.testSchema())
+       s.Require().NoError(err)
+
+       var tables []table.Identifier
+       for found, err := range s.cat.ListTables(s.ctx, parent) {
+               s.Require().NoError(err)
+               tables = append(tables, found)
+       }
+       s.Equal([]table.Identifier{directTable}, tables)
+}
+
+func (s *HadoopMinIOIntegrationSuite) 
TestDropTableRemovesTableFromListingAndLoad() {
+       ns := table.Identifier{"drop_ns"}
+       ident := table.Identifier{"drop_ns", "tbl"}
+
+       s.Require().NoError(s.cat.CreateNamespace(s.ctx, ns, nil))
+       _, err := s.cat.CreateTable(s.ctx, ident, s.testSchema())
+       s.Require().NoError(err)
+
+       s.Require().NoError(s.cat.DropTable(s.ctx, ident))
+
+       _, err = s.cat.LoadTable(s.ctx, ident)
+       s.ErrorIs(err, catalog.ErrNoSuchTable)
+
+       var tables []table.Identifier
+       for found, err := range s.cat.ListTables(s.ctx, ns) {
+               s.Require().NoError(err)
+               tables = append(tables, found)
+       }
+       s.Empty(tables)
+}
+
+func TestHadoopMinIOIntegration(t *testing.T) {

Review Comment:
   The repo's `integration-hadoop` make target runs 
`-run="^TestHadoopIntegration"`, and this name (TestHadoopMinIOIntegration) 
doesn't match that anchored pattern — it diverges at "Hadoop*M*inIO". So this 
whole suite is skipped in CI and the green checks aren't exercising any of the 
new blob layout. Could we rename this to TestHadoopIntegrationMinIO so the 
existing target picks it up (or otherwise wire it into the integration run)? 
Everything else here really wants this suite executing.



##########
io/gocloud/blob.go:
##########
@@ -256,10 +319,178 @@ func (bfs *blobFileIO) DeleteFiles(ctx context.Context, 
paths []string) ([]strin
        return deleted, errs
 }
 
+// MkdirAll mimics creating a directory by creating a zero-length object for 
each component of the path
+func (bfs *BlobFileIO) MkdirAll(path string) error {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+       }
+
+       key = strings.Trim(key, "/")
+       if key == "" || key == "." {
+               return nil
+       }
+
+       parts := strings.Split(key, "/")
+       for idx := range parts {
+               marker := strings.Join(parts[:idx+1], "/") + "/"
+               if err := bfs.WriteAll(bfs.ctx, marker, nil, nil); err != nil {
+                       return &fs.PathError{Op: "mkdir", Path: path, Err: err}
+               }
+       }
+
+       return nil
+}
+
+// ReadFile reads the contents of the file at the given path and returns it as 
a byte slice.
+func (bfs *BlobFileIO) ReadFile(path string) ([]byte, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "ReadFile", Path: path, Err: err}
+       }
+
+       data, err := bfs.ReadAll(bfs.ctx, key)
+       if err != nil {
+               return nil, blobErrToFsErr("ReadFile", path, err)
+       }
+
+       return data, nil
+}
+
+// Stat interprets the input path as a directory or file and returns the 
corresponding FileInfo.
+// If the path does not exist, it returns fs.ErrNotExist
+func (bfs *BlobFileIO) Stat(path string) (fs.FileInfo, error) {
+       key, err := bfs.preprocess(path)
+       if err != nil {
+               return nil, &fs.PathError{Op: "Stat", Path: path, Err: err}
+       }
+
+       attrs, err := bfs.Attributes(bfs.ctx, key)
+       // if there is no error and we have attributes, we can return a 
FileInfo for object
+       if err == nil {
+               return blobFileInfo{
+                       name:    filepath.Base(key),

Review Comment:
    filepath.Base here vs path.Base in directoryName for the dir case — object 
keys are always  '/'-separated, so filepath.Base won't split them on Windows. 
Suggest path.Base for consistency.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to