mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-30 08:50:48 +01:00
* iceberg: serve a registered table's metadata on load and commit RegisterTable saved only the metadata location, and loads and commits never read the file there. They served a placeholder with no schema, no snapshots and a new random table UUID on every load, so a registered table read as empty, an INSERT failed against the empty schema, and every other commit failed its assert-table-uuid requirement. Metadata uploaded through the S3 gateway, as a migrated table's is, is stored in chunks that registration did not read, so it registered an empty table. Read the file following its chunks, reject one that is not Iceberg metadata with a 400, and keep its content and table UUID in the record the way CreateTable does. Records registered before this change hold only the location. Loads and commits now read the file there, and the first commit stores it in the record under the file's table UUID. * iceberg: transactions read a location-only registration's metadata file * iceberg: cover transactions on a location-only registration * iceberg: refresh the default name mapping on transaction commits * iceberg: cover name-mapping refresh in transactions * iceberg: roll a transaction back to a registration's metadata file A transaction that failed after flipping a table registered before the record kept its metadata could not undo the flip. The rollback restored the record's prior metadata, which for such a table is only a location, and UpdateTable keeps the full metadata a flip stored when the restore sends none. Loads went on serving the change the client was told had failed, and a client retrying the transaction could apply it twice. Restore the metadata loads served from the file instead, under the file's table UUID. * iceberg: register oversized metadata by its location Registration sends the metadata file's content to the S3 Tables manager, which refuses a request over its 10MB body limit, so a table whose metadata is larger could no longer be registered at all. Register such a file by its location and table UUID alone; loads read it from that location, as they do for a record that never kept its metadata. Export the limit as s3tables.MaxRequestBodySize so the catalog can check against it. A commit to such a table still sends the whole metadata to UpdateTable and stays over the limit. * iceberg: commit oversized metadata by its location * iceberg: let maintenance follow a location-only table record * admin: read a table's metadata file when the record keeps none * iceberg: bound the metadata file a location-only record reads * iceberg: bound only chunked metadata reads in the worker Inline content is what the catalog itself wrote and the REST load path accepts it at any size, so rejecting it in loadMetadata stranded tables over 64 MiB from every maintenance scan. Cap only the chunked read, where the recorded location can name any object in the bucket. --------- Co-authored-by: chrislusf <chrislusf@users.noreply.github.com>
393 lines
15 KiB
Go
393 lines
15 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/apache/iceberg-go/table"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
func TestValidateCreateTableRequestRequiresName(t *testing.T) {
|
|
err := validateCreateTableRequest(CreateTableRequest{})
|
|
if !errors.Is(err, errTableNameRequired) {
|
|
t.Fatalf("validateCreateTableRequest() error = %v, want errTableNameRequired", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateCreateTableRequestAcceptsWithName(t *testing.T) {
|
|
err := validateCreateTableRequest(CreateTableRequest{Name: "orders"})
|
|
if err != nil {
|
|
t.Fatalf("validateCreateTableRequest() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestIsStageCreateEnabledDefaultsToTrue(t *testing.T) {
|
|
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", "")
|
|
if !isStageCreateEnabled() {
|
|
t.Fatalf("isStageCreateEnabled() = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestIsStageCreateEnabledFalseValues(t *testing.T) {
|
|
falseValues := []string{"0", "false", "FALSE", "no", "off"}
|
|
for _, value := range falseValues {
|
|
t.Setenv("ICEBERG_ENABLE_STAGE_CREATE", value)
|
|
if isStageCreateEnabled() {
|
|
t.Fatalf("isStageCreateEnabled() = true for value %q, want false", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustParseSchema(t *testing.T, raw string) *iceberg.Schema {
|
|
t.Helper()
|
|
var schema iceberg.Schema
|
|
if err := json.Unmarshal([]byte(raw), &schema); err != nil {
|
|
t.Fatalf("parse schema: %v", err)
|
|
}
|
|
return &schema
|
|
}
|
|
|
|
const variantSchema = `{"type":"struct","schema-id":0,"fields":[
|
|
{"id":1,"name":"id","required":true,"type":"long"},
|
|
{"id":2,"name":"payload","required":false,"type":"variant"}]}`
|
|
|
|
// A v3-only column type without format-version 3 is the client's mistake, and
|
|
// the reason has to travel back to them rather than only into the server log.
|
|
func TestNewTableMetadataRejectsV3TypeBelowV3(t *testing.T) {
|
|
_, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil, nil)
|
|
if err == nil {
|
|
t.Fatal("newTableMetadata() error = nil, want invalid schema")
|
|
}
|
|
if !errors.Is(err, iceberg.ErrInvalidSchema) {
|
|
t.Fatalf("newTableMetadata() error = %v, want ErrInvalidSchema", err)
|
|
}
|
|
|
|
// writeManagerError turns this into a 400; see TestWriteManagerError.
|
|
if !strings.Contains(err.Error(), "variant is not supported until v3") {
|
|
t.Errorf("error = %q, want the underlying reason", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestNewTableMetadataAcceptsV3TypeAtV3(t *testing.T) {
|
|
metadata, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", mustParseSchema(t, variantSchema), nil, nil,
|
|
iceberg.Properties{"format-version": "3"})
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata() error = %v, want nil", err)
|
|
}
|
|
if got := metadata.Version(); got != 3 {
|
|
t.Fatalf("metadata.Version() = %d, want 3", got)
|
|
}
|
|
if _, found := metadata.CurrentSchema().FindFieldByName("payload"); !found {
|
|
t.Error("variant field missing from stored schema")
|
|
}
|
|
}
|
|
|
|
// A LoadTable response must never carry nil metadata: it serializes as
|
|
// "metadata":null under HTTP 200, which no Iceberg client can parse.
|
|
func TestBuildLoadTableResultNeverReturnsNilMetadata(t *testing.T) {
|
|
cases := map[string]s3tables.GetTableResponse{
|
|
"no stored metadata": {MetadataLocation: "s3://bkt/ns/t/metadata/v1.metadata.json"},
|
|
"empty full metadata": {Metadata: &s3tables.TableMetadata{}},
|
|
"unparseable metadata": {Metadata: &s3tables.TableMetadata{FullMetadata: json.RawMessage(`{"nope":`)}},
|
|
}
|
|
for name, getResp := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodGet, "/v1/namespaces/ns/tables/t", nil)
|
|
// The filer is empty, so a record holding only a location has no
|
|
// file behind it to serve.
|
|
result, err := (&Server{filerClient: newMemFiler()}).buildLoadTableResult(r, getResp, "bkt", []string{"ns"}, "t")
|
|
if err != nil {
|
|
t.Fatalf("buildLoadTableResult() error = %v, want nil", err)
|
|
}
|
|
if result.Metadata == nil {
|
|
t.Fatal("buildLoadTableResult() returned nil metadata with no error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newCreateTableRequest(t *testing.T, bucket, namespace, body, identity string) *http.Request {
|
|
t.Helper()
|
|
r := httptest.NewRequest(http.MethodPost, "/v1/"+bucket+"/namespaces/"+namespace+"/tables", strings.NewReader(body))
|
|
r = mux.SetURLVars(r, map[string]string{"prefix": bucket, "namespace": namespace})
|
|
return r.WithContext(s3_constants.SetIdentityNameInContext(r.Context(), identity))
|
|
}
|
|
|
|
// A caller who may not create the table must be refused before anything is
|
|
// written: both branches of CreateTable write to the bucket, and stage-create
|
|
// never reaches the registration that carries the authorization.
|
|
func TestCreateTableDeniedBeforeAnyWrite(t *testing.T) {
|
|
const bucket = "warehouse"
|
|
for name, body := range map[string]string{
|
|
"stage-create": `{"name":"quarterly_reports","stage-create":true}`,
|
|
"create": `{"name":"quarterly_reports"}`,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, bucket, "finance", "alice")
|
|
s := NewServer(fc, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", body, "mallory"))
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String())
|
|
}
|
|
for p, entry := range fc.entries {
|
|
if !entry.IsDirectory && strings.Contains(p, "quarterly_reports") {
|
|
t.Errorf("unauthorized caller wrote %s", p)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The namespace owner still gets the staged metadata and marker stage-create
|
|
// exists to leave behind.
|
|
func TestStageCreateWritesStagedFilesForOwner(t *testing.T) {
|
|
const bucket = "warehouse"
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, bucket, "finance", "alice")
|
|
s := NewServer(fc, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "alice"))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
staged := 0
|
|
for p, entry := range fc.entries {
|
|
if !entry.IsDirectory && strings.Contains(p, stageCreateMarkerDirName) {
|
|
staged++
|
|
}
|
|
}
|
|
if staged != 2 {
|
|
t.Fatalf("staged files = %d, want the metadata file and its marker", staged)
|
|
}
|
|
if _, ok := fc.entries[s3tables.GetTablePath(bucket, "finance", "quarterly_reports")]; ok {
|
|
t.Error("stage-create registered the table")
|
|
}
|
|
}
|
|
|
|
// A caller allowed by the actions on its own identity, rather than by ownership
|
|
// or a resource policy, has to survive the hop into the s3tables manager: the
|
|
// gate must not refuse what the create behind it would allow.
|
|
func TestCreateTableAllowedByIdentityActions(t *testing.T) {
|
|
const bucket = "warehouse"
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, bucket, "finance", "alice")
|
|
s := NewServer(fc, nil)
|
|
|
|
r := newCreateTableRequest(t, bucket, "finance", `{"name":"quarterly_reports","stage-create":true}`, "mallory")
|
|
r = r.WithContext(s3_constants.SetIdentityInContext(r.Context(), &struct {
|
|
Name string
|
|
Actions []string
|
|
}{Name: "mallory", Actions: []string{"s3tables:CreateTable"}}))
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleCreateTable(w, r)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// Apache Spark numbers the columns of a new table from 0
|
|
// and leaves assigning fresh IDs to the catalog. This is the schema Spark
|
|
// sent for CREATE TABLE t (id BIGINT, v STRING, ts TIMESTAMP).
|
|
const zeroBasedSchema = `{"type":"struct","schema-id":0,"fields":[
|
|
{"id":0,"name":"id","required":false,"type":"long"},
|
|
{"id":1,"name":"v","required":false,"type":"string"},
|
|
{"id":2,"name":"ts","required":false,"type":"timestamptz"}]}`
|
|
|
|
// The same table as PyIceberg sends it, numbered from 1.
|
|
const oneBasedSchema = `{"type":"struct","schema-id":0,"fields":[
|
|
{"id":1,"name":"id","required":false,"type":"long"},
|
|
{"id":2,"name":"v","required":false,"type":"string"},
|
|
{"id":3,"name":"ts","required":false,"type":"timestamptz"}]}`
|
|
|
|
func createTableBody(name, schema, spec, order string) string {
|
|
parts := []string{`"name":"` + name + `"`, `"schema":` + schema}
|
|
if spec != "" {
|
|
parts = append(parts, `"partition-spec":`+spec)
|
|
}
|
|
if order != "" {
|
|
parts = append(parts, `"write-order":`+order)
|
|
}
|
|
return "{" + strings.Join(parts, ",") + "}"
|
|
}
|
|
|
|
type partitionWant struct{ name, transform, column string }
|
|
|
|
type sourceIDCase struct {
|
|
name string
|
|
// spec and order reference the zero-based schema; oneBasedSpec and
|
|
// oneBasedOrder are the same definitions against oneBasedSchema.
|
|
spec, order, oneBasedSpec, oneBasedOrder string
|
|
wantPartitions []partitionWant
|
|
wantSortColumns []string
|
|
}
|
|
|
|
var sourceIDCases = []sourceIDCase{
|
|
{
|
|
// The body Spark sent for PARTITIONED BY (bucket(4, id)).
|
|
name: "bucket on the first column",
|
|
spec: `{"spec-id":0,"fields":[{"name":"id_bucket","transform":"bucket[4]","source-id":0,"field-id":1000}]}`,
|
|
oneBasedSpec: `{"spec-id":0,"fields":[{"name":"id_bucket","transform":"bucket[4]","source-id":1,"field-id":1000}]}`,
|
|
wantPartitions: []partitionWant{{"id_bucket", "bucket[4]", "id"}},
|
|
},
|
|
{
|
|
name: "identity on the first column and day on another",
|
|
spec: `{"spec-id":0,"fields":[{"name":"id","transform":"identity","source-id":0,"field-id":1000},
|
|
{"name":"ts_day","transform":"day","source-id":2,"field-id":1001}]}`,
|
|
oneBasedSpec: `{"spec-id":0,"fields":[{"name":"id","transform":"identity","source-id":1,"field-id":1000},
|
|
{"name":"ts_day","transform":"day","source-id":3,"field-id":1001}]}`,
|
|
wantPartitions: []partitionWant{{"id", "identity", "id"}, {"ts_day", "day", "ts"}},
|
|
},
|
|
{
|
|
name: "write order on the first column",
|
|
order: `{"order-id":1,"fields":[{"source-id":0,"transform":"identity","direction":"asc","null-order":"nulls-first"}]}`,
|
|
oneBasedOrder: `{"order-id":1,"fields":[{"source-id":1,"transform":"identity","direction":"asc","null-order":"nulls-first"}]}`,
|
|
wantSortColumns: []string{"id"},
|
|
},
|
|
}
|
|
|
|
func metadataFromBody(t *testing.T, body string) table.Metadata {
|
|
t.Helper()
|
|
var req CreateTableRequest
|
|
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
t.Fatalf("decode create-table request: %v", err)
|
|
}
|
|
metadata, err := newTableMetadata(uuid.New(), "s3://bkt/ns/t", req.Schema, req.partitionSpec(), req.writeOrder(), req.Properties)
|
|
if err != nil {
|
|
t.Fatalf("newTableMetadata() error = %v", err)
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
func checkResolvedSourceIDs(t *testing.T, metadata table.Metadata, tc sourceIDCase) {
|
|
t.Helper()
|
|
schema := metadata.CurrentSchema()
|
|
if id, ok := schema.FindFieldByName("id"); !ok || id.ID != 1 {
|
|
t.Errorf("first column id = %+v, want fresh field ID 1", id)
|
|
}
|
|
|
|
spec := metadata.PartitionSpec()
|
|
var got []partitionWant
|
|
for _, field := range spec.Fields() {
|
|
column, _ := schema.FindColumnName(field.SourceID())
|
|
got = append(got, partitionWant{field.Name, field.Transform.String(), column})
|
|
}
|
|
if len(got) != len(tc.wantPartitions) {
|
|
t.Fatalf("partition fields = %+v, want %+v", got, tc.wantPartitions)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.wantPartitions[i] {
|
|
t.Errorf("partition field %d = %+v, want %+v", i, got[i], tc.wantPartitions[i])
|
|
}
|
|
}
|
|
|
|
var sortColumns []string
|
|
for _, field := range metadata.SortOrder().Fields() {
|
|
column, _ := schema.FindColumnName(field.SourceIDs[0])
|
|
sortColumns = append(sortColumns, column)
|
|
}
|
|
if strings.Join(sortColumns, ",") != strings.Join(tc.wantSortColumns, ",") {
|
|
t.Errorf("sort columns = %v, want %v", sortColumns, tc.wantSortColumns)
|
|
}
|
|
}
|
|
|
|
// A bound PartitionSpec or SortOrder refuses source-id 0, which rejected every
|
|
// Spark CREATE TABLE partitioned or sorted by its first column. newTableMetadata
|
|
// resolves the client's placeholders through the request schema, so a
|
|
// zero-based request yields the same table as the one-based request PyIceberg
|
|
// sends, which the bound types always accepted.
|
|
func TestNewTableMetadataResolvesZeroBasedSourceIDs(t *testing.T) {
|
|
marshal := func(t *testing.T, metadata table.Metadata) string {
|
|
t.Helper()
|
|
spec := metadata.PartitionSpec()
|
|
out, err := json.Marshal([]any{metadata.CurrentSchema(), &spec, metadata.SortOrder()})
|
|
if err != nil {
|
|
t.Fatalf("marshal metadata parts: %v", err)
|
|
}
|
|
return string(out)
|
|
}
|
|
for _, tc := range sourceIDCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
zero := metadataFromBody(t, createTableBody("t", zeroBasedSchema, tc.spec, tc.order))
|
|
checkResolvedSourceIDs(t, zero, tc)
|
|
|
|
one := metadataFromBody(t, createTableBody("t", oneBasedSchema, tc.oneBasedSpec, tc.oneBasedOrder))
|
|
if got, want := marshal(t, zero), marshal(t, one); got != want {
|
|
t.Errorf("zero-based request produced\n%s\nwant (one-based)\n%s", got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Through the handler, the request Spark sends is accepted and answered with
|
|
// the resolved spec.
|
|
func TestCreateTablePartitionedByFirstColumn(t *testing.T) {
|
|
const bucket = "warehouse"
|
|
tc := sourceIDCases[1]
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, bucket, "finance", "alice")
|
|
s := NewServer(fc, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
body := createTableBody("events", zeroBasedSchema, tc.spec, "")
|
|
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", body, "alice"))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String())
|
|
}
|
|
|
|
var result struct {
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
metadata, err := table.ParseMetadataBytes(result.Metadata)
|
|
if err != nil {
|
|
t.Fatalf("parse response metadata: %v", err)
|
|
}
|
|
checkResolvedSourceIDs(t, metadata, tc)
|
|
}
|
|
|
|
// A spec or order naming a column the request schema lacks is the client's
|
|
// error: 400, not 500.
|
|
func TestCreateTableRejectsUnresolvableSourceID(t *testing.T) {
|
|
const bucket = "warehouse"
|
|
for name, body := range map[string]string{
|
|
"partition spec": createTableBody("orders", zeroBasedSchema,
|
|
`{"spec-id":0,"fields":[{"name":"x","transform":"identity","source-id":42,"field-id":1000}]}`, ""),
|
|
"write order": createTableBody("orders", zeroBasedSchema, "",
|
|
`{"order-id":1,"fields":[{"source-id":42,"transform":"identity","direction":"asc","null-order":"nulls-first"}]}`),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
fc := newMemFiler()
|
|
seedNamespace(fc, bucket, "finance", "alice")
|
|
s := NewServer(fc, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
s.handleCreateTable(w, newCreateTableRequest(t, bucket, "finance", body, "alice"))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d (body: %s)", w.Code, http.StatusBadRequest, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "BadRequestException") {
|
|
t.Errorf("body = %s, want a BadRequestException", w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|