Skip to content

Commit c3af674

Browse files
committed
feat(catalog): support writing and committing to a branch
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.
1 parent aae05cc commit c3af674

36 files changed

Lines changed: 1270 additions & 251 deletions

‎docs/source/user_guide/catalog.rst‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,8 @@ Its metadata files are retained. Use ``FilterAndCommit`` for batch recovery;
180180
after a real-time error, recreate the writer and context and recover from durable
181181
offsets as described by ``CommitWithProgress``.
182182

183-
Only the main branch is supported by ``WithCatalog``. Both builders reject other
184-
branches in the identifier or ``branch`` option; the write builder also checks
185-
``WithBranch``. Explicit ``tbl$branch_main`` and ``branch=main`` are accepted.
186-
187-
The current schema and latest snapshot need no files under the table path.
183+
The main branch's current schema and latest snapshot need no files under the table
184+
path; a branch keeps both under its own directory, as described below.
188185
A catalog response of ``{"snapshot": null}`` means the table has no snapshot.
189186
Only a catalog reporting that snapshot loading is unsupported falls back to
190187
file-system lookup; other catalog errors propagate to the caller.
@@ -193,7 +190,8 @@ Read ``Table::CatalogUuid()`` before preparing changes and pass it to
193190
``WithTableId`` so the server can reject commits to a dropped and recreated table.
194191
``Table::Uuid()`` can fall back to the table name and must not supply this id.
195192
An unset table id is sent as null and subject to server validation. Each commit
196-
attempt reloads the catalog's current schema id for the new snapshot.
193+
attempt to the main branch reloads the catalog's current schema id for the new
194+
snapshot; on a branch it reloads the id published under the branch.
197195

198196
.. note::
199197

@@ -224,7 +222,73 @@ restored by ``RollbackToAsLatest``. Serialize rollback and expiration through
224222
the upstream coordinator: a rollback must finish before expiration starts,
225223
so its restored file references are visible to the expiration operation.
226224

225+
.. warning::
226+
227+
Expiration reads the retained snapshots of the branch it runs on and of no
228+
other, while data files are shared by every branch of the table, so a file
229+
that only another branch still refers to is not preserved by that reference
230+
and is deleted. This holds for the main branch as much as for the others:
231+
expiring the main branch deletes a file only a branch refers to. Expire only
232+
where the retained snapshots of the branch cover every file the other branches
233+
still read, or keep those files reachable from the expiring branch through the
234+
upstream coordinator.
235+
227236
The C++ REST catalog covers the database, table, snapshot and commit operations
228237
of the ``Catalog`` API. The parts of the Java REST catalog that have no C++
229238
counterpart yet — altering a database or a table, views, functions, partitions,
230-
tags, branch management and consumers — are not supported.
239+
tags, branch management and consumers — are not supported. Creating, deleting
240+
and merging a branch is what branch management covers; committing to a branch
241+
that already exists is supported, as described below.
242+
243+
Committing to a branch
244+
~~~~~~~~~~~~~~~~~~~~~~
245+
This library creates no branch: one has to exist, with its schema published under
246+
``branch/branch-<name>``, before a write or a commit can be aimed at it.
247+
248+
Naming the branch
249+
^^^^^^^^^^^^^^^^^
250+
A branch is addressed by the identifier of ``WithCatalog``, which
251+
``paimon::Identifier("db", "tbl", "dev")`` builds as ``tbl$branch_dev``, so that
252+
the catalog answers for the branch: the snapshot it loads is the branch's, and
253+
the commit request is addressed to that branch by that identifier rather than by
254+
anything in its body. The ``branch`` option and, for writers, ``WithBranch`` may
255+
name the same branch as well; naming two different branches is rejected rather
256+
than resolved, and ``tbl$branch_main``, ``branch=main`` and an empty name all
257+
mean the main branch. Naming a branch only in the option or ``WithBranch`` while
258+
the identifier names the bare table is rejected, as the catalog would then answer
259+
for the table while the files were written for the branch.
260+
261+
Names a catalog cannot tell apart from another object are rejected where a
262+
builder sees them: a name differing from ``main`` by case alone, such as
263+
``tbl$branch_MAIN`` or ``branch=MAIN``, which a catalog reads as the main branch
264+
while its snapshots go to a directory of their own; and a name holding a ``$``,
265+
such as ``branch=dev$options``, which ``tbl$branch_dev$options`` reads back as
266+
the ``options`` system table of branch ``dev``. ``paimon::Identifier("db",
267+
"tbl", "MAIN")`` never reaches that check: the constructor folds every spelling
268+
of ``main`` into the bare table name, as the Java client does, and so names the
269+
main branch.
270+
271+
What a branch keeps and what it shares
272+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
273+
A branch other than the main one keeps its schema, snapshots and real-time
274+
offsets under ``branch/branch-<name>``; the main branch keeps them under the
275+
table path. Data and manifests are shared by all branches under the table path,
276+
which is what the expiration warning above turns on.
277+
278+
A write or a commit aimed at a branch reads that schema rather than the
279+
catalog's, just as a read of that branch does, and asks the catalog for no schema
280+
at all, so a catalog serving only the table's own current schema still takes the
281+
branch's snapshots. The new snapshot records the schema id published there as
282+
well, which is the id a read of the branch resolves under
283+
``branch/branch-<name>``; only on the main branch is the recorded id the one the
284+
catalog reports, as its current schema may live nowhere else. Data files record
285+
the schema they were written with, as they do on the main branch.
286+
287+
Addressing a branch without a catalog client
288+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
289+
``UseRESTCatalogCommit`` builds the request instead of sending it, and takes the
290+
branch from the ``branch`` option alone, as it is configured without an
291+
identifier. The request body carries the table id but no table name and no
292+
branch, so the branch has to appear in the URL the caller sends that body to:
293+
the commit endpoint of ``tbl$branch_dev``, not the one of ``tbl``. A body sent
294+
to the bare table's URL publishes the branch's snapshot on the main branch.

