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

laskoviymishka 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 a15bea708 feat(rest): support isolated IO registries (#2366)
a15bea708 is described below

commit a15bea70891abc79a3eea7bdf7db5ec9f5acc97f
Author: Minh Vu <[email protected]>
AuthorDate: Wed Nov 4 18:39:10 2026 +0100

    feat(rest): support isolated IO registries (#2366)
---
 catalog/rest/io_registry_test.go   | 218 +++++++++++++++++++++++++++++++++++++
 catalog/rest/options.go            |   8 ++
 catalog/rest/rest.go               |   9 +-
 catalog/rest/scan_planning.go      |   5 +-
 catalog/rest/scan_planning_test.go |  12 +-
 catalog/rest/vended_creds.go       |  28 ++++-
 6 files changed, 267 insertions(+), 13 deletions(-)

diff --git a/catalog/rest/io_registry_test.go b/catalog/rest/io_registry_test.go
new file mode 100644
index 000000000..7390fde95
--- /dev/null
+++ b/catalog/rest/io_registry_test.go
@@ -0,0 +1,218 @@
+// 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 rest
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "net/http/httptest"
+       "net/url"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/require"
+       "golang.org/x/sync/semaphore"
+)
+
+func TestIsolatedIORegistryForVendedCredentials(t *testing.T) {
+       registry := &iceio.Registry{}
+       fs := iceio.NewMemFS()
+       var gotProps map[string]string
+       require.NoError(t, registry.Register("isolated", func(_ 
context.Context, _ *url.URL, props map[string]string) (iceio.IO, error) {
+               gotProps = props
+
+               return fs, nil
+       }))
+       r := &vendedCredentialRefresher{
+               mu: semaphore.NewWeighted(1), ioRegistry: registry,
+               location: "isolated://bucket/metadata", props: 
iceberg.Properties{"base": "value", "token": "vended"},
+               vended: iceberg.Properties{"token": "vended"},
+       }
+       loaded, err := r.loadFS(t.Context())
+       require.NoError(t, err)
+       require.Same(t, fs, loaded)
+       require.Equal(t, "value", gotProps["base"])
+       require.Equal(t, "vended", gotProps["token"])
+       require.NoError(t, r.close())
+}
+
+func TestIsolatedIORegistryForPlanCredentials(t *testing.T) {
+       registry := &iceio.Registry{}
+       fs := iceio.NewMemFS()
+       var gotProps map[string]string
+       require.NoError(t, registry.Register("isolated", func(_ 
context.Context, _ *url.URL, props map[string]string) (iceio.IO, error) {
+               gotProps = props
+
+               return fs, nil
+       }))
+       p := planIOFromCredentials([]StorageCredential{{Prefix: 
"isolated://bucket/", Config: iceberg.Properties{"token": "vended"}}}, 
"isolated://bucket/metadata", iceberg.Properties{"base": "value"}, registry)
+       require.NotNil(t, p)
+       loaded, err := p.Load(t.Context())
+       require.NoError(t, err)
+       resolver, ok := loaded.(*prefixScopedIO)
+       require.True(t, ok)
+       actual, err := resolver.filesystemFor("isolated://bucket/data")
+       require.NoError(t, err)
+       require.Same(t, fs, actual)
+       require.Equal(t, "value", gotProps["base"])
+       require.Equal(t, "vended", gotProps["token"])
+       require.NoError(t, p.Close())
+}
+
+func TestCatalogPlanFilesUsesIsolatedIORegistry(t *testing.T) {
+       registry := &iceio.Registry{}
+       fs := iceio.NewMemFS()
+       var gotProps map[string]string
+       require.NoError(t, registry.Register("isolated", func(_ 
context.Context, _ *url.URL, props map[string]string) (iceio.IO, error) {
+               gotProps = props
+
+               return fs, nil
+       }))
+
+       cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan}, 
func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w 
http.ResponseWriter, _ *http.Request) {
+                       _, _ = 
w.Write([]byte(`{"status":"completed","plan-id":"plan-1","storage-credentials":[{"prefix":"isolated://bucket/","config":{"token":"vended"}}]}`))
+               })
+       }, WithIORegistry(registry))
+
+       req := planFilesReq()
+       req.MetadataLocation = "isolated://bucket/metadata.json"
+       req.FileIOProperties = iceberg.Properties{"base": "value", "token": 
"table"}
+       result, err := cat.PlanFiles(t.Context(), req)
+       require.NoError(t, err)
+       require.NotNil(t, result.IO)
+       t.Cleanup(func() { require.NoError(t, result.IO.Close()) })
+
+       loaded, err := result.IO.Load(t.Context())
+       require.NoError(t, err)
+       resolver, ok := loaded.(*prefixScopedIO)
+       require.True(t, ok)
+       actual, err := resolver.filesystemFor("isolated://bucket/data.parquet")
+       require.NoError(t, err)
+       require.Same(t, fs, actual)
+       require.Equal(t, "value", gotProps["base"])
+       require.Equal(t, "vended", gotProps["token"])
+}
+
+// A catalog with an explicit empty registry must not silently use the global
+// file backend when a remote plan supplies credentials for that scheme.
+func TestCatalogPlanFilesDoesNotFallBackToGlobalIO(t *testing.T) {
+       cat := newScanPlanningTestCatalog(t, []endpoint{endpointPlanTableScan}, 
func(mux *http.ServeMux) {
+               mux.HandleFunc("/v1/namespaces/db/tables/tbl/plan", func(w 
http.ResponseWriter, _ *http.Request) {
+                       _, _ = 
w.Write([]byte(`{"status":"completed","plan-id":"plan-1","storage-credentials":[{"prefix":"file:///warehouse/","config":{"token":"vended"}}]}`))
+               })
+       }, WithIORegistry(&iceio.Registry{}))
+
+       req := planFilesReq()
+       req.MetadataLocation = "file:///warehouse/metadata.json"
+       result, err := cat.PlanFiles(t.Context(), req)
+       require.NoError(t, err)
+       require.NotNil(t, result.IO)
+       t.Cleanup(func() { require.NoError(t, result.IO.Close()) })
+
+       loaded, err := result.IO.Load(t.Context())
+       require.NoError(t, err)
+       resolver, ok := loaded.(*prefixScopedIO)
+       require.True(t, ok)
+
+       _, err = resolver.filesystemFor("file:///warehouse/data.parquet")
+       require.ErrorIs(t, err, iceio.ErrIOSchemeNotFound)
+}
+
+func TestCatalogUsesIsolatedIORegistry(t *testing.T) {
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       server := httptest.NewServer(mux)
+       defer server.Close()
+
+       registry := &iceio.Registry{}
+       fs := iceio.NewMemFS()
+       require.NoError(t, registry.Register("isolated", func(context.Context, 
*url.URL, map[string]string) (iceio.IO, error) { return fs, nil }))
+       cat, err := NewCatalog(t.Context(), "test", server.URL, 
WithIORegistry(registry))
+       require.NoError(t, err)
+       defer cat.Close()
+       for _, vended := range []bool{false, true} {
+               t.Run(fmt.Sprintf("vended=%t", vended), func(t *testing.T) {
+                       tbl, err := cat.tableFromResponse(t.Context(), 
[]string{"ns", "tbl"}, nil, "isolated://bucket/metadata", nil, nil, vended, 
nil, nil)
+                       require.NoError(t, err)
+                       loaded, err := tbl.FS(t.Context())
+                       require.NoError(t, err)
+                       require.Same(t, fs, loaded)
+               })
+       }
+}
+
+func TestIsolatedIORegistryDoesNotUseGlobalFactories(t *testing.T) {
+       registry := &iceio.Registry{}
+       _, err := loadRegistryFS(t.Context(), registry, nil, 
"file:///metadata.json")
+       require.ErrorIs(t, err, iceio.ErrIOSchemeNotFound)
+       _, err = loadRegistryFSFunc(registry, nil, 
"file:///metadata.json")(t.Context())
+       require.ErrorIs(t, err, iceio.ErrIOSchemeNotFound)
+
+       fs, err := loadRegistryFS(t.Context(), nil, nil, 
"file:///metadata.json")
+       require.NoError(t, err)
+       require.IsType(t, iceio.LocalFS{}, fs)
+       fs, err = loadRegistryFSFunc(nil, nil, 
"file:///metadata.json")(t.Context())
+       require.NoError(t, err)
+       require.IsType(t, iceio.LocalFS{}, fs)
+}
+
+func TestCatalogsCanUseDifferentFactoriesForSameScheme(t *testing.T) {
+       mux := http.NewServeMux()
+       mux.HandleFunc("/v1/config", serveEmptyConfig)
+       server := httptest.NewServer(mux)
+       defer server.Close()
+
+       cases := []struct {
+               name string
+               fs   *iceio.MemFS
+       }{
+               {name: "first", fs: iceio.NewMemFS()},
+               {name: "second", fs: iceio.NewMemFS()},
+       }
+       catalogs := make([]*Catalog, len(cases))
+       for i, tc := range cases {
+               registry := &iceio.Registry{}
+               fs := tc.fs
+               require.NoError(t, registry.Register("same-scheme", 
func(context.Context, *url.URL, map[string]string) (iceio.IO, error) {
+                       return fs, nil
+               }))
+
+               cat, err := NewCatalog(t.Context(), tc.name, server.URL, 
WithIORegistry(registry))
+               require.NoError(t, err)
+               defer cat.Close()
+               catalogs[i] = cat
+       }
+
+       // Both catalogs must remain alive while the same URI scheme resolves to
+       // different factories, including through vended-credential refreshers.
+       for i, tc := range cases {
+               t.Run(tc.name, func(t *testing.T) {
+                       for _, vended := range []bool{false, true} {
+                               tbl, err := catalogs[i].tableFromResponse(
+                                       t.Context(), []string{"ns", "tbl"}, 
nil, "same-scheme://bucket/metadata", nil, nil, vended, nil, nil)
+                               require.NoError(t, err)
+                               loaded, err := tbl.FS(t.Context())
+                               require.NoError(t, err)
+                               require.Same(t, tc.fs, loaded)
+                       }
+               })
+       }
+}
diff --git a/catalog/rest/options.go b/catalog/rest/options.go
index 7c13f51c7..a5e321ba5 100644
--- a/catalog/rest/options.go
+++ b/catalog/rest/options.go
@@ -23,10 +23,17 @@ import (
        "net/url"
 
        "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
 )
 
 type Option func(*options)
 
