Skip to content

feat(catalog): support writing and committing to a branch - #357

Merged
lxy-9602 merged 2 commits into
apache:mainfrom
SteNicholas:PAIMON-351
Sep 17, 2026
Merged

lxy-9602 merged 2 commits into
apache:mainfrom
SteNicholas:PAIMON-351

Conversation

@SteNicholas

Copy link
Copy Markdown
Member

Purpose

Linked issue: close #351

Java Paimon addresses a branch by the <table>$branch_<branch> object name and hands that branch down to SnapshotCommit#commit(baseSnapshotUuid, snapshot, branch, statistics), while AbstractFileStoreTable#switchToBranch reads the branch's schema from branch/branch-<name> through a FileSystemSchemaManager. Paimon C++ had none of this on the commit path: FileStoreCommit::Create() built its SchemaManager and SnapshotManager without a branch, so a commit configured with branch=dev read the main schema and published its snapshot under the main branch's directory, and a write through a catalog refused a branch outright with a write through a catalog requires the main branch.

This PR makes a branch a first-class target of the write and commit path, for a file-system commit, a catalog commit and a request-only commit alike.

Resolving the branch

  • BranchManager::ResolveBranch() resolves the branch an operation is aimed at from every source that may name it: the table identifier, the branch option and, where a builder has one, WithBranch(). Names that disagree are refused rather than chosen between, and tbl$branch_main, branch=main, an empty and a blank name all mean the main branch. A read resolves it the same way, so the branch option now names a read's branch as WithBranch() already did.
  • Naming a branch only in the option while the identifier of WithCatalog() names the bare table is refused: the catalog would answer for the table while the files were written for the branch.

Addressing the branch on a catalog

  • Identifier(database, table, branch) builds the <table>$branch_<branch> object name, folding every spelling of main into the bare table name as the Java client does. CatalogUtils::BranchIdentifier() rebuilds it for every catalog call, so what is read and what is committed name one object; RestCatalog now goes through the same builder instead of splicing the name itself.
  • SnapshotCommit::Commit() takes the branch. CatalogSnapshotCommit commits to the branch's object name, and RenamingSnapshotCommit writes the snapshot through a manager of that branch.
  • UseRESTCatalogCommit() still builds a body that names no branch, so the caller sends it to the branch's own URL: the commit endpoint of tbl$branch_dev, not the one of tbl.
  • Two kinds of name a catalog cannot tell apart from another object are refused where a builder sees them: a branch differing from main by case alone, whose snapshots would go to a directory of their own, and a branch holding a $, which reads back as a system table of another branch.

Schema of a branch

  • A branch keeps its schema, snapshots and real-time offsets under branch/branch-<name>, while data and manifests stay shared under the table path. A write or a commit aimed at a branch reads the schema published there rather than the catalog's, as switchToBranch does, so a catalog serving only the table's own current schema still takes the branch's snapshots. Only the main branch reloads the catalog's schema id for a new snapshot.
  • This library creates no branch: one has to exist, with its schema published under branch/branch-<name>, before a write or a commit can be aimed at it. Catalog branch management (creating, deleting and merging a branch) remains unimplemented.

Expiration

Expire() and snapshot loading follow the commit's branch, so expiring on a branch manages that branch's snapshots and its EARLIEST hint. As in Java's ExpireSnapshotsImpl, only the retained snapshots of that branch are read while every branch shares the table's data files, so a file that only another branch still refers to is deleted. This is stated in include/paimon/file_store_commit.h and called out as a warning in the catalog guide; cross-branch reachability belongs to orphan-file cleaning, as it does upstream.

Tests

New and extended UT:

  • identifier_test.cpp: BranchConstructor (object name, round trip, and every spelling of main folding to the bare table); a blank branch added to InvalidEmptySystemTableNameParts.
  • branch_manager_test.cpp: TestResolveBranch (each source, agreement, disagreement, names that would leave the table root), TestCheckCatalogAddressableBranch.
  • catalog_snapshot_commit_test.cpp: TestCommitToBranch (branch object name per attempt, the branch of the attempt winning over the one the identifier was built for, MAIN refused); TestBuildRequestWithoutCatalog extended so a refused attempt leaves no request behind.
  • renaming_snapshot_commit_test.cpp: TestCommitToBranch (snapshot and LATEST under the branch, main untouched, and a commit aimed back at main from a branch manager).
  • commit_context_test.cpp / write_context_test.cpp: TestCatalogAddressesBranchByIdentifier, TestBranch. read_context_test.cpp: TestBranch.
  • file_store_commit_impl_test.cpp: TestCommitToBranch, TestRequestOnlyCommitToBranch, TestCatalogCommitToBranchTakesTheBranchSnapshot, TestBranchCommitReadsTheSchemaFromTheBranch, TestBranchCommitNamesTheBranchWhenItFails, TestCatalogCommitCanonicalizesTheMainBranchIdentifier, TestBranchExpireLeavesMainSnapshotMetadataAlone.
  • file_store_write_test.cpp: TestCatalogWriteAsksTheCatalogOnlyForTheMainBranchSchema.
  • rest_catalog_test.cpp: ListSnapshots extended with a blank branch, which is the bare table as main and MAIN are.

New and extended IT:

  • write_inte_test.cpp: TestBranchCommitAndRead writes and commits on dev and on main, then reads each back.
  • pk_compaction_inte_test.cpp: WriteAndCompactWithBranch now commits the compaction to branch-rt and scans the branch for the compacted file, instead of verifying the commit messages only.

API and Format

