Skip to content

Reject existing ref names in create_branch and create_tag - #4319

Merged
Fokko merged 2 commits into
apache:mainfrom
deepdave98:manage-snapshots-ref-guards
Nov 10, 2026
Merged

Fokko merged 2 commits into
apache:mainfrom
deepdave98:manage-snapshots-ref-guards

Conversation

@deepdave98

@deepdave98 deepdave98 commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #4187

Rationale for this change

ManageSnapshots.create_branch and create_tag set the ref without checking whether the name exists, so an existing branch was reset to the given snapshot and lost its commits, and an existing tag moved. remove_branch and remove_tag checked nothing either: a missing name was accepted, a tag could be removed as a branch, and remove_branch("main") left the table reading as empty.

The names are now checked against the refs as they stand after earlier operations in the same chain (_effective_refs), and the error messages match Java's UpdateSnapshotReferencesOperation: Ref x already exists, Cannot remove main branch, Branch does not exist: x, Tag does not exist: x, Ref x is a tag not a branch, Ref x is a branch not a tag. Replacing a ref on purpose is still possible with remove_branch(...).create_branch(...) in one chain.

AI assistance was used for the analysis and drafting; the change was reviewed and tested by the author.

Are these changes tested?

Four unit tests in tests/table/test_manage_snapshots.py: an existing name is refused for both kinds, a name staged earlier in the same chain is refused, every remove_* refusal, and remove-then-create of the same name in one chain still commits both updates in order. The script from the issue now raises Ref dev already exists and Ref v1 already exists, and dev keeps [1, 2]. make lint and make test pass.

Are there any user-facing changes?

Yes. Calls that silently replaced or removed a ref now raise. remove_branch and remove_tag raise NoSuchSnapshotRefError and SnapshotRefTypeError (both ValueError subclasses), as fast_forward_branch does. create_branch, create_tag, and removing main raise plain ValueError. This needs the changelog label.

ManageSnapshots.create_branch and create_tag set the ref without checking
whether the name is taken, so an existing branch was reset to the given
snapshot and lost its commits, and an existing tag moved. remove_branch and
remove_tag checked nothing either: a missing name was accepted, a tag could
be removed as a branch, and remove_branch("main") emptied the table.

Check the names against the refs as they stand after the earlier
operations in the same chain, and raise ValueError with the messages the
Java UpdateSnapshotReferencesOperation uses: "Ref x already exists",
"Cannot remove main branch", "Branch does not exist: x", "Tag does not
exist: x", "Ref x is a tag not a branch", "Ref x is a branch not a tag".

Closes apache#4187
Comment thread pyiceberg/table/update/snapshot.py Outdated
"""
ref = self._effective_refs().get(tag_name)
if ref is None:
raise ValueError(f"Tag does not exist: {tag_name}")

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.

The rest of this class raises domain exceptions for ref problems. fast_forward_branch uses NoSuchSnapshotRefError and SnapshotRefTypeError for the same conditions. Could we do the same here (and in remove_branch)?

  • Tag does not exist: x / Branch does not exist: x -> NoSuchSnapshotRefError
  • Ref x is a branch not a tag / Ref x is a tag not a branch -> SnapshotRefTypeError

This follows the project convention to use domain-specific exceptions for Iceberg-specific conditions. If you'd rather keep ValueError, please say so in the PR description, since the raised type is part of the user-facing change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. remove_branch and remove_tag now raise NoSuchSnapshotRefError and SnapshotRefTypeError, with the same message shape as fast_forward_branch. create_branch, create_tag, and the main check keep ValueError as invalid arguments; I added that to the description.

table_v2_main_behind.catalog.commit_table.assert_not_called()


def test_remove_then_create_same_name_in_one_chain(table_v2_main_behind: Table) -> None:

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.

Could we add a case where the ref kind changes within a chain, e.g. remove_tag("test").create_branch(..., "test")? It would confirm _effective_refs handles the type switch, not just same-kind replacement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added test_remove_then_create_same_name_as_other_kind_in_one_chain: remove_tag("test").create_branch(..., "test") commits a RemoveSnapshotRefUpdate followed by a SetSnapshotRefUpdate of type branch.

Comment thread tests/table/test_manage_snapshots.py Outdated
Comment on lines +114 to +120
for method, name, message in [
(ms.remove_branch, "main", "Cannot remove main branch"),
(ms.remove_branch, "missing", "Branch does not exist: missing"),
(ms.remove_branch, "test", "Ref test is a tag not a branch"),
(ms.remove_tag, "missing", "Tag does not exist: missing"),
(ms.remove_tag, "audit", "Ref audit is a branch not a tag"),
]:

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.

nit: The loop over (bound_method, name, message) tuples is hard to read, and a failure won't say which case broke. Plain consecutive pytest.raises blocks, one per case, would be clearer. Five short blocks cost little.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Replaced the loop with five pytest.raises blocks, each asserting the specific exception.

…nch and remove_tag

The rest of ManageSnapshots raises these domain exceptions for a missing
ref and for a ref of the wrong kind, so the remove methods now do the
same. Each refusal gets its own test, and a chain that removes a tag and
creates a branch under the same name is covered.

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

All review comments addressed, the exception types now match the rest of the class. LGTM! 🙌 Thanks @deepdave98!

@Fokko
Fokko added this pull request to the merge queue Nov 10, 2026
Merged via the queue into apache:main with commit 639a3bf Nov 10, 2026
20 checks passed
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.

create_branch and create_tag replace an existing ref of the same name

3 participants