mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-17 12:00:44 +02:00
* 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.
252 lines
8.5 KiB
Go
252 lines
8.5 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
// parseNamespace parses the namespace from path parameter.
|
|
// Iceberg uses unit separator (0x1F) for multi-level namespaces.
|
|
// Note: mux already decodes URL-encoded path parameters, so we only split by unit separator.
|
|
func parseNamespace(encoded string) []string {
|
|
if encoded == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(encoded, "\x1F")
|
|
// Filter empty parts
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// encodeNamespace encodes namespace parts using the Iceberg REST protocol's
|
|
// unit separator (0x1F) convention. This is only appropriate for protocol-level
|
|
// encoding (e.g. URL path parameters), NOT for filesystem/S3 paths.
|
|
func encodeNamespace(parts []string) string {
|
|
return strings.Join(parts, "\x1F")
|
|
}
|
|
|
|
// flattenNamespacePath joins namespace parts with "." for use in S3 location
|
|
// and filer paths, matching the S3 Tables storage layer convention.
|
|
func flattenNamespacePath(parts []string) string {
|
|
return strings.Join(parts, ".")
|
|
}
|
|
|
|
func parseS3Location(location string) (bucketName, tablePath string, err error) {
|
|
if !strings.HasPrefix(location, "s3://") {
|
|
return "", "", fmt.Errorf("unsupported location: %s", location)
|
|
}
|
|
trimmed := strings.TrimPrefix(location, "s3://")
|
|
trimmed = strings.TrimSuffix(trimmed, "/")
|
|
if trimmed == "" {
|
|
return "", "", fmt.Errorf("invalid location: %s", location)
|
|
}
|
|
parts := strings.SplitN(trimmed, "/", 2)
|
|
bucketName = parts[0]
|
|
if bucketName == "" {
|
|
return "", "", fmt.Errorf("invalid location bucket: %s", location)
|
|
}
|
|
if len(parts) == 2 {
|
|
tablePath = parts[1]
|
|
}
|
|
return bucketName, tablePath, nil
|
|
}
|
|
|
|
func tableLocationFromMetadataLocation(metadataLocation string) string {
|
|
trimmed := strings.TrimSuffix(metadataLocation, "/")
|
|
if idx := strings.LastIndex(trimmed, "/metadata/"); idx != -1 {
|
|
return trimmed[:idx]
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// writeJSON writes a JSON response.
|
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if v != nil {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
glog.Errorf("Iceberg: failed to encode response: %v", err)
|
|
return
|
|
}
|
|
w.Write(data)
|
|
}
|
|
}
|
|
|
|
// writeError writes an Iceberg error response.
|
|
func writeError(w http.ResponseWriter, status int, errType, message string) {
|
|
resp := ErrorResponse{
|
|
Error: ErrorModel{
|
|
Message: message,
|
|
Type: errType,
|
|
Code: status,
|
|
},
|
|
}
|
|
writeJSON(w, status, resp)
|
|
}
|
|
|
|
// nameValidationError reports whether err is an S3 Tables namespace or table
|
|
// name validation failure. Such names are client input (the S3 Tables charset
|
|
// is stricter than the Iceberg REST spec), so the catalog answers 400 rather
|
|
// than treating it as a server fault.
|
|
func nameValidationError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
// Match the validator's own phrasings rather than a bare "namespace name"/
|
|
// "table name" so unrelated faults (e.g. "failed to resolve table name")
|
|
// aren't misreported as client errors. Lowercased for resilience to
|
|
// capitalization changes.
|
|
msg := strings.ToLower(err.Error())
|
|
for _, marker := range []string{
|
|
"invalid namespace name", "namespace name must", "namespace name cannot",
|
|
"invalid table name", "table name must", "table name cannot",
|
|
} {
|
|
if strings.Contains(msg, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// writeManagerError maps a residual error to a response: name validation
|
|
// failures and rejected schemas come from the request body, so they are client
|
|
// errors (400) and must carry the reason -- a variant field without
|
|
// format-version 3 otherwise reads as a bare 500 with "variant is not supported
|
|
// until v3" only in the log. Anything else is a server fault (500).
|
|
func writeManagerError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case nameValidationError(err),
|
|
errors.Is(err, iceberg.ErrInvalidSchema),
|
|
errors.Is(err, iceberg.ErrInvalidPartitionSpec),
|
|
errors.Is(err, iceberg.ErrInvalidTypeString),
|
|
errors.Is(err, iceberg.ErrInvalidTransform),
|
|
errors.Is(err, iceberg.ErrInvalidArgument):
|
|
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
|
|
}
|
|
|
|
// getBucketFromPrefix extracts table bucket name from prefix parameter.
|
|
// For now, we use the prefix as the table bucket name.
|
|
//
|
|
// The Iceberg REST spec lets clients identify a catalog either by embedding
|
|
// its prefix in the URL (/v1/{prefix}/...) or by passing ?warehouse=s3://
|
|
// <bucket>/ as a query parameter. Clients that skip the /v1/config handshake
|
|
// (or ignore its overrides) still routinely send the warehouse parameter on
|
|
// every request, so honor it as a fallback before the env-var default.
|
|
// See issue #9103.
|
|
func getBucketFromPrefix(r *http.Request) string {
|
|
vars := mux.Vars(r)
|
|
if prefix := vars["prefix"]; prefix != "" {
|
|
return prefix
|
|
}
|
|
if warehouse := strings.TrimSpace(r.URL.Query().Get("warehouse")); warehouse != "" {
|
|
if bucket, _, err := parseS3Location(warehouse); err == nil && bucket != "" {
|
|
return bucket
|
|
}
|
|
}
|
|
if bucket := os.Getenv("S3TABLES_DEFAULT_BUCKET"); bucket != "" {
|
|
return bucket
|
|
}
|
|
// Default bucket if no prefix - use "warehouse" for Iceberg
|
|
return "warehouse"
|
|
}
|
|
|
|
// buildTableBucketARN builds an ARN for a table bucket.
|
|
func buildTableBucketARN(bucketName string) string {
|
|
arn, _ := s3tables.BuildBucketARN(s3tables.DefaultRegion, s3_constants.AccountAdminId, bucketName)
|
|
return arn
|
|
}
|
|
|
|
const (
|
|
defaultListPageSize = 1000
|
|
maxListPageSize = 1000
|
|
)
|
|
|
|
func getPaginationQueryParam(r *http.Request, primary, fallback string) string {
|
|
if v := strings.TrimSpace(r.URL.Query().Get(primary)); v != "" {
|
|
return v
|
|
}
|
|
return strings.TrimSpace(r.URL.Query().Get(fallback))
|
|
}
|
|
|
|
func parsePagination(r *http.Request) (pageToken string, pageSize int, err error) {
|
|
pageToken = getPaginationQueryParam(r, "pageToken", "page-token")
|
|
pageSize = defaultListPageSize
|
|
|
|
pageSizeValue := getPaginationQueryParam(r, "pageSize", "page-size")
|
|
if pageSizeValue == "" {
|
|
return pageToken, pageSize, nil
|
|
}
|
|
|
|
parsedPageSize, parseErr := strconv.Atoi(pageSizeValue)
|
|
if parseErr != nil || parsedPageSize <= 0 {
|
|
return pageToken, 0, fmt.Errorf("invalid pageSize %q: must be a positive integer", pageSizeValue)
|
|
}
|
|
if parsedPageSize > maxListPageSize {
|
|
return pageToken, 0, fmt.Errorf("invalid pageSize %q: must be <= %d", pageSizeValue, maxListPageSize)
|
|
}
|
|
|
|
return pageToken, parsedPageSize, nil
|
|
}
|
|
|
|
func normalizeNamespaceProperties(properties map[string]string) map[string]string {
|
|
if properties == nil {
|
|
return map[string]string{}
|
|
}
|
|
return properties
|
|
}
|
|
|
|
// namespaceLocationProperty is the well-known Iceberg key used to advertise a
|
|
// default base path for tables created in a namespace.
|
|
const namespaceLocationProperty = "location"
|
|
|
|
// defaultNamespaceLocation returns the s3 path under which tables in the
|
|
// namespace should be stored when the namespace itself does not carry an
|
|
// explicit "location" property. The flattened namespace path mirrors the on-
|
|
// disk layout used by handleCreateTable so a deferred client-side commit ends
|
|
// up at the same place a server-computed one would.
|
|
func defaultNamespaceLocation(bucket string, namespace []string) string {
|
|
if bucket == "" || len(namespace) == 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("s3://%s/%s", bucket, flattenNamespacePath(namespace))
|
|
}
|
|
|
|
// withDefaultNamespaceLocation populates the Iceberg "location" property on a
|
|
// namespace response when the storage layer does not carry one. Trino's REST
|
|
// catalog falls back to an eager createTable code path when the namespace
|
|
// has no location, which races our metadata write against Trino's emptiness
|
|
// check and surfaces as a "Cannot create a table on a non-empty location"
|
|
// error on the very first CREATE TABLE. Advertising a default location lets
|
|
// Trino take the deferred-transaction path and pick a unique per-table path.
|
|
// See issue #9074.
|
|
func withDefaultNamespaceLocation(properties map[string]string, bucket string, namespace []string) map[string]string {
|
|
properties = normalizeNamespaceProperties(properties)
|
|
if _, ok := properties[namespaceLocationProperty]; ok {
|
|
return properties
|
|
}
|
|
if def := defaultNamespaceLocation(bucket, namespace); def != "" {
|
|
properties[namespaceLocationProperty] = def
|
|
}
|
|
return properties
|
|
}
|