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 d6624129f refactor(hadoop)!: split catalog operations and preserve 
initialization context (#2290)
d6624129f is described below

commit d6624129f01fac7aed407d7169e9db70ae1e6c04
Author: Minh Vu <[email protected]>
AuthorDate: Mon Oct 26 18:08:58 2026 +0100

    refactor(hadoop)!: split catalog operations and preserve initialization 
context (#2290)
    
    * refactor(hadoop)!: split catalog operations and preserve initialization 
context
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * chore: sync release notes with main to resolve conflicts
    
    ---------
    
    Signed-off-by: Minh Vu <[email protected]>
    Co-authored-by: Matt Topol <[email protected]>
---
 catalog/hadoop/conformance_test.go           |   3 +-
 catalog/hadoop/hadoop.go                     | 666 +--------------------------
 catalog/hadoop/hadoop_context_test.go        | 215 +++++++++
 catalog/hadoop/hadoop_integration_test.go    |   2 +-
 catalog/hadoop/hadoop_s3_integration_test.go |   2 +-
 catalog/hadoop/hadoop_test.go                |  22 +-
 catalog/hadoop/namespaces.go                 | 242 ++++++++++
 catalog/hadoop/tables.go                     | 468 +++++++++++++++++++
 cmd/iceberg/main.go                          |   2 +-
 website/src/api.md                           |   3 +-
 10 files changed, 960 insertions(+), 665 deletions(-)

diff --git a/catalog/hadoop/conformance_test.go 
b/catalog/hadoop/conformance_test.go
index 33c0e1a7f..72e74eaa3 100644
--- a/catalog/hadoop/conformance_test.go
+++ b/catalog/hadoop/conformance_test.go
@@ -18,6 +18,7 @@
 package hadoop_test
 
 import (
+       "context"
        "testing"
 
        "github.com/apache/iceberg-go/catalog"
@@ -29,7 +30,7 @@ import (
 func TestHadoopCatalogConformance(t *testing.T) {
        catalogtest.RunCatalogTests(t, catalogtest.Config{
                NewCatalog: func(t *testing.T) catalog.Catalog {
-                       cat, err := hadoop.NewCatalog("test", t.TempDir(), nil)
+                       cat, err := hadoop.NewCatalog(context.Background(), 
"test", t.TempDir(), nil)
                        require.NoError(t, err)
                        t.Cleanup(func() { require.NoError(t, cat.Close()) })
 
diff --git a/catalog/hadoop/hadoop.go b/catalog/hadoop/hadoop.go
index 005015b9c..7f4e5b656 100644
--- a/catalog/hadoop/hadoop.go
+++ b/catalog/hadoop/hadoop.go
@@ -28,33 +28,29 @@ import (
        "errors"
        "fmt"
        "io/fs"
-       "iter"
        "log"
        "net/url"
        "path"
        "path/filepath"
        "regexp"
-       "slices"
        "strconv"
        "strings"
        "time"
 
        "github.com/apache/iceberg-go"
        "github.com/apache/iceberg-go/catalog"
-       "github.com/apache/iceberg-go/catalog/internal"
        "github.com/apache/iceberg-go/io"
        "github.com/apache/iceberg-go/metrics"
        "github.com/apache/iceberg-go/table"
-       "github.com/apache/iceberg-go/table/maintenance"
        "github.com/google/uuid"
 )
 
 func init() {
        catalog.Register(string(catalog.Hadoop), catalog.RegistrarFunc(
-               func(_ context.Context, name string, props iceberg.Properties) 
(catalog.Catalog, error) {
+               func(ctx context.Context, name string, props 
iceberg.Properties) (catalog.Catalog, error) {
                        warehouse := props.Get("warehouse", "")
 
-                       return NewCatalog(name, warehouse, props)
+                       return NewCatalog(ctx, name, warehouse, props)
                },
        ))
 }
@@ -198,11 +194,13 @@ type Catalog struct {
 }
 
 // NewCatalog creates a new Hadoop catalog rooted at the given warehouse path.
+// The caller context is checked for cancellation around filesystem setup.
+// Its values, but not its cancellation or deadline, are kept for later I/O.
 // When using a local filesystem, the warehouse directory
 // is not created on construction; it is created implicitly by the first 
CreateNamespace
 // call. When using other schemes, the property `allow-unsafe-commits` must be 
set to
 // true since custom schemes and blob filesystems do not have the same 
atomicity guarantees.
-func NewCatalog(name, warehouse string, props iceberg.Properties) (*Catalog, 
error) {
+func NewCatalog(ctx context.Context, name, warehouse string, props 
iceberg.Properties) (*Catalog, error) {
        if warehouse == "" {
                return nil, errors.New("hadoop catalog requires a warehouse 
path")
        }
@@ -241,8 +239,18 @@ func NewCatalog(name, warehouse string, props 
iceberg.Properties) (*Catalog, err
 
                warehouse = absWarehouse
        }
-       // TODO: propagate caller context once NewCatalog accepts one
-       filesystem, err := io.LoadFS(context.Background(), props, warehouse)
+       if err := ctx.Err(); err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to load 
filesystem: %w", err)
+       }
+
+       // Some filesystem implementations retain their construction context for
+       // every later operation. Keep the caller's values, but not its 
cancellation
+       // or deadline, for the lifetime of the catalog. The original context is
+       // checked before and after initialization so canceled setup still 
fails.
+       filesystem, err := io.LoadFS(context.WithoutCancel(ctx), props, 
warehouse)
+       if ctxErr := ctx.Err(); ctxErr != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to load 
filesystem: %w", ctxErr)
+       }
        if err != nil {
                return nil, fmt.Errorf("hadoop catalog: failed to load 
filesystem: %w", err)
        }
@@ -503,643 +511,3 @@ func (c *Catalog) findVersion(ident table.Identifier) 
(int, error) {
 
        return version, nil
 }
-
-func (c *Catalog) CreateTable(ctx context.Context, ident table.Identifier, sc 
*iceberg.Schema, opts ...catalog.CreateTableOpt) (*table.Table, error) {
-       var cfg catalog.CreateTableCfg
-       for _, opt := range opts {
-               opt(&cfg)
-       }
-
-       if err := validateTableIdentifier(ident); err != nil {
-               return nil, err
-       }
-
-       // Resolve the reporter before any catalog mutation: a bad
-       // metrics-reporter-impl must fail here, not after the metadata file and
-       // version hint are written, which would report a failure for a table 
that
-       // was actually created (and make the retry hit ErrTableAlreadyExists).
-       reporter, err := c.reporter.Get(c.props)
-       if err != nil {
-               return nil, fmt.Errorf("failed to initialize metrics reporter: 
%w", err)
-       }
-
-       ns := catalog.NamespaceFromIdent(ident)
-       nsPath := c.namespaceToPath(ns)
-
-       info, err := c.filesystem.Stat(nsPath)
-       if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) {
-               return nil, fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
-       }
-
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace directory: %w", err)
-       }
-
-       loc := c.defaultTableLocation(ident)
-       if cfg.Location != "" && cfg.Location != loc {
-               return nil, errors.New("hadoop catalog: custom table locations 
are not supported")
-       }
-
-       exists, err := isTableDir(c.filesystem, c.isLocal, loc)
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to inspect table 
directory: %w", err)
-       }
-       if exists {
-               return nil, fmt.Errorf("%w: %s", catalog.ErrTableAlreadyExists, 
strings.Join(ident, "."))
-       }
-
-       metadata, err := table.NewMetadata(sc, cfg.PartitionSpec, 
cfg.SortOrder, loc, cfg.Properties)
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to create table 
metadata: %w", err)
-       }
-
-       metaDir := c.metadataDir(ident)
-       if err := c.filesystem.MkdirAll(metaDir); err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to create 
metadata directory: %w", err)
-       }
-
-       version := 1
-       compression := table.MetadataCompressionDefault
-       if cfg.Properties != nil {
-               if v, ok := cfg.Properties[table.MetadataCompressionKey]; ok {
-                       compression = v
-               }
-       }
-
-       metaPath, err := c.metadataFilePathForCompression(ident, version, 
compression)
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to determine 
metadata file path: %w", err)
-       }
-
-       tempPath := joinPath(c.isLocal, metaDir, 
uuid.New().String()+".metadata.json")
-
-       if err := internal.WriteTableMetadata(metadata, c.filesystem, tempPath, 
compression); err != nil {
-               _ = c.filesystem.Remove(context.WithoutCancel(ctx), tempPath)
-
-               return nil, fmt.Errorf("hadoop catalog: failed to write table 
metadata: %w", err)
-       }
-
-       if err := c.commitMetadataFile(ctx, ident, version, tempPath, metaPath, 
catalog.ErrTableAlreadyExists); err != nil {
-               return nil, err
-       }
-
-       c.writeVersionHint(ident, version)
-
-       tbl := table.New(
-               ident,
-               metadata,
-               metaPath,
-               io.LoadFSFunc(c.props, metaPath),
-               c,
-               table.WithMetricsReporter(reporter),
-               table.WithScanPlanningIOProperties(c.props),
-       )
-
-       return tbl, nil
-}
-
-func (c *Catalog) loadTable(ctx context.Context, ident table.Identifier) 
(*table.Table, int, error) {
-       if err := validateTableIdentifier(ident); err != nil {
-               return nil, 0, err
-       }
-
-       metaPath, version, err := c.findMetadataLocation(ident)
-       if err != nil {
-               return nil, 0, err
-       }
-
-       reporter, err := c.reporter.Get(c.props)
-       if err != nil {
-               return nil, 0, fmt.Errorf("failed to initialize metrics 
reporter: %w", err)
-       }
-
-       tbl, err := table.NewFromLocation(
-               ctx, ident, metaPath, io.LoadFSFunc(c.props, metaPath), c,
-               table.WithMetricsReporter(reporter),
-               table.WithScanPlanningIOProperties(c.props),
-       )
-       if err != nil {
-               return nil, 0, err
-       }
-
-       return tbl, version, nil
-}
-
-func (c *Catalog) LoadTable(ctx context.Context, ident table.Identifier) 
(*table.Table, error) {
-       tbl, _, err := c.loadTable(ctx, ident)
-
-       return tbl, err
-}
-
-func (c *Catalog) CheckTableExists(_ context.Context, ident table.Identifier) 
(bool, error) {
-       if err := validateTableIdentifier(ident); err != nil {
-               return false, err
-       }
-
-       // Direct existence checks surface metadata directory read errors; 
listing
-       // paths use isTableDirForListing to skip unreadable entries.
-       exists, err := isTableDir(c.filesystem, c.isLocal, c.tableToPath(ident))
-       if err != nil {
-               return false, err
-       }
-
-       return exists, nil
-}
-
-func (c *Catalog) CommitTable(ctx context.Context, ident table.Identifier, 
reqs []table.Requirement, updates []table.Update) (table.Metadata, string, 
error) {
-       if err := validateTableIdentifier(ident); err != nil {
-               return nil, "", err
-       }
-
-       // Step 1: Load current table (nil for create-via-commit).
-       current, currentVersion, err := c.loadTable(ctx, ident)
-       if err != nil && !errors.Is(err, catalog.ErrNoSuchTable) {
-               return nil, "", err
-       }
-
-       // Step 2: Validate requirements against current metadata.
-       var currentMetadata table.Metadata
-       if current != nil {
-               currentMetadata = current.Metadata()
-       }
-       if err := internal.ValidateRequirements(ident, currentMetadata, reqs); 
err != nil {
-               return nil, "", err
-       }
-
-       // Step 3: Apply updates to produce new metadata.
-       var baseMeta table.Metadata
-       var currentMetadataLoc string
-
-       if current != nil {
-               baseMeta = current.Metadata()
-               currentMetadataLoc = current.MetadataLocation()
-       } else {
-               baseMeta, err = table.NewMetadata(iceberg.NewSchema(0), nil, 
table.UnsortedSortOrder, "", nil)
-               if err != nil {
-                       return nil, "", fmt.Errorf("hadoop catalog: failed to 
create base metadata: %w", err)
-               }
-       }
-
-       updated, err := internal.UpdateTableMetadata(baseMeta, updates, 
currentMetadataLoc)
-       if err != nil {
-               return nil, "", fmt.Errorf("hadoop catalog: failed to apply 
updates: %w", err)
-       }
-
-       // Step 4: Validate table location has not changed.
-       if current != nil && updated.Location() != current.Location() {
-               return nil, "", errors.New("hadoop catalog: table location 
cannot be changed")
-       }
-
-       // Step 5: Reject write.metadata.location property.
-       if v := updated.Properties().Get(table.WriteMetadataLocationKey, ""); v 
!= "" {
-               return nil, "", fmt.Errorf("hadoop catalog: %s property is not 
supported", table.WriteMetadataLocationKey)
-       }
-
-       // Step 6: Determine next version number.
-       newVersion := currentVersion + 1
-
-       // Step 7: Create metadata directory if needed (create-via-commit).
-       metaDir := c.metadataDir(ident)
-       if err := c.filesystem.MkdirAll(metaDir); err != nil {
-               return nil, "", fmt.Errorf("hadoop catalog: failed to create 
metadata directory: %w", err)
-       }
-
-       compression := updated.Properties().Get(table.MetadataCompressionKey, 
table.MetadataCompressionDefault)
-       newMetaPath, err := c.metadataFilePathForCompression(ident, newVersion, 
compression)
-       if err != nil {
-               return nil, "", fmt.Errorf("hadoop catalog: failed to determine 
metadata file path: %w", err)
-       }
-
-       tempPath := joinPath(c.isLocal, metaDir, 
uuid.New().String()+".metadata.json")
-
-       if err := internal.WriteTableMetadata(updated, c.filesystem, tempPath, 
compression); err != nil {
-               _ = c.filesystem.Remove(context.WithoutCancel(ctx), tempPath)
-
-               return nil, "", fmt.Errorf("hadoop catalog: failed to write 
table metadata: %w", err)
-       }
-
-       if err := c.commitMetadataFile(ctx, ident, newVersion, tempPath, 
newMetaPath, table.ErrCommitFailed); err != nil {
-               return nil, "", err
-       }
-
-       // Step 8: Best-effort version hint update.
-       c.writeVersionHint(ident, newVersion)
-
-       return updated, newMetaPath, nil
-}
-
-func (c *Catalog) commitMetadataFile(ctx context.Context, ident 
table.Identifier, version int, tempPath, metaPath string, conflictErr error) 
error {
-       claimPath := c.metadataVersionClaimPath(ident, version)
-       // Cleanup on a failed commit must not inherit ctx's cancellation, or a
-       // canceled commit would leave the temp file or the version claim 
behind.
-       cleanupCtx := context.WithoutCancel(ctx)
-       for {
-               if err := c.filesystem.RenameNoReplace(tempPath, claimPath); 
err != nil {
-                       if !errors.Is(err, fs.ErrExist) {
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("hadoop catalog: failed to 
claim metadata version: %w", err)
-                       }
-
-                       existingPath, exists, err := 
c.metadataVersionLocation(ident, version)
-                       if err != nil {
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("hadoop catalog: failed to 
inspect metadata directory for version %d: %w",
-                                       version, err)
-                       }
-                       if exists {
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("%w: metadata file already 
exists for table %s: %s",
-                                       conflictErr, strings.Join(ident, "."), 
existingPath)
-                       }
-
-                       claimInfo, err := c.filesystem.Stat(claimPath)
-                       if err != nil {
-                               if errors.Is(err, fs.ErrNotExist) {
-                                       continue
-                               }
-
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("hadoop catalog: failed to 
inspect stale metadata claim %s: %w", claimPath, err)
-                       }
-                       if time.Since(claimInfo.ModTime()) < 
metadataClaimStaleAfter {
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("%w: metadata version already 
claimed for table %s: %s",
-                                       conflictErr, strings.Join(ident, "."), 
claimPath)
-                       }
-
-                       if err := c.filesystem.Remove(ctx, claimPath); err != 
nil && !errors.Is(err, fs.ErrNotExist) {
-                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
-
-                               return fmt.Errorf("hadoop catalog: failed to 
clear stale metadata claim %s: %w", claimPath, err)
-                       }
-
-                       continue
-               }
-
-               break
-       }
-
-       removeClaim := true
-       defer func() {
-               if removeClaim {
-                       _ = c.filesystem.Remove(cleanupCtx, claimPath)
-               }
-       }()
-
-       existingPath, exists, err := c.metadataVersionLocation(ident, version)
-       if err != nil {
-               return fmt.Errorf("hadoop catalog: failed to inspect metadata 
directory for version %d: %w",
-                       version, err)
-       }
-       if exists {
-               return fmt.Errorf("%w: metadata file already exists for table 
%s: %s",
-                       conflictErr, strings.Join(ident, "."), existingPath)
-       }
-
-       if err := c.filesystem.RenameNoReplace(claimPath, metaPath); err != nil 
{
-               if errors.Is(err, fs.ErrExist) {
-                       return fmt.Errorf("%w: metadata file already exists for 
table %s: %s",
-                               conflictErr, strings.Join(ident, "."), metaPath)
-               }
-               if errors.Is(err, fs.ErrNotExist) {
-                       existingPath, exists, err := 
c.metadataVersionLocation(ident, version)
-                       if err != nil {
-                               return fmt.Errorf("hadoop catalog: failed to 
inspect metadata directory for version %d: %w",
-                                       version, err)
-                       }
-                       if exists {
-                               return fmt.Errorf("%w: metadata file already 
exists for table %s: %s",
-                                       conflictErr, strings.Join(ident, "."), 
existingPath)
-                       }
-
-                       return fmt.Errorf("%w: metadata claim for table %s and 
version %d was removed before publish",
-                               conflictErr, strings.Join(ident, "."), version)
-               }
-
-               return fmt.Errorf("hadoop catalog: failed to commit metadata 
file: %w", err)
-       }
-
-       removeClaim = false
-
-       return nil
-}
-
-func (c *Catalog) ListTables(_ context.Context, ns table.Identifier) 
iter.Seq2[table.Identifier, error] {
-       return func(yield func(table.Identifier, error) bool) {
-               if err := validateIdentifier(ns); err != nil {
-                       yield(nil, err)
-
-                       return
-               }
-
-               nsPath := c.namespaceToPath(ns)
-
-               info, err := c.filesystem.Stat(nsPath)
-               if errors.Is(err, fs.ErrNotExist) || (err == nil && 
!info.IsDir()) {
-                       yield(nil, fmt.Errorf("%w: %s", 
catalog.ErrNoSuchNamespace, strings.Join(ns, ".")))
-
-                       return
-               }
-
-               if err != nil {
-                       yield(nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace: %w", err))
-
-                       return
-               }
-
-               err = c.filesystem.WalkDir(nsPath, func(path string, d 
fs.DirEntry, err error) error {
-                       if err != nil {
-                               if path != nsPath && 
shouldSuppressPermissionError(err) {
-                                       log.Printf("hadoop catalog: unable to 
inspect path %s while listing tables: %v", path, err)
-
-                                       return fs.SkipDir
-                               }
-
-                               return err
-                       }
-
-                       // Anything that is a file is not a namespace or table, 
so skip it.
-                       if !d.IsDir() {
-                               return nil
-                       }
-
-                       // Skip the namespace directory itself.
-                       if path == nsPath {
-                               return nil
-                       }
-
-                       // Skip anything that is not a table directory.
-                       isTable, err := isTableDirForListing(c.filesystem, 
c.isLocal, path)
-                       if err != nil {
-                               return err
-                       }
-                       if !isTable {
-                               return fs.SkipDir
-                       }
-                       ident := make(table.Identifier, len(ns)+1)
-                       copy(ident, ns)
-                       ident[len(ns)] = d.Name()
-                       if !yield(ident, nil) {
-                               return fs.SkipAll
-                       }
-                       // If a table has been found, then that directory
-                       // doesn't need to be walked further
-                       return fs.SkipDir
-               })
-               if err != nil {
-                       yield(nil, fmt.Errorf("hadoop catalog: failed to read 
namespace directory: %w", err))
-
-                       return
-               }
-       }
-}
-
-func (c *Catalog) DropTable(ctx context.Context, ident table.Identifier) error 
{
-       if _, _, err := c.loadTable(ctx, ident); err != nil {
-               return err
-       }
-
-       tablePath := c.tableToPath(ident)
-
-       return c.filesystem.RemoveAll(tablePath)
-}
-
-func (c *Catalog) PurgeTable(ctx context.Context, identifier table.Identifier) 
error {
-       if err := validateTableIdentifier(identifier); err != nil {
-               return err
-       }
-
-       tbl, err := c.LoadTable(ctx, identifier)
-       if err != nil {
-               return err
-       }
-
-       // For Hadoop catalog, physical files walk must run BEFORE deleting the 
table directory root
-       if purgeErr := maintenance.PurgeFiles(ctx, tbl); purgeErr != nil {
-               log.Printf("WARNING: failing to purge some files in Hadoop 
table %s: %v", identifier, purgeErr)
-       }
-
-       if !tbl.Metadata().Properties().GetBool("gc.enabled", true) {
-               return c.filesystem.RemoveAll(c.metadataDir(identifier))
-       }
-
-       return c.filesystem.RemoveAll(c.tableToPath(identifier))
-}
-
-func (c *Catalog) RenameTable(_ context.Context, _, _ table.Identifier) 
(*table.Table, error) {
-       return nil, fmt.Errorf("%w: hadoop catalog: rename table is not 
supported", iceberg.ErrNotImplemented)
-}
-
-func (c *Catalog) CreateNamespace(_ context.Context, ns table.Identifier, 
props iceberg.Properties) error {
-       if err := validateIdentifier(ns); err != nil {
-               return err
-       }
-
-       if len(props) > 0 {
-               return fmt.Errorf("%w: hadoop catalog: namespace properties are 
not supported", iceberg.ErrNotImplemented)
-       }
-
-       // Raise an error if the namespace already exists
-       if err := c.checkedMkdirAll(ns); err != nil {
-               if errors.Is(err, fs.ErrExist) {
-                       return fmt.Errorf("%w: %s", 
catalog.ErrNamespaceAlreadyExists, strings.Join(ns, "."))
-               }
-
-               if errors.Is(err, fs.ErrNotExist) {
-                       return fmt.Errorf("%w: parent namespace does not exist 
for %s",
-                               catalog.ErrNoSuchNamespace, strings.Join(ns, 
"."))
-               }
-
-               return fmt.Errorf("hadoop catalog: failed to create namespace: 
%w", err)
-       }
-
-       return nil
-}
-
-func (c *Catalog) DropNamespace(ctx context.Context, ns table.Identifier) 
error {
-       if err := validateIdentifier(ns); err != nil {
-               return err
-       }
-
-       path := c.namespaceToPath(ns)
-
-       // Walk the namespace directory directly so existence and type checks 
use the
-       // same filesystem view. The root entry preserves file-at-namespace 
handling.
-       rootNotDir := false
-       foundEntries := false
-       err := c.filesystem.WalkDir(path, func(p string, d fs.DirEntry, err 
error) error {
-               if err != nil {
-                       return err
-               }
-
-               if p == path {
-                       rootNotDir = !d.IsDir()
-
-                       return nil
-               }
-
-               foundEntries = true
-
-               return fs.SkipAll
-       })
-       if err != nil {
-               if errors.Is(err, fs.ErrNotExist) {
-                       return fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
-               }
-
-               return fmt.Errorf("hadoop catalog: failed to read namespace 
directory: %w", err)
-       }
-
-       if rootNotDir {
-               return fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
-       }
-
-       if foundEntries {
-               return fmt.Errorf("%w: %s", catalog.ErrNamespaceNotEmpty, 
strings.Join(ns, "."))
-       }
-
-       return c.filesystem.Remove(ctx, path)
-}
-
-func (c *Catalog) CheckNamespaceExists(_ context.Context, ns table.Identifier) 
(bool, error) {
-       if err := validateIdentifier(ns); err != nil {
-               return false, err
-       }
-
-       path := c.namespaceToPath(ns)
-
-       info, err := c.filesystem.Stat(path)
-       if errors.Is(err, fs.ErrNotExist) {
-               return false, nil
-       }
-
-       if err != nil {
-               return false, err
-       }
-
-       return info.IsDir(), nil
-}
-
-func (c *Catalog) ListNamespaces(_ context.Context, parent table.Identifier) 
([]table.Identifier, error) {
-       if len(parent) > 0 {
-               if err := validateIdentifier(parent); err != nil {
-                       return nil, err
-               }
-       }
-
-       var path string
-
-       if len(parent) == 0 {
-               path = c.warehouse
-       } else {
-               path = c.namespaceToPath(parent)
-
-               info, err := c.filesystem.Stat(path)
-               if errors.Is(err, fs.ErrNotExist) || (err == nil && 
!info.IsDir()) {
-                       return nil, fmt.Errorf("%w: %s", 
catalog.ErrNoSuchNamespace, strings.Join(parent, "."))
-               }
-       }
-
-       result := []table.Identifier{}
-       err := c.filesystem.WalkDir(path, func(p string, d fs.DirEntry, err 
error) error {
-               if err != nil {
-                       if p != path && shouldSuppressPermissionError(err) {
-                               log.Printf("hadoop catalog: unable to inspect 
path %s while listing namespaces: %v", p, err)
-
-                               return fs.SkipDir
-                       }
-
-                       return err
-               }
-               if p == path {
-                       return nil
-               }
-
-               if !d.IsDir() {
-                       // skip plain files
-                       return nil
-               }
-               isTable, err := isTableDirForListing(c.filesystem, c.isLocal, p)
-               if err != nil {
-                       return err
-               }
-               if isTable {
-                       // if a table, not a namespace, don't descend
-                       return fs.SkipDir
-               }
-               child := append(slices.Clone(parent), d.Name())
-               result = append(result, child)
-               // found a namespace dir, don't recurse into it
-               return fs.SkipDir
-       })
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to read 
directory: %w", err)
-       }
-
-       return result, nil
-}
-
-// LoadNamespaceProperties returns a synthetic "location" property for the
-// namespace. This is a Go-only convenience — the Hadoop catalog does not
-// persist user-defined namespace properties.
-func (c *Catalog) LoadNamespaceProperties(_ context.Context, ns 
table.Identifier) (iceberg.Properties, error) {
-       if err := validateIdentifier(ns); err != nil {
-               return nil, err
-       }
-
-       path := c.namespaceToPath(ns)
-
-       info, err := c.filesystem.Stat(path)
-       if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) {
-               return nil, fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
-       }
-
-       if err != nil {
-               return nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace: %w", err)
-       }
-
-       var loc string
-       if c.isLocal {
-               loc = (&url.URL{Scheme: "file", Path: path}).String()
-       } else {
-               // the path variable contains the proper scheme
-               // already if it is not a local file, so we can
-               // use it directly
-               loc = path
-       }
-
-       return iceberg.Properties{"location": loc}, nil
-}
-
-func (c *Catalog) UpdateNamespaceProperties(_ context.Context, _ 
table.Identifier, _ []string, _ iceberg.Properties) 
(catalog.PropertiesUpdateSummary, error) {
-       return catalog.PropertiesUpdateSummary{}, fmt.Errorf("%w: hadoop 
catalog: namespace properties are not supported", iceberg.ErrNotImplemented)
-}
-
-// checkedMkdirAll is a helper function that checks
-// all subdirectories of a given identifier path exist before creating the 
full path.
-// This function is not atomic and may not return ErrNamespaceAlreadyExists if
-// called concurrently with other calls that change the same identifier
-func (c *Catalog) checkedMkdirAll(id table.Identifier) error {
-       path := c.namespaceToPath(id)
-       // Start at index 1 to skip the root warehouse directory
-       for pathIndex := 1; pathIndex < len(id); pathIndex++ {
-               subPath := id[:pathIndex]
-               parentPath := c.namespaceToPath(subPath)
-               if _, err := c.filesystem.Stat(parentPath); err != nil {
-                       return err
-               }
-       }
-       // Check the final element in the path
-       if _, err := c.filesystem.Stat(path); err == nil {
-               // If there is no error and stat returns successfully,
-               // it means that it must already exist
-               return fs.ErrExist
-       }
-
-       return c.filesystem.MkdirAll(path)
-}
diff --git a/catalog/hadoop/hadoop_context_test.go 
b/catalog/hadoop/hadoop_context_test.go
new file mode 100644
index 000000000..6cc2ad372
--- /dev/null
+++ b/catalog/hadoop/hadoop_context_test.go
@@ -0,0 +1,215 @@
+// 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.
+
+package hadoop
+
+import (
+       "context"
+       "io/fs"
+       "net/url"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/catalog"
+       icebergio "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/require"
+)
+
+type hadoopContextKey struct{}
+
+// contextBoundHadoopFS models cloud filesystems that retain their construction
+// context and use it for later operations. LocalFS provides the other
+// methods; the overridden operations check the retained context.
+type contextBoundHadoopFS struct {
+       icebergio.LocalFS
+       ctx  context.Context
+       root string
+}
+
+func (f *contextBoundHadoopFS) localPath(name string) string {
+       parsed, err := url.Parse(name)
+       if err != nil {
+               return filepath.Join(f.root, name)
+       }
+
+       return filepath.Join(f.root, parsed.Host, 
filepath.FromSlash(strings.TrimPrefix(parsed.Path, "/")))
+}
+
+func (f *contextBoundHadoopFS) Stat(name string) (fs.FileInfo, error) {
+       if err := f.ctx.Err(); err != nil {
+               return nil, err
+       }
+
+       return f.LocalFS.Stat(f.localPath(name))
+}
+
+func (f *contextBoundHadoopFS) MkdirAll(name string) error {
+       if err := f.ctx.Err(); err != nil {
+               return err
+       }
+
+       return f.LocalFS.MkdirAll(f.localPath(name))
+}
+
+func (f *contextBoundHadoopFS) ReadFile(name string) ([]byte, error) {
+       if err := f.ctx.Err(); err != nil {
+               return nil, err
+       }
+
+       return f.LocalFS.ReadFile(f.localPath(name))
+}
+
+func (f *contextBoundHadoopFS) WriteFile(name string, content []byte) error {
+       if err := f.ctx.Err(); err != nil {
+               return err
+       }
+
+       return f.LocalFS.WriteFile(f.localPath(name), content)
+}
+
+func registerHadoopContextFS(t *testing.T, scheme string, factory 
icebergio.SchemeFactory) {
+       t.Helper()
+       icebergio.Register(scheme, factory)
+       t.Cleanup(func() { icebergio.Unregister(scheme) })
+}
+
+// TestNewCatalogFilesystemContextLifetime checks both the direct constructor
+// and catalog registry. Filesystem operations must keep working after the
+// caller cancels the context used to initialize the catalog.
+func TestNewCatalogFilesystemContextLifetime(t *testing.T) {
+       for _, useRegistry := range []bool{false, true} {
+               name := "direct constructor"
+               if useRegistry {
+                       name = "catalog registry"
+               }
+
+               t.Run(name, func(t *testing.T) {
+                       const scheme = "hadoop-context-lifetime-test"
+                       root := t.TempDir()
+                       warehouse := scheme + "://bucket/warehouse"
+                       ctx, cancel := 
context.WithTimeout(context.WithValue(context.Background(), hadoopContextKey{}, 
"present"), time.Hour)
+                       defer cancel()
+
+                       var factoryContext context.Context
+                       registerHadoopContextFS(t, scheme, func(got 
context.Context, _ *url.URL, _ map[string]string) (icebergio.IO, error) {
+                               factoryContext = got
+
+                               return &contextBoundHadoopFS{ctx: got, root: 
root}, nil
+                       })
+
+                       props := iceberg.Properties{
+                               "warehouse":            warehouse,
+                               "allow-unsafe-commits": "true",
+                       }
+                       var cat catalog.Catalog
+                       var err error
+                       if useRegistry {
+                               props["type"] = string(catalog.Hadoop)
+                               cat, err = catalog.Load(ctx, "test", props)
+                       } else {
+                               cat, err = NewCatalog(ctx, "test", warehouse, 
props)
+                       }
+                       require.NoError(t, err)
+                       require.Equal(t, "present", 
factoryContext.Value(hadoopContextKey{}))
+                       require.Nil(t, factoryContext.Done(), "filesystem 
context must outlive the caller")
+                       _, hasDeadline := factoryContext.Deadline()
+                       require.False(t, hasDeadline, "filesystem context must 
not inherit the caller deadline")
+
+                       cancel()
+                       require.ErrorIs(t, ctx.Err(), context.Canceled)
+                       require.NoError(t, factoryContext.Err())
+                       require.NoError(t, 
cat.CreateNamespace(context.Background(), table.Identifier{"ns"}, nil))
+
+                       fs := cat.(*Catalog).filesystem
+                       location := warehouse + "/ns/after-cancel.txt"
+                       content := []byte("still writable")
+                       require.NoError(t, fs.WriteFile(location, content))
+                       readBack, err := fs.ReadFile(location)
+                       require.NoError(t, err)
+                       require.Equal(t, content, readBack)
+               })
+       }
+}
+
+func TestNewCatalogHonorsContextCancellation(t *testing.T) {
+       for _, useRegistry := range []bool{false, true} {
+               name := "direct constructor"
+               if useRegistry {
+                       name = "catalog registry"
+               }
+
+               t.Run(name, func(t *testing.T) {
+                       const scheme = "hadoop-context-canceled-test"
+                       ctx, cancel := context.WithCancel(context.Background())
+                       cancel()
+
+                       factoryCalled := false
+                       registerHadoopContextFS(t, scheme, 
func(context.Context, *url.URL, map[string]string) (icebergio.IO, error) {
+                               factoryCalled = true
+
+                               return nil, nil
+                       })
+
+                       warehouse := scheme + "://bucket/warehouse"
+                       props := iceberg.Properties{
+                               "warehouse":            warehouse,
+                               "allow-unsafe-commits": "true",
+                       }
+                       var err error
+                       if useRegistry {
+                               props["type"] = string(catalog.Hadoop)
+                               _, err = catalog.Load(ctx, "test", props)
+                       } else {
+                               _, err = NewCatalog(ctx, "test", warehouse, 
props)
+                       }
+
+                       require.ErrorIs(t, err, context.Canceled)
+                       require.False(t, factoryCalled, "filesystem 
initialization must not run for a canceled context")
+               })
+       }
+}
+
+func TestNewCatalogHonorsCancellationDuringFilesystemLoad(t *testing.T) {
+       const scheme = "hadoop-context-load-cancel-test"
+       root := t.TempDir()
+       loadStarted := make(chan struct{})
+       finishLoad := make(chan struct{})
+       registerHadoopContextFS(t, scheme, func(got context.Context, _ 
*url.URL, _ map[string]string) (icebergio.IO, error) {
+               close(loadStarted)
+               <-finishLoad
+
+               return &contextBoundHadoopFS{ctx: got, root: root}, nil
+       })
+
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+       warehouse := scheme + "://bucket/warehouse"
+       result := make(chan error, 1)
+       go func() {
+               _, err := NewCatalog(ctx, "test", warehouse, 
iceberg.Properties{"allow-unsafe-commits": "true"})
+               result <- err
+       }()
+
+       <-loadStarted
+       cancel()
+       close(finishLoad)
+       require.ErrorIs(t, <-result, context.Canceled)
+}
diff --git a/catalog/hadoop/hadoop_integration_test.go 
b/catalog/hadoop/hadoop_integration_test.go
index ca9fe1674..0edb2a068 100644
--- a/catalog/hadoop/hadoop_integration_test.go
+++ b/catalog/hadoop/hadoop_integration_test.go
@@ -56,7 +56,7 @@ func (s *HadoopIntegrationSuite) SetupSuite() {
 func (s *HadoopIntegrationSuite) SetupTest() {
        s.ctx = context.Background()
 
-       cat, err := hadoop.NewCatalog("hadoop_test", s.warehouse, nil)
+       cat, err := hadoop.NewCatalog(s.ctx, "hadoop_test", s.warehouse, nil)
        s.Require().NoError(err)
        s.cat = cat
 }
diff --git a/catalog/hadoop/hadoop_s3_integration_test.go 
b/catalog/hadoop/hadoop_s3_integration_test.go
index 184282799..f5ce035ad 100644
--- a/catalog/hadoop/hadoop_s3_integration_test.go
+++ b/catalog/hadoop/hadoop_s3_integration_test.go
@@ -57,7 +57,7 @@ func (s *HadoopS3IntegrationSuite) SetupTest() {
                // from `make integration-env` or recipe.Start.
        }
 
-       cat, err := hadoop.NewCatalog("hadoop_s3_test", s.warehouse, s.props)
+       cat, err := hadoop.NewCatalog(s.ctx, "hadoop_s3_test", s.warehouse, 
s.props)
        s.Require().NoError(err)
        s.cat = cat
 }
diff --git a/catalog/hadoop/hadoop_test.go b/catalog/hadoop/hadoop_test.go
index 9571e6308..d6160ce2f 100644
--- a/catalog/hadoop/hadoop_test.go
+++ b/catalog/hadoop/hadoop_test.go
@@ -142,7 +142,7 @@ func (s *HadoopCatalogTestSuite) SetupTest() {
        s.warehouse, err = os.MkdirTemp("", "test_hadoop_*")
        s.Require().NoError(err)
 
-       s.cat, err = NewCatalog("test", s.warehouse, nil)
+       s.cat, err = NewCatalog(context.Background(), "test", s.warehouse, nil)
        s.Require().NoError(err)
 }
 
@@ -377,27 +377,27 @@ func (s *HadoopCatalogTestSuite) 
TestRegistrationMissingWarehouse() {
 }
 
 func (s *HadoopCatalogTestSuite) TestNewCatalogTrimsTrailingSlash() {
-       cat, err := NewCatalog("test", "/tmp/wh/", nil)
+       cat, err := NewCatalog(context.Background(), "test", "/tmp/wh/", nil)
        s.Require().NoError(err)
        s.Equal("/tmp/wh", cat.warehouse)
 }
 
 func (s *HadoopCatalogTestSuite) TestNewCatalogStripsFilePrefix() {
-       cat, err := NewCatalog("test", "file:///tmp/wh", nil)
+       cat, err := NewCatalog(context.Background(), "test", "file:///tmp/wh", 
nil)
        s.Require().NoError(err)
        s.Equal("/tmp/wh", cat.warehouse)
 
-       cat, err = NewCatalog("test", "file:/tmp/wh", nil)
+       cat, err = NewCatalog(context.Background(), "test", "file:/tmp/wh", nil)
        s.Require().NoError(err)
        s.Equal("/tmp/wh", cat.warehouse)
 }
 
 func (s *HadoopCatalogTestSuite) TestNewCatalogRequiresOptInForRemoteSchemes() 
{
-       _, err := NewCatalog("test", "s3://bucket/path", nil)
+       _, err := NewCatalog(context.Background(), "test", "s3://bucket/path", 
nil)
        s.Require().Error(err)
        s.Contains(err.Error(), "`allow-unsafe-commits` must be set to true")
 
-       _, err = NewCatalog("test", "hdfs://namenode/warehouse", nil)
+       _, err = NewCatalog(context.Background(), "test", 
"hdfs://namenode/warehouse", nil)
        s.Require().Error(err)
        s.Contains(err.Error(), "`allow-unsafe-commits` must be set to true")
 
@@ -408,7 +408,7 @@ func (s *HadoopCatalogTestSuite) 
TestNewCatalogRequiresOptInForRemoteSchemes() {
                return &stubHadoopCatalogFS{}, nil
        })
 
-       _, err = NewCatalog("test", scheme+"://bucket/path", nil)
+       _, err = NewCatalog(context.Background(), "test", 
scheme+"://bucket/path", nil)
        s.Require().Error(err)
        s.Contains(err.Error(), "`allow-unsafe-commits` must be set to true")
        s.False(loadFSCalled)
@@ -423,7 +423,7 @@ func (s *HadoopCatalogTestSuite) 
TestNewCatalogRemoteHappyPathWithUnsafeCommits(
                return &stubHadoopCatalogFS{}, nil
        })
 
-       cat, err := NewCatalog("test", scheme+"://bucket/wh/", 
iceberg.Properties{
+       cat, err := NewCatalog(context.Background(), "test", 
scheme+"://bucket/wh/", iceberg.Properties{
                "allow-unsafe-commits": "true",
        })
        s.Require().NoError(err)
@@ -446,7 +446,7 @@ func (s *HadoopCatalogTestSuite) 
TestNewCatalogRemoteRequiresHadoopCatalogFS() {
                return stubIO{}, nil
        })
 
-       _, err := NewCatalog("test", scheme+"://bucket/wh", iceberg.Properties{
+       _, err := NewCatalog(context.Background(), "test", 
scheme+"://bucket/wh", iceberg.Properties{
                "allow-unsafe-commits": "true",
        })
        s.Require().Error(err)
@@ -1571,7 +1571,7 @@ func (s *HadoopCatalogTestSuite) TestCreateTableAndLoad() 
{
 func (s *HadoopCatalogTestSuite) TestCreateTableInvalidReporterDoesNotMutate() 
{
        s.Require().NoError(os.Mkdir(filepath.Join(s.warehouse, "ns"), 0o755))
 
-       _, err := NewCatalog("test", s.warehouse, iceberg.Properties{
+       _, err := NewCatalog(context.Background(), "test", s.warehouse, 
iceberg.Properties{
                metrics.ReporterImplKey: "does-not-exist",
        })
        s.Require().Error(err)
@@ -2149,7 +2149,7 @@ func (s *HadoopCatalogTestSuite) TestLoadTableNotExists() 
{
 
 func (s *HadoopCatalogTestSuite) 
TestLoadTableEnablesCatalogManifestContentCache() {
        ctx := context.Background()
-       cat, err := NewCatalog("test", s.warehouse, iceberg.Properties{
+       cat, err := NewCatalog(ctx, "test", s.warehouse, iceberg.Properties{
                table.IOManifestCacheEnabledKey: "true",
        })
        s.Require().NoError(err)
diff --git a/catalog/hadoop/namespaces.go b/catalog/hadoop/namespaces.go
new file mode 100644
index 000000000..b8074951d
--- /dev/null
+++ b/catalog/hadoop/namespaces.go
@@ -0,0 +1,242 @@
+// 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.
+
+package hadoop
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "io/fs"
+       "log"
+       "net/url"
+       "slices"
+       "strings"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/catalog"
+       "github.com/apache/iceberg-go/table"
+)
+
+func (c *Catalog) CreateNamespace(_ context.Context, ns table.Identifier, 
props iceberg.Properties) error {
+       if err := validateIdentifier(ns); err != nil {
+               return err
+       }
+
+       if len(props) > 0 {
+               return fmt.Errorf("%w: hadoop catalog: namespace properties are 
not supported", iceberg.ErrNotImplemented)
+       }
+
+       // Raise an error if the namespace already exists
+       if err := c.checkedMkdirAll(ns); err != nil {
+               if errors.Is(err, fs.ErrExist) {
+                       return fmt.Errorf("%w: %s", 
catalog.ErrNamespaceAlreadyExists, strings.Join(ns, "."))
+               }
+
+               if errors.Is(err, fs.ErrNotExist) {
+                       return fmt.Errorf("%w: parent namespace does not exist 
for %s",
+                               catalog.ErrNoSuchNamespace, strings.Join(ns, 
"."))
+               }
+
+               return fmt.Errorf("hadoop catalog: failed to create namespace: 
%w", err)
+       }
+
+       return nil
+}
+
+func (c *Catalog) DropNamespace(ctx context.Context, ns table.Identifier) 
error {
+       if err := validateIdentifier(ns); err != nil {
+               return err
+       }
+
+       path := c.namespaceToPath(ns)
+
+       // Walk the namespace directory directly so existence and type checks 
use the
+       // same filesystem view. The root entry preserves file-at-namespace 
handling.
+       rootNotDir := false
+       foundEntries := false
+       err := c.filesystem.WalkDir(path, func(p string, d fs.DirEntry, err 
error) error {
+               if err != nil {
+                       return err
+               }
+
+               if p == path {
+                       rootNotDir = !d.IsDir()
+
+                       return nil
+               }
+
+               foundEntries = true
+
+               return fs.SkipAll
+       })
+       if err != nil {
+               if errors.Is(err, fs.ErrNotExist) {
+                       return fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
+               }
+
+               return fmt.Errorf("hadoop catalog: failed to read namespace 
directory: %w", err)
+       }
+
+       if rootNotDir {
+               return fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
+       }
+
+       if foundEntries {
+               return fmt.Errorf("%w: %s", catalog.ErrNamespaceNotEmpty, 
strings.Join(ns, "."))
+       }
+
+       return c.filesystem.Remove(ctx, path)
+}
+
+func (c *Catalog) CheckNamespaceExists(_ context.Context, ns table.Identifier) 
(bool, error) {
+       if err := validateIdentifier(ns); err != nil {
+               return false, err
+       }
+
+       path := c.namespaceToPath(ns)
+
+       info, err := c.filesystem.Stat(path)
+       if errors.Is(err, fs.ErrNotExist) {
+               return false, nil
+       }
+
+       if err != nil {
+               return false, err
+       }
+
+       return info.IsDir(), nil
+}
+
+func (c *Catalog) ListNamespaces(_ context.Context, parent table.Identifier) 
([]table.Identifier, error) {
+       if len(parent) > 0 {
+               if err := validateIdentifier(parent); err != nil {
+                       return nil, err
+               }
+       }
+
+       var path string
+
+       if len(parent) == 0 {
+               path = c.warehouse
+       } else {
+               path = c.namespaceToPath(parent)
+
+               info, err := c.filesystem.Stat(path)
+               if errors.Is(err, fs.ErrNotExist) || (err == nil && 
!info.IsDir()) {
+                       return nil, fmt.Errorf("%w: %s", 
catalog.ErrNoSuchNamespace, strings.Join(parent, "."))
+               }
+       }
+
+       result := []table.Identifier{}
+       err := c.filesystem.WalkDir(path, func(p string, d fs.DirEntry, err 
error) error {
+               if err != nil {
+                       if p != path && shouldSuppressPermissionError(err) {
+                               log.Printf("hadoop catalog: unable to inspect 
path %s while listing namespaces: %v", p, err)
+
+                               return fs.SkipDir
+                       }
+
+                       return err
+               }
+               if p == path {
+                       return nil
+               }
+
+               if !d.IsDir() {
+                       // skip plain files
+                       return nil
+               }
+               isTable, err := isTableDirForListing(c.filesystem, c.isLocal, p)
+               if err != nil {
+                       return err
+               }
+               if isTable {
+                       // if a table, not a namespace, don't descend
+                       return fs.SkipDir
+               }
+               child := append(slices.Clone(parent), d.Name())
+               result = append(result, child)
+               // found a namespace dir, don't recurse into it
+               return fs.SkipDir
+       })
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to read 
directory: %w", err)
+       }
+
+       return result, nil
+}
+
+// LoadNamespaceProperties returns a synthetic "location" property for the
+// namespace. This is a Go-only convenience — the Hadoop catalog does not
+// persist user-defined namespace properties.
+func (c *Catalog) LoadNamespaceProperties(_ context.Context, ns 
table.Identifier) (iceberg.Properties, error) {
+       if err := validateIdentifier(ns); err != nil {
+               return nil, err
+       }
+
+       path := c.namespaceToPath(ns)
+
+       info, err := c.filesystem.Stat(path)
+       if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) {
+               return nil, fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace: %w", err)
+       }
+
+       var loc string
+       if c.isLocal {
+               loc = (&url.URL{Scheme: "file", Path: path}).String()
+       } else {
+               // the path variable contains the proper scheme
+               // already if it is not a local file, so we can
+               // use it directly
+               loc = path
+       }
+
+       return iceberg.Properties{"location": loc}, nil
+}
+
+func (c *Catalog) UpdateNamespaceProperties(_ context.Context, _ 
table.Identifier, _ []string, _ iceberg.Properties) 
(catalog.PropertiesUpdateSummary, error) {
+       return catalog.PropertiesUpdateSummary{}, fmt.Errorf("%w: hadoop 
catalog: namespace properties are not supported", iceberg.ErrNotImplemented)
+}
+
+// checkedMkdirAll is a helper function that checks
+// all subdirectories of a given identifier path exist before creating the 
full path.
+// This function is not atomic and may not return ErrNamespaceAlreadyExists if
+// called concurrently with other calls that change the same identifier
+func (c *Catalog) checkedMkdirAll(id table.Identifier) error {
+       path := c.namespaceToPath(id)
+       // Start at index 1 to skip the root warehouse directory
+       for pathIndex := 1; pathIndex < len(id); pathIndex++ {
+               subPath := id[:pathIndex]
+               parentPath := c.namespaceToPath(subPath)
+               if _, err := c.filesystem.Stat(parentPath); err != nil {
+                       return err
+               }
+       }
+       // Check the final element in the path
+       if _, err := c.filesystem.Stat(path); err == nil {
+               // If there is no error and stat returns successfully,
+               // it means that it must already exist
+               return fs.ErrExist
+       }
+
+       return c.filesystem.MkdirAll(path)
+}
diff --git a/catalog/hadoop/tables.go b/catalog/hadoop/tables.go
new file mode 100644
index 000000000..bb947c4ba
--- /dev/null
+++ b/catalog/hadoop/tables.go
@@ -0,0 +1,468 @@
+// 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.
+
+package hadoop
+
+import (
+       "context"
+       "errors"
+       "fmt"
+       "io/fs"
+       "iter"
+       "log"
+       "strings"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/catalog"
+       "github.com/apache/iceberg-go/catalog/internal"
+       "github.com/apache/iceberg-go/io"
+       "github.com/apache/iceberg-go/table"
+       "github.com/apache/iceberg-go/table/maintenance"
+       "github.com/google/uuid"
+)
+
+func (c *Catalog) CreateTable(ctx context.Context, ident table.Identifier, sc 
*iceberg.Schema, opts ...catalog.CreateTableOpt) (*table.Table, error) {
+       var cfg catalog.CreateTableCfg
+       for _, opt := range opts {
+               opt(&cfg)
+       }
+
+       if err := validateTableIdentifier(ident); err != nil {
+               return nil, err
+       }
+
+       // Resolve the reporter before any catalog mutation: a bad
+       // metrics-reporter-impl must fail here, not after the metadata file and
+       // version hint are written, which would report a failure for a table 
that
+       // was actually created (and make the retry hit ErrTableAlreadyExists).
+       reporter, err := c.reporter.Get(c.props)
+       if err != nil {
+               return nil, fmt.Errorf("failed to initialize metrics reporter: 
%w", err)
+       }
+
+       ns := catalog.NamespaceFromIdent(ident)
+       nsPath := c.namespaceToPath(ns)
+
+       info, err := c.filesystem.Stat(nsPath)
+       if errors.Is(err, fs.ErrNotExist) || (err == nil && !info.IsDir()) {
+               return nil, fmt.Errorf("%w: %s", catalog.ErrNoSuchNamespace, 
strings.Join(ns, "."))
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace directory: %w", err)
+       }
+
+       loc := c.defaultTableLocation(ident)
+       if cfg.Location != "" && cfg.Location != loc {
+               return nil, errors.New("hadoop catalog: custom table locations 
are not supported")
+       }
+
+       exists, err := isTableDir(c.filesystem, c.isLocal, loc)
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to inspect table 
directory: %w", err)
+       }
+       if exists {
+               return nil, fmt.Errorf("%w: %s", catalog.ErrTableAlreadyExists, 
strings.Join(ident, "."))
+       }
+
+       metadata, err := table.NewMetadata(sc, cfg.PartitionSpec, 
cfg.SortOrder, loc, cfg.Properties)
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to create table 
metadata: %w", err)
+       }
+
+       metaDir := c.metadataDir(ident)
+       if err := c.filesystem.MkdirAll(metaDir); err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to create 
metadata directory: %w", err)
+       }
+
+       version := 1
+       compression := table.MetadataCompressionDefault
+       if cfg.Properties != nil {
+               if v, ok := cfg.Properties[table.MetadataCompressionKey]; ok {
+                       compression = v
+               }
+       }
+
+       metaPath, err := c.metadataFilePathForCompression(ident, version, 
compression)
+       if err != nil {
+               return nil, fmt.Errorf("hadoop catalog: failed to determine 
metadata file path: %w", err)
+       }
+
+       tempPath := joinPath(c.isLocal, metaDir, 
uuid.New().String()+".metadata.json")
+
+       if err := internal.WriteTableMetadata(metadata, c.filesystem, tempPath, 
compression); err != nil {
+               _ = c.filesystem.Remove(context.WithoutCancel(ctx), tempPath)
+
+               return nil, fmt.Errorf("hadoop catalog: failed to write table 
metadata: %w", err)
+       }
+
+       if err := c.commitMetadataFile(ctx, ident, version, tempPath, metaPath, 
catalog.ErrTableAlreadyExists); err != nil {
+               return nil, err
+       }
+
+       c.writeVersionHint(ident, version)
+
+       tbl := table.New(
+               ident,
+               metadata,
+               metaPath,
+               io.LoadFSFunc(c.props, metaPath),
+               c,
+               table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
+       )
+
+       return tbl, nil
+}
+
+func (c *Catalog) loadTable(ctx context.Context, ident table.Identifier) 
(*table.Table, int, error) {
+       if err := validateTableIdentifier(ident); err != nil {
+               return nil, 0, err
+       }
+
+       metaPath, version, err := c.findMetadataLocation(ident)
+       if err != nil {
+               return nil, 0, err
+       }
+
+       reporter, err := c.reporter.Get(c.props)
+       if err != nil {
+               return nil, 0, fmt.Errorf("failed to initialize metrics 
reporter: %w", err)
+       }
+
+       tbl, err := table.NewFromLocation(
+               ctx, ident, metaPath, io.LoadFSFunc(c.props, metaPath), c,
+               table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
+       )
+       if err != nil {
+               return nil, 0, err
+       }
+
+       return tbl, version, nil
+}
+
+func (c *Catalog) LoadTable(ctx context.Context, ident table.Identifier) 
(*table.Table, error) {
+       tbl, _, err := c.loadTable(ctx, ident)
+
+       return tbl, err
+}
+
+func (c *Catalog) CheckTableExists(_ context.Context, ident table.Identifier) 
(bool, error) {
+       if err := validateTableIdentifier(ident); err != nil {
+               return false, err
+       }
+
+       // Direct existence checks surface metadata directory read errors; 
listing
+       // paths use isTableDirForListing to skip unreadable entries.
+       exists, err := isTableDir(c.filesystem, c.isLocal, c.tableToPath(ident))
+       if err != nil {
+               return false, err
+       }
+
+       return exists, nil
+}
+
+func (c *Catalog) CommitTable(ctx context.Context, ident table.Identifier, 
reqs []table.Requirement, updates []table.Update) (table.Metadata, string, 
error) {
+       if err := validateTableIdentifier(ident); err != nil {
+               return nil, "", err
+       }
+
+       // Step 1: Load current table (nil for create-via-commit).
+       current, currentVersion, err := c.loadTable(ctx, ident)
+       if err != nil && !errors.Is(err, catalog.ErrNoSuchTable) {
+               return nil, "", err
+       }
+
+       // Step 2: Validate requirements against current metadata.
+       var currentMetadata table.Metadata
+       if current != nil {
+               currentMetadata = current.Metadata()
+       }
+       if err := internal.ValidateRequirements(ident, currentMetadata, reqs); 
err != nil {
+               return nil, "", err
+       }
+
+       // Step 3: Apply updates to produce new metadata.
+       var baseMeta table.Metadata
+       var currentMetadataLoc string
+
+       if current != nil {
+               baseMeta = current.Metadata()
+               currentMetadataLoc = current.MetadataLocation()
+       } else {
+               baseMeta, err = table.NewMetadata(iceberg.NewSchema(0), nil, 
table.UnsortedSortOrder, "", nil)
+               if err != nil {
+                       return nil, "", fmt.Errorf("hadoop catalog: failed to 
create base metadata: %w", err)
+               }
+       }
+
+       updated, err := internal.UpdateTableMetadata(baseMeta, updates, 
currentMetadataLoc)
+       if err != nil {
+               return nil, "", fmt.Errorf("hadoop catalog: failed to apply 
updates: %w", err)
+       }
+
+       // Step 4: Validate table location has not changed.
+       if current != nil && updated.Location() != current.Location() {
+               return nil, "", errors.New("hadoop catalog: table location 
cannot be changed")
+       }
+
+       // Step 5: Reject write.metadata.location property.
+       if v := updated.Properties().Get(table.WriteMetadataLocationKey, ""); v 
!= "" {
+               return nil, "", fmt.Errorf("hadoop catalog: %s property is not 
supported", table.WriteMetadataLocationKey)
+       }
+
+       // Step 6: Determine next version number.
+       newVersion := currentVersion + 1
+
+       // Step 7: Create metadata directory if needed (create-via-commit).
+       metaDir := c.metadataDir(ident)
+       if err := c.filesystem.MkdirAll(metaDir); err != nil {
+               return nil, "", fmt.Errorf("hadoop catalog: failed to create 
metadata directory: %w", err)
+       }
+
+       compression := updated.Properties().Get(table.MetadataCompressionKey, 
table.MetadataCompressionDefault)
+       newMetaPath, err := c.metadataFilePathForCompression(ident, newVersion, 
compression)
+       if err != nil {
+               return nil, "", fmt.Errorf("hadoop catalog: failed to determine 
metadata file path: %w", err)
+       }
+
+       tempPath := joinPath(c.isLocal, metaDir, 
uuid.New().String()+".metadata.json")
+
+       if err := internal.WriteTableMetadata(updated, c.filesystem, tempPath, 
compression); err != nil {
+               _ = c.filesystem.Remove(context.WithoutCancel(ctx), tempPath)
+
+               return nil, "", fmt.Errorf("hadoop catalog: failed to write 
table metadata: %w", err)
+       }
+
+       if err := c.commitMetadataFile(ctx, ident, newVersion, tempPath, 
newMetaPath, table.ErrCommitFailed); err != nil {
+               return nil, "", err
+       }
+
+       // Step 8: Best-effort version hint update.
+       c.writeVersionHint(ident, newVersion)
+
+       return updated, newMetaPath, nil
+}
+
+func (c *Catalog) commitMetadataFile(ctx context.Context, ident 
table.Identifier, version int, tempPath, metaPath string, conflictErr error) 
error {
+       // Cleanup on a failed commit must not inherit ctx's cancellation, or a
+       // canceled commit would leave the temp file or the version claim 
behind.
+       cleanupCtx := context.WithoutCancel(ctx)
+       claimPath := c.metadataVersionClaimPath(ident, version)
+       for {
+               if err := c.filesystem.RenameNoReplace(tempPath, claimPath); 
err != nil {
+                       if !errors.Is(err, fs.ErrExist) {
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("hadoop catalog: failed to 
claim metadata version: %w", err)
+                       }
+
+                       existingPath, exists, err := 
c.metadataVersionLocation(ident, version)
+                       if err != nil {
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("hadoop catalog: failed to 
inspect metadata directory for version %d: %w",
+                                       version, err)
+                       }
+                       if exists {
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("%w: metadata file already 
exists for table %s: %s",
+                                       conflictErr, strings.Join(ident, "."), 
existingPath)
+                       }
+
+                       claimInfo, err := c.filesystem.Stat(claimPath)
+                       if err != nil {
+                               if errors.Is(err, fs.ErrNotExist) {
+                                       continue
+                               }
+
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("hadoop catalog: failed to 
inspect stale metadata claim %s: %w", claimPath, err)
+                       }
+                       if time.Since(claimInfo.ModTime()) < 
metadataClaimStaleAfter {
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("%w: metadata version already 
claimed for table %s: %s",
+                                       conflictErr, strings.Join(ident, "."), 
claimPath)
+                       }
+
+                       if err := c.filesystem.Remove(ctx, claimPath); err != 
nil && !errors.Is(err, fs.ErrNotExist) {
+                               _ = c.filesystem.Remove(cleanupCtx, tempPath)
+
+                               return fmt.Errorf("hadoop catalog: failed to 
clear stale metadata claim %s: %w", claimPath, err)
+                       }
+
+                       continue
+               }
+
+               break
+       }
+
+       removeClaim := true
+       defer func() {
+               if removeClaim {
+                       _ = c.filesystem.Remove(cleanupCtx, claimPath)
+               }
+       }()
+
+       existingPath, exists, err := c.metadataVersionLocation(ident, version)
+       if err != nil {
+               return fmt.Errorf("hadoop catalog: failed to inspect metadata 
directory for version %d: %w",
+                       version, err)
+       }
+       if exists {
+               return fmt.Errorf("%w: metadata file already exists for table 
%s: %s",
+                       conflictErr, strings.Join(ident, "."), existingPath)
+       }
+
+       if err := c.filesystem.RenameNoReplace(claimPath, metaPath); err != nil 
{
+               if errors.Is(err, fs.ErrExist) {
+                       return fmt.Errorf("%w: metadata file already exists for 
table %s: %s",
+                               conflictErr, strings.Join(ident, "."), metaPath)
+               }
+               if errors.Is(err, fs.ErrNotExist) {
+                       existingPath, exists, err := 
c.metadataVersionLocation(ident, version)
+                       if err != nil {
+                               return fmt.Errorf("hadoop catalog: failed to 
inspect metadata directory for version %d: %w",
+                                       version, err)
+                       }
+                       if exists {
+                               return fmt.Errorf("%w: metadata file already 
exists for table %s: %s",
+                                       conflictErr, strings.Join(ident, "."), 
existingPath)
+                       }
+
+                       return fmt.Errorf("%w: metadata claim for table %s and 
version %d was removed before publish",
+                               conflictErr, strings.Join(ident, "."), version)
+               }
+
+               return fmt.Errorf("hadoop catalog: failed to commit metadata 
file: %w", err)
+       }
+
+       removeClaim = false
+
+       return nil
+}
+
+func (c *Catalog) ListTables(_ context.Context, ns table.Identifier) 
iter.Seq2[table.Identifier, error] {
+       return func(yield func(table.Identifier, error) bool) {
+               if err := validateIdentifier(ns); err != nil {
+                       yield(nil, err)
+
+                       return
+               }
+
+               nsPath := c.namespaceToPath(ns)
+
+               info, err := c.filesystem.Stat(nsPath)
+               if errors.Is(err, fs.ErrNotExist) || (err == nil && 
!info.IsDir()) {
+                       yield(nil, fmt.Errorf("%w: %s", 
catalog.ErrNoSuchNamespace, strings.Join(ns, ".")))
+
+                       return
+               }
+
+               if err != nil {
+                       yield(nil, fmt.Errorf("hadoop catalog: failed to stat 
namespace: %w", err))
+
+                       return
+               }
+
+               err = c.filesystem.WalkDir(nsPath, func(path string, d 
fs.DirEntry, err error) error {
+                       if err != nil {
+                               if path != nsPath && 
shouldSuppressPermissionError(err) {
+                                       log.Printf("hadoop catalog: unable to 
inspect path %s while listing tables: %v", path, err)
+
+                                       return fs.SkipDir
+                               }
+
+                               return err
+                       }
+
+                       // Anything that is a file is not a namespace or table, 
so skip it.
+                       if !d.IsDir() {
+                               return nil
+                       }
+
+                       // Skip the namespace directory itself.
+                       if path == nsPath {
+                               return nil
+                       }
+
+                       // Skip anything that is not a table directory.
+                       isTable, err := isTableDirForListing(c.filesystem, 
c.isLocal, path)
+                       if err != nil {
+                               return err
+                       }
+                       if !isTable {
+                               return fs.SkipDir
+                       }
+                       ident := make(table.Identifier, len(ns)+1)
+                       copy(ident, ns)
+                       ident[len(ns)] = d.Name()
+                       if !yield(ident, nil) {
+                               return fs.SkipAll
+                       }
+                       // If a table has been found, then that directory
+                       // doesn't need to be walked further
+                       return fs.SkipDir
+               })
+               if err != nil {
+                       yield(nil, fmt.Errorf("hadoop catalog: failed to read 
namespace directory: %w", err))
+
+                       return
+               }
+       }
+}
+
+func (c *Catalog) DropTable(ctx context.Context, ident table.Identifier) error 
{
+       if _, _, err := c.loadTable(ctx, ident); err != nil {
+               return err
+       }
+
+       tablePath := c.tableToPath(ident)
+
+       return c.filesystem.RemoveAll(tablePath)
+}
+
+func (c *Catalog) PurgeTable(ctx context.Context, identifier table.Identifier) 
error {
+       if err := validateTableIdentifier(identifier); err != nil {
+               return err
+       }
+
+       tbl, err := c.LoadTable(ctx, identifier)
+       if err != nil {
+               return err
+       }
+
+       // For Hadoop catalog, physical files walk must run BEFORE deleting the 
table directory root
+       if purgeErr := maintenance.PurgeFiles(ctx, tbl); purgeErr != nil {
+               log.Printf("WARNING: failing to purge some files in Hadoop 
table %s: %v", identifier, purgeErr)
+       }
+
+       if !tbl.Metadata().Properties().GetBool("gc.enabled", true) {
+               return c.filesystem.RemoveAll(c.metadataDir(identifier))
+       }
+
+       return c.filesystem.RemoveAll(c.tableToPath(identifier))
+}
+
+func (c *Catalog) RenameTable(_ context.Context, _, _ table.Identifier) 
(*table.Table, error) {
+       return nil, fmt.Errorf("%w: hadoop catalog: rename table is not 
supported", iceberg.ErrNotImplemented)
+}
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index d18b2fc77..608bf8fa6 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -439,7 +439,7 @@ func initCatalog(ctx context.Context, args Args) 
catalog.Catalog {
                        logFatal(err)
                }
        case catalog.Hadoop:
-               if cat, err = hadoop.NewCatalog("hadoop", args.Warehouse, 
iceberg.Properties{
+               if cat, err = hadoop.NewCatalog(ctx, "hadoop", args.Warehouse, 
iceberg.Properties{
                        "warehouse": args.Warehouse,
                }); err != nil {
                        logFatal(err)
diff --git a/website/src/api.md b/website/src/api.md
index bf854e5a2..82ccc145a 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -92,9 +92,10 @@ cat, err := hive.NewCatalog(iceberg.Properties{},
 import (
     "github.com/apache/iceberg-go"
     "github.com/apache/iceberg-go/catalog/hadoop"
+    "context"
 )
 
-cat, err := hadoop.NewCatalog("default", "file:///tmp/warehouse", 
iceberg.Properties{})
+cat, err := hadoop.NewCatalog(context.Background(), "default", 
"file:///tmp/warehouse", iceberg.Properties{})
 ```
 
 #### Via the registry

Reply via email to