Files
seaweedfs/weed/s3api/iceberg/handlers_transaction.go
T
Chris Lu ac6f3c92ef s3api/iceberg: report the reason a table schema was rejected (#10473)
* s3api/iceberg: report the reason a table schema was rejected

newTableMetadata swallowed the iceberg-go error and returned nil, so every
schema the metadata builder refused came back as a bare 500 "Failed to build
table metadata". A v3-only column type is the common case: creating a table
with a variant field but no format-version 3 property leaves the client with
nothing, while "variant is not supported until v3" sits in the server log.

Return the error instead and classify it. Schema, spec and argument failures
are the caller's input, so they answer 400 with the underlying reason; the
rest stay 500. Paths that build placeholder metadata with no schema keep
their existing 500 via newEmptyTableMetadata.

* s3api/iceberg: fail LoadTable when placeholder metadata cannot be built

buildLoadTableResult dropped a nil from the placeholder path straight into
the response. That serializes as "metadata":null under HTTP 200, which no
Iceberg client can parse -- a worse outcome than the 500 the nil was meant
to signal.

Return an error instead and let the five callers answer 500. The nil-return
convention goes away with it, so the commit and transaction paths check an
error rather than a sentinel.

* s3api/iceberg: route rejected schemas through writeManagerError

The two helpers added here duplicated work the package already does.
writeManagerError is the canonical error-to-response mapper -- it already
downgrades client-input failures to 400 and defaults the rest to 500 -- so
teach it the iceberg-go schema and spec sentinels instead of standing up a
parallel classifier. The placeholder wrapper was a pure alias for
newTableMetadata with nil arguments; call that directly.

No behavior change beyond the 500 message, which now reads err.Error()
like every other manager error rather than carrying its own prefix.
2026-07-28 14:44:56 -07:00

350 lines
14 KiB
Go

package iceberg
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path"
"strings"
"github.com/apache/iceberg-go/table"
"github.com/google/uuid"
"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"
)
// CommitTransactionRequest is sent to POST /v1/transactions/commit.
type CommitTransactionRequest struct {
TableChanges []tableChangeRequest `json:"table-changes"`
}
type tableChangeRequest struct {
Identifier *TableIdentifier `json:"identifier"`
Requirements json.RawMessage `json:"requirements"`
Updates []json.RawMessage `json:"updates"`
}
// preparedTableCommit holds the per-table work resolved during validation so
// pointer flips (and their rollback) can run after every table is validated.
type preparedTableCommit struct {
namespace []string
tableName string
tableUUID uuid.UUID
versionToken string
metadataBucket string
metadataPath string
metadataFileName string
metadataBytes []byte
metadataVersion int
newMetadataLoc string
// prior table state captured before the flip, so a rollback can revert
// every field handleUpdateTable would have mutated (not just the location).
prevMetadataLoc string
prevMetadataVersion int
prevMetadata *s3tables.TableMetadata
}
// handleCommitTransaction commits changes to multiple tables in one request.
// Validation is atomic (all requirements evaluated before any write); pointer
// flips are best-effort with rollback, so this is not crash-atomic.
func (s *Server) handleCommitTransaction(w http.ResponseWriter, r *http.Request) {
bucketName := getBucketFromPrefix(r)
bucketARN := buildTableBucketARN(bucketName)
identityName := s3_constants.GetIdentityNameFromContext(r)
var req CommitTransactionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid request body: "+err.Error())
return
}
if len(req.TableChanges) == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
// Phase 1: resolve, load, and validate every table; build new metadata bytes.
prepared := make([]preparedTableCommit, 0, len(req.TableChanges))
for _, change := range req.TableChanges {
if change.Identifier == nil || len(change.Identifier.Namespace) == 0 || change.Identifier.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Each table change requires identifier namespace and name")
return
}
pc, reqErr := s.prepareTableCommit(r.Context(), bucketName, bucketARN, identityName, change)
if reqErr != nil {
writeError(w, reqErr.status, reqErr.errType, reqErr.message)
return
}
prepared = append(prepared, *pc)
}
// Phase 2: write each new metadata.json object.
for i := range prepared {
pc := &prepared[i]
if err := s.saveMetadataFile(r.Context(), pc.metadataBucket, pc.metadataPath, pc.metadataFileName, pc.metadataBytes); err != nil {
// No pointer flipped yet, so every written file is safe to delete.
s.cleanupPreparedMetadata(r.Context(), prepared[:i+1], nil)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
}
// Phase 3: flip each table's pointer xattr; on failure roll back prior flips.
for i := range prepared {
pc := &prepared[i]
if err := s.flipTablePointer(r.Context(), bucketARN, identityName, pc); err != nil {
rolledBack := s.rollbackTablePointers(r.Context(), bucketARN, identityName, prepared[:i])
s.cleanupPreparedMetadata(r.Context(), prepared, cleanupSafeMetadata(prepared, i, rolledBack))
if isS3TablesConflict(err) {
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
glog.Errorf("Iceberg: CommitTransaction UpdateTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
return
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) prepareTableCommit(ctx context.Context, bucketName, bucketARN, identityName string, change tableChangeRequest) (*preparedTableCommit, *icebergRequestError) {
namespace := []string(change.Identifier.Namespace)
tableName := change.Identifier.Name
requirements, updates, statisticsUpdates, reqErr := parseTableChange(change)
if reqErr != nil {
return nil, reqErr
}
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(ctx, mgrClient, "GetTable", getReq, &getResp, identityName)
})
if err != nil {
if isS3TablesNotFound(err) {
return nil, &icebergRequestError{http.StatusNotFound, "NoSuchTableException", fmt.Sprintf("Table does not exist: %s", tableName)}
}
glog.V(1).Infof("Iceberg: CommitTransaction GetTable error: %v", err)
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", err.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, parseErr := uuid.Parse(getResp.Metadata.Iceberg.TableUUID); parseErr == nil {
tableUUID = parsed
}
}
if tableUUID == uuid.Nil {
tableUUID = uuid.New()
}
var currentMetadata table.Metadata
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata"}
}
} else {
currentMetadata, err = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
if err != nil {
glog.Errorf("Iceberg: CommitTransaction placeholder metadata for %s: %v", tableName, err)
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata"}
}
}
for _, requirement := range requirements {
if err := requirement.Validate(currentMetadata); err != nil {
return nil, &icebergRequestError{http.StatusConflict, "CommitFailedException", "Requirement failed: " + err.Error()}
}
}
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: " + err.Error()}
}
for _, update := range updates {
if err := update.Apply(builder); err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply update: " + err.Error()}
}
}
newMetadata, err := builder.Build()
if err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: " + err.Error()}
}
metadataVersion := getResp.MetadataVersion + 1
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: " + err.Error()}
}
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
if err != nil {
return nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: " + err.Error()}
}
metadataBytes = ensureMetadataSpecCompliance(metadataBytes)
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
return nil, &icebergRequestError{http.StatusInternalServerError, "InternalServerError", "Invalid table location: " + err.Error()}
}
return &preparedTableCommit{
namespace: namespace,
tableName: tableName,
tableUUID: tableUUID,
versionToken: getResp.VersionToken,
metadataBucket: metadataBucket,
metadataPath: metadataPath,
metadataFileName: metadataFileName,
metadataBytes: metadataBytes,
metadataVersion: metadataVersion,
newMetadataLoc: newMetadataLocation,
prevMetadataLoc: getResp.MetadataLocation,
prevMetadataVersion: getResp.MetadataVersion,
prevMetadata: cloneTableMetadata(getResp.Metadata),
}, nil
}
// cloneTableMetadata deep-copies the prior table metadata so a later rollback
// restores the exact pre-transaction bytes without aliasing the get response.
func cloneTableMetadata(m *s3tables.TableMetadata) *s3tables.TableMetadata {
if m == nil {
return nil
}
clone := &s3tables.TableMetadata{}
if m.Iceberg != nil {
iceberg := *m.Iceberg
clone.Iceberg = &iceberg
}
if len(m.FullMetadata) > 0 {
clone.FullMetadata = append(json.RawMessage(nil), m.FullMetadata...)
}
return clone
}
func parseTableChange(change tableChangeRequest) (table.Requirements, table.Updates, []statisticsUpdate, *icebergRequestError) {
var requirements table.Requirements
if len(change.Requirements) > 0 {
if err := json.Unmarshal(change.Requirements, &requirements); err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid requirements: " + err.Error()}
}
}
var updates table.Updates
var statisticsUpdates []statisticsUpdate
if len(change.Updates) > 0 {
var err error
updates, statisticsUpdates, err = parseCommitUpdates(change.Updates)
if err != nil {
return nil, nil, nil, &icebergRequestError{http.StatusBadRequest, "BadRequestException", "Invalid updates: " + err.Error()}
}
}
return requirements, updates, statisticsUpdates, nil
}
func (s *Server) flipTablePointer(ctx context.Context, bucketARN, identityName string, pc *preparedTableCommit) error {
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: pc.namespace,
Name: pc.tableName,
VersionToken: pc.versionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{TableUUID: pc.tableUUID.String()},
FullMetadata: pc.metadataBytes,
},
MetadataVersion: pc.metadataVersion,
MetadataLocation: pc.newMetadataLoc,
}
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
})
}
// rollbackTablePointers restores already-flipped tables to their full prior
// state. handleUpdateTable applies partial field updates, so the restore must
// re-send every field the flip changed (location, version, and full metadata),
// not just the location. Best-effort: a failed rollback is logged, not surfaced.
// The returned slice flags, per flipped table, whether the restore succeeded so
// the caller can avoid deleting metadata a still-flipped pointer references.
func (s *Server) rollbackTablePointers(ctx context.Context, bucketARN, identityName string, flipped []preparedTableCommit) []bool {
restored := make([]bool, len(flipped))
for i := range flipped {
pc := &flipped[i]
updateReq := buildTableRestoreRequest(bucketARN, pc)
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(ctx, mgrClient, "UpdateTable", updateReq, nil, identityName)
})
if err != nil {
glog.Errorf("Iceberg: CommitTransaction rollback of %s failed: %v", pc.tableName, err)
continue
}
restored[i] = true
}
return restored
}
// buildTableRestoreRequest reconstructs the UpdateTableRequest that reverts a
// flipped table to its captured prior state. Every field handleUpdateTable
// would have mutated on the flip is re-supplied here so the partial update
// fully restores it (ModifiedAt and VersionToken are always regenerated by the
// handler and cannot be pinned back).
func buildTableRestoreRequest(bucketARN string, pc *preparedTableCommit) *s3tables.UpdateTableRequest {
return &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: pc.namespace,
Name: pc.tableName,
Metadata: cloneTableMetadata(pc.prevMetadata),
MetadataVersion: pc.prevMetadataVersion,
MetadataLocation: pc.prevMetadataLoc,
}
}
// cleanupSafeMetadata reports, per prepared table, whether deleting its newly
// written metadata file is safe after a flip failed at failedIndex. A file is
// safe to delete only when its table's pointer no longer references it: tables
// at or past failedIndex were never flipped, and earlier tables are safe only
// if their rollback succeeded. A table whose rollback failed still points at the
// new metadata, so deleting it would strand the pointer on a missing file.
func cleanupSafeMetadata(prepared []preparedTableCommit, failedIndex int, rolledBack []bool) []bool {
safe := make([]bool, len(prepared))
for i := range prepared {
switch {
case i >= failedIndex:
safe[i] = true
case i < len(rolledBack):
safe[i] = rolledBack[i]
}
}
return safe
}
// cleanupPreparedMetadata deletes the newly written metadata file for each
// prepared table flagged safe; unflagged tables are left in place because a
// still-flipped pointer references them.
func (s *Server) cleanupPreparedMetadata(ctx context.Context, prepared []preparedTableCommit, safe []bool) {
for i := range prepared {
pc := &prepared[i]
if i < len(safe) && !safe[i] {
glog.Errorf("Iceberg: CommitTransaction keeping metadata %s; %s rollback failed and still references it", pc.newMetadataLoc, pc.tableName)
continue
}
if cleanupErr := s.deleteMetadataFile(ctx, pc.metadataBucket, pc.metadataPath, pc.metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s: %v", pc.newMetadataLoc, cleanupErr)
}
}
}