laskoviymishka commented on code in PR #1988:
URL: https://github.com/apache/iceberg-go/pull/1988#discussion_r3966674061
##########
table/metadata.go:
##########
@@ -2053,24 +2058,27 @@ func assignMissingPartitionFieldIDsFromMetadata(b
[]byte, metadata map[string]js
return nil, err
}
field["field-id"] = rawFieldID
+ normalizedLastPartitionID = max(normalizedLastPartitionID,
lastAssignedID)
}
- if usesSpecList {
- rawSpecs, err := json.Marshal(specs)
- if err != nil {
- return nil, err
- }
- metadata["partition-specs"] = rawSpecs
- } else {
- rawFields, err := json.Marshal(specs[0].Fields)
- if err != nil {
- return nil, err
+ if len(missingFields) > 0 {
+ if usesSpecList {
+ rawSpecs, err := json.Marshal(specs)
+ if err != nil {
+ return nil, err
+ }
+ metadata["partition-specs"] = rawSpecs
+ } else {
+ rawFields, err := json.Marshal(specs[0].Fields)
+ if err != nil {
+ return nil, err
+ }
+ metadata["partition-spec"] = rawFields
}
- metadata["partition-spec"] = rawFields
}
if lastPartitionIDSet {
- rawLastPartitionID, err := json.Marshal(lastAssignedID)
+ rawLastPartitionID, err :=
json.Marshal(normalizedLastPartitionID)
Review Comment:
I think doing the repair at read time is the wrong layer here. Once we
rewrite last-partition-id from 999 to 1000, Metadata().LastPartitionSpecID()
returns 1000, and UpdateSpec.BuildUpdates (update_spec.go:190) feeds that
straight into AssertLastAssignedPartitionID. Against a REST/Java catalog that
still persists 999, that assertion is a guaranteed false 409, and because every
reload re-normalizes the same bytes, it never clears. So for the exact
stale-counter table this targets, spec evolution goes from silently allocating
a colliding id to permanently un-committable on REST.
Java only recomputes last-partition-id from specs when the field is absent;
present-but-stale is used verbatim, and the allocation floor is applied at
evolution time, not at parse. I'd move the max-field-id scan to the write side:
compute the floor as max(lastPartitionID, max field id across b.specs) in
MetadataBuilder.AddPartitionSpec before binding, and leave the persisted
counter untouched until a new spec actually builds. That keeps
AssertLastAssignedPartitionID(999) matching the catalog, still gives the new
field 1001 with no collision, and lets the server self-heal to 1001 on commit.
wdyt?
##########
table/metadata_preflight_test.go:
##########
@@ -85,6 +86,88 @@ func TestParseMetadataBytesAssignsMissingPartitionFieldIDs(t
*testing.T) {
}
}
+func TestParseMetadataBytesNormalizesStaleLastPartitionID(t *testing.T) {
+ data := strings.Replace(ExampleTableMetadataV2,
+ `"last-partition-id": 1000`, `"last-partition-id": 999`, 1)
+
+ parsed, err := ParseMetadataBytes([]byte(data))
+ require.NoError(t, err)
+ require.NotNil(t, parsed.LastPartitionSpecID())
+ assert.Equal(t, 1000, *parsed.LastPartitionSpecID())
+
+ update := NewUpdateSpec(New(nil, parsed, "", nil,
nil).NewTransaction(), false).
+ AddField("x", iceberg.BucketTransform{NumBuckets: 16},
"x_bucket")
+ _, _, err = update.BuildUpdates()
Review Comment:
This is the piece that gives false confidence on the case above.
BuildUpdates emits AssertLastAssignedPartitionID from parsed, and we then
validate it against that same normalized metadata (1000 == 1000), so it passes
locally, but it never compares the emitted requirement against the original
persisted 999, which is what a REST catalog checks. If we assert that update's
AssertLastAssignedPartitionID requirement equals 999 here, this test goes red
and surfaces the desync.
##########
table/metadata.go:
##########
@@ -2053,24 +2058,27 @@ func assignMissingPartitionFieldIDsFromMetadata(b
[]byte, metadata map[string]js
return nil, err
}
field["field-id"] = rawFieldID
+ normalizedLastPartitionID = max(normalizedLastPartitionID,
lastAssignedID)
Review Comment:
Small thing: this max is always lastAssignedID. Both cursors start from the
same value and after the lastAssignedID++ above it's strictly greater, so the
max can never pick normalizedLastPartitionID. It reads like it's guarding a
case that can't happen. I'd just write normalizedLastPartitionID =
lastAssignedID. Same idea for the field-scan update up at line 2045: it's dead
when lastPartitionIDSet is false, so it could be gated on that. Not blocking.
##########
table/metadata_preflight_test.go:
##########
@@ -85,6 +86,88 @@ func TestParseMetadataBytesAssignsMissingPartitionFieldIDs(t
*testing.T) {
}
}
+func TestParseMetadataBytesNormalizesStaleLastPartitionID(t *testing.T) {
+ data := strings.Replace(ExampleTableMetadataV2,
Review Comment:
This only exercises the fix if the Replace actually lands. If the fixture
spacing ever changes and the replace silently no-ops, last-partition-id stays
1000 and both assertions pass without touching the fix. I'd add a
require.Contains right after, asserting the "last-partition-id": 999 substring
is present in data, so a missed replace fails loudly.
##########
table/metadata_preflight_test.go:
##########
@@ -85,6 +86,88 @@ func TestParseMetadataBytesAssignsMissingPartitionFieldIDs(t
*testing.T) {
}
}
+func TestParseMetadataBytesNormalizesStaleLastPartitionID(t *testing.T) {
+ data := strings.Replace(ExampleTableMetadataV2,
+ `"last-partition-id": 1000`, `"last-partition-id": 999`, 1)
+
+ parsed, err := ParseMetadataBytes([]byte(data))
+ require.NoError(t, err)
+ require.NotNil(t, parsed.LastPartitionSpecID())
+ assert.Equal(t, 1000, *parsed.LastPartitionSpecID())
+
+ update := NewUpdateSpec(New(nil, parsed, "", nil,
nil).NewTransaction(), false).
+ AddField("x", iceberg.BucketTransform{NumBuckets: 16},
"x_bucket")
+ _, _, err = update.BuildUpdates()
+ require.NoError(t, err)
+ updated, err := update.Apply()
+ require.NoError(t, err)
+ require.Equal(t, 2, updated.NumFields())
+ assert.Equal(t, 1001, updated.Field(1).FieldID)
+}
+
+func TestAssignMissingPartitionFieldIDsPreservesConsistentMetadata(t
*testing.T) {
+ for _, tt := range []struct {
+ name string
+ input string
+ }{
+ {
+ name: "counter below assignment floor with no fields",
+ input:
`{"last-updated-ms":0,"last-partition-id":0,"partition-specs":[{"spec-id":0,"fields":[]}]}`,
+ },
+ {
+ name: "counter above greatest field ID",
+ input:
`{"last-updated-ms":0,"last-partition-id":1001,"partition-specs":[{"spec-id":0,"fields":[{"field-id":1000}]}]}`,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ normalized, err :=
assignMissingPartitionFieldIDs([]byte(tt.input))
+ require.NoError(t, err)
+ assert.Equal(t, tt.input, string(normalized))
+ })
+ }
+}
+
+func TestAssignMissingPartitionFieldIDsNormalizesStaleCounter(t *testing.T) {
Review Comment:
While we're here, every stale-counter case uses a single spec in list form.
Could we add a two-spec case where the stale counter sits below a field id in
the second spec, and one using the V1 partition-spec key? The scan crosses all
specs and the re-marshal path differs for the non-list form, so those are the
two branches currently unexercised. Low priority.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]