+// WithIORegistry selects the registry used to load table and scan FileIOs.
+// A nil registry uses the package-level default.
+func WithIORegistry(registry *iceio.Registry) Option {
+       return func(o *options) { o.ioRegistry = registry }
+}
+
 // TransportFactory creates a transport owned by the REST catalog and returns
 // the cleanup function to run when the catalog is closed. The TLS config is
 // the one configured with WithTLSConfig.
@@ -213,6 +220,7 @@ func WithTransportFactory(factory TransportFactory) Option {
 }
 
 type options struct {
+       ioRegistry        *iceio.Registry
        signer            RequestSigner
        signerFactory     SignerFactory
        tlsConfig         *tls.Config
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 304fa03df..d99368246 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -907,8 +907,9 @@ func toProps(o *options) iceberg.Properties {
 }
 
 type Catalog struct {
-       baseURI *url.URL
-       cl      *http.Client
+       ioRegistry *iceio.Registry
+       baseURI    *url.URL
+       cl         *http.Client
        // closeSession releases transports owned by the catalog. 
Caller-provided
        // transports are excluded when the session is constructed.
        closeSession     func()
@@ -1073,6 +1074,7 @@ func setupOAuthManager(r *Catalog, cl *http.Client, opts 
*options) (AuthManager,
 }
 
 func (r *Catalog) init(ctx context.Context, ops *options, uri string) error {
+       r.ioRegistry = ops.ioRegistry
        baseuri, err := url.Parse(uri)
        if err != nil {
                return err
@@ -1340,6 +1342,7 @@ func (r *Catalog) tableFromResponse(
                refresher := &vendedCredentialRefresher{
                        mu:         semaphore.NewWeighted(1),
                        identifier: identifier,
+                       ioRegistry: r.ioRegistry,
                        location:   loc,
                        props:      config,
                        vended:     vended,
@@ -1349,7 +1352,7 @@ func (r *Catalog) tableFromResponse(
                }
                fsF = refresher.loadFS
        } else {
-               fsF = iceio.LoadFSFunc(config, loc)
+               fsF = loadRegistryFSFunc(r.ioRegistry, config, loc)
        }
 
        // Resolve the reporter from the client-supplied properties only (see 
the
diff --git a/catalog/rest/scan_planning.go b/catalog/rest/scan_planning.go
index 920d9ce01..150aad825 100644
--- a/catalog/rest/scan_planning.go
+++ b/catalog/rest/scan_planning.go
@@ -266,7 +266,7 @@ func (r *Catalog) PlanFiles(ctx context.Context, req 
table.ScanPlanningRequest)
 
        result := table.ScanPlanningResult{
                Tasks: tasks,
-               IO:    planIOFromCredentials(completed.StorageCredentials, 
req.MetadataLocation, r.planIOBaseProps(req)),
+               IO:    planIOFromCredentials(completed.StorageCredentials, 
req.MetadataLocation, r.planIOBaseProps(req), r.ioRegistry),
        }
        if result.IO == nil {
                // Without plan-scoped credentials, all task materialization is 
complete
@@ -471,12 +471,13 @@ func remoteScanTasks(envelopes []ScanTasks, req 
table.ScanPlanningRequest) ([]ta
 // for each data or delete file it opens, overlaid on the table's base IO props
 // so vended keys win on collision. With none vended it returns a nil PlanIO,
 // and the scan falls back to the table's own FileIO.
-func planIOFromCredentials(creds []StorageCredential, location string, 
baseProps iceberg.Properties) table.PlanIO {
+func planIOFromCredentials(creds []StorageCredential, location string, 
baseProps iceberg.Properties, registry *iceio.Registry) table.PlanIO {
        if len(creds) == 0 {
                return nil
        }
 
        return &planScopedIO{refresher: &vendedCredentialRefresher{
+               ioRegistry:  registry,
                mu:          semaphore.NewWeighted(1),
                location:    location,
                props:       maps.Clone(baseProps),
diff --git a/catalog/rest/scan_planning_test.go 
b/catalog/rest/scan_planning_test.go
index 88f733a10..2254097f8 100644
--- a/catalog/rest/scan_planning_test.go
+++ b/catalog/rest/scan_planning_test.go
@@ -967,7 +967,7 @@ func TestScanPlanningEndpointGating(t *testing.T) {
        }
 }
 
-func newScanPlanningTestCatalog(t *testing.T, endpoints []endpoint, register 
func(*http.ServeMux)) *Catalog {
+func newScanPlanningTestCatalog(t *testing.T, endpoints []endpoint, register 
func(*http.ServeMux), opts ...Option) *Catalog {
        t.Helper()
 
        mux := http.NewServeMux()
@@ -991,7 +991,7 @@ func newScanPlanningTestCatalog(t *testing.T, endpoints 
[]endpoint, register fun
        srv := httptest.NewServer(mux)
        t.Cleanup(srv.Close)
 
-       cat, err := NewCatalog(context.Background(), "rest", srv.URL)
+       cat, err := NewCatalog(context.Background(), "rest", srv.URL, opts...)
        require.NoError(t, err)
 
        return cat
@@ -1932,7 +1932,7 @@ func TestPlanScopedIOExpiredCredentials(t *testing.T) {
                },
        }}
 
-       planIO := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil)
+       planIO := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil, nil)
        require.NotNil(t, planIO)
 
        p, ok := planIO.(*planScopedIO)
@@ -1971,7 +1971,7 @@ func TestPlanScopedIOAlreadyExpiredCredentials(t 
*testing.T) {
                },
        }}
 
-       p, ok := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil).(*planScopedIO)
+       p, ok := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil, nil).(*planScopedIO)
        require.True(t, ok)
        p.refresher.nowFunc = func() time.Time { return now }
 
@@ -2000,7 +2000,7 @@ func 
TestPlanScopedIOIgnoresExpiredCredentialForUnrelatedPrefix(t *testing.T) {
                },
        }
 
-       p, ok := planIOFromCredentials(creds, "file:///metadata/v1.json", 
nil).(*planScopedIO)
+       p, ok := planIOFromCredentials(creds, "file:///metadata/v1.json", nil, 
nil).(*planScopedIO)
        require.True(t, ok)
        p.refresher.nowFunc = func() time.Time { return now }
 
@@ -2022,7 +2022,7 @@ func TestPlanScopedIOCredentialsWithoutExpiry(t 
*testing.T) {
 
        creds := []StorageCredential{{Prefix: "file:///bucket/", Config: 
iceberg.Properties{"s3.access-key-id": "vended"}}}
 
-       p, ok := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil).(*planScopedIO)
+       p, ok := planIOFromCredentials(creds, 
"file:///bucket/db/tbl/metadata/v1.json", nil, nil).(*planScopedIO)
        require.True(t, ok)
 
        _, err := p.Load(context.Background())
diff --git a/catalog/rest/vended_creds.go b/catalog/rest/vended_creds.go
index d3b4a8229..aecb595e5 100644
--- a/catalog/rest/vended_creds.go
+++ b/catalog/rest/vended_creds.go
@@ -110,6 +110,7 @@ func parseCredentialExpiry(config iceberg.Properties) 
(time.Time, bool) {
 }
 
 type vendedCredentialRefresher struct {
+       ioRegistry *iceio.Registry
        // Use a weighted semaphore with a single unit to use as an exclusive 
lock
        // but cancellation (via context) is supported. This is important as we 
do IO
        // while holding this lock and we want to allow others to cancel during 
acquisition.
@@ -211,11 +212,12 @@ func (v *vendedCredentialRefresher) loadFS(ctx 
context.Context) (iceio.IO, error
        if len(v.credentials) > 0 {
                prefixIO := newPrefixScopedIO(ioCtx, v.props, v.credentials)
                prefixIO.nowFunc = v.now
+               prefixIO.ioRegistry = v.ioRegistry
                newIO = prefixIO
                // Expiry is enforced by prefixScopedIO against only the 
credential
                // selected for each object location.
        } else {
-               loaded, err := iceio.LoadFS(ioCtx, config, v.location)
+               loaded, err := loadRegistryFS(ioCtx, v.ioRegistry, config, 
v.location)
                if err != nil {
                        ioCancel()
 
@@ -340,6 +342,7 @@ func (v *vendedCredentialRefresher) close() error {
 // files in different storage prefixes, so resolving credentials once at plan
 // creation would be incorrect.
 type prefixScopedIO struct {
+       ioRegistry  *iceio.Registry
        ctx         context.Context
        baseProps   iceberg.Properties
        credentials []StorageCredential
@@ -408,7 +411,7 @@ func (p *prefixScopedIO) filesystemFor(name string) 
(iceio.IO, error) {
 
        props := p.propertiesForLocation(name)
 
-       fs, err := iceio.LoadFS(p.ctx, props, name)
+       fs, err := loadRegistryFS(p.ctx, p.ioRegistry, props, name)
        if err != nil {
                return nil, err
        }
@@ -534,3 +537,24 @@ func (p *prefixScopedIO) Close() error {
 
        return closeErr
 }
+
+// loadRegistryFS preserves the global registry when none is configured.
+func loadRegistryFS(ctx context.Context, registry *iceio.Registry, props 
iceberg.Properties, location string) (iceio.IO, error) {
+       if registry == nil {
+               return iceio.LoadFS(ctx, props, location)
+       }
+
+       return registry.LoadFS(ctx, props, location)
+}
+
+// loadRegistryFSFunc preserves the global registry for lazy loads when none 
is configured.
+func loadRegistryFSFunc(registry *iceio.Registry, props iceberg.Properties, 
location string) func(context.Context) (iceio.IO, error) {
+       return func(ctx context.Context) (iceio.IO, error) {
+               fs, err := loadRegistryFS(ctx, registry, props, location)
+               if err != nil {
+                       return nil, fmt.Errorf("failed to load metadata file at 
%s: %w", location, err)
+               }
+
+               return fs, nil
+       }
+}

Reply via email to