Skip to content

feat(io): split io/gocloud into per-cloud backend packages - #1961

Merged
laskoviymishka merged 5 commits into
apache:mainfrom
badalprasadsingh:feat/split-per-cloud-backends
Aug 31, 2026
Merged

feat(io): split io/gocloud into per-cloud backend packages#1961
laskoviymishka merged 5 commits into
apache:mainfrom
badalprasadsingh:feat/split-per-cloud-backends

Conversation

@badalprasadsingh

@badalprasadsingh badalprasadsingh commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Solves #1874, not adding the "Fixes" tag as leaving upon the user to close the issue once the PR is merged :)

The implementation is based on the Proposal shared earlier.

Following this PR, io/gocloud is split into blobfs, a bucket-based FileIO with no cloud SDK dependency, plus per-cloud s3, gcs and azure registration packages with io/gocloud behaving as an aggregator.

Changelog Note

  • io/gocloud is split into per-cloud backend packages. Blank-importing io/gocloud still registers all nine schemes. Its exported symbols remain available as Deprecated: aliases.
  • Behavior change: catalog/hadoop no longer registers cloud schemes. It previously did so as a side effect if importing io/gocloud, which linked all three cloud SDKs into every binary using the catalog. Hadoop-catalog users on cloud storage must blank-import the matching backend, otherwise the first LoadFS for a cloud path fails with ErrIOSchemeNotFound naming the package to import. This is recorded in the catalog/hadoop package doc so it is visible from go doc on upgrade.

Public API

blobfs is now the extension point for custom backends. ObjectLocation (with Scheme, Authority and Key accessors), NewObjectLocation, ObjectLocationExtractor, KeyExtractorFromObjectLocation, DefaultObjectLocationExtractor and Writer are exported and documented.

Signed-off-by: badalprasadsingh <badal@datazip.io>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like the shape of this. The dependency-free blobfs base with per-cloud registration packages is the right decomposition, and keeping io/gocloud as a type-alias + blank-import shim means existing callers don't break on the move.

I'd hold it before merging though, because this reshapes the public io surface and a few follow-ups will build on whatever contract lands here, so I'd like that contract to be solid first.

The one that matters most is catalog/hadoop. It used to pull in io/gocloud's init() transitively, so importing the hadoop catalog registered all nine cloud schemes for free. Now it imports only blobfs, which has no registration, so a program that imports just catalog/hadoop and uses s3/gcs/azure will build fine and then fail at the first LoadFS with ErrIOSchemeNotFound after upgrading. I don't think we should re-link all three SDKs into hadoop to paper over it, but we also shouldn't let it break silently: either keep the blank import or make it a loud, documented break. And the compat shim only tests type aliases today, not the "blank-importing io/gocloud registers all nine schemes" promise that's its entire reason to exist.

Things I'd like to settle in this PR before the follow-ups build on it:

  • a deliberate decision on catalog/hadoop registration, reflected in the CHANGELOG and package doc rather than only the website config page
  • a test that the shim actually registers all nine schemes, plus one for the catalog/hadoop behavior after that decision
  • the newly-exported blobfs surface (ObjectLocation with no accessors, PropertiesWithPrefix) settled as either a usable and documented public API or kept internal

Once those land I'm happy to take another pass and approve. Thanks for taking this on, it's a good cleanup.

