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 e0c37e689 refactor(catalog)!: require Close and centralize catalog
cleanup (#2292)
e0c37e689 is described below
commit e0c37e68929117b4ead9815be5d24bd7f2a6f37c
Author: Minh Vu <[email protected]>
AuthorDate: Sun Oct 25 10:09:31 2026 +0100
refactor(catalog)!: require Close and centralize catalog cleanup (#2292)
* refactor(catalog)!: require Close in the public catalog interface
Make resource ownership explicit, centralize conformance cleanup, and
release catalogs in successful CLI flows.
Signed-off-by: Minh Vu <[email protected]>
* refactor(catalog)!: use required Close across built-in catalogs
Drop redundant Closer assertions and let the conformance suite own teardown.
Signed-off-by: Minh Vu <[email protected]>
* test(catalog): update test doubles for required Close
Signed-off-by: Minh Vu <[email protected]>
* test(catalog): keep package overview in the package file
Signed-off-by: Minh Vu <[email protected]>
* style(catalog): format required close changes
Signed-off-by: Minh Vu <[email protected]>
* docs(releases): describe breaking API migration
Signed-off-by: Minh Vu <[email protected]>
* fix(cli): close catalogs before exiting
Signed-off-by: Minh Vu <[email protected]>
* fix(cli): remove unused compact import
Signed-off-by: Minh Vu <[email protected]>
* fix(catalog): retain conformance cleanup contract and clarify Close docs
* fix(catalog): retain conformance cleanup contract and clarify Close docs
* test(catalog): remove obsolete suite-owned cleanup assertion
* test(catalog): restore factory-owned conformance cleanup
* test(catalog): restore factory-owned conformance cleanup
* test(catalog): restore factory-owned conformance cleanup
* test(catalog): restore factory-owned conformance cleanup
* test(catalog): restore factory-owned conformance cleanup
* test(cli): ensure command errors close catalogs before exiting
* style(catalogtest): remove extra blank line flagged by gofmt
---------
Signed-off-by: Minh Vu <[email protected]>
---
catalog/catalog.go | 36 +++----
catalog/example_multi_table_transaction_test.go | 2 +
catalog/glue/glue.go | 5 +-
catalog/hadoop/hadoop.go | 5 +-
catalog/hive/hive.go | 5 +-
catalog/multi_table_transaction_test.go | 2 +
catalog/registry.go | 3 +
catalog/rest/conformance_test.go | 6 +-
catalog/rest/external_conformance_test.go | 6 +-
catalog/rest/rest.go | 5 +-
catalog/sql/conformance_test.go | 2 +-
catalog/sql/sql.go | 5 +-
cmd/iceberg/clean_orphan_files.go | 8 +-
cmd/iceberg/compact.go | 13 ++-
cmd/iceberg/drop_test.go | 2 +
cmd/iceberg/exit.go | 43 +++++++-
cmd/iceberg/exit_test.go | 129 ++++++++++++++++++++++++
cmd/iceberg/expire_snapshots.go | 12 +--
cmd/iceberg/main.go | 92 +++++++++--------
cmd/iceberg/output.go | 11 +-
cmd/iceberg/properties_test.go | 2 +
cmd/iceberg/upgrade_rollback_test.go | 2 +
website/src/api.md | 5 +
website/src/configuration.md | 2 +-
website/src/releases.md | 1 +
25 files changed, 281 insertions(+), 123 deletions(-)
diff --git a/catalog/catalog.go b/catalog/catalog.go
index e7ea6791b..d5bda95dc 100644
--- a/catalog/catalog.go
+++ b/catalog/catalog.go
@@ -31,6 +31,7 @@ import (
"context"
"errors"
"fmt"
+ "io"
"iter"
"strings"
"unicode"
@@ -112,8 +113,14 @@ func NewCreateViewCfg() CreateViewCfg {
}
}
-// Catalog for iceberg table operations like create, drop, load, list and
others.
+// Catalog provides table and namespace operations plus an explicit resource
+// lifecycle. Implementations must release catalog-owned resources in Close.
+// Ownership of constructor-supplied dependencies depends on the
implementation.
+// Using a catalog after Close is unsupported; repeated Close calls have
+// implementation-specific behavior.
type Catalog interface {
+ io.Closer
+
// CatalogType returns the type of the catalog.
CatalogType() Type
@@ -152,6 +159,11 @@ type Catalog interface {
removals []string, updates iceberg.Properties)
(PropertiesUpdateSummary, error)
}
+// Closer is retained as an alias for io.Closer.
+//
+// Deprecated: Use [io.Closer] instead; Catalog embeds it directly.
+type Closer = io.Closer
+
// TransactionalCatalog is an optional interface implemented by catalogs
// that support atomic multi-table commits. Callers should check for this
// capability via a type assertion:
@@ -191,28 +203,6 @@ type PurgeableTable interface {
PurgeTable(ctx context.Context, identifier table.Identifier) error
}
-// Closer is an optional interface implemented by catalogs that hold releasable
-// resources — currently a metrics reporter and stateful resources (e.g.
-// HTTP-backed clients). It is not part of [Catalog] because adding a method
to that
-// widely-implemented interface would break every external implementation; a
-// follow-up may promote it. Callers holding a [Catalog] from [Load] should
-// release it via a type assertion:
-//
-// cat, err := catalog.Load(ctx, name, props)
-// if err != nil {
-// return err
-// }
-// if closer, ok := cat.(catalog.Closer); ok {
-// defer closer.Close()
-// }
-//
-// All built-in catalogs (REST, SQL, Glue, Hive, Hadoop) implement Closer.
Close
-// releases the catalog's own resources; a catalog built on a caller-owned
handle
-// (e.g. the SQL catalog's *sql.DB) does not close that handle.
-type Closer interface {
- Close() error
-}
-
// RefreshableCredentialCatalog is an optional interface implemented by
catalogs that
// support temporarily-vended credentials. Temporarily-vended credentials
scope object
// storage access to just files allowed by the user making a related request
to the
diff --git a/catalog/example_multi_table_transaction_test.go
b/catalog/example_multi_table_transaction_test.go
index 8deff9161..5a8614b58 100644
--- a/catalog/example_multi_table_transaction_test.go
+++ b/catalog/example_multi_table_transaction_test.go
@@ -34,6 +34,8 @@ type exampleCatalog struct {
tables map[string]*table.Table
}
+func (c *exampleCatalog) Close() error { return nil }
+
func (c *exampleCatalog) CatalogType() catalog.Type { return catalog.REST }
func (c *exampleCatalog) CommitTransaction(_ context.Context, _
[]table.TableCommit) error {
diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index 9e554c9c8..c86660edc 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -273,12 +273,9 @@ func (c *Catalog) CatalogType() catalog.Type {
// Close releases the catalog's metrics reporter. The Glue catalog does not own
// the lifetime of the AWS clients it was configured with, so only the reporter
-// is released. Callers holding a [catalog.Catalog] can reach this via a
-// [catalog.Closer] type assertion.
+// is released. Close is required by [catalog.Catalog].
func (c *Catalog) Close() error { return c.reporter.Close() }
-var _ catalog.Closer = (*Catalog)(nil)
-
// CreateTable creates a new Iceberg table in the Glue catalog.
// This function will create the metadata file in S3 using the catalog and
table properties,
// to determine the bucket and key for the metadata location.
diff --git a/catalog/hadoop/hadoop.go b/catalog/hadoop/hadoop.go
index b59ecd2e4..fc277687a 100644
--- a/catalog/hadoop/hadoop.go
+++ b/catalog/hadoop/hadoop.go
@@ -277,12 +277,9 @@ func (c *Catalog) CatalogType() catalog.Type {
return catalog.Hadoop
}
-// Close releases the catalog's metrics reporter. Callers holding a
-// [catalog.Catalog] can reach this via a [catalog.Closer] type assertion.
+// Close releases the catalog's metrics reporter. Close is required by
[catalog.Catalog].
func (c *Catalog) Close() error { return c.reporter.Close() }
-var _ catalog.Closer = (*Catalog)(nil)
-
// joinPath is a helper that allows paths to be joined as both local filesystem
// paths or as remote URIs needed for filesystems like blob stores.
func joinPath(isLocal bool, base string, parts ...string) string {
diff --git a/catalog/hive/hive.go b/catalog/hive/hive.go
index 0cc298968..d460c2191 100644
--- a/catalog/hive/hive.go
+++ b/catalog/hive/hive.go
@@ -103,8 +103,7 @@ func (c *Catalog) CatalogType() catalog.Type {
// resource — not just when a close returns an error (errors.Join already keeps
// both), but when it panics: a bare errors.Join(c.client.Close(),
c.reporter.Close())
// would let a panic in the first close abort the second. Recovered panics are
-// converted to errors and joined with any close errors. Callers holding a
-// [catalog.Catalog] can reach this via a [catalog.Closer] type assertion.
+// converted to errors and joined with any close errors. Close is required by
[catalog.Catalog].
func (c *Catalog) Close() error {
return errors.Join(
closeIsolated(c.client.Close),
@@ -124,8 +123,6 @@ func closeIsolated(closeFn func() error) (err error) {
return closeFn()
}
-var _ catalog.Closer = (*Catalog)(nil)
-
// ListTables returns a list of table identifiers in the given namespace.
func (c *Catalog) ListTables(ctx context.Context, namespace table.Identifier)
iter.Seq2[table.Identifier, error] {
return func(yield func(table.Identifier, error) bool) {
diff --git a/catalog/multi_table_transaction_test.go
b/catalog/multi_table_transaction_test.go
index 87ee69e5a..88ddc42ec 100644
--- a/catalog/multi_table_transaction_test.go
+++ b/catalog/multi_table_transaction_test.go
@@ -111,6 +111,8 @@ func (s *fullStubCatalog)
UpdateNamespaceProperties(context.Context, table.Ident
return PropertiesUpdateSummary{}, nil
}
+func (s *fullStubCatalog) Close() error { return nil }
+
func (s *fullStubCatalog) CatalogType() Type { return REST }
func mtxTestTable(t *testing.T, ns, name string) *table.Table {
diff --git a/catalog/registry.go b/catalog/registry.go
index 1500e6516..e17ec2668 100644
--- a/catalog/registry.go
+++ b/catalog/registry.go
@@ -94,6 +94,9 @@ func GetRegisteredCatalogs() []string {
return defaultRegistry.getKeys()
}
+// Load returns a catalog that must be closed by the caller to release its
+// reporter, transports, and other catalog-owned resources.
+//
// Load allows loading a specific catalog by URI and properties.
//
// This is utilized alongside RegisterCatalog/UnregisterCatalog to not only
allow
diff --git a/catalog/rest/conformance_test.go b/catalog/rest/conformance_test.go
index 15f4e17f5..8e2c6192c 100644
--- a/catalog/rest/conformance_test.go
+++ b/catalog/rest/conformance_test.go
@@ -43,11 +43,7 @@ func TestRestCatalogConformance(t *testing.T) {
io.S3SecretAccessKey: "password",
})
require.NoError(t, err)
- t.Cleanup(func() {
- if closer, ok := cat.(catalog.Closer); ok {
- require.NoError(t, closer.Close())
- }
- })
+ t.Cleanup(func() { require.NoError(t, cat.Close()) })
return cat
},
diff --git a/catalog/rest/external_conformance_test.go
b/catalog/rest/external_conformance_test.go
index d76e82714..8e9746cfd 100644
--- a/catalog/rest/external_conformance_test.go
+++ b/catalog/rest/external_conformance_test.go
@@ -77,11 +77,7 @@ func TestExternalRestCatalogConformance(t *testing.T) {
NewCatalog: func(t *testing.T) catalog.Catalog {
cat, err := catalog.Load(context.Background(),
"external", props)
require.NoError(t, err)
- t.Cleanup(func() {
- if closer, ok := cat.(catalog.Closer); ok {
- require.NoError(t, closer.Close())
- }
- })
+ t.Cleanup(func() { require.NoError(t, cat.Close()) })
return cat
},
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 17c4b2ae4..7e2eb7602 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1280,8 +1280,7 @@ func (r *Catalog) CatalogType() catalog.Type { return
catalog.REST }
// releases its metrics reporter. Closing the metrics dispatcher cancels any
// in-flight reports and drains its workers so outbound telemetry stops.
// Caller-provided transports remain caller-owned. Close is safe to call more
-// than once. Callers holding a [catalog.Catalog] can reach this via a
-// [catalog.Closer] type assertion.
+// than once. Close is required by [catalog.Catalog].
func (r *Catalog) Close() error {
r.closeSessionOnce.Do(func() {
if r.closeSession != nil {
@@ -1297,8 +1296,6 @@ func (r *Catalog) Close() error {
return r.closeErr
}
-var _ catalog.Closer = (*Catalog)(nil)
-
func (r *Catalog) checkValidNamespace(ident table.Identifier) error {
if err := catalog.ValidateNamespaceIdentifier(ident); err != nil {
return err
diff --git a/catalog/sql/conformance_test.go b/catalog/sql/conformance_test.go
index 2c4129041..94068a846 100644
--- a/catalog/sql/conformance_test.go
+++ b/catalog/sql/conformance_test.go
@@ -45,7 +45,7 @@ func TestSqlCatalogConformance(t *testing.T) {
"warehouse": "file://" + warehouse,
})
require.NoError(t, err)
- t.Cleanup(func() { require.NoError(t,
cat.(*sqlcat.Catalog).Close()) })
+ t.Cleanup(func() { require.NoError(t, cat.Close()) })
return cat
},
diff --git a/catalog/sql/sql.go b/catalog/sql/sql.go
index a6c0da0a8..5a52b9300 100644
--- a/catalog/sql/sql.go
+++ b/catalog/sql/sql.go
@@ -462,8 +462,7 @@ func (c *Catalog) CatalogType() catalog.Type {
// Close releases the catalog's metrics reporter. When the catalog opened its
own
// database handle (the registry [catalog.Load] path), Close also closes that
// handle's connection pool; a handle supplied to [NewCatalog] stays owned by
the
-// caller and is left open. Callers holding a [catalog.Catalog] can reach this
via
-// a [catalog.Closer] type assertion.
+// caller and is left open. Close is required by [catalog.Catalog].
func (c *Catalog) Close() error {
err := c.reporter.Close()
if c.ownsDB {
@@ -479,8 +478,6 @@ func (c *Catalog) Close() error {
return err
}
-var _ catalog.Closer = (*Catalog)(nil)
-
func (c *Catalog) CreateSQLTables(ctx context.Context) error {
_, err := c.db.NewCreateTable().Model((*sqlIcebergTable)(nil)).
IfNotExists().Exec(ctx)
diff --git a/cmd/iceberg/clean_orphan_files.go
b/cmd/iceberg/clean_orphan_files.go
index b2b28a270..b0fb5f281 100644
--- a/cmd/iceberg/clean_orphan_files.go
+++ b/cmd/iceberg/clean_orphan_files.go
@@ -34,7 +34,7 @@ func runCleanOrphanFiles(ctx context.Context, output Output,
cat catalog.Catalog
olderThan, err := parseDuration(cmd.OlderThan)
if err != nil {
output.Error(fmt.Errorf("invalid --older-than: %w", err))
- os.Exit(1)
+ osExit(1)
}
tbl := loadTable(ctx, output, cat, cmd.TableID)
@@ -50,7 +50,7 @@ func runCleanOrphanFiles(ctx context.Context, output Output,
cat catalog.Catalog
plan, err := maintenance.PlanOrphanFiles(ctx, tbl, opts...)
if err != nil {
output.Error(fmt.Errorf("orphan file scan failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
planFiles := plan.Files()
@@ -83,13 +83,13 @@ func runCleanOrphanFiles(ctx context.Context, output
Output, cat catalog.Catalog
len(planFiles), formatBytes(plan.TotalSizeBytes()),
tableIDString(tbl))
if err := confirmAction(prompt, cmd.Yes); err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
deleteResult, err := maintenance.ExecuteOrphanCleanup(ctx, tbl, plan)
if err != nil {
output.Error(fmt.Errorf("orphan file deletion failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
cliResult = buildCleanOrphanFilesResult(tbl, deleteResult, false)
diff --git a/cmd/iceberg/compact.go b/cmd/iceberg/compact.go
index 9c0e776f8..3b689acbe 100644
--- a/cmd/iceberg/compact.go
+++ b/cmd/iceberg/compact.go
@@ -20,7 +20,6 @@ package main
import (
"context"
"fmt"
- "os"
"strconv"
"github.com/apache/iceberg-go/catalog"
@@ -51,7 +50,7 @@ func compact(ctx context.Context, output Output, cat
catalog.Catalog, cfg compac
plan, err := compaction.Analyze(ctx, tbl, compCfg)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
if len(plan.Groups) == 0 {
@@ -138,7 +137,7 @@ func compactRun(ctx context.Context, output Output, tbl
*table.Table, plan compa
fs, err := tbl.FS(ctx)
if err != nil {
output.Error(fmt.Errorf("open fs for eq-delete
cleanup: %w", err))
- os.Exit(1)
+ osExit(1)
}
rewrittenSet := make(map[string]struct{})
for _, g := range plan.Groups {
@@ -150,7 +149,7 @@ func compactRun(ctx context.Context, output Output, tbl
*table.Table, plan compa
ctx, fs, tbl.Metadata(), snap, rewrittenSet)
if err != nil {
output.Error(fmt.Errorf("collect dead equality
deletes: %w", err))
- os.Exit(1)
+ osExit(1)
}
rewriteOpts.ExtraDeleteFilesToRemove = deadEqDeletes
}
@@ -162,20 +161,20 @@ func compactRun(ctx context.Context, output Output, tbl
*table.Table, plan compa
output.Text(fmt.Sprintf(" (partial: %d groups
committed before failure)", result.RewrittenGroups))
}
output.Error(fmt.Errorf("compaction failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
if !cfg.partialProgress {
if _, err := tx.Commit(ctx); err != nil {
output.Error(fmt.Errorf("commit failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
}
if len(result.FailedGroups) > 0 {
failure := result.FailedGroups[0]
output.Error(fmt.Errorf("compaction failed for %d group(s);
first group %q: %w",
len(result.FailedGroups), failure.PartitionKey,
failure.Err))
- os.Exit(1)
+ osExit(1)
}
totalRemovedDeletes := result.RemovedPositionDeleteFiles +
result.RemovedEqualityDeleteFiles
diff --git a/cmd/iceberg/drop_test.go b/cmd/iceberg/drop_test.go
index 74bc48b7f..ca4723c1b 100644
--- a/cmd/iceberg/drop_test.go
+++ b/cmd/iceberg/drop_test.go
@@ -41,6 +41,8 @@ type mockCatalogForDrop struct {
checkExistsErr error
}
+func (m *mockCatalogForDrop) Close() error { return nil }
+
func (m *mockCatalogForDrop) CatalogType() catalog.Type {
return m.catalogType
}
diff --git a/cmd/iceberg/exit.go b/cmd/iceberg/exit.go
index 6834c2094..3b26d588a 100644
--- a/cmd/iceberg/exit.go
+++ b/cmd/iceberg/exit.go
@@ -17,8 +17,49 @@
package main
-import "os"
+import (
+ "log"
+ "os"
+ "sync"
+
+ "github.com/apache/iceberg-go/catalog"
+)
// osExit is an indirection over os.Exit so command handlers can be exercised
in
// tests by swapping it for a func that records the exit code and unwinds.
var osExit = os.Exit
+
+// closeCatalogBeforeExit routes command exits through catalog cleanup and also
+// closes the catalog when the command returns normally. The returned function
+// restores the previous exit hook.
+func closeCatalogBeforeExit(cat catalog.Catalog, reportCloseError func(error))
func() {
+ previousExit := osExit
+ var closeOnce sync.Once
+ closeCatalog := func() {
+ closeOnce.Do(func() {
+ if err := cat.Close(); err != nil && reportCloseError
!= nil {
+ reportCloseError(err)
+ }
+ })
+ }
+
+ osExit = func(code int) {
+ closeCatalog()
+ previousExit(code)
+ }
+
+ return func() {
+ osExit = previousExit
+ closeCatalog()
+ }
+}
+
+func logFatal(v ...any) {
+ log.Print(v...)
+ osExit(1)
+}
+
+func logFatalf(format string, v ...any) {
+ log.Printf(format, v...)
+ osExit(1)
+}
diff --git a/cmd/iceberg/exit_test.go b/cmd/iceberg/exit_test.go
new file mode 100644
index 000000000..835ebf093
--- /dev/null
+++ b/cmd/iceberg/exit_test.go
@@ -0,0 +1,129 @@
+// 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 main
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/apache/iceberg-go/catalog"
+)
+
+type closeCountingCatalog struct {
+ catalog.Catalog
+ closeCalls int
+ closeErr error
+}
+
+func (c *closeCountingCatalog) Close() error {
+ c.closeCalls++
+
+ return c.closeErr
+}
+
+func TestCloseCatalogBeforeExit(t *testing.T) {
+ closeErr := errors.New("close failed")
+ tests := []struct {
+ name string
+ exit bool
+ exitCode int
+ closeErr error
+ wantCloseErr error
+ }{
+ {name: "normal return"},
+ {name: "exit closes first", exit: true, exitCode: 7},
+ {name: "close error does not block exit", exit: true, exitCode:
9, closeErr: closeErr, wantCloseErr: closeErr},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ previousExit := osExit
+ t.Cleanup(func() { osExit = previousExit })
+
+ cat := &closeCountingCatalog{closeErr: tt.closeErr}
+ exitCalled := false
+ codeAtExit := 0
+ closesAtExit := 0
+ var reportedCloseErr error
+ osExit = func(code int) {
+ exitCalled = true
+ codeAtExit = code
+ closesAtExit = cat.closeCalls
+ }
+
+ restore := closeCatalogBeforeExit(cat, func(err error)
{ reportedCloseErr = err })
+ if tt.exit {
+ osExit(tt.exitCode)
+ }
+ restore()
+
+ if cat.closeCalls != 1 {
+ t.Fatalf("Close called %d times, want 1",
cat.closeCalls)
+ }
+ if exitCalled != tt.exit {
+ t.Fatalf("exit called = %t, want %t",
exitCalled, tt.exit)
+ }
+ if tt.exit && closesAtExit != 1 {
+ t.Fatalf("Close calls at exit = %d, want 1",
closesAtExit)
+ }
+ if tt.exit && codeAtExit != tt.exitCode {
+ t.Fatalf("exit code = %d, want %d", codeAtExit,
tt.exitCode)
+ }
+ if !errors.Is(reportedCloseErr, tt.wantCloseErr) {
+ t.Fatalf("reported close error = %v, want %v",
reportedCloseErr, tt.wantCloseErr)
+ }
+ })
+ }
+}
+
+func TestOutputErrorClosesCatalogBeforeExit(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ output Output
+ }{
+ {name: "text", output: textOutput{}},
+ {name: "json", output: jsonOutput{}},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ previousExit := osExit
+ t.Cleanup(func() { osExit = previousExit })
+
+ cat := &closeCountingCatalog{}
+ exitCode := 0
+ closesAtExit := 0
+ osExit = func(code int) {
+ exitCode = code
+ closesAtExit = cat.closeCalls
+ }
+
+ restore := closeCatalogBeforeExit(cat, nil)
+ tt.output.Error(errors.New("command failed"))
+ restore()
+
+ if exitCode != 1 {
+ t.Fatalf("exit code = %d, want 1", exitCode)
+ }
+ if closesAtExit != 1 {
+ t.Fatalf("Close calls before exit = %d, want
1", closesAtExit)
+ }
+ if cat.closeCalls != 1 {
+ t.Fatalf("Close calls = %d, want 1",
cat.closeCalls)
+ }
+ })
+ }
+}
diff --git a/cmd/iceberg/expire_snapshots.go b/cmd/iceberg/expire_snapshots.go
index 852b5336a..197aa49cd 100644
--- a/cmd/iceberg/expire_snapshots.go
+++ b/cmd/iceberg/expire_snapshots.go
@@ -39,7 +39,7 @@ func runExpireSnapshots(ctx context.Context, output Output,
cat catalog.Catalog,
d, err := parseDuration(cmd.OlderThan)
if err != nil {
output.Error(fmt.Errorf("invalid --older-than: %w",
err))
- os.Exit(1)
+ osExit(1)
}
opts = append(opts, table.WithOlderThan(d))
@@ -54,13 +54,13 @@ func runExpireSnapshots(ctx context.Context, output Output,
cat catalog.Catalog,
tx := tbl.NewTransaction()
if err := tx.ExpireSnapshots(opts...); err != nil {
output.Error(fmt.Errorf("expire snapshots failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
staged, err := tx.StagedTable()
if err != nil {
output.Error(fmt.Errorf("staging table failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
expired := diffSnapshots(tbl.Metadata().Snapshots(),
staged.Metadata().Snapshots())
@@ -87,7 +87,7 @@ func runExpireSnapshots(ctx context.Context, output Output,
cat catalog.Catalog,
prompt := fmt.Sprintf("Expire %d snapshot(s) from %s?", len(expired),
tableIDString(tbl))
if err := confirmAction(prompt, cmd.Yes); err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
realOpts := make([]table.ExpireSnapshotsOpt, 0, len(opts)-1)
@@ -102,12 +102,12 @@ func runExpireSnapshots(ctx context.Context, output
Output, cat catalog.Catalog,
realTx := tbl.NewTransaction()
if err := realTx.ExpireSnapshots(realOpts...); err != nil {
output.Error(fmt.Errorf("expire snapshots failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
if _, err := realTx.Commit(ctx); err != nil {
output.Error(fmt.Errorf("commit failed: %w", err))
- os.Exit(1)
+ osExit(1)
}
result.DryRun = false
diff --git a/cmd/iceberg/main.go b/cmd/iceberg/main.go
index ae6349f9d..f894614d0 100644
--- a/cmd/iceberg/main.go
+++ b/cmd/iceberg/main.go
@@ -242,7 +242,7 @@ func main() {
if parser.Subcommand() == nil {
parser.WriteHelp(os.Stderr)
- os.Exit(1)
+ osExit(1)
}
// Determine which flags were explicitly supplied on the command line so
@@ -258,29 +258,29 @@ func main() {
if err := applyConfigFile(&args, explicitFlags); err != nil {
log.Printf("configuration error: %v", err)
- os.Exit(1)
+ osExit(1)
}
// Validate nested subcommands before catalog init.
switch {
case args.Create != nil && args.Create.Namespace == nil &&
args.Create.Table == nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "create")
- os.Exit(1)
+ osExit(1)
case args.Drop != nil && args.Drop.Namespace == nil && args.Drop.Table
== nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "drop")
- os.Exit(1)
+ osExit(1)
case args.Properties != nil && args.Properties.Get == nil &&
args.Properties.Set == nil && args.Properties.Remove == nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "properties")
- os.Exit(1)
+ osExit(1)
case args.Compact != nil && args.Compact.Analyze == nil &&
args.Compact.Run == nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "compact")
- os.Exit(1)
+ osExit(1)
case args.Branch != nil && args.Branch.Create == nil &&
args.Branch.Delete == nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "branch")
- os.Exit(1)
+ osExit(1)
case args.Tag != nil && args.Tag.Create == nil && args.Tag.Delete ==
nil:
_ = parser.WriteHelpForSubcommand(os.Stderr, "tag")
- os.Exit(1)
+ osExit(1)
}
var output Output
@@ -290,7 +290,7 @@ func main() {
case "json":
output = jsonOutput{}
default:
- log.Fatal("unimplemented output type")
+ logFatal("unimplemented output type")
}
// Validate the rollback selector before catalog init so invalid flags
fail
@@ -298,7 +298,7 @@ func main() {
if args.Rollback != nil {
if err := validateRollbackSelector(args.Rollback); err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
}
@@ -306,10 +306,14 @@ func main() {
// as other user-facing errors (respects --output).
if catalog.Type(strings.ToLower(args.Catalog)) == catalog.DynamoDB {
output.Error(errors.New("dynamodb catalog is not implemented"))
- os.Exit(1)
+ osExit(1)
}
cat := initCatalog(ctx, args)
+ // Route command exits through a closer so os.Exit cannot skip cleanup.
+ defer closeCatalogBeforeExit(cat, func(err error) {
+ log.Printf("failed to close catalog: %v", err)
+ })()
switch {
case args.List != nil:
@@ -404,7 +408,7 @@ func initCatalog(ctx context.Context, args Args)
catalog.Catalog {
}
if cat, err = rest.NewCatalog(ctx, "rest", args.URI, opts...);
err != nil {
- log.Fatal(err)
+ logFatal(err)
}
case catalog.Glue:
var awsLoadOpts []func(*awsconfig.LoadOptions) error
@@ -414,14 +418,14 @@ func initCatalog(ctx context.Context, args Args)
catalog.Catalog {
awscfg, err := awsconfig.LoadDefaultConfig(ctx, awsLoadOpts...)
if err != nil {
- log.Fatal(err)
+ logFatal(err)
}
opts := []glue.Option{
glue.WithAwsConfig(awscfg),
}
if cat, err = glue.NewCatalog(opts...); err != nil {
- log.Fatal(err)
+ logFatal(err)
}
case catalog.Hive:
props := iceberg.Properties{
@@ -432,13 +436,13 @@ func initCatalog(ctx context.Context, args Args)
catalog.Catalog {
}
if cat, err = hive.NewCatalog(props); err != nil {
- log.Fatal(err)
+ logFatal(err)
}
case catalog.Hadoop:
if cat, err = hadoop.NewCatalog("hadoop", args.Warehouse,
iceberg.Properties{
"warehouse": args.Warehouse,
}); err != nil {
- log.Fatal(err)
+ logFatal(err)
}
case catalog.SQL:
// Always set uri/warehouse/credential keys (even when empty) so
@@ -459,10 +463,10 @@ func initCatalog(ctx context.Context, args Args)
catalog.Catalog {
}
if cat, err = catalog.Load(ctx, args.CatalogName, props); err
!= nil {
- log.Fatal(err)
+ logFatal(err)
}
default:
- log.Fatal("unrecognized catalog type")
+ logFatal("unrecognized catalog type")
}
return cat
@@ -481,14 +485,14 @@ func runDescribe(ctx context.Context, output Output, cat
catalog.Catalog, cmd *D
ident = cmd.Target
if ident == "" {
- log.Fatal("missing IDENTIFIER for describe namespace")
+ logFatal("missing IDENTIFIER for describe namespace")
}
case "table":
entityType = "tbl"
ident = cmd.Target
if ident == "" {
- log.Fatal("missing IDENTIFIER for describe table")
+ logFatal("missing IDENTIFIER for describe table")
}
}
@@ -511,7 +515,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
err := cat.CreateNamespace(ctx,
catalog.ToIdentifier(ns.Identifier), props)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
output.Text("Namespace " + ns.Identifier + " created
successfully")
@@ -519,7 +523,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
tbl := cmd.Table
if tbl.Schema != "" && tbl.InferSchema != "" {
output.Error(errors.New("--schema and --infer-schema
are mutually exclusive"))
- os.Exit(1)
+ osExit(1)
}
var schema *iceberg.Schema
@@ -531,7 +535,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
schema, err = iceberg.NewSchemaFromJsonFields(0,
tbl.Schema)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
case tbl.InferSchema != "":
var err error
@@ -539,14 +543,14 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
schema, err = schemaFromFile(tbl.InferSchema)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
output.Text("Inferred schema from " + tbl.InferSchema +
":")
output.Schema(schema)
default:
output.Error(errors.New("missing --schema or
--infer-schema for table creation"))
- os.Exit(1)
+ osExit(1)
}
var opts []catalog.CreateTableOpt
@@ -558,7 +562,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
props, err := parseProperties(tbl.Properties)
if err != nil {
output.Error(fmt.Errorf("failed to parse
properties: %w", err))
- os.Exit(1)
+ osExit(1)
}
opts = append(opts, catalog.WithProperties(props))
@@ -568,7 +572,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
spec, err := parsePartitionSpec(tbl.PartitionSpec,
schema)
if err != nil {
output.Error(fmt.Errorf("failed to parse
partition spec: %w", err))
- os.Exit(1)
+ osExit(1)
}
opts = append(opts, catalog.WithPartitionSpec(spec))
@@ -578,7 +582,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
sortOrder, err := parseSortOrder(tbl.SortOrder, schema)
if err != nil {
output.Error(fmt.Errorf("failed to parse sort
order: %w", err))
- os.Exit(1)
+ osExit(1)
}
opts = append(opts, catalog.WithSortOrder(sortOrder))
@@ -588,7 +592,7 @@ func runCreate(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Cre
_, err := cat.CreateTable(ctx, ident, schema, opts...)
if err != nil {
output.Error(fmt.Errorf("failed to create table: %w",
err))
- os.Exit(1)
+ osExit(1)
}
output.Text("Table " + tbl.Identifier + " created successfully")
@@ -647,7 +651,7 @@ func runRename(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Ren
catalog.ToIdentifier(cmd.From), catalog.ToIdentifier(cmd.To))
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
output.Text("Renamed table from " + cmd.From + " to " + cmd.To)
@@ -655,7 +659,7 @@ func runRename(ctx context.Context, output Output, cat
catalog.Catalog, cmd *Ren
func validateEntityType(t string) {
if t != "namespace" && t != "table" {
- log.Fatalf("expected 'namespace' or 'table', got %q", t)
+ logFatalf("expected 'namespace' or 'table', got %q", t)
}
}
@@ -675,7 +679,7 @@ func runProperties(ctx context.Context, output Output, cat
catalog.Catalog, cmd
props, err = cat.LoadNamespaceProperties(ctx, ident)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
tbl := loadTable(ctx, output, cat, get.Identifier)
@@ -707,7 +711,7 @@ func runProperties(ctx context.Context, output Output, cat
catalog.Catalog, cmd
nil, iceberg.Properties{set.PropName:
set.Value})
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
tbl := loadTable(ctx, output, cat, set.Identifier)
@@ -715,7 +719,7 @@ func runProperties(ctx context.Context, output Output, cat
catalog.Catalog, cmd
[]table.Update{table.NewSetPropertiesUpdate(iceberg.Properties{set.PropName:
set.Value})})
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
}
@@ -733,7 +737,7 @@ func runProperties(ctx context.Context, output Output, cat
catalog.Catalog, cmd
[]string{rm.PropName}, nil)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
tbl := loadTable(ctx, output, cat, rm.Identifier)
@@ -741,7 +745,7 @@ func runProperties(ctx context.Context, output Output, cat
catalog.Catalog, cmd
[]table.Update{table.NewRemovePropertiesUpdate([]string{rm.PropName})})
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
}
@@ -772,7 +776,7 @@ func list(ctx context.Context, output Output, cat
catalog.Catalog, parent string
if pageSize < 0 {
output.Error(fmt.Errorf("--page-size must be non-negative, got
%d", pageSize))
- os.Exit(1)
+ osExit(1)
}
if pageSize > 0 {
@@ -788,7 +792,7 @@ func list(ctx context.Context, output Output, cat
catalog.Catalog, parent string
for id, err := range iter {
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
ids = append(ids, id)
@@ -799,7 +803,7 @@ func list(ctx context.Context, output Output, cat
catalog.Catalog, parent string
ns, err := cat.ListNamespaces(ctx, prnt)
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
ids = ns
@@ -818,11 +822,11 @@ func describe(ctx context.Context, output Output, cat
catalog.Catalog, id string
if errors.Is(err, catalog.ErrNoSuchNamespace) {
if entityType != "any" || len(ident) == 1 {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
isNS = true
@@ -835,7 +839,7 @@ func describe(ctx context.Context, output Output, cat
catalog.Catalog, id string
if err != nil {
if !errors.Is(err, catalog.ErrNoSuchTable) ||
entityType != "any" {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
} else {
isTbl = true
@@ -846,7 +850,7 @@ func describe(ctx context.Context, output Output, cat
catalog.Catalog, id string
if !isNS && !isTbl {
output.Error(fmt.Errorf("%w: table or namespace does not exist:
%s",
catalog.ErrNoSuchNamespace, ident))
- os.Exit(1)
+ osExit(1)
}
}
@@ -854,7 +858,7 @@ func loadTable(ctx context.Context, output Output, cat
catalog.Catalog, id strin
tbl, err := cat.LoadTable(ctx, catalog.ToIdentifier(id))
if err != nil {
output.Error(err)
- os.Exit(1)
+ osExit(1)
}
return tbl
diff --git a/cmd/iceberg/output.go b/cmd/iceberg/output.go
index 2b19987e7..c69239b49 100644
--- a/cmd/iceberg/output.go
+++ b/cmd/iceberg/output.go
@@ -21,7 +21,6 @@ import (
"context"
"encoding/json"
"fmt"
- "log"
"os"
"strconv"
"strings"
@@ -158,12 +157,12 @@ func (t textOutput) Files(tbl *table.Table, history bool)
{
afs, err := tbl.FS(context.TODO())
if err != nil {
t.Error(err)
- os.Exit(1)
+ osExit(1)
}
manifestList, err := snap.Manifests(afs)
if err != nil {
t.Error(err)
- os.Exit(1)
+ osExit(1)
}
for _, m := range manifestList {
@@ -183,7 +182,7 @@ func (t textOutput) Files(tbl *table.Table, history bool) {
}
if iterErr != nil {
t.Error(iterErr)
- os.Exit(1)
+ osExit(1)
}
}
}
@@ -273,7 +272,7 @@ func (textOutput) Uuid(u uuid.UUID) {
}
func (textOutput) Error(err error) {
- log.Fatal(err)
+ logFatal(err)
}
type jsonOutput struct{}
@@ -398,5 +397,5 @@ func (j jsonOutput) Uuid(u uuid.UUID) {
}
func (j jsonOutput) Error(err error) {
- log.Fatal(err)
+ logFatal(err)
}
diff --git a/cmd/iceberg/properties_test.go b/cmd/iceberg/properties_test.go
index 303513dd4..ebaec58f2 100644
--- a/cmd/iceberg/properties_test.go
+++ b/cmd/iceberg/properties_test.go
@@ -43,6 +43,8 @@ func (m *mockCatalogForProperties) LoadTable(ctx
context.Context, ident table.Id
return m.tbl, nil
}
+func (m *mockCatalogForProperties) Close() error { return nil }
+
func (m *mockCatalogForProperties) CatalogType() catalog.Type {
panic("CatalogType must not be called")
}
diff --git a/cmd/iceberg/upgrade_rollback_test.go
b/cmd/iceberg/upgrade_rollback_test.go
index 70d5a3279..0774c7f28 100644
--- a/cmd/iceberg/upgrade_rollback_test.go
+++ b/cmd/iceberg/upgrade_rollback_test.go
@@ -408,6 +408,8 @@ type panicCatalog struct {
tbl *table.Table
}
+func (p *panicCatalog) Close() error { return nil }
+
func (p *panicCatalog) CatalogType() catalog.Type { return "panic" }
func (p *panicCatalog) CreateTable(context.Context, table.Identifier,
*iceberg.Schema, ...catalog.CreateTableOpt) (*table.Table, error) {
panic("CreateTable must not be called")
diff --git a/website/src/api.md b/website/src/api.md
index 225c06b01..0f00ddd37 100644
--- a/website/src/api.md
+++ b/website/src/api.md
@@ -112,8 +112,13 @@ cat, err := catalog.Load(ctx, "default",
iceberg.Properties{
"uri": "http://localhost:8181",
"warehouse": "s3://my-bucket/warehouse",
})
+if err != nil { panic(err) }
+defer cat.Close()
```
+Every catalog implements `Close() error`. Close it when done to release
catalog-owned
+resources. A caller-supplied database connection remains caller-owned.
+
### Namespaces
```go
diff --git a/website/src/configuration.md b/website/src/configuration.md
index 0ec57818c..8f61d8a93 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -338,7 +338,7 @@ func init() {
}
```
-After registration, `catalog.Load(ctx, "default", iceberg.Properties{"type":
"mycatalog", ...})` will route to the factory. Built-in types: `rest`, `hive`,
`glue`, `sql`, `hadoop`.
+After registration, `catalog.Load(ctx, "default", iceberg.Properties{"type":
"mycatalog", ...})` will route to the factory. Custom catalogs must implement
`Close() error` to satisfy `catalog.Catalog`, and callers must close them when
finished. Built-in types: `rest`, `hive`, `glue`, `sql`, `hadoop`.
`catalog.GetRegisteredCatalogs()` returns the current list;
`catalog.Unregister(catalogType)` removes one.
diff --git a/website/src/releases.md b/website/src/releases.md
index 8dc9053cb..ba5859370 100644
--- a/website/src/releases.md
+++ b/website/src/releases.md
@@ -48,6 +48,7 @@ Per-release notes (highlights, breaking changes,
contributors) are published on
- **Substrait expression conversion no longer takes case sensitivity.**
`substrait.ConvertExpr(schema, expression, caseSensitive)` becomes
`substrait.ConvertExpr(schema, expression)`. Case sensitivity remains part of
expression binding.
- **Transform formatting requires the result type.**
`Transform.ToHumanStr(value)` is removed. Use
`Transform.ToHumanStrType(resultType, value)` to format partition values,
including timestamps with time zones.
+- **Catalogs now require cleanup.** The `catalog.Catalog` interface includes
`Close() error`. Custom catalog implementations must provide `Close`, and
callers should close catalog instances after use. `catalog.Closer` remains
available as an alias for `io.Closer`.
- **Puffin writer property setter renamed.** `puffin.Writer.AddProperties` is
now `puffin.Writer.SetProperties`. Repeated calls still merge keys; use
`ClearProperties` to clear custom properties before finishing.