Skip to content

fix(partitions,sort): accept ordinal source IDs when decoding - #1665

Open
twuebi wants to merge 1 commit into
apache:mainfrom
twuebi:fix/decode-allow-ordinal-source-ids
Open

fix(partitions,sort): accept ordinal source IDs when decoding#1665
twuebi wants to merge 1 commit into
apache:mainfrom
twuebi:fix/decode-allow-ordinal-source-ids

Conversation

@twuebi

@twuebi twuebi commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

fixes #1664

Decoding rejected source-id 0 in partition specs and sort orders, but positivity is only an invariant for specs already bound to a schema. Create-table and commit requests carry unbound specs whose source IDs are the client's ordinal placeholders: Spark numbers the root struct's fields from zero, so partitioning or sorting by a table's first column sends source-id 0. Those requests failed with "source ID must be positive: 0".

Reject only negative IDs during decoding and leave positivity to schema binding, which is where Java enforces it. TableMetadata.newTableMetadata assigns fresh IDs and remaps each spec field by looking the source name up in the request schema; reassignIDs already does the same, so ordinal IDs were handled correctly once past decoding. Non-resolvable IDs are still caught: AddPartitionFieldBySourceID and SortOrder.CheckCompatibility report source IDs missing from the schema.

Regression tests cover a create-table request numbered from zero through NewMetadata and a metadata round trip, for both partition specs and sort orders.

Decoding rejected source-id 0 in partition specs and sort orders, but
positivity is only an invariant for specs already bound to a schema.
Create-table and commit requests carry unbound specs whose source IDs
are the client's ordinal placeholders: Spark numbers the root struct's
fields from zero, so partitioning or sorting by a table's first column
sends source-id 0. Those requests failed with "source ID must be
positive: 0".

Reject only negative IDs during decoding and leave positivity to schema
binding, which is where Java enforces it. TableMetadata.newTableMetadata
assigns fresh IDs and remaps each spec field by looking the source name
up in the request schema; reassignIDs already does the same, so ordinal
IDs were handled correctly once past decoding. Non-resolvable IDs are
still caught: AddPartitionFieldBySourceID and SortOrder.CheckCompatibility
report source IDs missing from the schema.

Regression tests cover a create-table request numbered from zero through
NewMetadata and a metadata round trip, for both partition specs and sort
orders.

Signed-off-by: Tobias Pütz <tobias@min.io>
@twuebi
twuebi requested a review from zeroshade as a code owner August 5, 2026 20:50
@fallintoplace

Copy link
Copy Markdown
Contributor

Thanks for catching this. I think your solution is correct. I checked the Java implementation and Spark’s schema conversion, and they confirm that source ID 0 is valid as an ordinal placeholder during table creation before the IDs are reassigned.

@zeroshade zeroshade left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for covering create-table requests from clients that use temporary IDs. The reassignment in table/metadata.go:2814-2841 performs an exact temporary-ID to name to fresh-ID mapping, so real IDs take precedence and there is no ordinal ambiguity.

However, this change does not do what the title says: it does not add an ordinal fallback. It accepts temporary ID zero globally, including persisted default and historical partition specs and sort orders. table/metadata.go:2207-2237 only confirms that the default spec/order exists and compatibility-checks the default order; it does not structurally validate every persisted spec and order, so invalid zero IDs can survive metadata parsing. Suggested fix: keep request decoding permissive in a request-specific path, but require positive source IDs for every bound/persisted spec and order, preserving only the source-less void exception.

Duplicate temporary IDs remain a separate issue. Multiple zeros can make the name lookup used by table/metadata.go:2814-2841 nondeterministic. Suggested fix: reject duplicate temporary IDs before reassignment. Please add persisted v1/v2/v3 rejection tests covering default and historical specs/orders, shuffled IDs proving exact matching, duplicate-zero rejection, and round-trip checks that write paths emit only positive IDs.