‎docs/source/user_guide/write.rst‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,16 @@ Operational Flow
172172
For a table of a catalog which owns its versions, such as a REST catalog, it
173173
passes that catalog and the table identifier to
174174
``WriteContextBuilder::WithCatalog`` and ``CommitContextBuilder::WithCatalog``.
175-
The writer loads the current schema and latest snapshot from the catalog.
176-
On ``Commit``, the snapshot and
177-
the statistics of the change go to the catalog, which decides whether this
178-
commit wins, and one that lost the race is rebased and retried the way a
179-
file-system commit is. A caller without a catalog client can instead enable
180-
REST catalog commit mode, call ``Commit``, obtain the JSON request from
181-
``GetLastCommitTableRequest``, and send that request to the REST catalog
182-
itself.
175+
The writer loads the latest snapshot from the catalog, and the current schema
176+
from it as well when it writes to the main branch; a write aimed at another
177+
branch reads the schema published under ``branch/branch-<name>`` instead. On
178+
``Commit``, the snapshot and the statistics of the change go to the catalog,
179+
which decides whether this commit wins, and one that lost the race is rebased
180+
and retried the way a file-system commit is. A caller without a catalog
181+
client can instead enable REST catalog commit mode, call ``Commit``, obtain
182+
the JSON request from ``GetLastCommitTableRequest``, and send that request to
183+
the REST catalog itself. That request body names no branch, so a commit aimed
184+
at one has to be sent to the URL of ``tbl$branch_dev`` rather than of ``tbl``.
183185

184186
6. The local committer or REST catalog validates the messages, updates
185187
manifests/metadata, and finalizes the snapshot atomically.