Yes.

  • include/paimon/catalog/identifier.h: new Identifier(database, table, branch) constructor.
  • include/paimon/file_store_commit.h: GetLastCommitTableRequest() now clears the request of the previous attempt, so an attempt that failed before building one returns an error rather than the request before it. Expire() documents that only the retained snapshots of its own branch are read.
  • include/paimon/commit_context.h, include/paimon/write_context.h, include/paimon/read_context.h: WithCatalog(), WithBranch() and the branch option document how a branch is named and how disagreeing names are refused. A commit or a catalog write aimed at a branch is accepted where it was refused before.
  • No storage format change: <table>$branch_<branch> and branch/branch-<name> are the existing Java spellings.

Documentation

Yes.

  • docs/source/user_guide/catalog.rst: a new Committing to a branch section with Naming the branch, What a branch keeps and what it shares and Addressing a branch without a catalog client; the expiration warning covering data files shared by every branch; and a note that branch management covers creating, deleting and merging a branch while committing to an existing one is supported.
  • docs/source/user_guide/write.rst: the main branch loads its current schema from the catalog while a branch reads the schema published under branch/branch-<name>, and a request-only commit aimed at a branch has to be sent to that branch's URL.

Generative AI tooling

Generated-by: Claude Code (claude-opus-5[1m])

🤖 Generated with Claude Code

@SteNicholas
SteNicholas force-pushed the PAIMON-351 branch 4 times, most recently from e0355d1 to c3af674 Compare September 16, 2026 05:28
@SteNicholas
SteNicholas requested review from lucasfang and lxy-9602 and removed request for lxy-9602 September 16, 2026 12:55
Comment thread test/inte/pk_compaction_inte_test.cpp Outdated
Comment thread docs/source/user_guide/catalog.rst
Comment thread src/paimon/core/catalog/renaming_snapshot_commit.h Outdated
Comment thread src/paimon/core/operation/file_store_commit_impl_test.cpp Outdated
A commit was hard-wired to the main branch: `FileStoreCommit::Create()`
built its schema and snapshot managers without a branch, so a commit
configured with `branch=dev` read the main schema and published its
snapshot under the main branch's directory. A write through a catalog
refused a branch outright.

Resolve the branch an operation is aimed at from every source that may
name it - the table identifier, the `branch` option and, where a builder
has one, `WithBranch()` - and refuse names that disagree rather than
choosing between them. A read resolves it the same way, so the `branch`
option now names a read's branch as `WithBranch()` already did.

A catalog is told which branch to answer for by the
`<table>$branch_<branch>` object name that `Identifier(db, table,
branch)` builds and `CatalogUtils::BranchIdentifier()` rebuilds for
every catalog call, as the Java client does. `SnapshotCommit::Commit()`
now takes that branch, so a renaming commit writes through a manager of
the branch and a catalog commit addresses the branch's object name.
`UseRESTCatalogCommit()` still builds a body naming no branch, so the
caller sends it to the branch's own URL.

A branch keeps its schema, snapshots and real-time offsets under
`branch/branch-<name>`, while data and manifests stay shared under the
table path. A write or a commit aimed at a branch reads the schema
published there rather than the catalog's, as
`AbstractFileStoreTable.switchToBranch()` does, so a catalog serving
only the table's own current schema still takes the branch's snapshots;
only the main branch reloads the catalog's schema id for a new snapshot.

Refuse the two kinds of name a catalog cannot tell apart from another
object: a branch differing from `main` by case alone, whose snapshots
would go to a directory of their own, and a branch holding a `$`, which
reads back as a system table of another branch.

Expiration and snapshot loading follow the commit's branch. Expiration
reads the retained snapshots of that branch alone, as Java does, while
every branch shares the table's data files, so a file that only another
branch still refers to is deleted, which the public header and the
catalog guide both state.
Expiration reads the retained snapshots of its own branch alone, while
every branch of a table shares its data files, so it could delete a file
that another branch still refers to.

Return `NotImplemented` from `Expire()` before reading any snapshot or
deleting any file when it runs on a branch other than main, or when it
runs on main and finds another branch under `branch/branch-<name>` of
the table path; the latter error lists the branches found. A branch held
only by a catalog, or created while expiration runs, is not found, so
the public header and the guides ask callers not to expire such a table
and to serialize branch creation and expiration.

Fail a `RenamingSnapshotCommit` aimed at a branch other than the one of
its snapshot manager instead of redirecting it.
`FileStoreCommit::Create()` hands the same manager to the commit and to
`FileStoreCommitImpl`, which passes that manager's branch down, so a
mismatch is a caller bug.

Stop the catalog guide from claiming that a branch keeps its latest
snapshot under its own directory: a version-managed catalog can hold it
alone, as it can for the main branch.

Tests:
- `FileStoreCommitImplTest.TestExpireOnBranchIsNotSupported` and
  `TestExpireOnMainOfTableWithBranchesIsNotSupported` of the same suite
  cover each refusal and check that no snapshot, manifest or data file
  is deleted.
- `RenamingSnapshotCommitTest.TestBranchHasToMatchSnapshotManager`
  covers the refusal of a mismatched branch.
- `PkCompactionInteTest.WriteAndCompactWithBranch` checks the snapshot
  of every split of the branch and reads all of them instead of picking
  the compacted one.
- `FileStoreCommitImplTest.TestBranchCommitNamesTheBranchWhenItFails`
  uses `ASSERT_NOK_WITH_MSG`.

@lxy-9602 lxy-9602 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.

+1

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

+1

Comment thread src/paimon/core/operation/file_store_commit.cpp
@lxy-9602
lxy-9602 merged commit 2711cf2 into apache:main Sep 17, 2026
16 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.

[Feature] Support committing snapshots to a branch

3 participants