mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-16 19:40:43 +02:00
* fix(s3tables/iceberg): make metadata spec-compliant and accept real-world manifest names
Two related issues prevent SeaweedFS S3 Tables from interoperating with
strict Iceberg clients (Java/Spark/Flink/Trino):
1. iceberg-go v0.5.0 serializes empty TableMetadata state by dropping
keys via `omitempty` on optional pointer/slice fields. The Iceberg
table spec, however, requires `current-snapshot-id`, `snapshots`,
`snapshot-log`, `metadata-log`, and `refs` to be present even when
empty (`current-snapshot-id` must be -1 for a table with no
snapshots). Java's TableMetadataParser uses JsonUtil.getLong on
`current-snapshot-id` and throws "Cannot parse missing long
current-snapshot-id" against responses produced by this server.
2. The Iceberg layout validator only accepts manifest filenames that
match Iceberg's internal naming (`{uuid}-m{n}.avro`,
`snap-{n}-{n}-{uuid}.avro`). Real writers — notably Flink's sink —
emit manifests like
`{flink-job-id}-{checkpoint}-{operator-id}-{n}.avro`, which the
validator rejects with 403, breaking INSERT commits.
Fixes:
* Add ensureMetadataSpecCompliance helper that backfills the five
spec-required empty-state fields when iceberg-go omits them or emits
explicit JSON null. Apply it on every code path that writes
v*.metadata.json to S3 or returns metadata to clients
(handlers_table create-table, handlers_commit, commit_helpers
create-on-commit, plus MarshalJSON on LoadTableResult and
CommitTableResponse). Real values from non-empty tables are never
overwritten.
* Add catch-all regex entries to metadataFilePatterns accepting any
*.avro / *.metadata.json filename composed of [A-Za-z0-9._-]. The
Iceberg spec does not mandate filename format; the strict patterns
remain for documentation. Metadata-directory subdirectory rejection
and the data-file path validation are unchanged.
No upstream dependencies are forked: iceberg-go stays at v0.5.0 and
go.mod is untouched. The compliance layer can be removed once upstream
emits spec-compliant output.
Tests (all pass under `go test -race`):
- metadata_compliance_test.go: 5 cases covering missing fields,
preserved real values, explicit null, invalid JSON, empty input.
- iceberg_layout_test.go: 3 groups (16 subtests) covering real-world
manifest names from Flink/Spark/Iceberg, security boundary
(subdirectories, bad extensions), and data-file regression.
* fix(s3tables/iceberg): preserve metadata key order and keep config field stable
Two small follow-ups on the spec-compliance fix:
* ensureMetadataSpecCompliance now splices missing keys in at the byte
level just before the closing brace, so iceberg-go's struct-declared
key order survives the backfill. The previous unmarshal/remarshal
through map[string]json.RawMessage silently alphabetized every key in
the document, which is spec-legal but breaks byte-equality fixtures
and any downstream hashing of the persisted metadata. The slower
remarshal path is kept for the rare explicit-null replacement case.
* LoadTableResult.MarshalJSON now serializes Config without omitempty,
matching the struct field tag. The custom marshaler had silently
flipped the tag to ,omitempty, which made the "config" key disappear
from the response whenever s3Endpoint was unset (since
buildFileIOConfig returned an empty but non-nil Properties map).
Tests:
- PreservesOriginalKeyOrder pins the byte-level output against
iceberg-go's emitted shape; would have caught the alphabetization
regression.
- EmptyObjectBackfilled covers the {} -> sentinels-only case (no
leading comma).
- AllPresentReturnsSameBytes confirms the no-op path returns input
bytes unchanged, with whitespace intact.
- iceberg_layout_test pins the catch-all $ anchor: metadata/file.avro.txt
must still be rejected.
* fix(s3tables/iceberg): guard ensureMetadataSpecCompliance against top-level null
json.Unmarshal of a JSON `null` literal succeeds but leaves the map nil.
The current byte-append path no-ops gracefully on this input, but the
slow remarshal path would panic with "assignment to entry in nil map"
if the input ever combined `null` with the explicit-null detection. Add
an explicit nil-map short-circuit so the safety property is obvious
from the source, and a test that pins the contract.
* test(s3tables/iceberg): assert full byte equality in AllPresentReturnsSameBytes
The prefix check only caught a missing "{\n " opener, so the test
would have passed even if the function silently reordered keys or
collapsed whitespace later in the document. Switch to a full string
comparison so any future regression in the no-op path is loud.
---------
Co-authored-by: Chris Lu <chris.lu@gmail.com>
163 lines
6.4 KiB
Go
163 lines
6.4 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestEnsureMetadataSpecCompliance_BackfillsMissingFields(t *testing.T) {
|
|
// Mirrors the real iceberg-go v0.5.0 output for a freshly created table:
|
|
// current-snapshot-id, snapshots, snapshot-log, metadata-log, refs all absent.
|
|
input := []byte(`{
|
|
"format-version": 2,
|
|
"table-uuid": "82e3eec4-3aee-414f-a444-94c03c641d20",
|
|
"location": "s3://s3table/default/t1",
|
|
"last-sequence-number": 0,
|
|
"last-updated-ms": 1779866785466,
|
|
"last-column-id": 2,
|
|
"current-schema-id": 0,
|
|
"default-spec-id": 0,
|
|
"last-partition-id": 999,
|
|
"default-sort-order-id": 0
|
|
}`)
|
|
|
|
out := ensureMetadataSpecCompliance(input)
|
|
|
|
var got map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &got); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
|
|
if v, ok := got["current-snapshot-id"]; !ok || string(v) != "-1" {
|
|
t.Errorf("current-snapshot-id missing or wrong: present=%v value=%s", ok, string(v))
|
|
}
|
|
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
|
|
v, ok := got[key]
|
|
if !ok || string(v) != "[]" {
|
|
t.Errorf("%s missing or wrong: present=%v value=%s", key, ok, string(v))
|
|
}
|
|
}
|
|
if v, ok := got["refs"]; !ok || string(v) != "{}" {
|
|
t.Errorf("refs missing or wrong: present=%v value=%s", ok, string(v))
|
|
}
|
|
}
|
|
|
|
func TestEnsureMetadataSpecCompliance_PreservesExistingFields(t *testing.T) {
|
|
// Real snapshot id and refs must not be overwritten by sentinels.
|
|
input := []byte(`{
|
|
"format-version": 2,
|
|
"current-snapshot-id": 9876543210,
|
|
"snapshots": [{"snapshot-id": 9876543210}],
|
|
"snapshot-log": [{"snapshot-id": 9876543210, "timestamp-ms": 1}],
|
|
"metadata-log": [{"metadata-file": "v1.metadata.json", "timestamp-ms": 1}],
|
|
"refs": {"main": {"snapshot-id": 9876543210, "type": "branch"}}
|
|
}`)
|
|
|
|
out := ensureMetadataSpecCompliance(input)
|
|
|
|
var got map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &got); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
if string(got["current-snapshot-id"]) != "9876543210" {
|
|
t.Errorf("real snapshot id was overwritten: %s", string(got["current-snapshot-id"]))
|
|
}
|
|
if string(got["snapshots"]) == "[]" {
|
|
t.Errorf("non-empty snapshots was overwritten with empty array")
|
|
}
|
|
}
|
|
|
|
func TestEnsureMetadataSpecCompliance_ReplacesExplicitNullsWithSentinels(t *testing.T) {
|
|
// Some writers emit explicit JSON null for unset values instead of omitting
|
|
// the key. Strict Iceberg clients reject these the same way as missing keys.
|
|
input := []byte(`{
|
|
"format-version": 2,
|
|
"current-snapshot-id": null,
|
|
"snapshots": null,
|
|
"snapshot-log": null,
|
|
"metadata-log": null,
|
|
"refs": null
|
|
}`)
|
|
|
|
out := ensureMetadataSpecCompliance(input)
|
|
|
|
var got map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &got); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
if string(got["current-snapshot-id"]) != "-1" {
|
|
t.Errorf("current-snapshot-id should be replaced with -1, got %s", string(got["current-snapshot-id"]))
|
|
}
|
|
for _, key := range []string{"snapshots", "snapshot-log", "metadata-log"} {
|
|
if string(got[key]) != "[]" {
|
|
t.Errorf("%s should be replaced with [], got %s", key, string(got[key]))
|
|
}
|
|
}
|
|
if string(got["refs"]) != "{}" {
|
|
t.Errorf("refs should be replaced with {}, got %s", string(got["refs"]))
|
|
}
|
|
}
|
|
|
|
func TestEnsureMetadataSpecCompliance_InvalidJSONReturnedUnchanged(t *testing.T) {
|
|
input := []byte(`{not valid json`)
|
|
out := ensureMetadataSpecCompliance(input)
|
|
if string(out) != string(input) {
|
|
t.Errorf("invalid JSON should be returned unchanged; got %s", string(out))
|
|
}
|
|
}
|
|
|
|
func TestEnsureMetadataSpecCompliance_EmptyInputReturnedUnchanged(t *testing.T) {
|
|
if out := ensureMetadataSpecCompliance(nil); out != nil {
|
|
t.Errorf("nil input should be returned unchanged, got %v", out)
|
|
}
|
|
if out := ensureMetadataSpecCompliance([]byte{}); len(out) != 0 {
|
|
t.Errorf("empty input should be returned unchanged, got %v", out)
|
|
}
|
|
// A top-level JSON null literal must not panic on the slow path.
|
|
if out := ensureMetadataSpecCompliance([]byte("null")); string(out) != "null" {
|
|
t.Errorf("top-level null should be returned unchanged, got %s", string(out))
|
|
}
|
|
}
|
|
|
|
// Original iceberg-go key ordering must survive the backfill: appended
|
|
// sentinels go at the end without disturbing prior fields. A map-based
|
|
// remarshal would have sorted everything alphabetically.
|
|
func TestEnsureMetadataSpecCompliance_PreservesOriginalKeyOrder(t *testing.T) {
|
|
// Compact JSON, keys in struct-declared order from iceberg-go.
|
|
input := []byte(`{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0}`)
|
|
|
|
out := ensureMetadataSpecCompliance(input)
|
|
|
|
// Prior keys keep their order, sentinels appended at the end.
|
|
want := `{"format-version":2,"table-uuid":"82e3eec4-3aee-414f-a444-94c03c641d20","location":"s3://x/t","last-sequence-number":0,"last-updated-ms":1,"last-column-id":2,"current-schema-id":0,"default-spec-id":0,"last-partition-id":999,"default-sort-order-id":0,"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
|
|
if string(out) != want {
|
|
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
|
|
}
|
|
|
|
// Sanity: still valid JSON.
|
|
var parsed map[string]json.RawMessage
|
|
if err := json.Unmarshal(out, &parsed); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
// An empty object {} must round-trip to a valid JSON object containing
|
|
// only the spec sentinels (no leading comma).
|
|
func TestEnsureMetadataSpecCompliance_EmptyObjectBackfilled(t *testing.T) {
|
|
out := ensureMetadataSpecCompliance([]byte(`{}`))
|
|
want := `{"current-snapshot-id":-1,"snapshots":[],"snapshot-log":[],"metadata-log":[],"refs":{}}`
|
|
if string(out) != want {
|
|
t.Errorf("unexpected output\n got: %s\nwant: %s", string(out), want)
|
|
}
|
|
}
|
|
|
|
// When all fields are already present, the original bytes must be returned
|
|
// untouched (no whitespace normalization, no key reordering).
|
|
func TestEnsureMetadataSpecCompliance_AllPresentReturnsSameBytes(t *testing.T) {
|
|
input := []byte("{\n \"current-snapshot-id\": 1,\n \"snapshots\": [],\n \"snapshot-log\": [],\n \"metadata-log\": [],\n \"refs\": {}\n}")
|
|
out := ensureMetadataSpecCompliance(input)
|
|
if string(out) != string(input) {
|
|
t.Errorf("expected original bytes returned unchanged\n got: %q\nwant: %q", string(out), string(input))
|
|
}
|
|
}
|