Files
seaweedfs/weed/s3api/iceberg/handlers_table.go
T
Chris LuandClaude Opus 5.5 5b1450b06b s3tables, iceberg: one catalog record per table location (#12473)
* s3tables: refuse a table or view location another record holds

A create, register, update or rename could name a location at, under or
above another catalog record's files, including the files a renamed table
left under its old name. The new record then loaded that table's metadata,
had credentials vended for its files, and could purge them on drop.

Each of those writes now scans the bucket's records and refuses a root
another record claims, and a drop of a record sharing its files is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg: check a table's location before writing to it

REST create and stage-create write a metadata file, and stage-create
vends write credentials, before the catalog registers the table, so the
create gate now also validates the location and refuses one another
record holds. RegisterTable runs the same gate before it reads the
metadata file, instead of reading first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg: keep a dropped location another record holds

DropTable and DropView purge the dropped record's location after the
catalog delete. A view recorded at a table's location took that table's
directory, record included. The purge now leaves a location another
record still holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: authorize CreateTable before reporting the namespace

A caller without CreateTable got 404 for a missing namespace, 403 for an
existing one and 500 with decoder text for an unreadable one. The grant is
now evaluated against the bucket first, and only a caller that passes
learns the namespace is missing or unreadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg maintenance: skip a table whose files another record shares

Orphan removal and snapshot expiry delete what a table's metadata does not
reference. For two records over one location that deletes the other's
files, so detection leaves such tables alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: count another record's catalog entry as claimed

A table keeping its files elsewhere still owns its entry directory, and a
view recorded there would take that entry with it on drop. The ownership
check now refuses a root at, above or under another record's entry too.
Maintenance still compares files only, so a table created over a renamed
table's old name does not stop that table being maintained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: check a claimed location again once the record is written

Two creates, or a create and an update moving a root, could both read the
same root free and both land. A create now checks again after writing its
still-marked record and backs out if another record claims the root, and an
update that moved its root checks again after the write and puts the previous
metadata back. Of two racing writers at least one sees the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg: enable stage-create in the location ownership test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* iceberg maintenance: check for a shared location again before removing orphans

A job can run long after its table was scheduled, or come from a detector
that never checked, so remove_orphans reads the bucket's records itself and
leaves the files alone when another record shares the table's location.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: check a renamed record's new root again once it is written

A record without a location moves its root with its name, and was checked
only before the rename wrote the destination, so a create landing on that
root in between could share it. The rename now checks again with the
destination written and backs out, like a create.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: put a moved record back when its location cannot be checked again

An update that moved its root kept the move when the check after the write
failed to list the bucket, so a racing writer that relied on that check could
share the root. The move is now undone as it is when the root is taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: hold a moved record's old root until the move lands

An update moving a record's root wrote the new metadata first and put the
old metadata back when the check that followed found the new root taken.
The old root was free in between, so a create racing for it could pass its
own check and then share it with the restored record.

The update now stores the metadata it is about to write beside the current
one, so the record holds both roots, checks the new root with that
reservation in place, and only then switches the metadata. A move that
backs out drops the reservation without touching the metadata, and an
update that keeps its root clears a reservation a crashed move left behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: test a move racing another update of the record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: drop a move's reservation when a rename or delete takes the record

A rename or a decoupled delete that took a record while an update held its
move reservation left the reservation on the old name, where the move could
no longer clear it, and later creates of that name were refused as taken.
Both now clear it with the record's other attributes, and a failed rename
does not put it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: register a table from metadata under its own location

The Spark register test registered a live table's metadata file under a
second name, which now fails because both records would own the source's
files. It checks that refusal instead, and registers a copy uploaded under
the new table's own directory, with its location pointing there, to cover
reading and writing a registered table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 05:04:09 -08:00

1113 lines
42 KiB
Go

package iceberg
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path"
"strconv"
"strings"
"github.com/apache/iceberg-go"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
var errTableNameRequired = errors.New("table name is required")
func validateCreateTableRequest(req CreateTableRequest) error {
if req.Name == "" {
return errTableNameRequired
}
return nil
}
// partitionSpec returns the requested spec with the client's source IDs, which
// newTableMetadata resolves through the request schema before assigning fresh
// IDs.
func (req CreateTableRequest) partitionSpec() *iceberg.PartitionSpec {
if req.PartitionSpec == nil {
return nil
}
return &req.PartitionSpec.PartitionSpec
}
// writeOrder returns the requested sort order with the client's source IDs; see
// partitionSpec.
func (req CreateTableRequest) writeOrder() *table.SortOrder {
if req.WriteOrder == nil {
return nil
}
return &req.WriteOrder.SortOrder
}
// handleListTables lists tables in a namespace.
func (s *Server) handleListTables(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
pageToken, pageSize, err := parsePagination(r)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
listReq := &s3tables.ListTablesRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
ContinuationToken: pageToken,
MaxTables: pageSize,
}
var listResp s3tables.ListTablesResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "ListTables", listReq, &listResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
}
glog.V(1).Infof("Iceberg: ListTables error: %v", err)
writeManagerError(w, err)
return
}
// Convert to Iceberg format
identifiers := make([]TableIdentifier, 0, len(listResp.Tables))
for _, t := range listResp.Tables {
identifiers = append(identifiers, TableIdentifier{
Namespace: namespace,
Name: t.Name,
})
}
result := ListTablesResponse{
NextPageToken: listResp.ContinuationToken,
Identifiers: identifiers,
}
writeJSON(w, http.StatusOK, result)
}
// tablePathOccupied reports whether a filer entry already exists at the catalog
// name path. A leftover directory there (e.g. data kept when another table was
// renamed to this name) means a table created at the default location would
// overwrite it, so the caller routes the new table to a unique location. A
// lookup failure other than not-found is returned so the caller can fail the
// create rather than fall back to the default path on a transient filer error.
func (s *Server) tablePathOccupied(ctx context.Context, bucketName, tablePath string) (bool, error) {
full := path.Join(s3tables.TablesPath, bucketName, tablePath)
dir, name := path.Split(full)
dir = strings.TrimSuffix(dir, "/")
occupied := false
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
_, err := filer_pb.LookupEntry(ctx, client, &filer_pb.LookupDirectoryEntryRequest{
Directory: dir,
Name: name,
})
if errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
if err != nil {
return err
}
occupied = true
return nil
})
return occupied, err
}
// authorizeCreateTable checks the caller may create the table at location, for
// the create paths that touch the bucket before the catalog registers it.
func (s *Server) authorizeCreateTable(ctx context.Context, bucketARN string, namespace []string, tableName, identityName, location string) error {
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeCreateTable(ctx, s3tables.NewManagerClient(client), &s3tables.CreateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
MetadataLocation: location,
}, identityName)
})
}
// handleCreateTable creates a new table.
func (s *Server) handleCreateTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req CreateTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
if err := validateCreateTableRequest(req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
if req.StageCreate && !isStageCreateEnabled() {
writeError(w, http.StatusNotImplemented, "NotImplementedException", "stage-create is disabled")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Generate UUID for the new table
tableUUID := uuid.New()
tablePath := path.Join(flattenNamespacePath(namespace), req.Name)
location := strings.TrimSuffix(req.Location, "/")
if location == "" {
// If a leftover directory already occupies the default location (data
// kept when another table was renamed to this name), route this table
// to a unique location so it cannot overwrite that table's files.
occupied, err := s.tablePathOccupied(r.Context(), bucketName, tablePath)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to check table location: "+err.Error())
return
}
if occupied {
tablePath = path.Join(flattenNamespacePath(namespace), req.Name+"-"+tableUUID.String())
}
if req.Properties != nil {
if warehouse := strings.TrimSuffix(req.Properties["warehouse"], "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, tablePath)
}
}
if location == "" {
if warehouse := strings.TrimSuffix(os.Getenv("ICEBERG_WAREHOUSE"), "/"); warehouse != "" {
location = fmt.Sprintf("%s/%s", warehouse, tablePath)
}
}
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, tablePath)
}
} else {
parsedBucket, parsedPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid table location: "+err.Error())
return
}
if parsedPath == "" {
location = fmt.Sprintf("s3://%s/%s", parsedBucket, tablePath)
}
}
// Build proper Iceberg table metadata using iceberg-go types
metadata, err := newTableMetadata(tableUUID, location, req.Schema, req.partitionSpec(), req.writeOrder(), req.Properties)
if err != nil {
glog.V(1).Infof("Iceberg: CreateTable %s metadata error: %v", req.Name, err)
// Everything the metadata is built from comes from the request, so a
// spec or order that does not resolve against its schema is the
// client's error, not a server fault.
if errors.Is(err, table.ErrInvalidMetadata) || errors.Is(err, table.ErrInvalidSortSourceID) {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
writeManagerError(w, err)
return
}
// Serialize metadata to JSON
metadataBytes, err := json.Marshal(metadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
return
}
// Backfill Iceberg spec-required fields that iceberg-go v0.5.0 omits when
// empty (e.g. current-snapshot-id), so the persisted v*.metadata.json is
// readable by strict clients (Java/Spark/Trino) that go directly to S3.
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
tableName := req.Name
metadataFileName := "v1.metadata.json" // Initial version is always 1
metadataLocation := fmt.Sprintf("%s/metadata/%s", location, metadataFileName)
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
return
}
// location/name are client-supplied; confine the metadata write to the
// authorized catalog bucket and reject traversal segments so path.Join in
// saveMetadataFile cannot escape into another bucket.
if metadataBucket != bucketName {
writeError(w, http.StatusBadRequest, "BadRequestException", "table location must be within bucket "+bucketName)
return
}
if !isValidTablePath(metadataPath) {
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid table location path")
return
}
// Authoritative existence check: ask the catalog whether a table is registered
// at this name. If it is, short-circuit with the existing table (idempotent
// CreateTable). Any leftover objects at the target path from a previous
// lifecycle of the same name are purged by handleDropTable on the way out —
// we deliberately do not clean storage here to keep CreateTable free of
// destructive side effects. See issue #9074.
existsReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var existsResp s3tables.GetTableResponse
existsErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", existsReq, &existsResp, identityName)
})
if existsErr == nil {
// Table already registered. Return the existing definition so CTAS/IF NOT
// EXISTS flows see a stable response instead of a 409.
result, buildErr := s.buildLoadTableResult(r, existsResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
if !isNoSuchTableError(existsErr) {
glog.V(1).Infof("Iceberg: CreateTable existence check failed for %s.%s: %v", flattenNamespacePath(namespace), tableName, existsErr)
writeManagerError(w, existsErr)
return
}
// Both branches below write into the table bucket, and stage-create never
// reaches the registration that carries the authorization, so the caller has
// to pass the CreateTable gate here.
if authErr := s.authorizeCreateTable(r.Context(), bucketARN, namespace, tableName, identityName, metadataLocation); authErr != nil {
writeManagerError(w, authErr)
return
}
// Registration confines the location to the table's namespace, but both
// branches write, and stage-create vends credentials, before it runs.
if err := s3tables.ValidateMetadataLocation(metadataLocation, bucketName, flattenNamespacePath(namespace)); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
// Stage-create persists metadata in the internal staged area and skips S3Tables registration.
if req.StageCreate {
stagedTablePath := stageCreateStagedTablePath(namespace, tableName, tableUUID)
if err := s.saveMetadataFile(r.Context(), metadataBucket, stagedTablePath, metadataFileName, metadataBytes, false); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save staged metadata file: "+err.Error())
return
}
stagedMetadataLocation := fmt.Sprintf("s3://%s/%s/metadata/%s", metadataBucket, stagedTablePath, metadataFileName)
if markerErr := s.writeStageCreateMarker(r.Context(), bucketName, namespace, tableName, tableUUID, location, stagedMetadataLocation); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to persist stage-create marker for %s.%s: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
// The staged table is not registered yet, so there is nothing to
// authorize an update against; the create gate above is the write
// authority for the prefix the staged table will occupy.
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool { return true })
result := LoadTableResult{
MetadataLocation: metadataLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}
writeLoadResult(w, http.StatusOK, result)
return
}
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes, false); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
// Use S3 Tables manager to create table
createReq := &s3tables.CreateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
Format: "ICEBERG",
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataLocation: metadataLocation,
MetadataVersion: 1,
}
var createResp s3tables.CreateTableResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateTable", createReq, &createResp, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeTableAlreadyExists {
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
getErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if getErr != nil {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
if strings.Contains(err.Error(), "already exists") {
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
getErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if getErr != nil {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr != nil {
glog.Errorf("Iceberg: CreateTable load existing %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
return
}
glog.V(1).Infof("Iceberg: CreateTable error: %v", err)
writeManagerError(w, err)
return
}
// Use returned location if available, otherwise fallback to local one
finalLocation := createResp.MetadataLocation
if finalLocation == "" {
finalLocation = metadataLocation
}
if markerErr := s.deleteStageCreateMarkers(r.Context(), bucketName, namespace, tableName); markerErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup stage-create markers for %s.%s after create: %v", flattenNamespacePath(namespace), tableName, markerErr)
}
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool {
return s.mayUpdateTable(r, bucketName, namespace, req.Name)
})
result := LoadTableResult{
MetadataLocation: finalLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}
writeLoadResult(w, http.StatusOK, result)
}
// handleRegisterTable registers an existing metadata.json under a new catalog
// entry without generating new metadata.
func (s *Server) handleRegisterTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
if len(namespace) == 0 {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace is required")
return
}
var req RegisterTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", errTableNameRequired.Error())
return
}
if req.MetadataLocation == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "metadata-location is required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
// Read the existing metadata object before registering, so a bad location
// is rejected (400) without leaving a dangling catalog entry.
metadataBucket, tablePath, err := parseS3Location(tableLocationFromMetadataLocation(req.MetadataLocation))
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid metadata-location: "+err.Error())
return
}
// metadata-location is client-supplied; confine the read to the authorized
// catalog bucket and reject traversal segments so path.Join in
// loadMetadataFile cannot escape into another bucket.
if metadataBucket != bucketName {
writeError(w, http.StatusBadRequest, "BadRequestException", "metadata-location must be within bucket "+bucketName)
return
}
if !isValidTablePath(tablePath) {
writeError(w, http.StatusBadRequest, "BadRequestException", "invalid metadata-location path")
return
}
if authErr := s.authorizeCreateTable(r.Context(), bucketARN, namespace, req.Name, identityName, req.MetadataLocation); authErr != nil {
writeManagerError(w, authErr)
return
}
metadataFileName := path.Base(req.MetadataLocation)
// A table migrated in has its metadata uploaded through the S3 gateway,
// which stores it in chunks rather than inline, so follow the chunks.
metadataBytes, err := s.loadTableMetadataBlob(r.Context(), metadataBucket, tablePath, metadataFileName)
if err != nil {
glog.V(1).Infof("Iceberg: RegisterTable load metadata at %s: %v", req.MetadataLocation, err)
writeError(w, http.StatusBadRequest, "BadRequestException", "Cannot read metadata at "+req.MetadataLocation)
return
}
registered, err := table.ParseMetadataBytes(metadataBytes)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", fmt.Sprintf("Invalid metadata at %s: %v", req.MetadataLocation, err))
return
}
registeredMetadata := &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: registered.TableUUID().String()},
FullMetadata: metadataBytes,
}
registerReq := &s3tables.RegisterTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: req.Name,
MetadataLocation: req.MetadataLocation,
Metadata: registeredMetadata,
}
// The manager refuses a request larger than s3tables.MaxRequestBodySize.
// A file too large to travel with the registration is registered by its
// location and UUID alone; loads and commits read it from that location.
if body, marshalErr := json.Marshal(registerReq); marshalErr == nil && len(body) > s3tables.MaxRequestBodySize {
registerReq.Metadata = &s3tables.TableMetadata{Iceberg: registeredMetadata.Iceberg}
}
var registerResp s3tables.RegisterTableResponse
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "RegisterTable", registerReq, &registerResp, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
switch tableErr.Type {
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", namespace))
return
case s3tables.ErrCodeTableAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("Table already exists: %s", req.Name))
return
}
}
glog.V(1).Infof("Iceberg: RegisterTable error: %v", err)
writeManagerError(w, err)
return
}
getResp := s3tables.GetTableResponse{
MetadataLocation: req.MetadataLocation,
Metadata: registeredMetadata,
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, req.Name)
if buildErr != nil {
glog.Errorf("Iceberg: RegisterTable %s: %v", req.Name, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
}
// handleLoadTable loads table metadata.
func (s *Server) handleLoadTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
return
}
glog.V(1).Infof("Iceberg: LoadTable error: %v", err)
writeManagerError(w, err)
return
}
result, buildErr := s.buildLoadTableResult(r, getResp, bucketName, namespace, tableName)
if buildErr == nil {
// Only LoadTable defines ?snapshots=; a create response always carries
// the metadata it just wrote.
result.Metadata, buildErr = applySnapshotsParam(r, result.Metadata)
}
if buildErr != nil {
glog.Errorf("Iceberg: LoadTable %s: %v", tableName, buildErr)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build table metadata")
return
}
writeLoadResult(w, http.StatusOK, result)
}
func (s *Server) buildLoadTableResult(r *http.Request, getResp s3tables.GetTableResponse, bucketName string, namespace []string, tableName string) (LoadTableResult, error) {
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s", bucketName, path.Join(flattenNamespacePath(namespace), tableName))
}
tableUUID := uuid.Nil
if getResp.Metadata != nil && getResp.Metadata.Iceberg != nil && getResp.Metadata.Iceberg.TableUUID != "" {
if parsed, err := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); err == nil {
tableUUID = parsed
}
}
// Use Nil UUID if not found in storage (legacy table)
// Stability is guaranteed by not generating random UUIDs on read
metadataBytes, err := s.currentMetadataBytes(r.Context(), getResp, bucketName)
if err != nil {
return LoadTableResult{}, fmt.Errorf("load metadata for %s: %w", tableName, err)
}
var metadata table.Metadata
if len(metadataBytes) > 0 {
metadata, err = table.ParseMetadataBytes(metadataBytes)
if err != nil {
glog.Warningf("Iceberg: Failed to parse persisted metadata for %s: %v", tableName, err)
// Attempt to reconstruct from IcebergMetadata if available, otherwise synthetic
// TODO: Extract schema/spec from getResp.Metadata.Iceberg if FullMetadata fails but partial info exists?
// For now, fallback to empty metadata
metadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
} else {
// No full metadata, create synthetic
// TODO: If we had stored schema in IcebergMetadata, we would pass it here
metadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
// A nil metadata would serialize as "metadata":null under HTTP 200, which no
// Iceberg client can parse. Fail the request instead.
if err != nil {
return LoadTableResult{}, fmt.Errorf("build metadata for %s: %w", tableName, err)
}
config, storageCredentials := s.buildFileIOConfig(r, location, func() bool {
return s.mayUpdateTable(r, bucketName, namespace, tableName)
})
return LoadTableResult{
MetadataLocation: getResp.MetadataLocation,
Metadata: metadata,
Config: config,
StorageCredentials: storageCredentials,
}, nil
}
// applySnapshotsParam honours ?snapshots=refs, which asks for only the
// snapshots that branches and tags point at. Clients use it to avoid pulling a
// long snapshot history they will not read. Anything else, including the
// default, returns the metadata untouched.
func applySnapshotsParam(r *http.Request, metadata table.Metadata) (table.Metadata, error) {
if !strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("snapshots")), "refs") {
return metadata, nil
}
if metadata == nil {
return metadata, nil
}
referenced := make(map[int64]struct{})
for _, ref := range metadata.Refs() {
referenced[ref.SnapshotID] = struct{}{}
}
if current := metadata.CurrentSnapshot(); current != nil {
referenced[current.SnapshotID] = struct{}{}
}
var unreferenced []int64
for _, snapshot := range metadata.Snapshots() {
if _, keep := referenced[snapshot.SnapshotID]; !keep {
unreferenced = append(unreferenced, snapshot.SnapshotID)
}
}
if len(unreferenced) == 0 {
return metadata, nil
}
builder, err := table.MetadataBuilderFromBase(metadata, "")
if err != nil {
return nil, err
}
if err := builder.RemoveSnapshots(unreferenced, false); err != nil {
return nil, err
}
return builder.Build()
}
// buildFileIOConfig returns the FileIO properties to advertise to catalog
// clients so they can read the table's data files directly from S3 without
// separately discovering the endpoint. The region defaults to the same
// value baked into table bucket ARNs so clients like DuckDB that require
// a region on attach don't need to be told it out-of-band. See issue #9103.
// It also returns, for a client that asked for credential vending, the
// credentials scoped to this table.
func (s *Server) buildFileIOConfig(r *http.Request, location string, canWrite func() bool) (iceberg.Properties, []StorageCredential) {
config := make(iceberg.Properties)
if s.s3Endpoint == "" {
return config, nil
}
vended := iceberg.Properties(nil)
if wantsVendedCredentials(r) {
vended = s.vendCredentials(r, location, canWrite)
// A client asking for vended credentials builds its storage credential
// out of whatever comes back here and stops using the one it was
// configured with. With nothing to vend, an endpoint on its own leaves
// it sending unsigned requests; say nothing instead and let it keep its
// own credentials.
if vended == nil {
return config, nil
}
}
config["s3.endpoint"] = s.s3Endpoint
config["s3.path-style-access"] = "true"
config["s3.region"] = s3tables.DefaultRegion
if vended == nil {
return config, nil
}
credentialConfig := make(iceberg.Properties, len(config)+len(vended))
for key, value := range config {
credentialConfig[key] = value
}
for key, value := range vended {
config[key] = value
credentialConfig[key] = value
}
return config, []StorageCredential{{Prefix: location, Config: credentialConfig}}
}
// vendCredentials mints credentials limited to this table's prefix. It returns
// nil when the deployment has no vending configured or the mint failed, which
// leaves the caller withholding the endpoint so the client keeps its own
// credentials rather than falling back to unsigned requests.
func (s *Server) vendCredentials(r *http.Request, location string, canWrite func() bool) iceberg.Properties {
if s.credentialVendor == nil || location == "" {
return nil
}
bucket, prefix, err := parseS3Location(location)
if err != nil {
glog.V(1).Infof("Iceberg: cannot vend credentials for %s: %v", location, err)
return nil
}
principal := s3_constants.GetIdentityNameFromContext(r)
credentials, err := s.credentialVendor.VendTableCredentials(r.Context(), principal, bucket, prefix, canWrite == nil || !canWrite())
if err != nil {
glog.Warningf("Iceberg: failed to vend credentials for %s: %v", location, err)
return nil
}
if credentials == nil {
return nil
}
vended := iceberg.Properties{
"s3.access-key-id": credentials.AccessKeyID,
"s3.secret-access-key": credentials.SecretAccessKey,
"s3.session-token": credentials.SessionToken,
}
if !credentials.Expiration.IsZero() {
vended["s3.session-token-expires-at-ms"] = strconv.FormatInt(credentials.Expiration.UnixMilli(), 10)
}
return vended
}
// mayUpdateTable reports whether the caller may commit to the table the
// credentials would cover. The session stays read-only when it may not, so
// the vend carries no more authority than the caller holds at the catalog.
func (s *Server) mayUpdateTable(r *http.Request, bucketName string, namespace []string, tableName string) bool {
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: buildTableBucketARN(bucketName),
Namespace: namespace,
Name: tableName,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeUpdateTable(r.Context(), s3tables.NewManagerClient(client), updateReq, identityName)
})
if err == nil {
err = s.tablesManager.CheckVectorNamespaceWrite(r.Context(), s.filerClient, bucketName, namespace)
}
if err != nil {
glog.V(1).Infof("Iceberg: UpdateTable check for %s/%s failed, vending read-only: %v", bucketName, tableName, err)
}
return err == nil
}
// mayUpdateView is mayUpdateTable for a view, whose commit path is UpdateView.
func (s *Server) mayUpdateView(r *http.Request, bucketName string, namespace []string, viewName string) bool {
updateReq := &s3tables.UpdateViewRequest{
TableBucketARN: buildTableBucketARN(bucketName),
Namespace: namespace,
Name: viewName,
}
identityName := s3_constants.GetIdentityNameFromContext(r)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return s.tablesManager.AuthorizeUpdateView(r.Context(), s3tables.NewManagerClient(client), updateReq, identityName)
})
if err != nil {
glog.V(1).Infof("Iceberg: UpdateView check for %s/%s failed, vending read-only: %v", bucketName, viewName, err)
}
return err == nil
}
// handleTableExists checks if a table exists.
func (s *Server) handleTableExists(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
getReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var getResp s3tables.GetTableResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDropTable deletes a table.
func (s *Server) handleDropTable(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
namespace := parseNamespace(vars["namespace"])
tableName := vars["table"]
if len(namespace) == 0 || tableName == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Namespace and table name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Resolve the table's storage location from the catalog before the
// delete, so we can purge data files afterwards. The catalog is the
// authoritative owner of this mapping — if the table is not registered,
// there is nothing to clean up and DeleteTable will return NoSuchTable.
var storedMetadataLocation string
lookupReq := &s3tables.GetTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
var lookupResp s3tables.GetTableResponse
lookupErr := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "GetTable", lookupReq, &lookupResp, identityName)
})
if lookupErr == nil {
storedMetadataLocation = lookupResp.MetadataLocation
}
deleteReq := &s3tables.DeleteTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "DeleteTable", deleteReq, nil, identityName)
})
if err != nil {
if strings.Contains(err.Error(), "not found") {
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName))
return
}
glog.V(1).Infof("Iceberg: DropTable error: %v", err)
writeManagerError(w, err)
return
}
// Purge data files that lived under the dropped table's location, so a
// subsequent CREATE TABLE at the same name (issue #9074) does not trip
// Trino's "non-empty location" pre-check. We only purge when the catalog
// told us a location: if GetTable above failed, there is no mapping to
// trust and we leave storage alone.
if storedMetadataLocation != "" {
if tableLoc := tableLocationFromMetadataLocation(storedMetadataLocation); tableLoc != "" {
if dataBucket, dataPath, parseErr := parseS3Location(tableLoc); parseErr == nil {
if cleanupErr := s.cleanupStaleTableLocation(r.Context(), dataBucket, dataPath); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to purge dropped table location s3://%s/%s: %v", dataBucket, dataPath, cleanupErr)
}
}
}
}
w.WriteHeader(http.StatusNoContent)
}
// handleRenameTable moves a table's catalog pointer to a new namespace/name.
func (s *Server) handleRenameTable(w http.ResponseWriter, r *http.Request) {
var req RenameTableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body")
return
}
source := parseNamespace(encodeNamespace(req.Source.Namespace))
dest := parseNamespace(encodeNamespace(req.Destination.Namespace))
if len(source) == 0 || req.Source.Name == "" || len(dest) == 0 || req.Destination.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "source and destination namespace and name are required")
return
}
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
renameReq := &s3tables.RenameTableRequest{
TableBucketARN: bucketARN,
SourceNamespace: source,
SourceName: req.Source.Name,
DestNamespace: dest,
DestName: req.Destination.Name,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "RenameTable", renameReq, nil, identityName)
})
if err != nil {
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
switch tableErr.Type {
case s3tables.ErrCodeNoSuchTable:
writeError(w, http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", req.Source.Name))
return
case s3tables.ErrCodeNoSuchNamespace:
writeError(w, http.StatusNotFound, "NoSuchNamespaceException", fmt.Sprintf("Namespace does not exist: %v", dest))
return
case s3tables.ErrCodeTableAlreadyExists:
writeError(w, http.StatusConflict, "AlreadyExistsException", fmt.Sprintf("Table already exists: %s", req.Destination.Name))
return
case s3tables.ErrCodeInvalidRequest:
writeError(w, http.StatusBadRequest, "BadRequestException", tableErr.Message)
return
}
}
glog.V(1).Infof("Iceberg: RenameTable error: %v", err)
writeManagerError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// isNoSuchTableError reports whether an error from the S3 Tables manager
// indicates the target table is not registered in the catalog.
func isNoSuchTableError(err error) bool {
if err == nil {
return false
}
var tableErr *s3tables.S3TablesError
if errors.As(err, &tableErr) {
return tableErr.Type == s3tables.ErrCodeNoSuchTable || tableErr.Type == s3tables.ErrCodeNoSuchNamespace
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "not found") || strings.Contains(msg, "no such table")
}
// cleanupStaleTableLocation purges any existing filer entry at the target
// table path inside the regular S3 bucket so that engines which verify an
// empty location (e.g. Trino CTAS) can proceed after a DROP. Callers must
// have confirmed via the catalog that no table is registered at this name —
// live tables must never be touched by this helper. Missing paths are not
// an error.
//
// The tablePath is validated to contain only safe, relative segments before
// being joined with the bucket prefix, so a maliciously crafted location
// (e.g. containing "..") cannot escape the bucket subtree and delete data
// outside of it.
func (s *Server) cleanupStaleTableLocation(ctx context.Context, bucketName, tablePath string) error {
tablePath = strings.Trim(tablePath, "/")
if bucketName == "" || tablePath == "" {
return nil
}
// Reject absolute paths and any traversal segments. path.Clean would
// silently collapse "foo/../bar" into "bar", masking an attempted
// escape, so we check each raw segment explicitly.
for _, segment := range strings.Split(tablePath, "/") {
if segment == "" || segment == "." || segment == ".." || strings.ContainsAny(segment, `\`) {
return fmt.Errorf("cleanupStaleTableLocation: refusing unsafe table path %q", tablePath)
}
}
parentDir := path.Join(s3_constants.DefaultBucketsPath, bucketName, path.Dir(tablePath))
name := path.Base(tablePath)
if name == "" || name == "." || name == ".." || name == "/" {
return fmt.Errorf("cleanupStaleTableLocation: refusing unsafe table path %q", tablePath)
}
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
roots, err := s3tables.CatalogRecordRoots(ctx, client, bucketName)
if err != nil {
return err
}
if s3tables.RootClaimed(roots, "", path.Join(parentDir, name)) {
return s3tables.ErrLocationInUse
}
err = filer_pb.DoRemove(ctx, client, parentDir, name, true, true, true, false, nil)
if err == nil || errors.Is(err, filer_pb.ErrNotFound) {
return nil
}
return err
})
}
// newTableMetadata creates a new table.Metadata object with the given parameters.
// Uses iceberg-go's MetadataBuilder pattern for proper spec compliance.
func newTableMetadata(
tableUUID uuid.UUID,
location string,
schema *iceberg.Schema,
partitionSpec *iceberg.PartitionSpec,
sortOrder *table.SortOrder,
props iceberg.Properties,
) (table.Metadata, error) {
// Add schema - use provided or create empty schema
var s *iceberg.Schema
if schema != nil {
s = schema
} else {
s = iceberg.NewSchema(0)
}
// Add partition spec
var pSpec *iceberg.PartitionSpec
if partitionSpec != nil {
pSpec = partitionSpec
} else {
unpartitioned := iceberg.NewPartitionSpecID(0)
pSpec = &unpartitioned
}
// Add sort order
var so table.SortOrder
if sortOrder != nil {
so = *sortOrder
} else {
so = table.UnsortedSortOrder
}
// Create properties map if nil
if props == nil {
props = make(iceberg.Properties)
}
// Create metadata directly using the constructor which ensures spec compliance for V2
metadata, err := table.NewMetadataWithUUID(s, pSpec, so, location, props, tableUUID)
if err != nil {
return nil, err
}
// The constructor reassigns field ids, so the default name mapping must be
// derived from the final schema rather than the request's.
raw, err := json.Marshal(metadata)
if err != nil {
return nil, err
}
if patched := refreshDefaultNameMapping(raw, metadata); !bytes.Equal(patched, raw) {
if withMapping, err := table.ParseMetadataBytes(patched); err == nil {
return withMapping, nil
}
}
return metadata, nil
}