Skip to content

Fix duplicate sort order id when adding an existing sort order - #4209

Open
tomron wants to merge 3 commits into
apache:mainfrom
tomron:fix/duplicate-sort-order-id
Open

tomron wants to merge 3 commits into
apache:mainfrom
tomron:fix/duplicate-sort-order-id

Conversation

@tomron

@tomron tomron commented Oct 30, 2026

Copy link
Copy Markdown

Closes #4202

Rationale for this change

UpdateSortOrder only asserts the default sort order id, so a stale handle can commit an AddSortOrderUpdate whose id already exists. When the orders are equal, the metadata ends up with two sort orders sharing an id, which Java's TableMetadataParser rejects. When they differ, it raises a non-retryable ValueError.

This mirrors Java's TableMetadata.Builder.addSortOrder:

  • Reuse the id of an existing sort order with the same fields instead of appending a duplicate.
  • Assign the next free id when the requested id is taken by a different sort order.
  • Track the last added sort order in the update context so SetDefaultSortOrderUpdate(-1) resolves to it, rather than the highest id.

Are these changes tested?

Yes. New catalog behavior tests cover the stale-handle scenarios from the issue and multiple sort order changes in one transaction. There are also unit tests for update_table_metadata.

Are there any user-facing changes?

No API changes. Stale sort order updates no longer produce invalid metadata.

A stale UpdateSortOrder could append a sort order whose id already
exists, producing metadata that Java cannot load. Mirror Java's
addSortOrder: reuse the id of an equal sort order, assign a fresh id
when the requested one is taken, and resolve the last-added sort order
from the update context.

Closes apache#4202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update.add_column(path="c", field_type=IntegerType())


def test_stale_update_sort_order_reuses_equal_sort_order(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try running this test. I had AI whip up something to show an issue around reassigned IDs + wrong orders becoming the default:

def test_stale_transaction_keeps_reassigned_sort_order_as_default(
    catalog: Catalog, table_schema_nested: Schema, test_table_identifier: Identifier
) -> None:
    namespace = Catalog.namespace_from(test_table_identifier)
    catalog.create_namespace(namespace)
    table_a = catalog.create_table(test_table_identifier, table_schema_nested)
    table_b = catalog.load_table(test_table_identifier)

    with table_a.update_sort_order() as update:
        update.asc("foo", IdentityTransform())
    with table_a.update_sort_order():
        pass  # Reset to unsorted

    # Stale handle stages bar (id 1), baz (id 2), then bar again, which it sends as an explicit SetDefault(1)
    with table_b.transaction() as transaction:
        with transaction.update_sort_order() as update:
            update.asc("bar", IdentityTransform())
        with transaction.update_sort_order() as update:
            update.asc("baz", IdentityTransform())
        with transaction.update_sort_order() as update:
            update.asc("bar", IdentityTransform())

    # bar and baz are reassigned to 2 and 3, so the default must follow bar rather than foo's id 1
    metadata = catalog.load_table(test_table_identifier).metadata
    assert [sort_order.order_id for sort_order in metadata.sort_orders] == [0, 1, 2, 3]
    assert metadata.default_sort_order_id == 2
    assert metadata.sort_order().fields[0].source_id == 2

@rambleraptor
rambleraptor self-requested a review October 31, 2026 00:27

@rambleraptor rambleraptor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Whoops, hit the wrong button accidentally.

A stale transaction that re-stages an existing sort order sends an
explicit SetDefaultSortOrderUpdate with the stale id. When the added
order was reassigned a fresh id, or matched an equal existing order
under a different id, the default ended up pointing at the wrong sort
order. Record the id reassignment in the update context and follow it
for explicit set-default updates.

Adds the stale-transaction regression test from review.

Generated-by: an LLM-based AI coding assistant, reviewed and tested by me

@dragonknightk dragonknightk 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.

Thanks for picking this up ʕ•ᴥ•ʔ The reuse path, the taken-id fallback, and following the reassigned ids all line up with what Java does, and both the catalog behavior tests and the new transaction test cover the repro from the issue nicely.

Left a couple of nits inline, both about what the new tests would still let through — no behavior changes.

Comment thread tests/table/test_init.py
),
)
assert new_metadata.sort_orders == table_v2.metadata.sort_orders
assert new_metadata.default_sort_order_id == existing_sort_order.order_id

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.

nit: the reused ids here are all the current default, so the suite stays green if the reuse path starts writing a metadata version. Asserting the timestamp keeps it honest.

Suggested change
assert new_metadata.default_sort_order_id == existing_sort_order.order_id
assert new_metadata.default_sort_order_id == existing_sort_order.order_id
assert new_metadata.last_updated_ms == table_v2.metadata.last_updated_ms

Comment thread tests/table/test_init.py
assert len(new_metadata.sort_orders) == len(table_v2.metadata.sort_orders) + 1
assert new_metadata.sort_orders[-1].fields == new_sort_order.fields
assert new_metadata.sort_orders[-1].order_id == existing_sort_order.order_id + 1
assert new_metadata.default_sort_order_id == existing_sort_order.order_id + 1

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.

nit: the reused ids here are always the highest one, so recording max(...) instead of the matched sort.order_id would still pass. The reuse path also has to resolve to the matched order itself when its id is no longer the highest — a writer that read metadata before a newer sort order appeared asks for the older one with the id that newer order took.

Suggested change
assert new_metadata.default_sort_order_id == existing_sort_order.order_id + 1
assert new_metadata.default_sort_order_id == existing_sort_order.order_id + 1
def test_update_metadata_add_existing_sort_order_reuses_its_own_id(table_v2: Table) -> None:
existing_sort_order = table_v2.sort_order()
newer_sort_order = SortOrder(SortField(source_id=1, transform=IdentityTransform()), order_id=existing_sort_order.order_id + 1)
metadata = update_table_metadata(
table_v2.metadata,
(AddSortOrderUpdate(sort_order=newer_sort_order), SetDefaultSortOrderUpdate(sort_order_id=-1)),
)
# A writer that read metadata before newer_sort_order was added picks its own next id, which newer_sort_order took.
# Reusing existing_sort_order has to resolve to its own id, which is no longer the highest one.
new_metadata = update_table_metadata(
metadata,
(
AddSortOrderUpdate(sort_order=SortOrder(*existing_sort_order.fields, order_id=newer_sort_order.order_id + 1)),
SetDefaultSortOrderUpdate(sort_order_id=-1),
),
)
assert new_metadata.sort_orders == metadata.sort_orders
assert new_metadata.default_sort_order_id == existing_sort_order.order_id

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tomron

tomron commented Nov 1, 2026

Copy link
Copy Markdown
Author

@dragonknightk, thanks for reviewing. Just pushed few fixes, please check

@dragonknightk

Copy link
Copy Markdown
Contributor

@tomron
Thanks for addressing both nits! The new test confirms that the reused sort order becomes the default even when it no longer has the highest ID. That’s exactly what I was worried about. LGTM (・ω・)ゞ

This branch has not been deployed

No deployments
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.

A stale update_sort_order adds a second sort order with an existing id, which Java cannot load

3 participants