Compare commits

..
Author SHA1 Message Date
Chris Lu 49703baa7c iceberg: reject unsupported stage-create table creation 2026-02-09 23:02:24 -08:00
Chris LuandGitHub d88f6ed0af Iceberg commit reliability: preserve statistics updates and return 409 conflicts (#8277)
* iceberg: harden table commit updates and conflict handling

* iceberg: refine commit retry and statistics patching

* iceberg: cleanup metadata on non-conflict commit errors
2026-02-09 23:00:03 -08:00
Chris LuandGitHub 5ae3be44d1 iceberg: persist namespace properties for create/get (#8276)
* iceberg: persist namespace properties via s3tables metadata

* iceberg: simplify namespace properties normalization

* s3tables: broaden namespace properties round-trip test

* adjust logs

* adjust logs
2026-02-09 22:20:45 -08:00
Chris LuGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1c62808c0e iceberg: wire pagination for list namespaces/tables REST APIs (#8275)
* s3api/iceberg: wire list pagination tokens and page size

* fmt

* Update weed/s3api/iceberg/iceberg.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-09 21:46:55 -08:00
Chris Lu db76eb26e7 compile 2026-02-09 21:06:07 -08:00
Chris Lu 4ccc7668ce admin: resolve merge conflicts 2026-02-09 20:56:20 -08:00
Chris LuandGitHub aef2de3109 s3tables: support multi-level namespaces in parser/admin paths (#8273)
* s3tables: support multi-level namespace normalization

* admin: handle namespace parsing errors centrally

* admin: clean namespace validation duplication
2026-02-09 20:20:05 -08:00
Chris Lu be26ce74ce s3tables: support multi-level namespace normalization 2026-02-09 19:42:31 -08:00
18 changed files with 1094 additions and 344 deletions
+58 -9
View File
@@ -63,6 +63,19 @@ type tableBucketMetadata struct {
const s3TablesAdminListLimit = 1000
func parseNamespaceInput(namespace string) ([]string, error) {
return s3tables.ParseNamespace(namespace)
}
func (s *AdminServer) parseNamespaceFromGin(c *gin.Context, namespace string) ([]string, bool) {
parts, err := parseNamespaceInput(namespace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid namespace: " + err.Error()})
return nil, false
}
return parts, true
}
func newS3TablesManager() *s3tables.Manager {
manager := s3tables.NewManager()
manager.SetAccountID(s3_constants.AccountAdminId)
@@ -158,7 +171,11 @@ func (s *AdminServer) GetS3TablesTablesData(ctx context.Context, bucketArn, name
var resp s3tables.ListTablesResponse
var ns []string
if namespace != "" {
ns = []string{namespace}
parts, err := parseNamespaceInput(namespace)
if err != nil {
return S3TablesTablesData{}, err
}
ns = parts
}
req := &s3tables.ListTablesRequest{TableBucketARN: bucketArn, Namespace: ns, MaxTables: s3TablesAdminListLimit}
if err := s.executeS3TablesOperation(ctx, "ListTables", req, &resp); err != nil {
@@ -257,9 +274,13 @@ func (s *AdminServer) GetIcebergTablesData(ctx context.Context, catalogName, buc
// GetIcebergTableDetailsData returns Iceberg table metadata and snapshot information.
func (s *AdminServer) GetIcebergTableDetailsData(ctx context.Context, catalogName, bucketArn, namespace, tableName string) (IcebergTableDetailsData, error) {
var resp s3tables.GetTableResponse
namespaceParts, err := parseNamespaceInput(namespace)
if err != nil {
return IcebergTableDetailsData{}, err
}
req := &s3tables.GetTableRequest{
TableBucketARN: bucketArn,
Namespace: []string{namespace},
Namespace: namespaceParts,
Name: tableName,
}
if err := s.executeS3TablesOperation(ctx, "GetTable", req, &resp); err != nil {
@@ -686,7 +707,11 @@ func (s *AdminServer) CreateS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn and name are required"})
return
}
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Name}}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Name)
if !ok {
return
}
createReq := &s3tables.CreateNamespaceRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts}
var resp s3tables.CreateNamespaceResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "CreateNamespace", createReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -705,7 +730,11 @@ func (s *AdminServer) DeleteS3TablesNamespace(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket and name query parameters are required"})
return
}
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
req := &s3tables.DeleteNamespaceRequest{TableBucketARN: bucketArn, Namespace: namespaceParts}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteNamespace", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -745,6 +774,10 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, and name are required"})
return
}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
if !ok {
return
}
format := req.Format
if format == "" {
format = "ICEBERG"
@@ -757,7 +790,7 @@ func (s *AdminServer) CreateS3TablesTable(c *gin.Context) {
}
createReq := &s3tables.CreateTableRequest{
TableBucketARN: req.BucketARN,
Namespace: []string{req.Namespace},
Namespace: namespaceParts,
Name: req.Name,
Format: format,
Tags: req.Tags,
@@ -780,7 +813,11 @@ func (s *AdminServer) DeleteS3TablesTable(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name, VersionToken: version}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
req := &s3tables.DeleteTableRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name, VersionToken: version}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTable", req, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -853,7 +890,11 @@ func (s *AdminServer) PutS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket_arn, namespace, name, and policy are required"})
return
}
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: []string{req.Namespace}, Name: req.Name, ResourcePolicy: req.Policy}
namespaceParts, ok := s.parseNamespaceFromGin(c, req.Namespace)
if !ok {
return
}
putReq := &s3tables.PutTablePolicyRequest{TableBucketARN: req.BucketARN, Namespace: namespaceParts, Name: req.Name, ResourcePolicy: req.Policy}
if err := s.executeS3TablesOperation(c.Request.Context(), "PutTablePolicy", putReq, nil); err != nil {
writeS3TablesError(c, err)
return
@@ -869,7 +910,11 @@ func (s *AdminServer) GetS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
getReq := &s3tables.GetTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
var resp s3tables.GetTablePolicyResponse
if err := s.executeS3TablesOperation(c.Request.Context(), "GetTablePolicy", getReq, &resp); err != nil {
writeS3TablesError(c, err)
@@ -886,7 +931,11 @@ func (s *AdminServer) DeleteS3TablesTablePolicy(c *gin.Context) {
c.JSON(400, gin.H{"error": "bucket, namespace, and name query parameters are required"})
return
}
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: []string{namespace}, Name: name}
namespaceParts, ok := s.parseNamespaceFromGin(c, namespace)
if !ok {
return
}
deleteReq := &s3tables.DeleteTablePolicyRequest{TableBucketARN: bucketArn, Namespace: namespaceParts, Name: name}
if err := s.executeS3TablesOperation(c.Request.Context(), "DeleteTablePolicy", deleteReq, nil); err != nil {
writeS3TablesError(c, err)
return
+20 -13
View File
@@ -725,20 +725,27 @@ function s3TablesNamespaceNameError(name) {
if (name.includes('/')) {
return "namespace name cannot contain '/'";
}
if (!isLowercaseLetterOrDigit(name[0])) {
return 'Namespace name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(name[name.length - 1])) {
return 'Namespace name must end with a letter or digit';
}
for (const ch of name) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
const parts = name.split('.');
for (const part of parts) {
if (!part) {
return 'namespace levels cannot be empty';
}
if (!isLowercaseLetterOrDigit(part[0])) {
return 'Namespace name must start with a letter or digit';
}
if (!isLowercaseLetterOrDigit(part[part.length - 1])) {
return 'Namespace name must end with a letter or digit';
}
for (const ch of part) {
if (isLowercaseLetterOrDigit(ch) || ch === '_') {
continue;
}
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
}
if (part.startsWith('aws')) {
return "namespace name cannot start with reserved prefix 'aws'";
}
return "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
}
if (name.startsWith('aws')) {
return "namespace name cannot start with reserved prefix 'aws'";
}
return '';
}
+1 -1
View File
@@ -195,7 +195,7 @@ templ IcebergNamespaces(data dash.IcebergNamespacesData) {
<div class="mb-3">
<label for="icebergNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="icebergNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
</div>
</div>
<div class="modal-footer">
@@ -327,7 +327,7 @@ func IcebergNamespaces(data dash.IcebergNamespacesData) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\"> <input type=\"hidden\" id=\"icebergNamespaceCsrfToken\" name=\"csrf_token\"><div class=\"mb-3\"><label for=\"icebergNamespaceName\" class=\"form-label\">Namespace</label> <input type=\"text\" class=\"form-control\" id=\"icebergNamespaceName\" name=\"name\" placeholder=\"analytics\" required><div class=\"form-text\">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div></div></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\"><i class=\"fas fa-plus me-1\"></i>Create</button></div></form></div></div></div><script>\n\t\tdocument.addEventListener('DOMContentLoaded', function() {\n\t\t\tinitIcebergNamespaces();\n\t\t});\n\t</script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+48 -36
View File
@@ -154,14 +154,14 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="createS3TablesNamespaceForm">
<div class="modal-body">
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
<div class="mb-3">
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Nested namespaces are not supported.</div>
<div class="modal-body">
<input type="hidden" name="bucket_arn" value={ data.BucketARN }/>
<div class="mb-3">
<label for="s3tablesNamespaceName" class="form-label">Namespace</label>
<input type="text" class="form-control" id="s3tablesNamespaceName" name="name" placeholder="analytics" required/>
<div class="form-text">Use lowercase letters, numbers, and underscores. Use dots for nested namespaces (for example, analytics.daily).</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
@@ -235,41 +235,53 @@ templ S3TablesNamespaces(data dash.S3TablesNamespacesData) {
input.setCustomValidity('');
return '';
}
let message = '';
if (!name) {
message = 'Namespace name is required';
} else if (name.length < 1 || name.length > 255) {
message = 'Namespace name must be between 1 and 255 characters';
} else if (name === '.' || name === '..') {
message = "namespace name cannot be '.' or '..'";
} else if (name.includes('/')) {
message = "namespace name cannot contain '/'";
} else {
const start = name[0];
const end = name[name.length - 1];
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
if (!isStartValid) {
message = 'Namespace name must start with a letter or digit';
} else if (!isEndValid) {
message = 'Namespace name must end with a letter or digit';
let message = '';
if (!name) {
message = 'Namespace name is required';
} else if (name.length < 1 || name.length > 255) {
message = 'Namespace name must be between 1 and 255 characters';
} else if (name === '.' || name === '..') {
message = "namespace name cannot be '.' or '..'";
} else if (name.includes('/')) {
message = "namespace name cannot contain '/'";
} else {
for (const ch of name) {
const isLower = ch >= 'a' && ch <= 'z';
const isDigit = ch >= '0' && ch <= '9';
if (!(isLower || isDigit || ch === '_')) {
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
for (const part of name.split('.')) {
if (!part) {
message = 'namespace levels cannot be empty';
break;
}
const start = part[0];
const end = part[part.length - 1];
const isStartValid = (start >= 'a' && start <= 'z') || (start >= '0' && start <= '9');
const isEndValid = (end >= 'a' && end <= 'z') || (end >= '0' && end <= '9');
if (!isStartValid) {
message = 'Namespace name must start with a letter or digit';
break;
}
if (!isEndValid) {
message = 'Namespace name must end with a letter or digit';
break;
}
for (const ch of part) {
const isLower = ch >= 'a' && ch <= 'z';
const isDigit = ch >= '0' && ch <= '9';
if (!(isLower || isDigit || ch === '_')) {
message = "invalid namespace name: only 'a-z', '0-9', and '_' are allowed";
break;
}
}
if (message) {
break;
}
if (part.startsWith('aws')) {
message = "namespace name cannot start with reserved prefix 'aws'";
break;
}
}
if (!message && name.startsWith('aws')) {
message = "namespace name cannot start with reserved prefix 'aws'";
}
}
input.setCustomValidity(message);
return message;
}
input.setCustomValidity(message);
return message;
}
document.addEventListener('DOMContentLoaded', function() {
s3tablesNamespaceDeleteModalInstance = new bootstrap.Modal(document.getElementById('deleteS3TablesNamespaceModal'));
File diff suppressed because one or more lines are too long
+441 -186
View File
@@ -6,10 +6,13 @@ package iceberg
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path"
"strconv"
"strings"
"time"
@@ -271,6 +274,196 @@ func (s *Server) saveMetadataFile(ctx context.Context, bucketName, tablePath, me
})
}
func (s *Server) deleteMetadataFile(ctx context.Context, bucketName, tablePath, metadataFileName string) error {
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
metadataDir := path.Join(s3tables.TablesPath, bucketName)
if tablePath != "" {
metadataDir = path.Join(metadataDir, tablePath)
}
metadataDir = path.Join(metadataDir, "metadata")
return s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return filer_pb.DoRemove(opCtx, client, metadataDir, metadataFileName, true, false, true, false, nil)
})
}
type statisticsUpdate struct {
set *table.StatisticsFile
remove *int64
}
var ErrIncompleteSetStatistics = errors.New("set-statistics requires snapshot-id, statistics-path, file-size-in-bytes, and file-footer-size-in-bytes")
type commitAction struct {
Action string `json:"action"`
}
type setStatisticsUpdate struct {
Action string `json:"action"`
SnapshotID *int64 `json:"snapshot-id,omitempty"`
StatisticsPath string `json:"statistics-path,omitempty"`
FileSizeInBytes *int64 `json:"file-size-in-bytes,omitempty"`
FileFooterSizeInBytes *int64 `json:"file-footer-size-in-bytes,omitempty"`
KeyMetadata *string `json:"key-metadata,omitempty"`
BlobMetadata []table.BlobMetadata `json:"blob-metadata,omitempty"`
Statistics *table.StatisticsFile `json:"statistics,omitempty"`
}
func (u *setStatisticsUpdate) asStatisticsFile() (*table.StatisticsFile, error) {
if u.Statistics != nil {
if u.Statistics.BlobMetadata == nil {
u.Statistics.BlobMetadata = []table.BlobMetadata{}
}
return u.Statistics, nil
}
if u.SnapshotID == nil || u.StatisticsPath == "" || u.FileSizeInBytes == nil || u.FileFooterSizeInBytes == nil {
return nil, ErrIncompleteSetStatistics
}
stats := &table.StatisticsFile{
SnapshotID: *u.SnapshotID,
StatisticsPath: u.StatisticsPath,
FileSizeInBytes: *u.FileSizeInBytes,
FileFooterSizeInBytes: *u.FileFooterSizeInBytes,
KeyMetadata: u.KeyMetadata,
BlobMetadata: u.BlobMetadata,
}
if stats.BlobMetadata == nil {
stats.BlobMetadata = []table.BlobMetadata{}
}
return stats, nil
}
type removeStatisticsUpdate struct {
Action string `json:"action"`
SnapshotID int64 `json:"snapshot-id"`
}
func parseCommitUpdates(rawUpdates []json.RawMessage) (table.Updates, []statisticsUpdate, error) {
filtered := make([]json.RawMessage, 0, len(rawUpdates))
statisticsUpdates := make([]statisticsUpdate, 0)
for _, raw := range rawUpdates {
var action commitAction
if err := json.Unmarshal(raw, &action); err != nil {
return nil, nil, err
}
switch action.Action {
case "set-statistics":
var setUpdate setStatisticsUpdate
if err := json.Unmarshal(raw, &setUpdate); err != nil {
return nil, nil, err
}
stats, err := setUpdate.asStatisticsFile()
if err != nil {
return nil, nil, err
}
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{set: stats})
case "remove-statistics":
var removeUpdate removeStatisticsUpdate
if err := json.Unmarshal(raw, &removeUpdate); err != nil {
return nil, nil, err
}
snapshotID := removeUpdate.SnapshotID
statisticsUpdates = append(statisticsUpdates, statisticsUpdate{remove: &snapshotID})
default:
filtered = append(filtered, raw)
}
}
if len(filtered) == 0 {
return nil, statisticsUpdates, nil
}
data, err := json.Marshal(filtered)
if err != nil {
return nil, nil, err
}
var updates table.Updates
if err := json.Unmarshal(data, &updates); err != nil {
return nil, nil, err
}
return updates, statisticsUpdates, nil
}
func applyStatisticsUpdates(metadataBytes []byte, updates []statisticsUpdate) ([]byte, error) {
if len(updates) == 0 {
return metadataBytes, nil
}
var metadata map[string]json.RawMessage
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, err
}
var statistics []table.StatisticsFile
if rawStatistics, ok := metadata["statistics"]; ok && len(rawStatistics) > 0 {
if err := json.Unmarshal(rawStatistics, &statistics); err != nil {
return nil, err
}
}
statisticsBySnapshot := make(map[int64]table.StatisticsFile, len(statistics))
orderedSnapshotIDs := make([]int64, 0, len(statistics))
inOrder := make(map[int64]bool, len(statistics))
for _, stat := range statistics {
statisticsBySnapshot[stat.SnapshotID] = stat
if !inOrder[stat.SnapshotID] {
orderedSnapshotIDs = append(orderedSnapshotIDs, stat.SnapshotID)
inOrder[stat.SnapshotID] = true
}
}
for _, update := range updates {
if update.set != nil {
statisticsBySnapshot[update.set.SnapshotID] = *update.set
if !inOrder[update.set.SnapshotID] {
orderedSnapshotIDs = append(orderedSnapshotIDs, update.set.SnapshotID)
inOrder[update.set.SnapshotID] = true
}
continue
}
if update.remove != nil {
delete(statisticsBySnapshot, *update.remove)
}
}
statistics = make([]table.StatisticsFile, 0, len(statisticsBySnapshot))
for _, snapshotID := range orderedSnapshotIDs {
stat, ok := statisticsBySnapshot[snapshotID]
if !ok {
continue
}
statistics = append(statistics, stat)
}
if len(statistics) == 0 {
delete(metadata, "statistics")
} else {
data, err := json.Marshal(statistics)
if err != nil {
return nil, err
}
metadata["statistics"] = data
}
return json.Marshal(metadata)
}
func isS3TablesConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, s3tables.ErrVersionTokenMismatch) {
return true
}
var tableErr *s3tables.S3TablesError
return errors.As(err, &tableErr) && tableErr.Type == s3tables.ErrCodeConflict
}
// 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.
@@ -368,6 +561,57 @@ func buildTableBucketARN(bucketName string) string {
return arn
}
const (
defaultListPageSize = 1000
maxListPageSize = 1000
)
var errStageCreateUnsupported = errors.New("stage-create is not supported")
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
}
func validateCreateTableRequest(req CreateTableRequest) error {
if req.Name == "" {
return errors.New("table name is required")
}
if req.StageCreate {
return errStageCreateUnsupported
}
return nil
}
// handleConfig returns catalog configuration.
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -388,14 +632,21 @@ func (s *Server) handleListNamespaces(w http.ResponseWriter, r *http.Request) {
// 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
}
// Use S3 Tables manager to list namespaces
var resp s3tables.ListNamespacesResponse
req := &s3tables.ListNamespacesRequest{
TableBucketARN: bucketARN,
MaxNamespaces: 1000,
TableBucketARN: bucketARN,
ContinuationToken: pageToken,
MaxNamespaces: pageSize,
}
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "ListNamespaces", req, &resp, identityName)
})
@@ -413,7 +664,8 @@ func (s *Server) handleListNamespaces(w http.ResponseWriter, r *http.Request) {
}
result := ListNamespacesResponse{
Namespaces: namespaces,
NextPageToken: resp.ContinuationToken,
Namespaces: namespaces,
}
writeJSON(w, http.StatusOK, result)
}
@@ -441,36 +693,29 @@ func (s *Server) handleCreateNamespace(w http.ResponseWriter, r *http.Request) {
createReq := &s3tables.CreateNamespaceRequest{
TableBucketARN: bucketARN,
Namespace: req.Namespace,
Properties: normalizeNamespaceProperties(req.Properties),
}
var createResp s3tables.CreateNamespaceResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
glog.Errorf("Iceberg: handleCreateNamespace calling Execute with identityName=%s", identityName)
glog.V(2).Infof("Iceberg: handleCreateNamespace calling Execute with identityName=%s", identityName)
return s.tablesManager.Execute(r.Context(), mgrClient, "CreateNamespace", createReq, &createResp, identityName)
})
if err != nil {
glog.Errorf("Iceberg: handleCreateNamespace error: %v", err)
if strings.Contains(err.Error(), "already exists") {
writeError(w, http.StatusConflict, "AlreadyExistsException", err.Error())
return
}
glog.Infof("Iceberg: CreateNamespace error: %v", err)
glog.Errorf("Iceberg: CreateNamespace error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
return
}
// Standardize property initialization for consistency with GetNamespace
props := req.Properties
if props == nil {
props = make(map[string]string)
}
result := CreateNamespaceResponse{
Namespace: req.Namespace,
Properties: props,
Namespace: Namespace(createResp.Namespace),
Properties: normalizeNamespaceProperties(createResp.Properties),
}
writeJSON(w, http.StatusOK, result)
}
@@ -513,8 +758,8 @@ func (s *Server) handleGetNamespace(w http.ResponseWriter, r *http.Request) {
}
result := GetNamespaceResponse{
Namespace: namespace,
Properties: make(map[string]string),
Namespace: Namespace(getResp.Namespace),
Properties: normalizeNamespaceProperties(getResp.Properties),
}
writeJSON(w, http.StatusOK, result)
}
@@ -615,14 +860,21 @@ func (s *Server) handleListTables(w http.ResponseWriter, r *http.Request) {
// 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,
MaxTables: 1000,
TableBucketARN: bucketARN,
Namespace: namespace,
ContinuationToken: pageToken,
MaxTables: pageSize,
}
var listResp s3tables.ListTablesResponse
err := s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
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)
})
@@ -647,7 +899,8 @@ func (s *Server) handleListTables(w http.ResponseWriter, r *http.Request) {
}
result := ListTablesResponse{
Identifiers: identifiers,
NextPageToken: listResp.ContinuationToken,
Identifiers: identifiers,
}
writeJSON(w, http.StatusOK, result)
}
@@ -667,8 +920,12 @@ func (s *Server) handleCreateTable(w http.ResponseWriter, r *http.Request) {
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "BadRequestException", "Table name is required")
if err := validateCreateTableRequest(req); err != nil {
if errors.Is(err, errStageCreateUnsupported) {
writeError(w, http.StatusNotImplemented, "NotImplementedException", "stage-create is not supported; submit create without stage-create")
return
}
writeError(w, http.StatusBadRequest, "BadRequestException", err.Error())
return
}
@@ -994,7 +1251,8 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
// Extract identity from context
identityName := s3_constants.GetIdentityNameFromContext(r)
// Parse the commit request, skipping update actions not supported by iceberg-go.
// Parse commit request and keep statistics updates separate because iceberg-go v0.4.0
// does not decode set/remove-statistics update actions yet.
var raw struct {
Identifier *TableIdentifier `json:"identifier,omitempty"`
Requirements json.RawMessage `json:"requirements"`
@@ -1007,6 +1265,7 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
var req CommitTableRequest
req.Identifier = raw.Identifier
var statisticsUpdates []statisticsUpdate
if len(raw.Requirements) > 0 {
if err := json.Unmarshal(raw.Requirements, &req.Requirements); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid requirements: "+err.Error())
@@ -1014,178 +1273,174 @@ func (s *Server) handleUpdateTable(w http.ResponseWriter, r *http.Request) {
}
}
if len(raw.Updates) > 0 {
filtered := make([]json.RawMessage, 0, len(raw.Updates))
for _, update := range raw.Updates {
var action struct {
Action string `json:"action"`
}
if err := json.Unmarshal(update, &action); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid update: "+err.Error())
var err error
req.Updates, statisticsUpdates, err = parseCommitUpdates(raw.Updates)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid updates: "+err.Error())
return
}
}
maxCommitAttempts := 3
generatedLegacyUUID := uuid.New()
for attempt := 1; attempt <= maxCommitAttempts; attempt++ {
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
}
if action.Action == "set-statistics" {
glog.V(1).Infof("Iceberg: CommitTable GetTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
return
}
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s/%s", bucketName, encodeNamespace(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 = generatedLegacyUUID
}
var currentMetadata table.Metadata
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
glog.Errorf("Iceberg: Failed to parse current metadata for %s: %v", tableName, err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata")
return
}
} else {
currentMetadata = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
if currentMetadata == nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
return
}
for _, requirement := range req.Requirements {
if err := requirement.Validate(currentMetadata); err != nil {
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
return
}
}
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: "+err.Error())
return
}
for _, update := range req.Updates {
if err := update.Apply(builder); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
return
}
}
newMetadata, err := builder.Build()
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: "+err.Error())
return
}
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 {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
return
}
// iceberg-go does not currently support set/remove-statistics updates in MetadataBuilder.
// Patch the encoded metadata JSON and parse it back to keep the response object consistent.
metadataBytes, err = applyStatisticsUpdates(metadataBytes, statisticsUpdates)
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply statistics updates: "+err.Error())
return
}
newMetadata, err = table.ParseMetadataBytes(metadataBytes)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse committed metadata: "+err.Error())
return
}
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
return
}
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
VersionToken: getResp.VersionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataVersion: metadataVersion,
MetadataLocation: newMetadataLocation,
}
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateTable", updateReq, nil, identityName)
})
if err == nil {
result := CommitTableResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
}
writeJSON(w, http.StatusOK, result)
return
}
if isS3TablesConflict(err) {
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s on conflict: %v", newMetadataLocation, cleanupErr)
}
if attempt < maxCommitAttempts {
glog.V(1).Infof("Iceberg: CommitTable conflict for %s (attempt %d/%d), retrying", tableName, attempt, maxCommitAttempts)
jitter := time.Duration(rand.Int64N(int64(25 * time.Millisecond)))
time.Sleep(time.Duration(50*attempt)*time.Millisecond + jitter)
continue
}
filtered = append(filtered, update)
}
if len(filtered) > 0 {
updatesBytes, err := json.Marshal(filtered)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse updates: "+err.Error())
return
}
if err := json.Unmarshal(updatesBytes, &req.Updates); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Invalid updates: "+err.Error())
return
}
}
}
// First, load current table metadata
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))
writeError(w, http.StatusConflict, "CommitFailedException", "Version token mismatch")
return
}
glog.V(1).Infof("Iceberg: CommitTable GetTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", err.Error())
return
}
// Build the current metadata
location := tableLocationFromMetadataLocation(getResp.MetadataLocation)
if location == "" {
location = fmt.Sprintf("s3://%s/%s/%s", bucketName, encodeNamespace(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
if cleanupErr := s.deleteMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName); cleanupErr != nil {
glog.V(1).Infof("Iceberg: failed to cleanup metadata file %s after update failure: %v", newMetadataLocation, cleanupErr)
}
}
if tableUUID == uuid.Nil {
tableUUID = uuid.New()
}
var currentMetadata table.Metadata
if getResp.Metadata != nil && len(getResp.Metadata.FullMetadata) > 0 {
var err error
currentMetadata, err = table.ParseMetadataBytes(getResp.Metadata.FullMetadata)
if err != nil {
glog.Errorf("Iceberg: Failed to parse current metadata for %s: %v", tableName, err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to parse current metadata")
return
}
} else {
// Fallback for tables without persisted full metadata (legacy or error state)
currentMetadata = newTableMetadata(tableUUID, location, nil, nil, nil, nil)
}
if currentMetadata == nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to build current metadata")
return
}
// Validate all requirements against current metadata
for _, requirement := range req.Requirements {
if err := requirement.Validate(currentMetadata); err != nil {
writeError(w, http.StatusConflict, "CommitFailedException", "Requirement failed: "+err.Error())
return
}
}
// Apply updates using MetadataBuilder
builder, err := table.MetadataBuilderFromBase(currentMetadata, getResp.MetadataLocation)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to create metadata builder: "+err.Error())
return
}
for _, update := range req.Updates {
if err := update.Apply(builder); err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to apply update: "+err.Error())
return
}
}
// Build the new metadata
newMetadata, err := builder.Build()
if err != nil {
writeError(w, http.StatusBadRequest, "BadRequestException", "Failed to build new metadata: "+err.Error())
return
}
// Determine next metadata version
metadataVersion := getResp.MetadataVersion + 1
metadataFileName := fmt.Sprintf("v%d.metadata.json", metadataVersion)
newMetadataLocation := fmt.Sprintf("%s/metadata/%s", strings.TrimSuffix(location, "/"), metadataFileName)
// Serialize metadata to JSON
metadataBytes, err := json.Marshal(newMetadata)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to serialize metadata: "+err.Error())
return
}
// 1. Save metadata file to filer
metadataBucket, metadataPath, err := parseS3Location(location)
if err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Invalid table location: "+err.Error())
return
}
if err := s.saveMetadataFile(r.Context(), metadataBucket, metadataPath, metadataFileName, metadataBytes); err != nil {
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to save metadata file: "+err.Error())
return
}
// Persist the new metadata and update the table reference
updateReq := &s3tables.UpdateTableRequest{
TableBucketARN: bucketARN,
Namespace: namespace,
Name: tableName,
VersionToken: getResp.VersionToken,
Metadata: &s3tables.TableMetadata{
Iceberg: &s3tables.IcebergMetadata{
TableUUID: tableUUID.String(),
},
FullMetadata: metadataBytes,
},
MetadataVersion: metadataVersion,
MetadataLocation: newMetadataLocation,
}
err = s.filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
mgrClient := s3tables.NewManagerClient(client)
// 1. Write metadata file (this would normally be an S3 PutObject,
// but s3tables manager handles the metadata storage logic)
// For now, we assume s3tables.UpdateTable handles the reference update.
return s.tablesManager.Execute(r.Context(), mgrClient, "UpdateTable", updateReq, nil, identityName)
})
if err != nil {
glog.Errorf("Iceberg: CommitTable UpdateTable error: %v", err)
writeError(w, http.StatusInternalServerError, "InternalServerError", "Failed to commit table update: "+err.Error())
return
}
// Return the new metadata
result := CommitTableResponse{
MetadataLocation: newMetadataLocation,
Metadata: newMetadata,
}
writeJSON(w, http.StatusOK, result)
}
// newTableMetadata creates a new table.Metadata object with the given parameters.
@@ -0,0 +1,103 @@
package iceberg
import (
"encoding/json"
"errors"
"testing"
"github.com/apache/iceberg-go/table"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
)
func TestParseCommitUpdatesSeparatesStatistics(t *testing.T) {
raw := []json.RawMessage{
json.RawMessage(`{"action":"set-statistics","snapshot-id":10,"statistics-path":"s3://bucket/table/metadata/stats.puffin","file-size-in-bytes":100,"file-footer-size-in-bytes":20,"blob-metadata":[]}`),
json.RawMessage(`{"action":"set-properties","updates":{"k":"v"}}`),
}
updates, stats, err := parseCommitUpdates(raw)
if err != nil {
t.Fatalf("parseCommitUpdates() error = %v", err)
}
if len(stats) != 1 {
t.Fatalf("statistics updates = %d, want 1", len(stats))
}
if stats[0].set == nil || stats[0].set.SnapshotID != 10 {
t.Fatalf("unexpected statistics update: %#v", stats[0])
}
if len(updates) != 1 {
t.Fatalf("decoded updates = %d, want 1", len(updates))
}
}
func TestParseCommitUpdatesRejectsIncompleteSetStatistics(t *testing.T) {
raw := []json.RawMessage{
json.RawMessage(`{"action":"set-statistics","snapshot-id":10}`),
}
_, _, err := parseCommitUpdates(raw)
if err == nil {
t.Fatalf("parseCommitUpdates() expected error")
}
if !errors.Is(err, ErrIncompleteSetStatistics) {
t.Fatalf("parseCommitUpdates() error = %v, want ErrIncompleteSetStatistics", err)
}
}
func TestApplyStatisticsUpdatesUpsertAndRemove(t *testing.T) {
metadata := []byte(`{"format-version":2,"statistics":[{"snapshot-id":1,"statistics-path":"s3://bucket/stats-1.puffin","file-size-in-bytes":10,"file-footer-size-in-bytes":1,"blob-metadata":[]},{"snapshot-id":2,"statistics-path":"s3://bucket/stats-2.puffin","file-size-in-bytes":20,"file-footer-size-in-bytes":2,"blob-metadata":[]}]} `)
snapshotID := int64(2)
setUpdates := []statisticsUpdate{
{
set: &statisticsFileForTest,
},
{
remove: &snapshotID,
},
}
updated, err := applyStatisticsUpdates(metadata, setUpdates)
if err != nil {
t.Fatalf("applyStatisticsUpdates() error = %v", err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(updated, &decoded); err != nil {
t.Fatalf("json.Unmarshal(updated) error = %v", err)
}
var stats []map[string]any
if err := json.Unmarshal(decoded["statistics"], &stats); err != nil {
t.Fatalf("json.Unmarshal(statistics) error = %v", err)
}
if len(stats) != 1 {
t.Fatalf("statistics length = %d, want 1", len(stats))
}
if got := int64(stats[0]["snapshot-id"].(float64)); got != 1 {
t.Fatalf("remaining snapshot-id = %d, want 1", got)
}
if got := int64(stats[0]["file-size-in-bytes"].(float64)); got != 11 {
t.Fatalf("remaining file-size-in-bytes = %d, want 11", got)
}
}
var statisticsFileForTest = table.StatisticsFile{
SnapshotID: 1,
StatisticsPath: "s3://bucket/stats-1.puffin",
FileSizeInBytes: 11,
FileFooterSizeInBytes: 2,
BlobMetadata: []table.BlobMetadata{},
}
func TestIsS3TablesConflict(t *testing.T) {
if !isS3TablesConflict(s3tables.ErrVersionTokenMismatch) {
t.Fatalf("expected ErrVersionTokenMismatch to be conflict")
}
if !isS3TablesConflict(&s3tables.S3TablesError{Type: s3tables.ErrCodeConflict, Message: "Version token mismatch"}) {
t.Fatalf("expected S3Tables conflict error to be conflict")
}
if isS3TablesConflict(errors.New("other")) {
t.Fatalf("unexpected conflict for non-conflict error")
}
}
@@ -0,0 +1,27 @@
package iceberg
import (
"errors"
"testing"
)
func TestValidateCreateTableRequestRejectsStageCreate(t *testing.T) {
err := validateCreateTableRequest(CreateTableRequest{Name: "orders", StageCreate: true})
if !errors.Is(err, errStageCreateUnsupported) {
t.Fatalf("validateCreateTableRequest() error = %v, want errStageCreateUnsupported", err)
}
}
func TestValidateCreateTableRequestRequiresName(t *testing.T) {
err := validateCreateTableRequest(CreateTableRequest{})
if err == nil {
t.Fatalf("validateCreateTableRequest() expected error")
}
}
func TestValidateCreateTableRequestAcceptsStandardCreate(t *testing.T) {
err := validateCreateTableRequest(CreateTableRequest{Name: "orders"})
if err != nil {
t.Fatalf("validateCreateTableRequest() error = %v, want nil", err)
}
}
@@ -0,0 +1,29 @@
package iceberg
import "testing"
func TestNormalizeNamespacePropertiesNil(t *testing.T) {
properties := normalizeNamespaceProperties(nil)
if properties == nil {
t.Fatalf("normalizeNamespaceProperties(nil) returned nil map")
}
if len(properties) != 0 {
t.Fatalf("normalizeNamespaceProperties(nil) length = %d, want 0", len(properties))
}
}
func TestNormalizeNamespacePropertiesReturnsInputWhenSet(t *testing.T) {
input := map[string]string{
"owner": "analytics",
}
properties := normalizeNamespaceProperties(input)
if properties["owner"] != "analytics" {
t.Fatalf("normalized properties value = %q, want %q", properties["owner"], "analytics")
}
input["owner"] = "updated"
if properties["owner"] != "updated" {
t.Fatalf("normalizeNamespaceProperties should reuse the input map when non-nil")
}
}
@@ -0,0 +1,69 @@
package iceberg
import (
"net/http/httptest"
"testing"
)
func TestParsePaginationDefaultValues(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "" {
t.Fatalf("pageToken = %q, want empty", pageToken)
}
if pageSize != defaultListPageSize {
t.Fatalf("pageSize = %d, want %d", pageSize, defaultListPageSize)
}
}
func TestParsePaginationUsesCamelCaseParameters(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces?pageToken=abc&pageSize=25", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "abc" {
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
}
if pageSize != 25 {
t.Fatalf("pageSize = %d, want %d", pageSize, 25)
}
}
func TestParsePaginationSupportsHyphenatedFallback(t *testing.T) {
req := httptest.NewRequest("GET", "/v1/namespaces?page-token=abc&page-size=17", nil)
pageToken, pageSize, err := parsePagination(req)
if err != nil {
t.Fatalf("parsePagination() error = %v", err)
}
if pageToken != "abc" {
t.Fatalf("pageToken = %q, want %q", pageToken, "abc")
}
if pageSize != 17 {
t.Fatalf("pageSize = %d, want %d", pageSize, 17)
}
}
func TestParsePaginationRejectsInvalidPageSize(t *testing.T) {
testCases := []string{
"/v1/namespaces?pageSize=0",
"/v1/namespaces?pageSize=-1",
"/v1/namespaces?pageSize=foo",
"/v1/namespaces?pageSize=1001",
}
for _, rawURL := range testCases {
t.Run(rawURL, func(t *testing.T) {
req := httptest.NewRequest("GET", rawURL, nil)
if _, _, err := parsePagination(req); err == nil {
t.Fatalf("parsePagination() expected error for url %q", rawURL)
}
})
}
}
+28 -58
View File
@@ -231,11 +231,23 @@ func parseOptionalNamespace(r *http.Request, name string) []string {
if value == "" {
return nil
}
if _, err := s3tables.ValidateNamespace([]string{value}); err != nil {
parts, err := s3tables.ParseNamespace(value)
if err != nil {
glog.V(1).Infof("invalid namespace value for %s: %q: %v", name, value, err)
return nil
}
return []string{value}
return parts
}
func parseRequiredNamespacePathParam(r *http.Request, name string) ([]string, error) {
value, err := getDecodedPathParam(r, name)
if err != nil {
return nil, err
}
if value == "" {
return nil, fmt.Errorf("%s is required", name)
}
return s3tables.ParseNamespace(value)
}
// parseTagKeys handles tag key parsing from query parameters.
@@ -352,19 +364,13 @@ func buildGetNamespaceRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
return &s3tables.GetNamespaceRequest{
TableBucketARN: tableBucketARN,
Namespace: []string{namespace},
Namespace: namespace,
}, nil
}
@@ -373,19 +379,13 @@ func buildDeleteNamespaceRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
return &s3tables.DeleteNamespaceRequest{
TableBucketARN: tableBucketARN,
Namespace: []string{namespace},
Namespace: namespace,
}, nil
}
@@ -398,18 +398,12 @@ func buildCreateTableRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
req.TableBucketARN = tableBucketARN
req.Namespace = []string{namespace}
req.Namespace = namespace
return &req, nil
}
@@ -453,16 +447,10 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -475,7 +463,7 @@ func buildDeleteTableRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.DeleteTableRequest{
TableBucketARN: tableBucketARN,
Namespace: []string{namespace},
Namespace: namespace,
Name: name,
VersionToken: r.URL.Query().Get("versionToken"),
}, nil
@@ -490,16 +478,10 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -511,7 +493,7 @@ func buildPutTablePolicyRequest(r *http.Request) (interface{}, error) {
return nil, err
}
req.TableBucketARN = tableBucketARN
req.Namespace = []string{namespace}
req.Namespace = namespace
req.Name = name
return &req, nil
}
@@ -521,16 +503,10 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -543,7 +519,7 @@ func buildGetTablePolicyRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.GetTablePolicyRequest{
TableBucketARN: tableBucketARN,
Namespace: []string{namespace},
Namespace: namespace,
Name: name,
}, nil
}
@@ -553,16 +529,10 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) {
if err != nil {
return nil, err
}
namespace, err := getDecodedPathParam(r, "namespace")
namespace, err := parseRequiredNamespacePathParam(r, "namespace")
if err != nil {
return nil, err
}
if namespace == "" {
return nil, fmt.Errorf("namespace is required")
}
if _, err := s3tables.ValidateNamespace([]string{namespace}); err != nil {
return nil, err
}
name, err := getDecodedPathParam(r, "name")
if err != nil {
return nil, err
@@ -575,7 +545,7 @@ func buildDeleteTablePolicyRequest(r *http.Request) (interface{}, error) {
}
return &s3tables.DeleteTablePolicyRequest{
TableBucketARN: tableBucketARN,
Namespace: []string{namespace},
Namespace: namespace,
Name: name,
}, nil
}
+3
View File
@@ -147,6 +147,7 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
Namespace: req.Namespace,
CreatedAt: now,
OwnerAccountID: bucketMetadata.OwnerAccountID,
Properties: req.Properties,
}
metadataBytes, err := json.Marshal(metadata)
@@ -177,6 +178,7 @@ func (h *S3TablesHandler) handleCreateNamespace(w http.ResponseWriter, r *http.R
resp := &CreateNamespaceResponse{
Namespace: req.Namespace,
TableBucketARN: req.TableBucketARN,
Properties: req.Properties,
}
h.writeJSON(w, http.StatusOK, resp)
@@ -265,6 +267,7 @@ func (h *S3TablesHandler) handleGetNamespace(w http.ResponseWriter, r *http.Requ
Namespace: metadata.Namespace,
CreatedAt: metadata.CreatedAt,
OwnerAccountID: metadata.OwnerAccountID,
Properties: metadata.Properties,
}
h.writeJSON(w, http.StatusOK, resp)
+2 -2
View File
@@ -408,7 +408,7 @@ func (h *S3TablesHandler) handleGetTable(w http.ResponseWriter, r *http.Request,
resp := &GetTableResponse{
Name: metadata.Name,
TableARN: tableARN,
Namespace: []string{metadata.Namespace},
Namespace: expandNamespace(metadata.Namespace),
Format: metadata.Format,
CreatedAt: metadata.CreatedAt,
ModifiedAt: metadata.ModifiedAt,
@@ -683,7 +683,7 @@ func (h *S3TablesHandler) listTablesWithClient(r *http.Request, client filer_pb.
tables = append(tables, TableSummary{
Name: entry.Entry.Name,
TableARN: tableARN,
Namespace: []string{namespaceName},
Namespace: expandNamespace(namespaceName),
CreatedAt: metadata.CreatedAt,
ModifiedAt: metadata.ModifiedAt,
})
+14 -10
View File
@@ -77,19 +77,22 @@ type DeleteTableBucketPolicyRequest struct {
// Namespace types
type Namespace struct {
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Properties map[string]string `json:"properties,omitempty"`
}
type CreateNamespaceRequest struct {
TableBucketARN string `json:"tableBucketARN"`
Namespace []string `json:"namespace"`
TableBucketARN string `json:"tableBucketARN"`
Namespace []string `json:"namespace"`
Properties map[string]string `json:"properties,omitempty"`
}
type CreateNamespaceResponse struct {
Namespace []string `json:"namespace"`
TableBucketARN string `json:"tableBucketARN"`
Namespace []string `json:"namespace"`
TableBucketARN string `json:"tableBucketARN"`
Properties map[string]string `json:"properties,omitempty"`
}
type GetNamespaceRequest struct {
@@ -98,9 +101,10 @@ type GetNamespaceRequest struct {
}
type GetNamespaceResponse struct {
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Properties map[string]string `json:"properties,omitempty"`
}
type ListNamespacesRequest struct {
+62 -25
View File
@@ -15,7 +15,7 @@ import (
const (
bucketNamePatternStr = `[a-z0-9-]+`
tableNamespacePatternStr = `[a-z0-9_]+`
tableNamespacePatternStr = `[a-z0-9_.]+`
tableNamePatternStr = `[a-z0-9_]+`
)
@@ -54,7 +54,6 @@ func ParseBucketNameFromARN(arn string) (string, error) {
// parseTableFromARN extracts bucket name, namespace, and table name from ARN
// ARN format: arn:aws:s3tables:{region}:{account}:bucket/{bucket-name}/table/{namespace}/{table-name}
func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err error) {
// Updated regex to align with namespace validation (single-segment)
matches := tableARNPattern.FindStringSubmatch(arn)
if len(matches) != 4 {
return "", "", "", fmt.Errorf("invalid table ARN: %s", arn)
@@ -66,9 +65,7 @@ func parseTableFromARN(arn string) (bucketName, namespace, tableName string, err
return "", "", "", fmt.Errorf("invalid bucket name in ARN: %v", err)
}
// Namespace is already constrained by the regex; validate it directly.
namespace = matches[2]
_, err = validateNamespace([]string{namespace})
namespace, err = validateNamespace([]string{matches[2]})
if err != nil {
return "", "", "", fmt.Errorf("invalid namespace in ARN: %v", err)
}
@@ -131,9 +128,10 @@ type tableBucketMetadata struct {
// namespaceMetadata stores metadata for a namespace
type namespaceMetadata struct {
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Namespace []string `json:"namespace"`
CreatedAt time.Time `json:"createdAt"`
OwnerAccountID string `json:"ownerAccountId"`
Properties map[string]string `json:"properties,omitempty"`
}
// tableMetadataInternal stores metadata for a table
@@ -326,35 +324,27 @@ func splitPath(p string) (dir, name string) {
return
}
// validateNamespace validates that the namespace provided is supported (single-level)
func validateNamespace(namespace []string) (string, error) {
if len(namespace) == 0 {
return "", fmt.Errorf("namespace is required")
}
if len(namespace) > 1 {
return "", fmt.Errorf("multi-level namespaces are not supported")
}
name := namespace[0]
func validateNamespacePart(name string) error {
if len(name) < 1 || len(name) > 255 {
return "", fmt.Errorf("namespace name must be between 1 and 255 characters")
return fmt.Errorf("namespace name must be between 1 and 255 characters")
}
// Prevent path traversal and multi-segment paths
if name == "." || name == ".." {
return "", fmt.Errorf("namespace name cannot be '.' or '..'")
return fmt.Errorf("namespace name cannot be '.' or '..'")
}
if strings.Contains(name, "/") {
return "", fmt.Errorf("namespace name cannot contain '/'")
return fmt.Errorf("namespace name cannot contain '/'")
}
// Must start and end with a letter or digit
start := name[0]
end := name[len(name)-1]
if !((start >= 'a' && start <= 'z') || (start >= '0' && start <= '9')) {
return "", fmt.Errorf("namespace name must start with a letter or digit")
return fmt.Errorf("namespace name must start with a letter or digit")
}
if !((end >= 'a' && end <= 'z') || (end >= '0' && end <= '9')) {
return "", fmt.Errorf("namespace name must end with a letter or digit")
return fmt.Errorf("namespace name must end with a letter or digit")
}
// Allowed characters: a-z, 0-9, _
@@ -362,15 +352,46 @@ func validateNamespace(namespace []string) (string, error) {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' {
continue
}
return "", fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed")
return fmt.Errorf("invalid namespace name: only 'a-z', '0-9', and '_' are allowed")
}
// Reserved prefix
if strings.HasPrefix(name, "aws") {
return "", fmt.Errorf("namespace name cannot start with reserved prefix 'aws'")
return fmt.Errorf("namespace name cannot start with reserved prefix 'aws'")
}
return name, nil
return nil
}
func normalizeNamespace(namespace []string) ([]string, error) {
if len(namespace) == 0 {
return nil, fmt.Errorf("namespace is required")
}
parts := namespace
if len(namespace) == 1 {
parts = strings.Split(namespace[0], ".")
}
normalized := make([]string, 0, len(parts))
for _, part := range parts {
if err := validateNamespacePart(part); err != nil {
return nil, err
}
normalized = append(normalized, part)
}
return normalized, nil
}
// validateNamespace validates namespace identifiers and returns an internal namespace key.
// A single dotted namespace value is interpreted as multi-level namespace for compatibility
// with path-style APIs, for example "analytics.daily" => ["analytics", "daily"].
func validateNamespace(namespace []string) (string, error) {
parts, err := normalizeNamespace(namespace)
if err != nil {
return "", err
}
return flattenNamespace(parts), nil
}
// ValidateNamespace is a wrapper to validate namespace for other packages.
@@ -378,6 +399,11 @@ func ValidateNamespace(namespace []string) (string, error) {
return validateNamespace(namespace)
}
// ParseNamespace parses a namespace string into namespace parts.
func ParseNamespace(namespace string) ([]string, error) {
return normalizeNamespace([]string{namespace})
}
// validateTableName validates a table name
func validateTableName(name string) (string, error) {
if len(name) < 1 || len(name) > 255 {
@@ -415,3 +441,14 @@ func flattenNamespace(namespace []string) string {
}
return strings.Join(namespace, ".")
}
func expandNamespace(namespace string) []string {
if namespace == "" {
return nil
}
parts, err := ParseNamespace(namespace)
if err != nil {
return []string{namespace}
}
return parts
}
+186
View File
@@ -0,0 +1,186 @@
package s3tables
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestValidateNamespaceSupportsMultiLevel(t *testing.T) {
got, err := validateNamespace([]string{"analytics", "daily"})
if err != nil {
t.Fatalf("validateNamespace returned error: %v", err)
}
if got != "analytics.daily" {
t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily")
}
}
func TestValidateNamespaceSupportsDottedInput(t *testing.T) {
got, err := validateNamespace([]string{"analytics.daily"})
if err != nil {
t.Fatalf("validateNamespace returned error: %v", err)
}
if got != "analytics.daily" {
t.Fatalf("validateNamespace = %q, want %q", got, "analytics.daily")
}
}
func TestValidateNamespaceRejectsEmptyDottedSegment(t *testing.T) {
_, err := validateNamespace([]string{"analytics..daily"})
if err == nil {
t.Fatalf("expected validateNamespace to fail for empty dotted segment")
}
}
func TestParseNamespace(t *testing.T) {
tests := []struct {
name string
namespace string
want []string
wantErr bool
}{
{
name: "single level",
namespace: "analytics",
want: []string{"analytics"},
},
{
name: "multi level dotted",
namespace: "analytics.daily",
want: []string{"analytics", "daily"},
},
{
name: "invalid reserved prefix",
namespace: "analytics.awsprod",
wantErr: true,
},
{
name: "invalid empty segment",
namespace: "analytics..daily",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseNamespace(tt.namespace)
if tt.wantErr {
if err == nil {
t.Fatalf("ParseNamespace(%q) expected error", tt.namespace)
}
return
}
if err != nil {
t.Fatalf("ParseNamespace(%q) unexpected error: %v", tt.namespace, err)
}
if len(got) != len(tt.want) {
t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("ParseNamespace(%q) = %v, want %v", tt.namespace, got, tt.want)
}
}
})
}
}
func TestParseTableFromARNWithMultiLevelNamespace(t *testing.T) {
arn := "arn:aws:s3tables:us-east-1:123456789012:bucket/testbucket/table/analytics.daily/events"
bucket, namespace, table, err := parseTableFromARN(arn)
if err != nil {
t.Fatalf("parseTableFromARN returned error: %v", err)
}
if bucket != "testbucket" {
t.Fatalf("bucket = %q, want %q", bucket, "testbucket")
}
if namespace != "analytics.daily" {
t.Fatalf("namespace = %q, want %q", namespace, "analytics.daily")
}
if table != "events" {
t.Fatalf("table = %q, want %q", table, "events")
}
}
func TestBuildTableARNWithDottedNamespace(t *testing.T) {
arn, err := BuildTableARN("us-east-1", "123456789012", "testbucket", "analytics.daily", "events")
if err != nil {
t.Fatalf("BuildTableARN returned error: %v", err)
}
if !strings.Contains(arn, "/table/analytics.daily/events") {
t.Fatalf("BuildTableARN returned %q, missing normalized namespace/table path", arn)
}
}
func TestExpandNamespace(t *testing.T) {
got := expandNamespace("analytics.daily")
want := []string{"analytics", "daily"}
if len(got) != len(want) {
t.Fatalf("expandNamespace = %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("expandNamespace = %v, want %v", got, want)
}
}
}
func TestNamespaceMetadataPropertiesRoundTrip(t *testing.T) {
testCases := []struct {
name string
metadata namespaceMetadata
}{
{
name: "with properties",
metadata: namespaceMetadata{
Namespace: []string{"analytics"},
Properties: map[string]string{"owner": "finance"},
OwnerAccountID: "123456789012",
},
},
{
name: "nil properties",
metadata: namespaceMetadata{
Namespace: []string{"analytics"},
Properties: nil,
OwnerAccountID: "123456789012",
},
},
{
name: "empty properties",
metadata: namespaceMetadata{
Namespace: []string{"analytics"},
Properties: map[string]string{},
OwnerAccountID: "123456789012",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
data, err := json.Marshal(tc.metadata)
if err != nil {
t.Fatalf("json.Marshal(metadata) returned error: %v", err)
}
var decoded namespaceMetadata
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("json.Unmarshal(data) returned error: %v", err)
}
// Due to `omitempty`, nil and empty maps are unmarshaled as nil.
if len(tc.metadata.Properties) == 0 {
if decoded.Properties != nil {
t.Fatalf("expected nil properties for empty/nil input, got %v", decoded.Properties)
}
return
}
if !reflect.DeepEqual(decoded.Properties, tc.metadata.Properties) {
t.Fatalf("decoded.Properties = %v, want %v", decoded.Properties, tc.metadata.Properties)
}
})
}
}
@@ -54,4 +54,3 @@ func TestCheckEcVolumeStatusCountOnlyDataShards(t *testing.T) {
t.Fatalf("expected shardCount=3, got %d", shardCount)
}
}