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 0df6c6f31 feat(catalog/glue): support Amazon S3 Tables federated
catalogs (#2001)
0df6c6f31 is described below
commit 0df6c6f313555e17b8c5ef7c880098427e980ffe
Author: İrem Çağın Yurttürk <[email protected]>
AuthorDate: Mon Oct 5 22:17:43 2026 +0300
feat(catalog/glue): support Amazon S3 Tables federated catalogs (#2001)
* feat(catalog/glue): support Amazon S3 Tables federated catalogs
Tables in a Glue database federated to Amazon S3 Tables (ConnectionType
aws:s3tables) could not be created through the Glue catalog: the service
assigns storage itself, so the generic client-side location resolution
fails with "no default path set".
Detect the federated database and create such tables in two phases,
mirroring pyiceberg: create a minimal Glue entry so S3 Tables allocates
storage, read the assigned location, write Iceberg metadata to it, then
repoint the Glue entry. The allocated entry is rolled back on any later
failure. An explicit location is rejected for these tables, and a missing
version id is treated as a failed commit.
S3 Tables entries also report a service-specific Glue TableType (e.g.
"customer"), so getRawTable now accepts any entry marked as Iceberg via
the table_type parameter, not only EXTERNAL_TABLE.
Adds unit coverage for detection, the two-phase create, location
rejection, every rollback path, and the relaxed TableType gate, plus a
gated live integration test.
Signed-off-by: iremcaginyurtturk <[email protected]>
* fix(catalog/glue): don't roll back S3 Tables table on reload failure
The final LoadTable ran inside the create's rollback scope, so a
transient read failure after a successful commit deleted an
already-created table (and reclaimed its storage). Move the reload out
of that scope: only allocate/write/commit failures roll back now, which
also matches the generic create path. Document that S3 Tables keeps its
own Glue TableType on the commit update.
Signed-off-by: iremcaginyurtturk <[email protected]>
* test(catalog/glue): reload a service TableType in the S3 Tables create
test
Have the successful create path's mocked reload return a service-specific
TableType ("customer"), so the happy path also exercises the relaxed
getRawTable gate rather than only EXTERNAL_TABLE.
Signed-off-by: iremcaginyurtturk <[email protected]>
* address review: lazy federation detection and scope getRawTable relaxation
- CreateTable now tries the generic path first and only probes for S3 Tables
federation when location resolution fails with ErrNoDefaultLocation. This
removes the redundant GetDatabase on default creates and avoids a new
glue:GetDatabase requirement on explicit-location creates.
- Export internal.ErrNoDefaultLocation so the probe matches it via errors.Is
instead of the error string.
- Scope the relaxed getRawTable TableType gate to entries actually federated
to S3 Tables (Table.FederatedTable.ConnectionType == aws:s3tables), so
non-federated databases keep their pre-existing behavior.
- Tests: prove explicit-location creates skip the probe, prove a
non-federated
iceberg-param table is still rejected, and update federated mocks.
* address review: reject explicit S3 Tables location, soften federation
probe
- CreateTable rejects an explicit location for a federated S3 Tables
database instead of silently creating a table outside managed storage.
- isS3TablesDatabase treats AccessDeniedException as not-federated so a
caller lacking glue:GetDatabase still reaches the generic create path.
- Document the two residual create failures left to the service.
- Tighten the metadata-write-failure test to assert the write error.
Signed-off-by: iremcaginyurtturk <[email protected]>
* catalog/glue: best-effort clean up S3 Tables metadata on commit failure
When UpdateTable fails after WriteMetadata, the rollback now best-effort
deletes the written metadata object before dropping the minimal Glue entry.
For S3 Tables the managed location may be unreachable, so the removal error
is ignored and reclaiming any remainder is left to the service, as
documented.
The existing UpdateTable-failure test now asserts the metadata file is gone.
Signed-off-by: iremcaginyurtturk <[email protected]>
* catalog/glue: make stranded S3 Tables entry droppable and harden the
commit
- DropTable/getRawTable now accept a minimal federated S3 Tables entry
(format=ICEBERG, no table_type), so a double-failure leftover is cleaned
up through DropTable, restoring parity with pyiceberg's delete_table.
- The UpdateTable repoint preserves the service-assigned TableType instead
of forcing EXTERNAL_TABLE, which S3 Tables rejects on write; a unit test
now asserts the TableType we send.
- The rollback DeleteTable runs on a context detached from a cancelled
create (context.WithoutCancel + timeout) and joins errors with
errors.Join.
- isS3TablesDatabase guards against a nil Database from getDatabase.
Signed-off-by: iremcaginyurtturk <[email protected]>
* catalog/glue: keep the live-proven EXTERNAL_TABLE on the S3 Tables repoint
Sending the allocated entry's own TableType was untested against S3 Tables
and races the service's federation (nil right after CreateTable, "customer"
once federated). Live testing confirmed S3 Tables accepts EXTERNAL_TABLE on
the repoint, so send it unconditionally and assert it in the unit test.
Signed-off-by: iremcaginyurtturk <[email protected]>
* catalog/glue: note S3 Tables ignores the repoint TableType (verified live)
Signed-off-by: iremcaginyurtturk <[email protected]>
* fix(catalog/glue): write table metadata with the catalog AWS config
The metadata writes in CreateTable, commitS3TablesTable, and CommitTable
used
a bare context, so the S3 FileIO resolver fell back to LoadDefaultConfig. A
catalog built with WithAwsConfig or glue.* static credentials would then
call
Glue as the configured principal but PUT metadata as the ambient one (or
fail
despite valid configured credentials). Wrap the context with the catalog's
AWS
config before each write, matching RegisterTable and convertGlueToIceberg;
for
commitS3TablesTable this also covers the staged.FS metadata cleanup.
* test(catalog/glue): mock GetDatabase in TestGlueCreateTableAlreadyExists
Merging main brought in TestGlueCreateTableAlreadyExists, which does an
explicit-location create. CreateTable now consults the database up front to
reject federated S3 Tables locations, so the test must mock GetDatabase; a
non-federated result lets it take the generic path and assert the
AlreadyExists mapping. In real AWS a missing glue:GetDatabase surfaces as
AccessDenied, which isS3TablesDatabase already treats as non-federated.
* address review: resolve federation up front and harden S3 Tables create
- CreateTable now fetches the database once and decides S3 Tables
federation for
both default and explicit-location creates, reusing the result for
namespace
properties so staging issues no second GetDatabase. This also catches
catalogs
with a `warehouse` property, where the previous lazy probe let a federated
create write outside the managed storage.
- Map AlreadyExistsException from the S3 Tables allocate step to
catalog.ErrTableAlreadyExists, matching the other create paths.
- Reject RenameTable for federated S3 Tables tables, whose managed storage
the
copy-then-delete rename cannot safely repoint.
- Replace isS3TablesDatabase with lookupDatabase (AccessDenied/NotFound
tolerant)
and extract namespacePropsFromDatabase.
Tests: credential-boundary regression tests (memfs FileIO recording the ctx
AWS
config) for CreateTable, commitS3TablesTable incl. the fs.Remove cleanup,
and
CommitTable; warehouse-set federated create; allocate AlreadyExists; rename
rejection; catalog-id pinning; and AssertExpectations on the fall-through
test.
* Trim comments
* Address review nits
---------
Signed-off-by: iremcaginyurtturk <[email protected]>
---
catalog/glue/glue.go | 226 +++++++++++-
catalog/glue/glue_test.go | 875 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 1092 insertions(+), 9 deletions(-)
diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index db4fe7481..4f085b34d 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -41,6 +41,7 @@ import (
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/glue"
"github.com/aws/aws-sdk-go-v2/service/glue/types"
+ "github.com/aws/smithy-go"
)
const (
@@ -61,6 +62,11 @@ const (
tableParamRenameToken = "iceberg.go.rename-token"
glueTypeIcebergRenaming = "ICEBERG_RENAMING"
+ // glueParamFormat marks the minimal entry that makes S3 Tables allocate
+ // storage; s3TablesConnectionType tags a database federated to S3
Tables.
+ glueParamFormat = "format"
+ s3TablesConnectionType = "aws:s3tables"
+
// The ID of the Glue Data Catalog where the tables reside. If none is
provided, Glue
// automatically uses the caller's AWS account ID by default.
// See:
https://docs.aws.amazon.com/glue/latest/dg/aws-glue-api-catalog-databases.html
@@ -276,20 +282,49 @@ var _ catalog.Closer = (*Catalog)(nil)
// 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.
func (c *Catalog) CreateTable(ctx context.Context, identifier
table.Identifier, schema *iceberg.Schema, opts ...catalog.CreateTableOpt)
(*table.Table, error) {
- // The reporter is resolved once at construction (see NewCatalog), so a
bad
- // metrics-reporter-impl already failed there — no per-op guard is
needed
- // before mutating the catalog, and the trailing LoadTable reuses the
cached
- // reporter.
- staged, err := internal.CreateStagedTable(ctx, c.props,
c.LoadNamespaceProperties, identifier, schema, opts...)
+ // A missing namespace is reported before touching Glue, matching the
contract
+ // callers rely on (an identifier without a database is not a missing
table).
+ if len(identifier) < 2 {
+ return nil, fmt.Errorf("%w: missing namespace or invalid
identifier %v", catalog.ErrNoSuchNamespace, identifier)
+ }
+
+ database, tableName, err := identifierToGlueTable(identifier)
if err != nil {
return nil, err
}
- database, tableName, err := identifierToGlueTable(identifier)
+ // Resolve federation up front so default, explicit-location and
`warehouse`
+ // creates all route correctly; a missing DB or AccessDenied is
non-federated.
+ db, err := c.lookupDatabase(ctx, database)
if err != nil {
return nil, err
}
+ if db != nil && isS3TablesFederatedDatabase(db) {
+ var cfg catalog.CreateTableCfg
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+
+ // S3 Tables assigns storage itself, so reject an explicit
location (as pyiceberg does).
+ if cfg.Location != "" {
+ return nil, fmt.Errorf("cannot specify a location for
table %s.%s: S3 Tables manages storage automatically", database, tableName)
+ }
+
+ return c.createS3TablesTable(ctx, database, tableName,
identifier, schema, opts...)
+ }
+
+ // The reporter is resolved once at construction (see NewCatalog), so a
bad
+ // metrics-reporter-impl already failed there — no per-op guard is
needed
+ // before mutating the catalog, and the trailing LoadTable reuses the
cached
+ // reporter.
+ staged, err := internal.CreateStagedTable(ctx, c.props,
c.namespacePropsFn(db), identifier, schema, opts...)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use the catalog's AWS config, not the ambient chain, for the
metadata write.
+ ctx = utils.WithAwsConfig(ctx, c.awsCfg)
if err := internal.WriteMetadata(ctx, staged.Table); err != nil {
return nil, err
}
@@ -310,6 +345,160 @@ func (c *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
return c.LoadTable(ctx, identifier)
}
+// lookupDatabase returns (nil, nil) for a missing database or AccessDenied so
+// create paths proceed as non-federated; other errors are returned.
+func (c *Catalog) lookupDatabase(ctx context.Context, database string)
(*types.Database, error) {
+ db, err := c.getDatabase(ctx, database)
+ if err != nil {
+ var apiErr smithy.APIError
+ if errors.Is(err, catalog.ErrNoSuchNamespace) ||
+ (errors.As(err, &apiErr) && apiErr.ErrorCode() ==
"AccessDeniedException") {
+ return nil, nil
+ }
+
+ return nil, err
+ }
+
+ return db, nil
+}
+
+// namespacePropsFn reuses an already-fetched database to avoid a second
+// GetDatabase, falling back to a fresh lookup when it is nil.
+func (c *Catalog) namespacePropsFn(db *types.Database)
internal.GetNamespacePropsFn {
+ return func(ctx context.Context, namespace table.Identifier)
(iceberg.Properties, error) {
+ if db != nil {
+ return namespacePropsFromDatabase(db), nil
+ }
+
+ return c.LoadNamespaceProperties(ctx, namespace)
+ }
+}
+
+func isS3TablesFederatedDatabase(db *types.Database) bool {
+ return db.FederatedDatabase != nil &&
+
strings.EqualFold(aws.ToString(db.FederatedDatabase.ConnectionType),
s3TablesConnectionType)
+}
+
+func isS3TablesFederatedTable(tbl *types.Table) bool {
+ return tbl.FederatedTable != nil &&
+
strings.EqualFold(aws.ToString(tbl.FederatedTable.ConnectionType),
s3TablesConnectionType)
+}
+
+// isS3TablesIcebergEntry also accepts the minimal format=ICEBERG entry, so
+// DropTable can clean up after a failed create.
+func isS3TablesIcebergEntry(tbl *types.Table) bool {
+ if !isS3TablesFederatedTable(tbl) {
+ return false
+ }
+ tableType := tbl.Parameters[tableParamTableType]
+
+ return strings.EqualFold(tableType, glueTypeIceberg) ||
+ tableType == glueTypeIcebergRenaming ||
+ strings.EqualFold(tbl.Parameters[glueParamFormat],
glueTypeIceberg)
+}
+
+// createS3TablesTable allocates storage with a minimal entry, then repoints
it at
+// the written metadata; on commit failure the minimal entry is rolled back.
+func (c *Catalog) createS3TablesTable(ctx context.Context, database, tableName
string, identifier table.Identifier, schema *iceberg.Schema, opts
...catalog.CreateTableOpt) (*table.Table, error) {
+ _, err := c.glueSvc.CreateTable(ctx, &glue.CreateTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ TableInput: &types.TableInput{
+ Name: aws.String(tableName),
+ Parameters: map[string]string{glueParamFormat:
glueTypeIceberg},
+ },
+ })
+ if err != nil {
+ if isAlreadyExistsException(err) {
+ return nil, fmt.Errorf("failed to create table %s.%s:
%w", database, tableName, catalog.ErrTableAlreadyExists)
+ }
+
+ return nil, fmt.Errorf("failed to allocate S3 Tables storage
for %s.%s: %w", database, tableName, err)
+ }
+
+ if err := c.commitS3TablesTable(ctx, database, tableName, identifier,
schema, opts...); err != nil {
+ // Roll back with a detached context so a cancelled create
still removes
+ // the minimal entry (S3 Tables enforces per-account table
limits).
+ cleanupCtx, cancel :=
context.WithTimeout(context.WithoutCancel(ctx), renameCleanupTimeout)
+ defer cancel()
+ if _, delErr := c.glueSvc.DeleteTable(cleanupCtx,
&glue.DeleteTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ Name: aws.String(tableName),
+ }); delErr != nil {
+ return nil, errors.Join(err, fmt.Errorf("failed to
clean up allocated table %s.%s: %w", database, tableName, delErr))
+ }
+
+ return nil, err
+ }
+
+ // The table is committed; load it outside the rollback scope so a
transient
+ // read failure does not delete an already-created table.
+ return c.LoadTable(ctx, identifier)
+}
+
+// commitS3TablesTable writes metadata to the service-assigned location and
repoints
+// the Glue entry; the caller reloads, so a read failure never triggers
rollback.
+func (c *Catalog) commitS3TablesTable(ctx context.Context, database, tableName
string, identifier table.Identifier, schema *iceberg.Schema, opts
...catalog.CreateTableOpt) error {
+ // Use the catalog's AWS config for WriteMetadata and the staged.FS
cleanup below.
+ ctx = utils.WithAwsConfig(ctx, c.awsCfg)
+
+ allocated, err := c.glueSvc.GetTable(ctx, &glue.GetTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ Name: aws.String(tableName),
+ })
+ if err != nil {
+ return fmt.Errorf("failed to load allocated S3 Tables table
%s.%s: %w", database, tableName, err)
+ }
+ if allocated == nil || allocated.Table == nil ||
allocated.Table.StorageDescriptor == nil {
+ return fmt.Errorf("S3 Tables did not return a storage
descriptor for %s.%s", database, tableName)
+ }
+ managedLocation :=
aws.ToString(allocated.Table.StorageDescriptor.Location)
+ if managedLocation == "" {
+ return fmt.Errorf("S3 Tables did not assign a storage location
for %s.%s", database, tableName)
+ }
+ if allocated.Table.VersionId == nil {
+ return fmt.Errorf("cannot commit table %s.%s: because Glue
table version id is missing", database, tableName)
+ }
+
+ // Copy rather than append onto the caller's opts, whose backing array
may
+ // have spare capacity we would otherwise clobber.
+ stagedOpts := make([]catalog.CreateTableOpt, len(opts), len(opts)+1)
+ copy(stagedOpts, opts)
+ stagedOpts = append(stagedOpts, catalog.WithLocation(managedLocation))
+
+ staged, err := internal.CreateStagedTable(ctx, c.props,
c.LoadNamespaceProperties, identifier, schema, stagedOpts...)
+ if err != nil {
+ return err
+ }
+
+ if err := internal.WriteMetadata(ctx, staged.Table); err != nil {
+ return err
+ }
+
+ // S3 Tables ignores TableType on this repoint (verified live) and
keeps its own
+ // service type on read, which getRawTable tolerates.
+ _, err = c.glueSvc.UpdateTable(ctx, &glue.UpdateTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ TableInput: constructTableInput(tableName, staged.Table,
allocated.Table),
+ VersionId: allocated.Table.VersionId,
+ SkipArchive: aws.Bool(c.props.GetBool(SkipArchive,
SkipArchiveDefault)),
+ })
+ if err != nil {
+ // Best-effort: drop the metadata object we just wrote; for S3
Tables the
+ // managed location may be unreachable, so leave any remainder
to the service.
+ if fs, fsErr := staged.FS(ctx); fsErr == nil {
+ _ = fs.Remove(staged.MetadataLocation())
+ }
+
+ return fmt.Errorf("failed to commit S3 Tables table %s.%s: %w",
database, tableName, err)
+ }
+
+ return nil
+}
+
// RegisterTable registers a new table using existing metadata.
func (c *Catalog) RegisterTable(ctx context.Context, identifier
table.Identifier, metadataLocation string) (*table.Table, error) {
database, tableName, err := identifierToGlueTable(identifier)
@@ -377,6 +566,8 @@ func (c *Catalog) CommitTable(ctx context.Context,
identifier table.Identifier,
if current != nil && staged.Metadata().Equals(current.Metadata()) {
return current.Metadata(), current.MetadataLocation(), nil
}
+ // Use the catalog's AWS config, not the ambient chain, for the
metadata write.
+ ctx = utils.WithAwsConfig(ctx, c.awsCfg)
if err := internal.WriteMetadata(ctx, staged.Table); err != nil {
return nil, "", err
}
@@ -436,7 +627,10 @@ func (c *Catalog) DropTable(ctx context.Context,
identifier table.Identifier) er
return err
}
tableType := glueTable.Parameters[tableParamTableType]
- if !strings.EqualFold(tableType, glueTypeIceberg) && tableType !=
glueTypeIcebergRenaming {
+ isIceberg := strings.EqualFold(tableType, glueTypeIceberg) || tableType
== glueTypeIcebergRenaming
+ // Also allow a minimal federated S3 Tables entry (format=ICEBERG, no
+ // table_type) so a failed create can be cleaned up through DropTable.
+ if !isIceberg && !isS3TablesIcebergEntry(glueTable) {
return fmt.Errorf("table %s.%s is not an iceberg table",
database, tableName)
}
@@ -498,6 +692,11 @@ func (c *Catalog) RenameTable(ctx context.Context, from,
to table.Identifier) (*
if err != nil {
return nil, fmt.Errorf("failed to fetch the table %s.%s: %w",
fromDatabase, fromTable, err)
}
+ // Copy-then-delete could leave the destination pointing at storage S3
Tables
+ // reclaims when the source is dropped.
+ if isS3TablesFederatedTable(fromGlueTable) {
+ return nil, fmt.Errorf("cannot rename table %s.%s: renaming is
not supported for S3 Tables managed tables", fromDatabase, fromTable)
+ }
if aws.ToString(fromGlueTable.VersionId) == "" {
return nil, fmt.Errorf("failed to rename the table %s.%s: Glue
table version id is missing", fromDatabase, fromTable)
}
@@ -745,6 +944,12 @@ func (c *Catalog) LoadNamespaceProperties(ctx
context.Context, namespace table.I
return nil, err
}
+ return namespacePropsFromDatabase(database), nil
+}
+
+// namespacePropsFromDatabase converts a Glue database into namespace
properties,
+// normalizing the description key and surfacing the database location.
+func namespacePropsFromDatabase(database *types.Database) iceberg.Properties {
props := make(map[string]string)
if database.Parameters != nil {
maps.Copy(props, database.Parameters)
@@ -762,7 +967,7 @@ func (c *Catalog) LoadNamespaceProperties(ctx
context.Context, namespace table.I
props[PropsKeyLocation] = aws.ToString(database.LocationUri)
}
- return props, nil
+ return props
}
// UpdateNamespaceProperties updates the properties of an Iceberg namespace in
the Glue catalog.
@@ -858,7 +1063,10 @@ func (c *Catalog) getRawTable(ctx context.Context,
database, tableName string) (
return nil, fmt.Errorf("failed to get table %s.%s: missing Glue
table response", database, tableName)
}
- if aws.ToString(tblRes.Table.TableType) != glueTableType {
+ // S3 Tables entries carry a service TableType (e.g. "customer");
accept those
+ // only when federated and marked Iceberg, not for every database.
+ isExternalTable := aws.ToString(tblRes.Table.TableType) == glueTableType
+ if !isExternalTable && !isS3TablesIcebergEntry(tblRes.Table) {
return nil, fmt.Errorf("table %s.%s is not an EXTERNAL_TABLE",
database, tableName)
}
diff --git a/catalog/glue/glue_test.go b/catalog/glue/glue_test.go
index 3e7222dab..5d8bb00fe 100644
--- a/catalog/glue/glue_test.go
+++ b/catalog/glue/glue_test.go
@@ -38,11 +38,13 @@ import (
iceio "github.com/apache/iceberg-go/io"
_ "github.com/apache/iceberg-go/io/gocloud"
"github.com/apache/iceberg-go/table"
+ "github.com/apache/iceberg-go/utils"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/glue"
"github.com/aws/aws-sdk-go-v2/service/glue/types"
"github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/aws/smithy-go"
"github.com/awsdocs/aws-doc-sdk-examples/gov2/testtools"
"github.com/google/uuid"
"github.com/stretchr/testify/mock"
@@ -2007,6 +2009,11 @@ func TestGlueCreateTableAlreadyExists(t *testing.T) {
assert := require.New(t)
mockGlueSvc := &mockGlueClient{}
+ // An explicit-location create consults the database to reject
federated S3
+ // Tables up front; a non-federated database takes the generic create
path.
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput(""), nil).Once()
mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
Return(&glue.CreateTableOutput{}, &types.AlreadyExistsException{
Message: aws.String("Table already exists"),
@@ -2034,6 +2041,9 @@ func TestGlueCreateTableRollbackOnInvalidMetadata(t
*testing.T) {
iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.Int64Type{}, Required: true},
iceberg.NestedField{ID: 2, Name: "name", Type:
iceberg.StringType{}, Required: true},
)
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput(""), nil)
mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).Return(&glue.CreateTableOutput{}, nil)
mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
DatabaseName: aws.String("test_database"),
@@ -2607,3 +2617,868 @@ func TestTableOperationsRejectEmptyIdentifiers(t
*testing.T) {
require.ErrorIs(t, err, catalog.ErrNoSuchTable)
}
}
+
+func s3TablesTestSchema() *iceberg.Schema {
+ return iceberg.NewSchemaWithIdentifiers(1, []int{1},
+ iceberg.NestedField{ID: 1, Name: "id", Type:
iceberg.Int64Type{}, Required: true},
+ iceberg.NestedField{ID: 2, Name: "name", Type:
iceberg.StringType{}, Required: false},
+ )
+}
+
+func federatedDatabaseOutput(connectionType string) *glue.GetDatabaseOutput {
+ db := &types.Database{Name: aws.String("test_database")}
+ if connectionType != "" {
+ db.FederatedDatabase = &types.FederatedDatabase{ConnectionType:
aws.String(connectionType)}
+ }
+
+ return &glue.GetDatabaseOutput{Database: db}
+}
+
+func TestGlueLookupDatabase(t *testing.T) {
+ tests := []struct {
+ name string
+ connectionType string
+ getErr error
+ wantNil bool
+ wantFederated bool
+ wantErr bool
+ }{
+ {name: "federated to s3 tables", connectionType:
"aws:s3tables", wantFederated: true},
+ {name: "federated case insensitive", connectionType:
"AWS:S3Tables", wantFederated: true},
+ {name: "federated to another source", connectionType:
"aws:redshift"},
+ {name: "not federated", connectionType: ""},
+ {name: "missing database tolerated", getErr:
&types.EntityNotFoundException{}, wantNil: true},
+ {name: "access denied tolerated", getErr:
&smithy.GenericAPIError{Code: "AccessDeniedException"}, wantNil: true},
+ {name: "get database error", getErr: errors.New("boom"),
wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockGlueSvc := &mockGlueClient{}
+ if tt.getErr != nil {
+ mockGlueSvc.On("GetDatabase", mock.Anything,
&glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ },
mock.Anything).Return((*glue.GetDatabaseOutput)(nil), tt.getErr).Once()
+ } else {
+ mockGlueSvc.On("GetDatabase", mock.Anything,
&glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ },
mock.Anything).Return(federatedDatabaseOutput(tt.connectionType), nil).Once()
+ }
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg:
&aws.Config{}}
+ db, err := cat.lookupDatabase(context.Background(),
"test_database")
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ if tt.wantNil {
+ require.Nil(t, db)
+ } else {
+ require.NotNil(t, db)
+ require.Equal(t, tt.wantFederated,
isS3TablesFederatedDatabase(db))
+ }
+ }
+ mockGlueSvc.AssertExpectations(t)
+ })
+ }
+}
+
+// TestGlueCreateTableS3TablesFederated exercises the full two-phase create.
+func TestGlueCreateTableS3TablesFederated(t *testing.T) {
+ ctx := context.Background()
+ managedLocation := "file://" + t.TempDir()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.MatchedBy(func(in
*glue.CreateTableInput) bool {
+ return aws.ToString(in.TableInput.Name) == "test_table" &&
+ in.TableInput.Parameters[glueParamFormat] ==
glueTypeIceberg &&
+ in.TableInput.StorageDescriptor == nil
+ }), mock.Anything).Return(&glue.CreateTableOutput{}, nil).Once()
+
+ allocated := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ TableType: aws.String("customer"),
+ Parameters: map[string]string{glueParamFormat:
glueTypeIceberg},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: allocated},
nil).Once()
+
+ // LoadTable at the end reads this entry; UpdateTable's Run below fills
in the
+ // iceberg parameters (including the metadata pointer) before it is
read.
+ loaded := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ // S3 Tables reports its own service TableType plus a
FederatedTable marker,
+ // exercising the relaxed getRawTable gate on the create
success path.
+ TableType: aws.String("customer"),
+ FederatedTable: &types.FederatedTable{ConnectionType:
aws.String(s3TablesConnectionType)},
+ Parameters: map[string]string{},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ var capturedMetadataLocation string
+ mockGlueSvc.On("UpdateTable", mock.Anything, mock.MatchedBy(func(in
*glue.UpdateTableInput) bool {
+ // The repoint sends EXTERNAL_TABLE even though the allocated
entry reports
+ // the service type "customer"; live testing confirmed S3
Tables accepts it.
+ return in.TableInput != nil && aws.ToString(in.VersionId) ==
"1" &&
+ in.TableInput.Parameters[tableParamTableType] ==
glueTypeIceberg &&
+ aws.ToString(in.TableInput.TableType) == glueTableType
+ }), mock.Anything).Run(func(args mock.Arguments) {
+ in := args.Get(1).(*glue.UpdateTableInput)
+ capturedMetadataLocation =
in.TableInput.Parameters[tableParamMetadataLocation]
+ loaded.Parameters[tableParamTableType] = glueTypeIceberg
+ loaded.Parameters[tableParamMetadataLocation] =
capturedMetadataLocation
+ }).Return(&glue.UpdateTableOutput{}, nil).Once()
+
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: loaded}, nil)
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ tbl, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.NoError(t, err)
+ require.Equal(t, TableIdentifier("test_database", "test_table"),
tbl.Identifier())
+ require.Equal(t, schema.Fields(), tbl.Schema().Fields())
+ require.Contains(t, tbl.MetadataLocation(), managedLocation)
+ require.Equal(t, capturedMetadataLocation, tbl.MetadataLocation())
+ require.FileExists(t, strings.TrimPrefix(capturedMetadataLocation,
"file://"))
+ mockGlueSvc.AssertNotCalled(t, "DeleteTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesCleanupOnFailure verifies the allocated entry is
+// deleted when the second phase fails, leaving no half-created table behind.
+func TestGlueCreateTableS3TablesCleanupOnFailure(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String("")},
+ }}, nil).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "did not assign a storage location")
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesCleanupErrorWrapped surfaces both the original
+// failure and the cleanup failure when deleting the allocated entry also
fails.
+func TestGlueCreateTableS3TablesCleanupErrorWrapped(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return((*glue.GetTableOutput)(nil), errors.New("get
boom")).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return((*glue.DeleteTableOutput)(nil), errors.New("delete
boom")).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "get boom")
+ require.ErrorContains(t, err, "failed to clean up allocated table")
+ require.ErrorContains(t, err, "delete boom")
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesAllocateError returns early without cleanup when
+// the initial allocation call itself fails.
+func TestGlueCreateTableS3TablesAllocateError(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return((*glue.CreateTableOutput)(nil), errors.New("allocate
boom")).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "failed to allocate S3 Tables storage")
+ mockGlueSvc.AssertNotCalled(t, "GetTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertNotCalled(t, "DeleteTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesRejectsExplicitLocation verifies an explicit
location
+// is refused for a federated database and nothing is created.
+func TestGlueCreateTableS3TablesRejectsExplicitLocation(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema,
+ catalog.WithLocation("file:///tmp/whatever"))
+ require.ErrorContains(t, err, "S3 Tables manages storage automatically")
+ mockGlueSvc.AssertNotCalled(t, "CreateTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableExplicitLocationNonFederated confirms an explicit
location
+// on a non-federated database still takes the generic path.
+func TestGlueCreateTableExplicitLocationNonFederated(t *testing.T) {
+ ctx := context.Background()
+ location := "file://" + t.TempDir()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput(""), nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ // The trailing reload is not the point here; fail it fast to avoid a
full
+ // metadata round-trip. What matters is that the generic create path
runs.
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return((*glue.GetTableOutput)(nil), errors.New("load
boom")).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema,
+ catalog.WithLocation(location))
+ require.ErrorContains(t, err, "load boom")
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesMissingVersionId fails and rolls back when the
+// allocated entry has no Glue version id to commit against.
+func TestGlueCreateTableS3TablesMissingVersionId(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String("file:///tmp/whatever")},
+ }}, nil).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "Glue table version id is missing")
+ mockGlueSvc.AssertNotCalled(t, "UpdateTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableNonFederatedFallsThrough confirms a non-federated
database
+// with no location still hits the generic path (and its "no default path"
error).
+func TestGlueCreateTableNonFederatedFallsThrough(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput(""), nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "no default path set")
+ mockGlueSvc.AssertNotCalled(t, "CreateTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesFederatedIntegration is gated by
TEST_S3TABLES_CATALOG_ID
+// (<account-id>:s3tablescatalog/<bucket>) and TEST_S3TABLES_DATABASE.
+func TestGlueCreateTableS3TablesFederatedIntegration(t *testing.T) {
+ catalogID := os.Getenv("TEST_S3TABLES_CATALOG_ID")
+ dbName := os.Getenv("TEST_S3TABLES_DATABASE")
+ if catalogID == "" || dbName == "" {
+ t.Skip()
+ }
+ assert := require.New(t)
+ ctx := context.Background()
+ awsCfg, err := config.LoadDefaultConfig(ctx)
+ assert.NoError(err)
+ ctlg, err := NewCatalog(WithAwsConfig(awsCfg),
WithAwsProperties(AwsProperties{CatalogIdKey: catalogID}))
+ assert.NoError(err)
+
+ tableName := fmt.Sprintf("it_%d", time.Now().UnixNano())
+ ident := TableIdentifier(dbName, tableName)
+ schema := s3TablesTestSchema()
+
+ tbl, err := ctlg.CreateTable(ctx, ident, schema)
+ assert.NoError(err)
+ defer func() { assert.NoError(ctlg.DropTable(ctx, ident)) }()
+
+ assert.Equal(ident, tbl.Identifier())
+ assert.Equal(schema.Fields(), tbl.Schema().Fields())
+ assert.Contains(tbl.MetadataLocation(), "--table-s3", "metadata must
land in the S3 Tables managed location")
+
+ reloaded, err := ctlg.LoadTable(ctx, ident)
+ assert.NoError(err)
+ assert.Equal(schema.Fields(), reloaded.Schema().Fields())
+ assert.Equal(tbl.MetadataLocation(), reloaded.MetadataLocation())
+}
+
+// TestGlueGetRawTableTableType covers the TableType gate: EXTERNAL_TABLE and
+// federated iceberg entries pass, anything else is rejected.
+func TestGlueGetRawTableTableType(t *testing.T) {
+ tests := []struct {
+ name string
+ tableType string
+ params map[string]string
+ federated bool
+ wantErr bool
+ }{
+ {
+ name: "standard external table",
+ tableType: glueTableType,
+ params: map[string]string{tableParamTableType:
glueTypeIceberg},
+ },
+ {
+ name: "s3 tables federated iceberg",
+ tableType: "customer",
+ params: map[string]string{tableParamTableType:
glueTypeIceberg},
+ federated: true,
+ },
+ {
+ name: "s3 tables federated renaming",
+ tableType: "customer",
+ params: map[string]string{tableParamTableType:
glueTypeIcebergRenaming},
+ federated: true,
+ },
+ {
+ name: "non-federated iceberg param unexpected type
rejected",
+ tableType: "customer",
+ params: map[string]string{tableParamTableType:
glueTypeIceberg},
+ wantErr: true,
+ },
+ {
+ name: "non-iceberg unexpected type rejected",
+ tableType: "VIRTUAL_VIEW",
+ params: map[string]string{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockGlueSvc := &mockGlueClient{}
+ glueTable := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ TableType: aws.String(tt.tableType),
+ Parameters: tt.params,
+ }
+ if tt.federated {
+ glueTable.FederatedTable =
&types.FederatedTable{ConnectionType: aws.String(s3TablesConnectionType)}
+ }
+ mockGlueSvc.On("GetTable", mock.Anything,
&glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table:
glueTable}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg:
&aws.Config{}}
+ tbl, err := cat.getRawTable(context.Background(),
"test_database", "test_table")
+ if tt.wantErr {
+ require.ErrorContains(t, err, "is not an
EXTERNAL_TABLE")
+ } else {
+ require.NoError(t, err)
+ require.Equal(t, tt.tableType,
aws.ToString(tbl.TableType))
+ }
+ mockGlueSvc.AssertExpectations(t)
+ })
+ }
+}
+
+// TestGlueCreateTableS3TablesRollbackOnMetadataWriteFailure verifies a failed
+// metadata write rolls back the allocated entry without issuing UpdateTable.
+func TestGlueCreateTableS3TablesRollbackOnMetadataWriteFailure(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ // An unregistered IO scheme fails deterministically at
metadata write,
+ // proving the rollback is triggered by the write and not by a
later step.
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String("unregisteredfs://bucket/table")},
+ }}, nil).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "scheme not registered")
+ mockGlueSvc.AssertNotCalled(t, "UpdateTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesRollbackOnUpdateFailure verifies a rejected
repoint
+// rolls back the allocated entry.
+func TestGlueCreateTableS3TablesRollbackOnUpdateFailure(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ managedLocation := "file://" + dir
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }}, nil).Once()
+ mockGlueSvc.On("UpdateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return((*glue.UpdateTableOutput)(nil), errors.New("update
rejected")).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "failed to commit S3 Tables table")
+ mockGlueSvc.AssertExpectations(t)
+
+ // The best-effort cleanup should have removed the metadata object it
wrote.
+ leftover, _ := filepath.Glob(filepath.Join(dir, "metadata",
"*.metadata.json"))
+ require.Empty(t, leftover)
+}
+
+// TestGlueCreateTableS3TablesNoRollbackOnLoadFailure verifies that a transient
+// failure of the final reload does not delete an already-committed table.
+func TestGlueCreateTableS3TablesNoRollbackOnLoadFailure(t *testing.T) {
+ ctx := context.Background()
+ managedLocation := "file://" + t.TempDir()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }}, nil).Once()
+ mockGlueSvc.On("UpdateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.UpdateTableOutput{}, nil).Once()
+ // The trailing reload (LoadTable -> GetTable) fails transiently.
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return((*glue.GetTableOutput)(nil), errors.New("load
boom")).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "load boom")
+ mockGlueSvc.AssertNotCalled(t, "DeleteTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesRollbackDetachesContext verifies rollback still
runs
+// on a detached context when the create is cancelled.
+func TestGlueCreateTableS3TablesRollbackDetachesContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, mock.Anything,
mock.Anything).
+ Return(federatedDatabaseOutput("aws:s3tables"), nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ // The commit load fails and the create is cancelled at the same moment.
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Run(func(mock.Arguments) { cancel() }).
+ Return((*glue.GetTableOutput)(nil), errors.New("commit
boom")).Once()
+ // The rollback must still see a live (detached) context, not the
cancelled one.
+ mockGlueSvc.On("DeleteTable",
+ mock.MatchedBy(func(c context.Context) bool { return c.Err() ==
nil }),
+ mock.Anything, mock.Anything).Return(&glue.DeleteTableOutput{},
nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorContains(t, err, "commit boom")
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueDropTableRemovesStrandedS3TablesEntry verifies DropTable removes a
+// minimal entry (format=ICEBERG, no table_type) left by a failed create.
+func TestGlueDropTableRemovesStrandedS3TablesEntry(t *testing.T) {
+ ctx := context.Background()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ TableType: aws.String("customer"),
+ FederatedTable: &types.FederatedTable{ConnectionType:
aws.String(s3TablesConnectionType)},
+ Parameters: map[string]string{glueParamFormat:
glueTypeIceberg},
+ }}, nil).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, &glue.DeleteTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("test_table"),
+ }, mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ require.NoError(t, cat.DropTable(ctx, TableIdentifier("test_database",
"test_table")))
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// recordingMemFS registers an in-memory FileIO that records the context's AWS
+// config on every resolution.
+func recordingMemFS(t *testing.T, scheme string) (*iceio.MemFS,
*[]*aws.Config) {
+ t.Helper()
+ memFS := iceio.NewMemFS()
+ recorded := new([]*aws.Config)
+ iceio.Unregister(scheme)
+ iceio.Register(scheme, func(ctx context.Context, _ *url.URL, _
map[string]string) (iceio.IO, error) {
+ *recorded = append(*recorded, utils.GetAwsConfig(ctx))
+
+ return memFS, nil
+ })
+ t.Cleanup(func() { iceio.Unregister(scheme) })
+
+ return memFS, recorded
+}
+
+func assertRecordedConfig(t *testing.T, want *aws.Config, recorded
*[]*aws.Config) {
+ t.Helper()
+ require.NotEmpty(t, *recorded, "expected the FileIO to be resolved at
least once")
+ for _, got := range *recorded {
+ require.Same(t, want, got, "metadata IO must use the catalog's
configured AWS config")
+ }
+}
+
+// TestGlueCreateTableS3TablesAllocateAlreadyExists verifies AlreadyExists
maps to
+// ErrTableAlreadyExists without rolling back a table this call did not create.
+func TestGlueCreateTableS3TablesAllocateAlreadyExists(t *testing.T) {
+ ctx := context.Background()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, &types.AlreadyExistsException{
+ Message: aws.String("Table already exists"),
+ }).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.ErrorIs(t, err, catalog.ErrTableAlreadyExists)
+ mockGlueSvc.AssertNotCalled(t, "DeleteTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesWarehouseSet verifies a `warehouse` property
does not
+// bypass federation: the create still goes through the minimal-entry allocate.
+func TestGlueCreateTableS3TablesWarehouseSet(t *testing.T) {
+ ctx := context.Background()
+ managedLocation := "file://" + t.TempDir()
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ // The minimal allocate entry carries no StorageDescriptor; a
warehouse-located
+ // create would send one. Matching on that pins the two-phase path.
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.MatchedBy(func(in
*glue.CreateTableInput) bool {
+ return in.TableInput.Parameters[glueParamFormat] ==
glueTypeIceberg &&
+ in.TableInput.StorageDescriptor == nil
+ }), mock.Anything).Return(&glue.CreateTableOutput{}, nil).Once()
+
+ allocated := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ TableType: aws.String("customer"),
+ Parameters: map[string]string{glueParamFormat:
glueTypeIceberg},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return(&glue.GetTableOutput{Table: allocated}, nil).Once()
+
+ loaded := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ TableType: aws.String("customer"),
+ FederatedTable: &types.FederatedTable{ConnectionType:
aws.String(s3TablesConnectionType)},
+ Parameters: map[string]string{},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ mockGlueSvc.On("UpdateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Run(func(args mock.Arguments) {
+ in := args.Get(1).(*glue.UpdateTableInput)
+ loaded.Parameters[tableParamTableType] = glueTypeIceberg
+ loaded.Parameters[tableParamMetadataLocation] =
in.TableInput.Parameters[tableParamMetadataLocation]
+ }).Return(&glue.UpdateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return(&glue.GetTableOutput{Table: loaded}, nil)
+
+ cat := &Catalog{
+ glueSvc: mockGlueSvc,
+ awsCfg: &aws.Config{},
+ props: iceberg.Properties{"warehouse":
"file:///tmp/warehouse"},
+ }
+ tbl, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.NoError(t, err)
+ require.Contains(t, tbl.MetadataLocation(), managedLocation)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableS3TablesPassesCatalogID pins that every Glue call on the
+// two-phase create path carries the federated catalog id.
+func TestGlueCreateTableS3TablesPassesCatalogID(t *testing.T) {
+ ctx := context.Background()
+ managedLocation := "file://" + t.TempDir()
+ catalogID := "123456789012:s3tablescatalog/bucket"
+ schema := s3TablesTestSchema()
+
+ matchID := func(id *string) bool { return aws.ToString(id) == catalogID
}
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.MatchedBy(func(context.Context) bool
{ return true }),
+ mock.MatchedBy(func(in *glue.GetDatabaseInput) bool { return
matchID(in.CatalogId) }),
+ mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything,
+ mock.MatchedBy(func(in *glue.CreateTableInput) bool { return
matchID(in.CatalogId) }),
+ mock.Anything).Return(&glue.CreateTableOutput{}, nil).Once()
+
+ allocated := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ Parameters: map[string]string{glueParamFormat:
glueTypeIceberg},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ mockGlueSvc.On("GetTable", mock.Anything,
+ mock.MatchedBy(func(in *glue.GetTableInput) bool { return
matchID(in.CatalogId) }),
+ mock.Anything).Return(&glue.GetTableOutput{Table: allocated},
nil).Once()
+
+ loaded := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ TableType: aws.String("customer"),
+ FederatedTable: &types.FederatedTable{ConnectionType:
aws.String(s3TablesConnectionType)},
+ Parameters: map[string]string{},
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }
+ mockGlueSvc.On("UpdateTable", mock.Anything,
+ mock.MatchedBy(func(in *glue.UpdateTableInput) bool { return
matchID(in.CatalogId) }),
+ mock.Anything).Run(func(args mock.Arguments) {
+ in := args.Get(1).(*glue.UpdateTableInput)
+ loaded.Parameters[tableParamTableType] = glueTypeIceberg
+ loaded.Parameters[tableParamMetadataLocation] =
in.TableInput.Parameters[tableParamMetadataLocation]
+ }).Return(&glue.UpdateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything,
+ mock.MatchedBy(func(in *glue.GetTableInput) bool { return
matchID(in.CatalogId) }),
+ mock.Anything).Return(&glue.GetTableOutput{Table: loaded}, nil)
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}, catalogId:
aws.String(catalogID)}
+ tbl, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.NoError(t, err)
+ require.Contains(t, tbl.MetadataLocation(), managedLocation)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueRenameTableS3TablesRejected verifies rename is refused for a
federated
+// S3 Tables table, which owns its managed storage, before any write.
+func TestGlueRenameTableS3TablesRejected(t *testing.T) {
+ ctx := context.Background()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, &glue.GetDatabaseInput{
+ Name: aws.String("test_database"),
+ }, mock.Anything).Return(federatedDatabaseOutput("aws:s3tables"),
nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, &glue.GetTableInput{
+ DatabaseName: aws.String("test_database"),
+ Name: aws.String("from_table"),
+ }, mock.Anything).Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("from_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ TableType: aws.String("customer"),
+ FederatedTable: &types.FederatedTable{ConnectionType:
aws.String(s3TablesConnectionType)},
+ Parameters: map[string]string{tableParamTableType:
glueTypeIceberg},
+ }}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: &aws.Config{}}
+ _, err := cat.RenameTable(ctx,
+ TableIdentifier("test_database", "from_table"),
+ TableIdentifier("test_database", "to_table"))
+ require.ErrorContains(t, err, "renaming is not supported for S3 Tables
managed tables")
+ mockGlueSvc.AssertNotCalled(t, "CreateTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertNotCalled(t, "DeleteTable", mock.Anything,
mock.Anything, mock.Anything)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCreateTableUsesCatalogAwsConfig pins that the generic create path
writes
+// and reads metadata with the catalog's configured AWS config.
+func TestGlueCreateTableUsesCatalogAwsConfig(t *testing.T) {
+ ctx := context.Background()
+ const scheme = "gluecreatecredcfg"
+ _, recorded := recordingMemFS(t, scheme)
+ awsCfg := &aws.Config{Region: "cred-regression"}
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, mock.Anything,
mock.Anything).
+ Return(federatedDatabaseOutput(""), nil).Once()
+ var metadataLoc string
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.MatchedBy(func(in
*glue.CreateTableInput) bool {
+ metadataLoc =
in.TableInput.Parameters[tableParamMetadataLocation]
+
+ return true
+ }), mock.Anything).Return(&glue.CreateTableOutput{}, nil).Once()
+
+ loaded := &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ TableType: aws.String(glueTableType),
+ Parameters: map[string]string{},
+ }
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Run(func(mock.Arguments) {
+ loaded.Parameters[tableParamTableType] = glueTypeIceberg
+ loaded.Parameters[tableParamMetadataLocation] =
metadataLoc
+ }).Return(&glue.GetTableOutput{Table: loaded}, nil)
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: awsCfg, props:
iceberg.Properties{"warehouse": scheme + "://bucket"}}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.NoError(t, err)
+ assertRecordedConfig(t, awsCfg, recorded)
+}
+
+// TestGlueCommitS3TablesTableUsesCatalogAwsConfig pins the catalog AWS config
on
+// the metadata write and the fs.Remove cleanup after a failed UpdateTable.
+func TestGlueCommitS3TablesTableUsesCatalogAwsConfig(t *testing.T) {
+ ctx := context.Background()
+ const scheme = "gluecommits3credcfg"
+ const catalogID = "123456789012:s3tablescatalog/bucket"
+ _, recorded := recordingMemFS(t, scheme)
+ managedLocation := scheme + "://bucket/test_table"
+ awsCfg := &aws.Config{Region: "cred-regression"}
+ schema := s3TablesTestSchema()
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetDatabase", mock.Anything, mock.Anything,
mock.Anything).
+ Return(federatedDatabaseOutput("aws:s3tables"), nil).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return(&glue.GetTableOutput{Table: &types.Table{
+ Name: aws.String("test_table"),
+ DatabaseName: aws.String("test_database"),
+ VersionId: aws.String("1"),
+ StorageDescriptor: &types.StorageDescriptor{Location:
aws.String(managedLocation)},
+ }}, nil).Once()
+ // Fail the repoint so the fs.Remove cleanup runs; the entry is then
rolled back.
+ mockGlueSvc.On("UpdateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return((*glue.UpdateTableOutput)(nil), errors.New("update
boom")).Once()
+ mockGlueSvc.On("DeleteTable", mock.Anything, mock.MatchedBy(func(in
*glue.DeleteTableInput) bool {
+ return aws.ToString(in.CatalogId) == catalogID
+ }), mock.Anything).Return(&glue.DeleteTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: awsCfg, catalogId:
aws.String(catalogID)}
+ _, err := cat.CreateTable(ctx, TableIdentifier("test_database",
"test_table"), schema)
+ require.Error(t, err)
+ assertRecordedConfig(t, awsCfg, recorded)
+ mockGlueSvc.AssertExpectations(t)
+}
+
+// TestGlueCommitTableUsesCatalogAwsConfig pins that CommitTable writes
metadata
+// with the catalog's configured AWS config.
+func TestGlueCommitTableUsesCatalogAwsConfig(t *testing.T) {
+ ctx := context.Background()
+ const scheme = "gluecommitcredcfg"
+ _, recorded := recordingMemFS(t, scheme)
+ awsCfg := &aws.Config{Region: "cred-regression"}
+ ident := TableIdentifier("test_database", "test_table")
+
+ mockGlueSvc := &mockGlueClient{}
+ mockGlueSvc.On("GetTable", mock.Anything, mock.Anything, mock.Anything).
+ Return(&glue.GetTableOutput{},
&types.EntityNotFoundException{}).Once()
+ mockGlueSvc.On("CreateTable", mock.Anything, mock.Anything,
mock.Anything).
+ Return(&glue.CreateTableOutput{}, nil).Once()
+
+ cat := &Catalog{glueSvc: mockGlueSvc, awsCfg: awsCfg}
+ _, _, err := cat.CommitTable(ctx, ident,
[]table.Requirement{table.AssertCreate()}, []table.Update{
+ table.NewSetLocationUpdate(scheme + "://bucket/test_table"),
+ })
+ require.NoError(t, err)
+ assertRecordedConfig(t, awsCfg, recorded)
+ mockGlueSvc.AssertExpectations(t)
+}