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 location 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. Staging is exclusive for the // same reason a single-table commit stages exclusively: a transaction racing // another writer on one of its tables would otherwise overwrite that // writer's metadata, and the pointer flip below decides who won. for i := range prepared { pc := &prepared[i] fileName, location, err := s.stageCommitMetadata(r.Context(), pc.metadataBucket, pc.metadataPath, pc.location, pc.metadataFileName, pc.metadataBytes) if 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 } pc.metadataFileName = fileName pc.newMetadataLoc = location } // 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, location: location, 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) } } }