laskoviymishka commented on code in PR #2001:
URL: https://github.com/apache/iceberg-go/pull/2001#discussion_r4020373123
##########
catalog/glue/glue.go:
##########
@@ -307,6 +351,125 @@ func (c *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
return c.LoadTable(ctx, identifier)
}
+// isS3TablesDatabase reports whether the Glue database is federated to the
+// Amazon S3 Tables service, which owns table storage and location assignment.
+func (c *Catalog) isS3TablesDatabase(ctx context.Context, database string)
(bool, error) {
+ db, err := c.getDatabase(ctx, database)
+ if err != nil {
+ // Best-effort: a missing database or a caller lacking
glue:GetDatabase
+ // is treated as "not federated" so the generic create path can
proceed.
+ var apiErr smithy.APIError
+ if errors.Is(err, catalog.ErrNoSuchNamespace) ||
+ (errors.As(err, &apiErr) && apiErr.ErrorCode() ==
"AccessDeniedException") {
+ return false, nil
+ }
+
+ return false, err
+ }
+
+ return db.FederatedDatabase != nil &&
Review Comment:
`getDatabase`'s result isn't nil-guarded before we read
`db.FederatedDatabase`, so if it ever hands back a success with a nil
`Database` (a mock returning `&glue.GetDatabaseOutput{}` does exactly that)
this line panics. Since `isS3TablesDatabase` now runs on every
explicit-location create too, I'd add `if db == nil { return false, nil }`
before the dereference.
##########
catalog/glue/glue.go:
##########
@@ -307,6 +351,125 @@ func (c *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
return c.LoadTable(ctx, identifier)
}
+// isS3TablesDatabase reports whether the Glue database is federated to the
+// Amazon S3 Tables service, which owns table storage and location assignment.
+func (c *Catalog) isS3TablesDatabase(ctx context.Context, database string)
(bool, error) {
+ db, err := c.getDatabase(ctx, database)
+ if err != nil {
+ // Best-effort: a missing database or a caller lacking
glue:GetDatabase
+ // is treated as "not federated" so the generic create path can
proceed.
+ var apiErr smithy.APIError
+ if errors.Is(err, catalog.ErrNoSuchNamespace) ||
+ (errors.As(err, &apiErr) && apiErr.ErrorCode() ==
"AccessDeniedException") {
+ return false, nil
+ }
+
+ return false, err
+ }
+
+ return db.FederatedDatabase != nil &&
+
strings.EqualFold(aws.ToString(db.FederatedDatabase.ConnectionType),
s3TablesConnectionType), nil
+}
+
+// createS3TablesTable creates a table in an S3 Tables federated database. The
+// service assigns storage, so a minimal entry is created first to allocate the
+// location, then updated with the written metadata pointer; on any later
+// failure the minimal entry is removed so no half-created table is left
behind.
+// On a commit failure the minimal Glue entry is rolled back and the written
+// metadata object is best-effort deleted; if that delete fails (the managed
+// location may be unreachable) reclaiming it is left to S3 Tables. A failed
+// rollback leaves a minimal entry to clear out of band, matching pyiceberg.
+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 {
+ 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 {
+ if _, delErr := c.glueSvc.DeleteTable(ctx,
&glue.DeleteTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ Name: aws.String(tableName),
+ }); delErr != nil {
+ return nil, fmt.Errorf("%w (failed to clean up
allocated table %s.%s: %w)", err, 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 reads the service-assigned location, writes the Iceberg
+// metadata to it, and points the Glue entry at that metadata. It does not
reload
+// the table: the caller does that only after a successful commit, so a read
+// failure never triggers a rollback of an already-created table.
+func (c *Catalog) commitS3TablesTable(ctx context.Context, database, tableName
string, identifier table.Identifier, schema *iceberg.Schema, opts
...catalog.CreateTableOpt) error {
+ 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
+ }
+
+ // constructTableInput sends TableType=EXTERNAL_TABLE; S3 Tables keeps
its own
+ // service type (e.g. "customer") on read, which getRawTable accepts.
+ _, err = c.glueSvc.UpdateTable(ctx, &glue.UpdateTableInput{
+ CatalogId: c.catalogId,
+ DatabaseName: aws.String(database),
+ TableInput: constructTableInput(tableName, staged.Table,
allocated.Table),
Review Comment:
the comment above says S3 Tables keeps its own service type on read, but
`constructTableInput` always sets `TableType=EXTERNAL_TABLE` and that's what we
send on this repoint. Do we actually know S3 Tables accepts `EXTERNAL_TABLE` on
write? If it validates and rejects the type, every S3 Tables create fails here
and rolls back, so the feature is silently broken for all callers.
Nothing in the unit tests catches this: the `UpdateTable` `MatchedBy` only
checks `VersionId` and the `table_type` param, not `TableInput.TableType`, so
the assumption rides entirely on the gated integration test.
I'd pass the allocated entry's own `TableType` through here instead of
forcing `EXTERNAL_TABLE`, or at minimum assert the `TableType` we send in a
unit test and cite the AWS contract in the comment. wdyt?
##########
catalog/glue/glue.go:
##########
@@ -307,6 +351,125 @@ func (c *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
return c.LoadTable(ctx, identifier)
}
+// isS3TablesDatabase reports whether the Glue database is federated to the
+// Amazon S3 Tables service, which owns table storage and location assignment.
+func (c *Catalog) isS3TablesDatabase(ctx context.Context, database string)
(bool, error) {
+ db, err := c.getDatabase(ctx, database)
+ if err != nil {
+ // Best-effort: a missing database or a caller lacking
glue:GetDatabase
+ // is treated as "not federated" so the generic create path can
proceed.
+ var apiErr smithy.APIError
+ if errors.Is(err, catalog.ErrNoSuchNamespace) ||
+ (errors.As(err, &apiErr) && apiErr.ErrorCode() ==
"AccessDeniedException") {
+ return false, nil
+ }
+
+ return false, err
+ }
+
+ return db.FederatedDatabase != nil &&
+
strings.EqualFold(aws.ToString(db.FederatedDatabase.ConnectionType),
s3TablesConnectionType), nil
+}
+
+// createS3TablesTable creates a table in an S3 Tables federated database. The
+// service assigns storage, so a minimal entry is created first to allocate the
+// location, then updated with the written metadata pointer; on any later
+// failure the minimal entry is removed so no half-created table is left
behind.
+// On a commit failure the minimal Glue entry is rolled back and the written
+// metadata object is best-effort deleted; if that delete fails (the managed
+// location may be unreachable) reclaiming it is left to S3 Tables. A failed
+// rollback leaves a minimal entry to clear out of band, matching pyiceberg.
Review Comment:
this is the orphan case I flagged last round, and I don't think it's
resolved. The comment now says the stranded entry is cleared out of band
"matching pyiceberg", but that equivalence doesn't hold: pyiceberg's
`drop_table` has no `table_type` guard and calls `delete_table` directly, so it
can remove the minimal entry. Ours can't.
The minimal entry only carries `Parameters{format: ICEBERG}` with no
`table_type`. So when `DropTable` runs `getRawTable`, `isFederatedIceberg` is
`federated && isIceberg`, and `isIceberg` comes from `tableParamTableType`,
which is empty here. The guard fires, `DropTable` returns "is not an
EXTERNAL_TABLE", and after a double failure the entry is invisible to
`ListTables` and undroppable through the public API. The only recovery is a raw
Glue call.
I'd either teach `getRawTable`/`DropTable` to accept a federated S3 Tables
entry whose `format` param is `ICEBERG` even when `table_type` is absent, or,
if we're consciously deferring cleanup, drop the "matching pyiceberg" line and
say plainly that the orphan needs an out-of-band delete. Either is fine, it
just shouldn't claim a parity it doesn't have. wdyt?
##########
catalog/glue/glue.go:
##########
@@ -307,6 +351,125 @@ func (c *Catalog) CreateTable(ctx context.Context,
identifier table.Identifier,
return c.LoadTable(ctx, identifier)
}
+// isS3TablesDatabase reports whether the Glue database is federated to the
+// Amazon S3 Tables service, which owns table storage and location assignment.
+func (c *Catalog) isS3TablesDatabase(ctx context.Context, database string)
(bool, error) {
+ db, err := c.getDatabase(ctx, database)
+ if err != nil {
+ // Best-effort: a missing database or a caller lacking
glue:GetDatabase
+ // is treated as "not federated" so the generic create path can
proceed.
+ var apiErr smithy.APIError
+ if errors.Is(err, catalog.ErrNoSuchNamespace) ||
+ (errors.As(err, &apiErr) && apiErr.ErrorCode() ==
"AccessDeniedException") {
+ return false, nil
+ }
+
+ return false, err
+ }
+
+ return db.FederatedDatabase != nil &&
+
strings.EqualFold(aws.ToString(db.FederatedDatabase.ConnectionType),
s3TablesConnectionType), nil
+}
+
+// createS3TablesTable creates a table in an S3 Tables federated database. The
+// service assigns storage, so a minimal entry is created first to allocate the
+// location, then updated with the written metadata pointer; on any later
+// failure the minimal entry is removed so no half-created table is left
behind.
+// On a commit failure the minimal Glue entry is rolled back and the written
+// metadata object is best-effort deleted; if that delete fails (the managed
+// location may be unreachable) reclaiming it is left to S3 Tables. A failed
+// rollback leaves a minimal entry to clear out of band, matching pyiceberg.
+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 {
+ 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 {
+ if _, delErr := c.glueSvc.DeleteTable(ctx,
&glue.DeleteTableInput{
Review Comment:
the rollback `DeleteTable` reuses `ctx`, the same context that just failed
the commit. If that failure was a cancellation, `ctx.Done()` is already closed
and `DeleteTable` returns immediately with `context.Canceled`, so the minimal
entry leaks, and S3 Tables has per-account table limits.
`rollbackRenameDestination` in this same file already handles the identical
shape with `context.WithoutCancel(ctx)` plus `renameCleanupTimeout`; I'd follow
that here.
While we're in here, the combined error uses the parenthetical `%w (...%w)`
form, but everywhere else in this file the two-error case is `errors.Join`. I'd
switch to `errors.Join(err, fmt.Errorf(...))` for consistency.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]