Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/layouts/shortcodes/generated/core_configuration.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@
<td>MemorySize</td>
<td>Target size of a blob file. Default is value of TARGET_FILE_SIZE.</td>
</tr>
<tr>
<td><h5>branch-merge.enabled</h5></td>
<td style="word-wrap: break-word;">false</td>
<td>Boolean</td>
<td>Whether to enable branch merge for this table. When enabled, the table must keep a pure-append history: compaction and INSERT OVERWRITE are rejected, and deletion vectors are not supported. This provides the invariant required by branch merge.</td>
</tr>
<tr>
<td><h5>bucket</h5></td>
<td style="word-wrap: break-word;">-1</td>
Expand Down
54 changes: 53 additions & 1 deletion docs/static/rest-catalog-open-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,48 @@ paths:
$ref: '#/components/responses/BranchNotExistErrorResponse'
"500":
$ref: '#/components/responses/ServerErrorResponse'
/v1/{prefix}/databases/{database}/tables/{table}/branches/merge:
post:
tags:
- branch
summary: Merge branch
description: >-
Merge data files from source branch into target branch.
The table must be created with 'branch-merge.enabled' = 'true'. This option
enforces a pure-append table history by rejecting compaction and INSERT OVERWRITE,
and it is incompatible with deletion vectors. Requires an append-only table with
consistent row-tracking settings and compatible schema history.
operationId: mergeBranch
parameters:
- name: prefix
in: path
required: true
schema:
type: string
- name: database
in: path
required: true
schema:
type: string
- name: table
in: path
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MergeBranchRequest'
responses:
"200":
description: Success, no content
"401":
$ref: '#/components/responses/UnauthorizedErrorResponse'
"404":
$ref: '#/components/responses/BranchNotExistErrorResponse'
"500":
$ref: '#/components/responses/ServerErrorResponse'
/v1/{prefix}/databases/{database}/tables/{table}/tags:
get:
tags:
Expand Down Expand Up @@ -3304,6 +3346,16 @@ components:
properties:
branch:
type: string
MergeBranchRequest:
type: object
required:
- sourceBranch
- targetBranch
properties:
sourceBranch:
type: string
targetBranch:
type: string
ListBranchesResponse:
type: object
properties:
Expand Down Expand Up @@ -3615,4 +3667,4 @@ components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
scheme: bearer
15 changes: 15 additions & 0 deletions paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,17 @@ public InlineElement getDescription() {
.defaultValue(false)
.withDescription("Whether enable data evolution for row tracking table.");

@Immutable
public static final ConfigOption<Boolean> BRANCH_MERGE_ENABLED =
key("branch-merge.enabled")
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.

It looks like we don't need this option, just throw exception in branch merging is oK.

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.

Thanks for the review. I added this option mainly to keep a clear invariant for branch merge: the table history must be pure-append.

My concern is that checking this only when mergeBranch is called may not be reliable, because old snapshots can expire. In that case, we may no longer be able to tell whether compaction or INSERT OVERWRITE happened before, and file-level merge could become unsafe.

So the option is intended to make this an explicit opt-in behavior and keep the table merge-safe from the beginning by rejecting compaction / INSERT OVERWRITE.

Another possible approach could be to persist some state indicating whether unsafe operations have ever happened, but that seems a bit heavier to me.

Please let me know what you think.

.booleanType()
.defaultValue(false)
.withDescription(
"Whether to enable branch merge for this table. "
+ "When enabled, the table must keep a pure-append history: "
+ "compaction and INSERT OVERWRITE are rejected, and deletion vectors "
+ "are not supported. This provides the invariant required by branch merge.");

public static final ConfigOption<Boolean> SNAPSHOT_IGNORE_EMPTY_COMMIT =
key("snapshot.ignore-empty-commit")
.booleanType()
Expand Down Expand Up @@ -3667,6 +3678,10 @@ public boolean dataEvolutionEnabled() {
return options.get(DATA_EVOLUTION_ENABLED);
}

public boolean branchMergeEnabled() {
return options.get(BRANCH_MERGE_ENABLED);
}

public boolean prepareCommitWaitCompaction() {
if (!needLookup()) {
return false;
Expand Down
20 changes: 20 additions & 0 deletions paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import org.apache.paimon.rest.requests.ForwardBranchRequest;
import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
import org.apache.paimon.rest.requests.MergeBranchRequest;
import org.apache.paimon.rest.requests.RegisterTableRequest;
import org.apache.paimon.rest.requests.RenameBranchRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
Expand Down Expand Up @@ -1006,6 +1007,25 @@ public void fastForward(Identifier identifier, String branch) {
restAuthFunction);
}

/**
* Merge branch for table.
*
* @param identifier database name and table name.
* @param sourceBranch source branch name
* @param targetBranch target branch name
* @throws NoSuchResourceException Exception thrown on HTTP 404 means the branch or table not
* exists
* @throws ForbiddenException Exception thrown on HTTP 403 means don't have the permission for
* this table
*/
public void mergeBranch(Identifier identifier, String sourceBranch, String targetBranch) {
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.

Why do we need it as a REST API? Does it feel like just creating a new commit?

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.

Thanks for the question. Yes, it creates a new commit on the target branch.

I added it for REST catalog because branch merge needs to read and validate metadata from both branches before committing. It is also aligned with fastForward, which is already a REST branch operation.

MergeBranchRequest request = new MergeBranchRequest(sourceBranch, targetBranch);
client.post(
resourcePaths.mergeBranch(identifier.getDatabaseName(), identifier.getObjectName()),
request,
restAuthFunction);
}

/**
* List branches for table.
*
Expand Down
12 changes: 12 additions & 0 deletions paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,18 @@ public String renameBranch(String databaseName, String tableName, String branch)
"rename");
}

public String mergeBranch(String databaseName, String tableName) {
return SLASH.join(
V1,
prefix,
DATABASES,
encodeString(databaseName),
TABLES,
encodeString(tableName),
BRANCHES,
"merge");
}

public String tags(String databaseName, String objectName) {
return SLASH.join(
V1,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.paimon.rest.requests;

import org.apache.paimon.rest.RESTRequest;

import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;

/** Request for merging branch. */
@JsonIgnoreProperties(ignoreUnknown = true)
public class MergeBranchRequest implements RESTRequest {

private static final String FIELD_SOURCE_BRANCH = "sourceBranch";
private static final String FIELD_TARGET_BRANCH = "targetBranch";

@JsonProperty(FIELD_SOURCE_BRANCH)
private final String sourceBranch;

@JsonProperty(FIELD_TARGET_BRANCH)
private final String targetBranch;

@JsonCreator
public MergeBranchRequest(
@JsonProperty(FIELD_SOURCE_BRANCH) String sourceBranch,
@JsonProperty(FIELD_TARGET_BRANCH) String targetBranch) {
this.sourceBranch = sourceBranch;
this.targetBranch = targetBranch;
}

@JsonGetter(FIELD_SOURCE_BRANCH)
public String sourceBranch() {
return sourceBranch;
}

@JsonGetter(FIELD_TARGET_BRANCH)
public String targetBranch() {
return targetBranch;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,12 @@ public void fastForward(Identifier identifier, String branch) throws BranchNotEx
throw new UnsupportedOperationException();
}

@Override
public void mergeBranch(Identifier identifier, String sourceBranch, String targetBranch)
throws BranchNotExistException {
throw new UnsupportedOperationException();
}

@Override
public List<String> listBranches(Identifier identifier) throws TableNotExistException {
throw new UnsupportedOperationException();
Expand Down
13 changes: 13 additions & 0 deletions paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,19 @@ void renameBranch(Identifier identifier, String fromBranch, String toBranch)
*/
void fastForward(Identifier identifier, String branch) throws BranchNotExistException;

/**
* Merge source branch into target branch.
*
* @param identifier path of the table, cannot be system or branch name.
* @param sourceBranch the source branch name
* @param targetBranch the target branch name
* @throws BranchNotExistException if the source or target branch doesn't exist
* @throws UnsupportedOperationException if the catalog does not {@link
* #supportsVersionManagement()}
*/
void mergeBranch(Identifier identifier, String sourceBranch, String targetBranch)
throws BranchNotExistException;

/**
* List all branches of the table.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ public void fastForward(Identifier identifier, String branch) throws BranchNotEx
wrapped.fastForward(identifier, branch);
}

@Override
public void mergeBranch(Identifier identifier, String sourceBranch, String targetBranch)
throws BranchNotExistException {
wrapped.mergeBranch(identifier, sourceBranch, targetBranch);
}

@Override
public List<String> listBranches(Identifier identifier) throws TableNotExistException {
return wrapped.listBranches(identifier);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ default Range nonNullRowIdRange() {

DataFileMeta assignFirstRowId(long firstRowId);

DataFileMeta newFirstRowId(@Nullable Long newFirstRowId);

default List<Path> collectFiles(DataFilePathFactory pathFactory) {
List<Path> paths = new ArrayList<>();
paths.add(pathFactory.toPath(this));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,31 @@ public PojoDataFileMeta assignFirstRowId(long firstRowId) {
writeCols);
}

@Override
public PojoDataFileMeta newFirstRowId(@Nullable Long newFirstRowId) {
return new PojoDataFileMeta(
fileName,
fileSize,
rowCount,
minKey,
maxKey,
keyStats,
valueStats,
minSequenceNumber,
maxSequenceNumber,
schemaId,
level,
extraFiles,
creationTime,
deleteRowCount,
embeddedIndex,
fileSource,
valueStatsCols,
externalPath,
newFirstRowId,
writeCols);
}

@Override
public PojoDataFileMeta copy(List<String> newExtraFiles) {
return new PojoDataFileMeta(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ public int commit(ManifestCommittable committable, boolean checkAppendFiles) {
int attempts = 0;

ManifestEntryChanges changes = collectChanges(committable.fileCommittables());
if (options.branchMergeEnabled()) {
validatePureAppendCommit(changes);
}
try {
List<SimpleFileEntry> appendSimpleEntries =
SimpleFileEntry.from(changes.appendTableFiles);
Expand Down Expand Up @@ -427,6 +430,13 @@ public int overwritePartition(
properties);
}

if (options.branchMergeEnabled()) {
throw new UnsupportedOperationException(
String.format(
"INSERT OVERWRITE is not allowed when %s is true.",
CoreOptions.BRANCH_MERGE_ENABLED.key()));
}

long started = System.nanoTime();
int generatedSnapshot = 0;
int attempts = 0;
Expand Down Expand Up @@ -685,6 +695,15 @@ private ManifestEntryChanges collectChanges(List<CommitMessage> commitMessages)
return changes;
}

private void validatePureAppendCommit(ManifestEntryChanges changes) {
checkArgument(
changes.compactTableFiles.isEmpty()
&& changes.compactChangelog.isEmpty()
&& changes.compactIndexFiles.isEmpty(),
"Compaction is not allowed when %s is true.",
CoreOptions.BRANCH_MERGE_ENABLED.key());
}

private int tryCommit(
CommitChangesProvider changesProvider,
long identifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ public void fastForward(String branchName) {
wrapped.fastForward(branchName);
}

@Override
public void mergeBranch(String sourceBranch, String targetBranch) {
privilegeChecker.assertCanInsert(identifier);
wrapped.mergeBranch(sourceBranch, targetBranch);
}

@Override
public ExpireSnapshots newExpireSnapshots() {
privilegeChecker.assertCanInsert(identifier);
Expand Down
12 changes: 12 additions & 0 deletions paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,18 @@ public void fastForward(Identifier identifier, String branch) throws BranchNotEx
}
}

@Override
public void mergeBranch(Identifier identifier, String sourceBranch, String targetBranch)
throws BranchNotExistException {
try {
api.mergeBranch(identifier, sourceBranch, targetBranch);
} catch (NoSuchResourceException e) {
throw new BranchNotExistException(identifier, e.resourceName(), e);
} catch (ForbiddenException e) {
throw new TableNoPermissionException(identifier, e);
}
}

@Override
public List<String> listBranches(Identifier identifier) throws TableNotExistException {
try {
Expand Down
Loading