Comment thread partitions.go
_, isVoid := p.Transform.(VoidTransform)
if sourceID <= 0 && (!isVoid || hasSourceID || hasSourceIDs) {
return fmt.Errorf("%w: partition source ID must be positive: %d", ErrInvalidPartitionSpec, sourceID)
if sourceID < 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This decoder is also used for persisted default and historical partition specs, so changing the global invariant admits source ID zero into stored metadata; checkPartitionSpecs does not bind or validate those source IDs. Suggested fix: accept temporary zero only in the unbound request path, then require positive IDs for all bound/persisted specs while retaining the existing source-less void tombstone exception.

Comment thread table/sorting.go
func validateSortSourceID(id int) error {
if id <= 0 {
return fmt.Errorf("source ID must be positive: %d", id)
if id < 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This global relaxation also admits zero into historical sort orders. Only the default order is compatibility-checked during metadata validation, so a historical order with source ID zero can persist. Suggested fix: limit zero to the unbound request path and structurally validate every persisted sort order with positive source IDs.

@laskoviymishka laskoviymishka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work tracking this down to ordinal source IDs from Spark's create-table path, and the reassign-by-name round trip in NewMetadata is the right shape, it lines up with Java's TableMetadata.newTableMetadata.

I'd hold this before merging though. My main concern is where the relaxation lives: PartitionField.UnmarshalJSON and validateSortSourceID are the same functions ParseMetadataBytes uses to parse persisted v1/v2/v3 metadata, and validateSortSourceID also backs the public NewSortOrder constructor. So relaxing <= 0 to < 0 there doesn't just accept ordinal placeholders from create-table requests, it also lets a persisted spec or a programmatically built sort order carry source-id 0 with no error. The create-table path doesn't need the parser relaxed at all, since reassignIDs already remaps ordinals by name.

Agreed with zeroshade that the permissiveness leaks past request decoding into persisted and historical specs/orders, and that duplicate zeros make the name-based reassignment nondeterministic. I'd defer to that thread rather than repeat it. One thing I'd add on top: even the default-only validation net has a hole. checkSortOrders only runs CheckCompatibility on the default order and checkPartitionSpecs only checks that the default spec appears in the list, so a historical non-default order/spec with source-id 0 isn't caught anywhere today.

A few things I'd want settled before merge:

  • keep UnmarshalJSON and validateSortSourceID rejecting source-id <= 0; move the ordinal-0 allowance to a request-specific unbound decode path
  • keep NewSortOrder rejecting source-id 0 (the flipped test is a semver-observable change to a published guarantee)
  • if the permissive parse path stays instead, validate every persisted spec/order, not just the default
  • add the persisted v1/v2/v3 rejection tests zeroshade asked for, covering historical specs/orders, not just the happy path
  • tighten the sort-order assertion in TestNewMetadataFromOrdinalNumberedRequest so it can't pass on an empty order

Once those are addressed, happy to take another pass and approve.

Comment thread partitions.go
_, isVoid := p.Transform.(VoidTransform)
if sourceID <= 0 && (!isVoid || hasSourceID || hasSourceIDs) {
return fmt.Errorf("%w: partition source ID must be positive: %d", ErrInvalidPartitionSpec, sourceID)
if sourceID < 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd keep this check strict and move the ordinal-0 allowance to a request-specific path. PartitionField.UnmarshalJSON is the same function ParseMetadataBytes uses to parse persisted v1/v2/v3 metadata, so relaxing <= 0 to < 0 here lets an already-bound persisted spec carry source-id 0 with no error, and sourceIdToFields ends up keyed on 0. It also quietly drops the old VoidTransform carve-out, so {"source-id":0,"transform":"void"} used to be rejected and now isn't.

The create-table path doesn't need the parser relaxed, reassignIDs already remaps ordinals by name via previousMapFn(f.SourceID()), so I'd leave this rejecting source-id <= 0 and add an unbound decode path for requests. Same shape on the sort side in validateSortSourceID. wdyt?

Comment thread table/sorting.go
func validateSortSourceID(id int) error {
if id <= 0 {
return fmt.Errorf("source ID must be positive: %d", id)
if id < 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the shared decode issue, relaxing validateSortSourceID also changes the public NewSortOrder constructor, which builds bound orders. It's called from there with validateSourceIDs=true, so a programmatic caller can now construct a SortOrder with source-id 0 and get no error at build time, and the flipped test (require.Error to require.NoError in TestNewSortOrderAcceptsZeroSourceID) turns that into a semver-observable removal of a published guarantee.

I'd keep NewSortOrder rejecting source-id 0 and let only the request-decode path permit it. Happy to be wrong if there's a bound case where 0 is legitimate, but I can't think of one.

// column is field-id 0 and partitioning or sorting by it yields source-id 0.
// NewMetadata must accept that and remap every source ID by name, matching
// Java's TableMetadata.newTableMetadata.
func TestNewMetadataFromOrdinalNumberedRequest(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers the accept side nicely, and the round-trip assert is a good touch. The reject side zeroshade asked for isn't here yet though, and there's a real gap behind it: checkSortOrders only runs CheckCompatibility on the default order and checkPartitionSpecs only checks that the default spec id appears in the list. So once the parser stops rejecting source-id 0, a persisted historical (non-default) spec or order carrying 0 has no validation net anywhere.

I'd add persisted v1/v2/v3 rejection tests here covering historical specs/orders, not just the default, plus a round-trip check that write paths only ever emit positive IDs. If instead the permissive parse path stays, checkSortOrders and checkPartitionSpecs would need to validate every spec/order rather than just the default.

assert.Equal(t, tt.wantName, gotSpec.Field(0).Name)

// The sort order references the first column, so it remaps to 1.
for _, field := range meta.SortOrder().Fields() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop can pass without asserting anything. If meta.SortOrder() comes back as UnsortedSortOrder (0 fields) because the sort remap silently failed, the range body never runs and the assertion is vacuous, so the exact regression this test is meant to catch would slip through green.

I'd add assert.Equal(t, 1, meta.SortOrder().Len()) right before the loop, mirroring the require.Equal(t, 1, gotSpec.NumFields()) guard you already have on the partition side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

valid partition source-ids are rejected

4 participants