‎include/paimon/catalog/identifier.h‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ class PAIMON_EXPORT Identifier {
3737

3838
explicit Identifier(const std::string& table);
3939
Identifier(const std::string& database, const std::string& table);
40+
/// Names `branch` of `table`: every branch but the main one is addressed by the object name
41+
/// this builds, which joins them as `tbl$branch_dev`.
42+
///
43+
/// The branch is matched against the main branch ignoring case, as the Java client matches it,
44+
/// so `MAIN` names the main branch here rather than a branch of its own. A branch of only
45+
/// whitespace names it too, which the Java client does not do: this library reads a blank
46+
/// branch as the main branch everywhere.
47+
///
48+
/// @param database Database holding the table.
49+
/// @param table Data table name, carrying no branch or system table suffix.
50+
/// @param branch Branch of `table`; any spelling of `main`, an empty and a blank name all
51+
/// name the main branch.
52+
Identifier(const std::string& database, const std::string& table, const std::string& branch);
4053

4154
bool operator==(const Identifier& other) const;
4255
const std::string& GetDatabaseName() const;

‎include/paimon/commit_context.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,11 @@ class PAIMON_EXPORT CommitContextBuilder {
195195
/// `Expire()` manages file-system snapshots after confirming that the catalog's current
196196
/// snapshot and retained history are published there. Configure writers with the same catalog
197197
/// via `WriteContextBuilder::WithCatalog()`.
198+
/// A branch is addressed by the identifier, as `tbl$branch_dev`, so that the catalog answers
199+
/// for that branch. Its schema is read from the branch directory rather than from the catalog,
200+
/// just as a read of that branch reads it. Configure the writer with the same identifier.
198201
/// @param catalog Non-null catalog, kept alive by this context.
199-
/// @param identifier Table to commit to. Only the main branch is supported.
202+
/// @param identifier Table to commit to, naming a branch of it as `tbl$branch_dev`.
200203
/// @return Reference to this builder for method chaining.
201204
CommitContextBuilder& WithCatalog(const std::shared_ptr<Catalog>& catalog,
202205
const Identifier& identifier);

‎include/paimon/file_store_commit.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ class PAIMON_EXPORT FileStoreCommit {
164164
const std::vector<std::shared_ptr<CommitMessage>>& commit_messages,
165165
int64_t commit_identifier, std::optional<int64_t> watermark = std::nullopt) = 0;
166166

167-
/// Returns the request from the latest commit attempt, including failed attempts.
167+
/// Returns the request from the latest commit attempt, including failed attempts. Each attempt
168+
/// clears the request of the one before it, so an attempt that failed before building one, as
169+
/// one naming a branch a catalog cannot address does, leaves this returning an error.
168170
/// Catalog commits send requests automatically; `UseRESTCatalogCommit()` only prepares them.
169171
/// @note Temporary interface for internal use, will be removed in the future.
170172
/// @return JSON with `tableId`, `baseSnapshotUuid`, `snapshot` and `statistics`.
@@ -179,6 +181,9 @@ class PAIMON_EXPORT FileStoreCommit {
179181
/// catalog or retained-metadata read errors propagate without deleting files.
180182
/// Retained snapshots with index manifests return `NotImplemented` before deletion.
181183
/// @note Coordinate rollback and expiration so they do not run concurrently.
184+
/// @note Only the retained snapshots of the branch this commit was built for are read, while
185+
/// the branches of a table share their data files, so a file that only another branch
186+
/// still refers to is deleted. This holds for the main branch as much as for the others.
182187
/// @return Result<int32_t> indicating the number of expired items or an error status.
183188
virtual Result<int32_t> Expire() = 0;
184189

‎include/paimon/read_context.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,9 +443,12 @@ class PAIMON_EXPORT ReadContextBuilder {
443443
/// Paimon supports branching for data versioning and time travel queries.
444444
/// This method allows reading from a specific branch instead of the main branch.
445445
///
446+
/// The `branch` option names the branch too, as it does for a scan of one branch; naming two
447+
/// different branches is refused rather than silently resolved.
448+
///
446449
/// @param branch Name of the branch to read from.
447450
/// @return Reference to this builder for method chaining.
448-
/// @note Default branch is "main" if not specified.
451+
/// @note Default branch is "main" if not specified. An empty name is the main branch.
449452
ReadContextBuilder& WithBranch(const std::string& branch);
450453

451454
/// Sets a mapping from URI schemes (e.g., "file", "oss") to registered file system

‎include/paimon/write_context.h‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,11 @@ class PAIMON_EXPORT WriteContextBuilder {
238238
WriteContextBuilder& WithWriteId(int32_t write_id);
239239

240240
/// Write to specific branch, default is main.
241+
///
242+
/// The identifier of `WithCatalog()` and the `branch` option may name the branch too; naming
243+
/// two different ones is refused rather than silently resolved. An empty name is the main
244+
/// branch.
245+
/// @param branch Name of the branch to write to.
241246
/// @return Reference to this builder for method chaining.
242247
WriteContextBuilder& WithBranch(const std::string& branch);
243248

@@ -258,9 +263,12 @@ class PAIMON_EXPORT WriteContextBuilder {
258263
/// snapshot. Use the same catalog and identifier as `CommitContextBuilder` to restore and
259264
/// refresh real-time progress.
260265
///
261-
/// Only the main branch is supported. Data, manifests and historical metadata remain on the
262-
/// file system. The catalog supplies that file system unless overridden
263-
/// by `WithFileSystem()` or `WithFileSystemSchemeToIdentifierMap()`.
266+
/// A branch is addressed by the identifier, as `tbl$branch_dev`, so that the catalog answers
267+
/// for that branch. Its schema is read from the branch directory rather than from the catalog,
268+
/// just as a read of that branch reads it.
269+
/// Data, manifests and historical metadata remain on the file system. The catalog supplies
270+
/// that file system unless overridden by `WithFileSystem()` or
271+
/// `WithFileSystemSchemeToIdentifierMap()`.
264272
/// @param catalog Non-null catalog, kept alive while the writer uses its snapshot loader.
265273
/// @param identifier The native table to write to.
266274
/// @return Reference to this builder for method chaining.

‎src/paimon/core/catalog/catalog_snapshot_commit.h‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@
2626

2727
#include "paimon/catalog/catalog.h"
2828
#include "paimon/catalog/identifier.h"
29+
#include "paimon/core/catalog/catalog_utils.h"
2930
#include "paimon/core/catalog/commit_table_request.h"
3031
#include "paimon/core/catalog/snapshot_commit.h"
3132
#include "paimon/core/catalog/version_managed_catalog.h"
33+
#include "paimon/core/utils/branch_manager.h"
34+
#include "paimon/result.h"
35+
#include "paimon/status.h"
3236

3337
namespace paimon {
3438

@@ -53,8 +57,14 @@ class CatalogSnapshotCommit : public SnapshotCommit {
5357
: identifier_("", ""), table_id_(table_id) {}
5458

5559
Result<bool> Commit(const std::optional<std::string>& base_snapshot_uuid,
56-
const Snapshot& snapshot,
60+
const Snapshot& snapshot, const std::string& branch,
5761
const std::vector<PartitionStatistics>& statistics) override {
62+
// Dropped before anything can refuse this attempt, so that what a caller reads back is
63+
// the attempt it just made or nothing at all, never the snapshot of an earlier one.
64+
commit_table_request_.reset();
65+
// Refused before the request is built: a branch whose object name reads back as another
66+
// object is one that neither this nor the caller of a request can commit to.
67+
PAIMON_RETURN_NOT_OK(BranchManager::CheckCatalogAddressableBranch(branch));
5868
commit_table_request_ =
5969
CommitTableRequest(table_id_, base_snapshot_uuid, snapshot, statistics);
6070
if (catalog_ == nullptr) {
@@ -65,8 +75,11 @@ class CatalogSnapshotCommit : public SnapshotCommit {
6575
"this catalog does not manage the versions of its tables, so there is nothing to "
6676
"commit the snapshot to");
6777
}
68-
return version_managed_catalog_->CommitSnapshot(identifier_, table_id_, base_snapshot_uuid,
69-
snapshot, statistics);
78+
// The branch this commit is aimed at wins over the one the identifier was built for.
79+
PAIMON_ASSIGN_OR_RAISE(Identifier branch_identifier,
80+
CatalogUtils::BranchIdentifier(identifier_, branch));
81+
return version_managed_catalog_->CommitSnapshot(branch_identifier, table_id_,
82+
base_snapshot_uuid, snapshot, statistics);
7083
}
7184

7285
std::string DescribeTarget() const override {
@@ -78,12 +91,16 @@ class CatalogSnapshotCommit : public SnapshotCommit {
7891
return catalog_ == nullptr;
7992
}
8093

81-
/// Returns the request from the latest attempt, including refusals and errors.
94+
/// Returns the request from the latest attempt, including refusals and errors. An attempt
95+
/// refused before the request is built, as one naming an unaddressable branch is, leaves no
96+
/// request behind rather than the one of the attempt before it.
8297
Result<std::string> GetLastCommitTableRequest() override {
8398
if (commit_table_request_) {
8499
return commit_table_request_.value().ToJsonString();
85100
} else {
86-
return Status::Invalid("Should call Commit first before GetLastCommitTableRequest.");
101+
return Status::Invalid(
102+
"Should call Commit first before GetLastCommitTableRequest. An attempt refused "
103+
"before it built a request leaves none behind.");
87104
}
88105
}
89106

0 commit comments

Comments
 (0)