Comment thread catalog/hadoop/io.go
import (
icebergio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/io/gocloud"
"github.com/apache/iceberg-go/io/gocloud/blobfs"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd hold on this import swap until we decide what happens to scheme registration for catalog/hadoop users.

Today the value import of io/gocloud pulls in that package's init(), so anyone importing catalog/hadoop gets all nine cloud schemes registered for free. Swapping to blobfs (which has no registration init()) means a program that imports only catalog/hadoop and talks to s3/gcs/azure will now build fine and then fail at the first LoadFS with ErrIOSchemeNotFound. That's a silent runtime regression on go get -u, with no compile-time signal.

I think the split itself is right, so I wouldn't want to re-link all three SDKs into hadoop just to preserve the old behavior. But we shouldn't let it break silently either. Either keep a blank import of io/gocloud here, or treat this as an intentional break and call it out loudly in the CHANGELOG and package doc (the website config page alone won't reach someone upgrading). wdyt?

Comment thread io/gocloud/compat_test.go

// For callers who still have not migrated
var (
_ *blobfs.FileIO = (*gocloud.BlobFileIO)(nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compat shim's whole contract is that blank-importing io/gocloud still registers all nine schemes, but this test only checks the type aliases and the wrapper functions.

If a future edit drops one of the sub-package imports from gocloud.go, the aliases still compile (they live in blobfs) and this test stays green, but the registration side effect silently disappears. I'd add an assertion that io.GetRegisteredSchemes() contains all nine cloud schemes after importing io/gocloud, mirroring the per-backend TestRegistersOnlyItsOwnSchemes.

While we're at it, the flip side of the catalog/hadoop change has no test either. A small case in catalog/hadoop that imports it without any backend and asserts LoadFS(ctx, nil, "s3://...") returns ErrIOSchemeNotFound would lock in whatever we decide above.

Comment thread io/gocloud/blobfs/blob.go Outdated
func (bfs *BlobFileIO) Remove(name string) error {
func (bfs *FileIO) Remove(name string) error {
var err error
name, err = bfs.preprocess(name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're moving this into a public package, worth fixing: Remove reassigns name with the preprocessed key, so when preprocess fails and returns "", the &fs.PathError{Path: name} below reports an empty path instead of the caller's original.

Open already guards against this with originalPath := path. Same gap in WriteFile and NewWriter. I'd take a separate key variable and leave name/path intact:

key, err := bfs.preprocess(name)
if err != nil {
    return &fs.PathError{Op: "remove", Path: name, Err: err}
}

Comment thread io/gocloud/blobfs/blob.go
var ErrUnsupportedObjectAuthority = errors.New("object URI authority is not supported by this FileIO")

type objectLocation struct {
type ObjectLocation struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that ObjectLocation is exported, its fields are all still unexported and the only constructor is NewObjectLocation. An external package writing a custom ObjectLocationExtractor can build one but can't read scheme/authority/key back off a value it's handed, which limits how far a custom extractor can route.

I'd either add Scheme()/Authority()/Key() accessors, or, if it's meant to be opaque, say so in a doc comment and point people at KeyExtractorFromObjectLocation. Either way these newly-exported symbols (ObjectLocation, NewObjectLocation, ObjectLocationExtractor, KeyExtractorFromObjectLocation, DefaultObjectLocationExtractor) are the extension surface for custom backends and none of them have doc comments yet.

Comment thread io/gocloud/blobfs/blob.go Outdated
// The caller must call Close on the returned Writer, even if the write is
// aborted.
func (bfs *BlobFileIO) NewWriter(ctx context.Context, path string, overwrite bool, opts *blob.WriterOptions) (w *blobWriteFile, err error) {
func (bfs *FileIO) NewWriter(ctx context.Context, path string, overwrite bool, opts *blob.WriterOptions) (w *blobWriteFile, err error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewWriter is exported on an exported type but returns *blobWriteFile, which callers outside the package can't name. It's valid Go and predates the split, but the rename makes it public surface now, so I'd either return an exported Writer interface or export the concrete type.

Comment thread internal/schemes/schemes.go Outdated
Azure = []string{"abfs", "abfss", "wasb", "wasbs"}
)

var byBackend = map[string][]string{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One subtle thing here: byBackend copies the S3/GCS/Azure slice headers at package load. Since these are exported mutable vars, if anything ever appends to one past its capacity, byBackend keeps pointing at the old backing array and BackendFor silently returns "" for the new scheme.

It's internal so the blast radius is small, but adding a scheme alias looks like a harmless one-liner. I'd at least add a doc comment noting these are read-only, or build byBackend lazily so it can't drift.

Comment thread io/gocloud/blobfs/utils.go Outdated
import "strings"

func propertiesWithPrefix(props map[string]string, prefix string) map[string]string {
func PropertiesWithPrefix(props map[string]string, prefix string) map[string]string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a generic map-prefix helper with no relationship to blob FS, and its only caller is azure.go. Exporting it from blobfs makes it permanent public API.

I'd keep it unexported (the azure package can hold its own two-line copy) or move it to an internal/ helper, rather than committing to it as public surface. If it does stay exported, it needs a doc comment on what it matches and whether the prefix is stripped from the keys.

Comment thread io/gocloud/s3/register_test.go Outdated
)

func TestRegistersOnlyItsOwnSchemes(t *testing.T) {
assert.ElementsMatch(t, append([]string{"file", "", "mem"}, schemes.S3...), io.GetRegisteredSchemes())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "mem" here comes from a transitive import rather than anything in this package, so if that dependency changes this fails with no obvious reason. A one-line comment naming where mem gets registered would save the next person the hunt.

The exact-set match via ElementsMatch also means any newly-registered legit scheme breaks all three of these. assert.Subset for the cloud schemes you actually care about would be less brittle. Same pattern in the gcs and azure register tests.

import "slices"

var (
S3 = []string{"s3", "s3a", "s3n", "oss"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a doc comment on this slice: oss routes through the AWS S3 SDK here, which works via OSS's S3-compatible API but diverges from Java (dedicated OSSFileIO) and means OSS users configure s3.* credential keys, not oss.*. Easy to trip over otherwise.

Comment thread io/gocloud/azure/register_test.go Outdated

for _, tt := range []struct {
location string
errValue string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small consistency thing: the s3 and gcs register tests call this field wantHint; only azure uses errValue. I'd rename to wantHint to match.

Signed-off-by: badalprasadsingh <badal@datazip.io>
Signed-off-by: badalprasadsingh <badal@datazip.io>

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@laskoviymishka
laskoviymishka merged commit ad748da into apache:main Aug 31, 2026
15 checks passed
@badalprasadsingh
badalprasadsingh deleted the feat/split-per-cloud-backends branch August 31, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants