This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new bf55204d58 [core][spark] Fix row ids and sequence numbers of files
copied by sys.copy (#10391)
bf55204d58 is described below
commit bf55204d582c858a9059bff07bbed19f5da737d4
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Fri Oct 16 13:20:53 2026 +0800
[core][spark] Fix row ids and sequence numbers of files copied by sys.copy
(#10391)
---
.../org/apache/paimon/migrate/CopiedDataFiles.java | 274 +++++++
.../operation/commit/RowTrackingCommitUtils.java | 23 +-
.../apache/paimon/migrate/CopiedDataFilesTest.java | 821 +++++++++++++++++++++
.../paimon/table/DataEvolutionTableTest.java | 2 +
.../paimon/table/RowTrackingCopiedFilesTest.java | 187 +++++
.../paimon/spark/copy/CopyFilesCommitOperator.java | 7 +
.../apache/paimon/spark/copy/CopyFilesUtil.java | 20 +
.../paimon/spark/copy/ListDataFilesOperator.java | 8 +-
.../paimon/spark/copy/CopyFilesUtilTest.java | 28 +
.../spark/procedure/CopyFilesProcedureTest.scala | 288 ++++++++
10 files changed, 1653 insertions(+), 5 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/migrate/CopiedDataFiles.java
b/paimon-core/src/main/java/org/apache/paimon/migrate/CopiedDataFiles.java
new file mode 100644
index 0000000000..b8e53f1dc5
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/migrate/CopiedDataFiles.java
@@ -0,0 +1,274 @@
+/*
+ * 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.migrate;
+
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.PojoDataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.SpecialFields;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+
+/**
+ * Prepares data files copied from another table, as {@code sys.copy} does,
for the commit into the
+ * table they are copied to.
+ *
+ * <p>A copied file arrives with the first row id and the sequence numbers it
had in the source
+ * table. Row ids identify rows within one table, and the target has its own:
committing the source
+ * ones would let the copied rows share row ids with rows of the target, and
on a data-evolution
+ * target the source sequence numbers could hide later updates. The copied
files are therefore
+ * committed like files written for the target: without a first row id and
with sequence numbers
+ * that the commit stamps with its snapshot id. A row-tracking commit then
assigns the row ids,
+ * against the snapshot it commits on, as it does for any writer, so a
concurrent commit to the
+ * target cannot take the same row ids.
+ *
+ * <p>A row-tracking commit gives a blob or vector-store file the row ids of
the normal file before
+ * it, so the files of one row id range are committed in order: the normal
file, then its dedicated
+ * files. A file that updates columns of rows another copied file holds cannot
be committed that
+ * way, and is refused: compacting the source first merges it into one file
per row id range. So is
+ * a blob or vector-store file that the commit would give the row ids of other
rows than it holds.
+ */
+public final class CopiedDataFiles {
+
+ private CopiedDataFiles() {}
+
+ /** Replaces, in place, every copied file by its metadata for {@code
target}. */
+ public static void adaptToTarget(
+ FileStoreTable target, Collection<List<DataFileMeta>>
copiedFilesByBucket) {
+ boolean rowTracking = target.coreOptions().rowTrackingEnabled();
+ boolean vectorStoreFiles =
+ copiedFilesByBucket.stream()
+ .flatMap(List::stream)
+ .anyMatch(file -> kind(file) == 2);
+ for (List<DataFileMeta> files : copiedFilesByBucket) {
+ checkNoColumnUpdates(target, files);
+ if (rowTracking) {
+ checkNoFileStoresRowIds(target, files);
+ // a normal file before the dedicated files of its row id range
+ files.sort(
+ Comparator.comparing(
+ (DataFileMeta file) ->
file.firstRowId(),
+
Comparator.nullsLast(Comparator.naturalOrder()))
+ .thenComparingInt(CopiedDataFiles::kind));
+ checkDedicatedFilesFollowTheirRows(target, files,
vectorStoreFiles);
+ }
+ ListIterator<DataFileMeta> iterator = files.listIterator();
+ while (iterator.hasNext()) {
+ DataFileMeta file = iterator.next();
+ // A table without row tracking keeps no row ids: its own
files have none either.
+ iterator.set(rowTracking ? asNewFile(file) :
file.newFirstRowId(null));
+ }
+ }
+ }
+
+ /**
+ * A file written for the target: no first row id, an append so that the
commit assigns one, and
+ * sequence numbers starting at 0 so that the commit stamps them with its
snapshot id.
+ */
+ private static DataFileMeta asNewFile(DataFileMeta file) {
+ return new PojoDataFileMeta(
+ file.fileName(),
+ file.fileSize(),
+ file.rowCount(),
+ file.minKey(),
+ file.maxKey(),
+ file.keyStats(),
+ file.valueStats(),
+ 0L,
+ 0L,
+ file.schemaId(),
+ file.level(),
+ file.extraFiles(),
+ file.creationTime(),
+ file.deleteRowCount().orElse(null),
+ file.embeddedIndex(),
+ FileSource.APPEND,
+ file.valueStatsCols(),
+ file.externalPath().orElse(null),
+ null,
+ file.writeCols(),
+ null);
+ }
+
+ /**
+ * Refuses a normal file whose row ids another normal file of the copy
holds too: a column
+ * update of a data-evolution table, which only shares the row ids of the
file it updates.
+ */
+ private static void checkNoColumnUpdates(FileStoreTable target,
List<DataFileMeta> files) {
+ List<DataFileMeta> normal = new ArrayList<>();
+ for (DataFileMeta file : files) {
+ if (file.firstRowId() != null && kind(file) == 0) {
+ normal.add(file);
+ }
+ }
+ normal.sort(Comparator.comparingLong(DataFileMeta::nonNullFirstRowId));
+ for (int i = 1; i < normal.size(); i++) {
+ DataFileMeta previous = normal.get(i - 1);
+ DataFileMeta current = normal.get(i);
+ if (current.nonNullFirstRowId() < previous.nonNullFirstRowId() +
previous.rowCount()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot copy data files %s and %s into table
%s: they hold the "
+ + "same rows, one updating columns of
the other. Compact "
+ + "the source table first, which
merges them.",
+ previous.fileName(), current.fileName(),
target.name()));
+ }
+ }
+ }
+
+ /**
+ * A file that stores the row ids of its rows, as a copy-on-write update
writes it, has no first
+ * row id: its row ids are in its data and would collide with the row ids
of the target.
+ */
+ private static void checkNoFileStoresRowIds(FileStoreTable target,
List<DataFileMeta> files) {
+ for (DataFileMeta file : files) {
+ List<String> writeCols = file.writeCols();
+ if (writeCols != null &&
writeCols.contains(SpecialFields.ROW_ID.name())) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Cannot copy data file %s into table %s: its
rows store their row "
+ + "ids, written by a copy-on-write
UPDATE, DELETE or MERGE "
+ + "INTO on the source, so they cannot
be given row ids of "
+ + "the target. Rewrite those rows in
the source first, for "
+ + "example with INSERT OVERWRITE.",
+ file.fileName(), target.name()));
+ }
+ }
+ }
+
+ /**
+ * Refuses a blob or vector-store file that the commit would give the row
ids of other rows than
+ * it holds. The commit gives a normal file the row ids after the normal
file before it, a blob
+ * file those from the start of the normal file before it, or after the
blob file of the same
+ * field following that normal file, and a vector-store file those after
the vector-store file
+ * before it in the whole commit. This replays that on the files of one
bucket, which the commit
+ * keeps together but may order among the other buckets in any way: so
when the copy has
+ * vector-store files, those of every bucket must also hold as many rows
as its normal files. A
+ * source breaks this when a blob or vector column was updated, or added
after rows were
+ * written.
+ */
+ private static void checkDedicatedFilesFollowTheirRows(
+ FileStoreTable target, List<DataFileMeta> files, boolean
vectorStoreFiles) {
+ // the row ids the commit gives, counted from the first one it gives
to the bucket
+ long start = 0;
+ long blobStartDefault = 0;
+ Map<String, Long> blobStarts = new HashMap<>();
+ long vectorStoreStart = 0;
+ List<DataFileMeta> normalFiles = new ArrayList<>();
+ List<Long> normalStarts = new ArrayList<>();
+ List<DataFileMeta> dedicatedFiles = new ArrayList<>();
+ List<Long> dedicatedStarts = new ArrayList<>();
+ for (DataFileMeta file : files) {
+ int kind = kind(file);
+ if (kind == 0) {
+ normalFiles.add(file);
+ normalStarts.add(start);
+ blobStartDefault = start;
+ blobStarts.clear();
+ start += file.rowCount();
+ continue;
+ }
+ long given;
+ if (kind == 1) {
+ String field = file.writeCols().get(0);
+ given = blobStarts.getOrDefault(field, blobStartDefault);
+ blobStarts.put(field, given + file.rowCount());
+ } else {
+ given = vectorStoreStart;
+ vectorStoreStart += file.rowCount();
+ }
+ if (given >= start) {
+ // no normal file holds these row ids yet: the commit refuses
it
+ throw rowsOfOtherFiles(target, file);
+ }
+ dedicatedFiles.add(file);
+ dedicatedStarts.add(given);
+ }
+
+ // the normal and the dedicated files are both in the order of their
source row ids
+ int normal = 0;
+ for (int i = 0; i < dedicatedFiles.size(); i++) {
+ DataFileMeta file = dedicatedFiles.get(i);
+ if (file.firstRowId() == null) {
+ continue;
+ }
+ long row = file.firstRowId();
+ long end = row + file.rowCount();
+ long offset = dedicatedStarts.get(i) - row;
+ while (normal < normalFiles.size() &&
endRowId(normalFiles.get(normal)) <= row) {
+ normal++;
+ }
+ // every row of the file must get the row id the commit gives it
in its normal file
+ for (int n = normal; row < end; n++) {
+ DataFileMeta normalFile = n < normalFiles.size() ?
normalFiles.get(n) : null;
+ if (normalFile == null
+ || normalFile.firstRowId() == null
+ || normalFile.firstRowId() > row
+ || normalStarts.get(n) - normalFile.firstRowId() !=
offset) {
+ throw rowsOfOtherFiles(target, file);
+ }
+ row = endRowId(normalFile);
+ }
+ }
+
+ if (vectorStoreFiles && vectorStoreStart < start) {
+ // rows without a vector-store file move those of the next buckets
onto other rows
+ for (int n = normalFiles.size() - 1; n >= 0; n--) {
+ if (normalStarts.get(n) <= vectorStoreStart) {
+ throw rowsOfOtherFiles(target, normalFiles.get(n));
+ }
+ }
+ }
+ }
+
+ private static long endRowId(DataFileMeta file) {
+ return file.firstRowId() == null ? Long.MIN_VALUE : file.firstRowId()
+ file.rowCount();
+ }
+
+ private static IllegalArgumentException rowsOfOtherFiles(
+ FileStoreTable target, DataFileMeta file) {
+ return new IllegalArgumentException(
+ String.format(
+ "Cannot copy data file %s into table %s: the commit
would give the blob or "
+ + "vector-store files of the source the row
ids of other rows "
+ + "than they hold, because they do not follow
the rows of the "
+ + "normal files, as after updating a blob or
vector column or "
+ + "adding one to rows already written. Rewrite
those rows in "
+ + "the source first, for example with INSERT
OVERWRITE.",
+ file.fileName(), target.name()));
+ }
+
+ /** 0 for a normal file, 1 for a blob file, 2 for a vector-store file. */
+ private static int kind(DataFileMeta file) {
+ if (isBlobFile(file.fileName())) {
+ return 1;
+ }
+ return isVectorStoreFile(file.fileName()) ? 2 : 0;
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowTrackingCommitUtils.java
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowTrackingCommitUtils.java
index f6e0b660ce..8b8fcd3476 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowTrackingCommitUtils.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/commit/RowTrackingCommitUtils.java
@@ -18,6 +18,7 @@
package org.apache.paimon.operation.commit;
+import org.apache.paimon.manifest.FileKind;
import org.apache.paimon.manifest.FileSource;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.table.SpecialFields;
@@ -43,10 +44,30 @@ public class RowTrackingCommitUtils {
// assign row id for new files
List<ManifestEntry> rowIdAssigned = new ArrayList<>();
long nextRowIdStart =
- assignRowTrackingMeta(firstRowIdStart, snapshotAssigned,
rowIdAssigned);
+ assignRowTrackingMeta(
+ Math.max(firstRowIdStart, rowIdEnd(deltaFiles)),
+ snapshotAssigned,
+ rowIdAssigned);
return new RowTrackingAssigned(nextRowIdStart, rowIdAssigned);
}
+ /**
+ * The end of the row ids that files added with a first row id they
already have occupy. The
+ * next row id of the table does not cover them when they come from
another table, for example
+ * files copied by {@code sys.copy}: new files must be assigned row ids
after them, and the next
+ * row id of the table must move past them, or later rows get the same row
ids.
+ */
+ private static long rowIdEnd(List<ManifestEntry> deltaFiles) {
+ long end = 0L;
+ for (ManifestEntry entry : deltaFiles) {
+ Long firstRowId = entry.file().firstRowId();
+ if (entry.kind() == FileKind.ADD && firstRowId != null) {
+ end = Math.max(end, firstRowId + entry.file().rowCount());
+ }
+ }
+ return end;
+ }
+
private static void assignSnapshotId(
long snapshotId, List<ManifestEntry> deltaFiles,
List<ManifestEntry> snapshotAssigned) {
for (ManifestEntry entry : deltaFiles) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/migrate/CopiedDataFilesTest.java
b/paimon-core/src/test/java/org/apache/paimon/migrate/CopiedDataFilesTest.java
new file mode 100644
index 0000000000..83c030fc2a
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/migrate/CopiedDataFilesTest.java
@@ -0,0 +1,821 @@
+/*
+ * 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.migrate;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator;
+import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.BlobData;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.manifest.ManifestEntry;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.table.TableTestBase;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableRead;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.Pair;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
+
+/**
+ * Tests for {@link CopiedDataFiles}: data files copied from another table and
committed the way
+ * {@code CopyFilesCommitOperator} of {@code sys.copy} does.
+ */
+public class CopiedDataFilesTest extends TableTestBase {
+
+ private static final Identifier SOURCE = new Identifier("default", "src");
+ private static final Identifier TARGET = new Identifier("default", "dst");
+
+ //
---------------------------------------------------------------------------------------------
+ // row ids
+ //
---------------------------------------------------------------------------------------------
+
+ @Test
+ public void testCopyIntoTableWithoutRowsAssignsRowIdsInOrder() throws
Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, false);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ write(table(SOURCE), row(3, "c", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, false);
+
+ copy(SOURCE, TARGET, null);
+
+ assertThat(rowIds(table(TARGET)))
+ .containsEntry(1, 0L)
+ .containsEntry(2, 1L)
+ .containsEntry(3, 2L);
+
assertThat(table(TARGET).snapshotManager().latestSnapshot().nextRowId()).isEqualTo(3L);
+ write(table(TARGET), row(4, "d", "p1"));
+ assertThat(rowIds(table(TARGET))).containsEntry(4, 3L);
+ assertUniqueRowIds(table(TARGET));
+ }
+
+ @Test
+ public void testCopyIntoOnePartitionFollowsTheRowsOfOtherPartitions()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, true);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, true);
+ write(table(TARGET), row(10, "x", "p2"), row(11, "y", "p2"));
+
+ // dynamic partition overwrite replaces p1 only: the rows of p2 keep
row ids 0 and 1
+ copy(SOURCE, TARGET, null);
+
+ FileStoreTable target = table(TARGET);
+ assertThat(values(target)).containsOnlyKeys(1, 2, 10, 11);
+ assertThat(rowIds(target))
+ .containsEntry(10, 0L)
+ .containsEntry(11, 1L)
+ .containsEntry(1, 2L)
+ .containsEntry(2, 3L);
+
assertThat(target.snapshotManager().latestSnapshot().nextRowId()).isEqualTo(4L);
+ assertUniqueRowIds(target);
+ }
+
+ @Test
+ public void testCopyWithPartitionFilterFollowsTheRowsOfOtherPartitions()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, true);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p2"));
+ createTable(TARGET, Options.ROW_TRACKING, true);
+ write(table(TARGET), row(10, "x", "p2"));
+
+ copy(SOURCE, TARGET, "p1");
+
+ assertThat(values(table(TARGET))).containsOnlyKeys(1, 10);
+ assertThat(rowIds(table(TARGET))).containsEntry(10,
0L).containsEntry(1, 1L);
+ assertUniqueRowIds(table(TARGET));
+ }
+
+ @Test
+ public void testCopyOverEveryRowFollowsTheRowIdsGivenOut() throws
Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, false);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, false);
+ write(table(TARGET), row(10, "x", "p1"), row(11, "y", "p1"), row(12,
"z", "p1"));
+
+ copy(SOURCE, TARGET, null);
+
+ // the row ids of the overwritten rows are not given out again, as for
any overwrite
+ assertThat(values(table(TARGET))).containsOnlyKeys(1, 2);
+ assertThat(rowIds(table(TARGET))).containsEntry(1,
3L).containsEntry(2, 4L);
+
assertThat(table(TARGET).snapshotManager().latestSnapshot().nextRowId()).isEqualTo(5L);
+ }
+
+ @Test
+ public void testAppendCommittedWhileTheCopyIsPreparedGetsOtherRowIds()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, true);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, true);
+ write(table(TARGET), row(10, "x", "p1"), row(11, "y", "p1"));
+
+ // the copy is prepared, then another writer appends to p2, then the
copy commits
+ Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> files =
prepareCopy(SOURCE, TARGET);
+ write(table(TARGET), row(20, "u", "p2"), row(21, "v", "p2"));
+ commitCopy(table(TARGET), files);
+
+ Map<Integer, Long> rowIds = rowIds(table(TARGET));
+ assertThat(rowIds).containsOnlyKeys(1, 2, 20, 21);
+ assertThat(rowIds).containsEntry(20, 2L).containsEntry(21, 3L);
+ assertThat(rowIds).containsEntry(1, 4L).containsEntry(2, 5L);
+ assertUniqueRowIds(table(TARGET));
+ }
+
+ @Test
+ public void testAppendToEmptyTargetWhileTheCopyIsPreparedGetsOtherRowIds()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, true);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, true);
+
+ Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> files =
prepareCopy(SOURCE, TARGET);
+ write(table(TARGET), row(20, "u", "p2"), row(21, "v", "p2"));
+ commitCopy(table(TARGET), files);
+
+ assertThat(rowIds(table(TARGET)))
+ .containsEntry(20, 0L)
+ .containsEntry(21, 1L)
+ .containsEntry(1, 2L)
+ .containsEntry(2, 3L);
+ assertUniqueRowIds(table(TARGET));
+ }
+
+ @Test
+ public void testCopyCommitRetriedAfterAConcurrentAppendGetsOtherRowIds()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, true);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, true);
+ write(table(TARGET), row(10, "x", "p1"));
+ Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> files =
prepareCopy(SOURCE, TARGET);
+
+ // The copy commit reads the latest snapshot and stops at its first
manifest write. An
+ // append commits meanwhile, so the copy loses the next snapshot id
and retries.
+ CountDownLatch paused = new CountDownLatch(1);
+ CountDownLatch released = new CountDownLatch(1);
+ AtomicReference<Thread> committer = new AtomicReference<>();
+ FileStoreTable pausing =
+ FileStoreTableFactory.create(
+ new PausingFileIO(committer, paused, released),
table(TARGET).location());
+ AtomicReference<Throwable> error = new AtomicReference<>();
+ Thread thread =
+ new Thread(
+ () -> {
+ try {
+ commitCopy(pausing, files);
+ } catch (Throwable t) {
+ error.set(t);
+ }
+ });
+ committer.set(thread);
+ thread.start();
+ assertThat(paused.await(30, TimeUnit.SECONDS)).isTrue();
+ write(table(TARGET), row(20, "u", "p2"), row(21, "v", "p2"));
+ released.countDown();
+ thread.join(TimeUnit.SECONDS.toMillis(60));
+ assertThat(error.get()).isNull();
+
+ Map<Integer, Long> rowIds = rowIds(table(TARGET));
+ assertThat(rowIds).containsOnlyKeys(1, 2, 20, 21);
+ assertThat(rowIds).containsEntry(20, 1L).containsEntry(21, 2L);
+ assertThat(rowIds).containsEntry(1, 3L).containsEntry(2, 4L);
+ assertUniqueRowIds(table(TARGET));
+ }
+
+ @Test
+ public void testColumnUpdatesOfTheSourceAreRefused() throws Exception {
+ createTable(SOURCE, Options.DATA_EVOLUTION, false);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ // a column update of both rows: shares the row id range of the file
above
+ updateColumn(table(SOURCE), 0, "a2", "b2");
+ createTable(TARGET, Options.DATA_EVOLUTION, false);
+
+ assertThatThrownBy(() -> copy(SOURCE, TARGET, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("they hold the same rows")
+ .hasMessageContaining("Compact the source table first");
+ assertThat(table(TARGET).snapshotManager().latestSnapshot()).isNull();
+ }
+
+ @Test
+ public void testCompactedDataEvolutionSourceIsCopied() throws Exception {
+ createTable(
+ SOURCE,
+ Options.DATA_EVOLUTION,
+ false,
+
Collections.singletonMap(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2"));
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ updateColumn(table(SOURCE), 0, "a2", "b2");
+ compactDataEvolution(table(SOURCE));
+ assertThat(liveFiles(table(SOURCE))).hasSize(1);
+ createTable(TARGET, Options.DATA_EVOLUTION, false);
+ write(table(TARGET), row(10, "x", "p1"));
+
+ copy(SOURCE, TARGET, null);
+
+ FileStoreTable target = table(TARGET);
+ assertThat(values(target)).containsEntry(1, "a2").containsEntry(2,
"b2");
+ assertThat(rowIds(target)).containsEntry(1, 1L).containsEntry(2, 2L);
+ // later column updates of the copied rows win
+ updateColumn(target, 1, "a3", "b3");
+ assertThat(values(table(TARGET))).containsEntry(1,
"a3").containsEntry(2, "b3");
+ }
+
+ @Test
+ public void testBlobFilesKeepTheRowIdsOfTheirRows() throws Exception {
+ createBlobTable(SOURCE);
+ writeBlobs(table(SOURCE), 1, 2);
+ writeBlobs(table(SOURCE), 3);
+ assertThat(liveFiles(table(SOURCE))).anyMatch(file ->
file.fileName().endsWith(".blob"));
+ createBlobTable(TARGET);
+ writeBlobs(table(TARGET), 10);
+
+ copy(SOURCE, TARGET, null);
+
+ // the copy replaces the row of the target; every copied row reads its
own blob, under
+ // row ids after the one the target gave out
+ Map<Integer, Long> rowIds = new TreeMap<>();
+ RowType readType =
+ SpecialFields.rowTypeWithRowTracking(table(TARGET).rowType(),
true, true);
+ for (InternalRow row : read(table(TARGET), readType)) {
+ int id = row.getInt(0);
+ assertThat(row.getBlob(1).toData()).isEqualTo(blob(id));
+ rowIds.put(id, row.getLong(2));
+ }
+ assertThat(rowIds).containsExactly(entry(1, 1L), entry(2, 2L),
entry(3, 3L));
+ }
+
+ @Test
+ public void testDedicatedFilesFollowingTheirRowsAreAccepted() throws
Exception {
+ createTable(TARGET, Options.DATA_EVOLUTION, false);
+ // vector-store files rolled at other rows than the normal files, two
blob files for one
+ // normal file, and a blob column added after the first rows
+ List<DataFileMeta> vectors =
+ files(
+ file("n0.parquet", 0, 100),
+ file("n1.parquet", 100, 100),
+ file("v0.vector.parquet", 0, 150, "e"),
+ file("v1.vector.parquet", 150, 50, "e"));
+ List<DataFileMeta> blobs =
+ files(
+ file("n2.parquet", 200, 2),
+ file("n3.parquet", 202, 4),
+ file("b0.blob", 202, 2, "b"),
+ file("b1.blob", 204, 2, "b"));
+
+ CopiedDataFiles.adaptToTarget(table(TARGET),
Collections.singletonList(vectors));
+ CopiedDataFiles.adaptToTarget(table(TARGET),
Collections.singletonList(blobs));
+
+ assertThat(vectors)
+ .extracting(DataFileMeta::fileName)
+ .containsExactly(
+ "n0.parquet", "v0.vector.parquet", "n1.parquet",
"v1.vector.parquet");
+ assertThat(blobs)
+ .extracting(DataFileMeta::fileName)
+ .containsExactly("n2.parquet", "n3.parquet", "b0.blob",
"b1.blob");
+ }
+
+ @Test
+ public void testDedicatedFilesTheCommitWouldGiveOtherRowsAreRefused()
throws Exception {
+ createTable(TARGET, Options.DATA_EVOLUTION, false);
+ // a vector column added after the first rows: the vector-store file
would get rows 0 and 1
+ assertRefused(
+ "v1.vector.parquet",
+ files(
+ file("n0.parquet", 0, 2),
+ file("n1.parquet", 2, 2),
+ file("v1.vector.parquet", 2, 2, "e")));
+ // a vector column updated
+ assertRefused(
+ "u0.vector.parquet",
+ files(
+ file("n0.parquet", 0, 2),
+ file("v0.vector.parquet", 0, 2, "e"),
+ file("u0.vector.parquet", 0, 1, "e")));
+ // a blob column updated
+ assertRefused(
+ "u0.blob",
+ files(
+ file("n0.parquet", 0, 2),
+ file("b0.blob", 0, 2, "b"),
+ file("u0.blob", 1, 1, "b")));
+ // a bucket without vector-store files would move those of the other
buckets
+ assertRefused(
+ "n0.parquet",
+ files(file("n0.parquet", 0, 2)),
+ files(file("n1.parquet", 2, 2), file("v1.vector.parquet", 2,
2, "e")));
+ }
+
+ @Test
+ public void testFilesThatStoreTheirRowIdsAreRefusedByRowTrackingTargets()
throws Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, false);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ copyOnWriteUpdate(table(SOURCE));
+
assertThat(liveFiles(table(SOURCE))).anyMatch(CopiedDataFilesTest::storesRowIds);
+
+ createTable(TARGET, Options.ROW_TRACKING, false);
+ assertThatThrownBy(() -> copy(SOURCE, TARGET, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("its rows store their row ids")
+ .hasMessageContaining("INSERT OVERWRITE");
+ assertThat(table(TARGET).snapshotManager().latestSnapshot()).isNull();
+
+ // a table without row tracking does not use them
+ Identifier plain = new Identifier("default", "plain");
+ createTable(plain, Options.PLAIN, false);
+ copy(SOURCE, plain, null);
+
assertThat(liveFiles(table(plain))).anyMatch(CopiedDataFilesTest::storesRowIds);
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // file metadata and sequence numbers
+ //
---------------------------------------------------------------------------------------------
+
+ @Test
+ public void testLaterColumnUpdatesWinOverCopiedRows() throws Exception {
+ createTable(SOURCE, Options.DATA_EVOLUTION, false);
+ for (int i = 0; i < 20; i++) {
+ write(table(SOURCE), row(i, "v" + i, "p1"));
+ }
+ createTable(TARGET, Options.DATA_EVOLUTION, false);
+
+ copy(SOURCE, TARGET, null);
+
+ FileStoreTable target = table(TARGET);
+ long copySnapshot = target.snapshotManager().latestSnapshotId();
+ assertThat(liveFiles(target))
+ .allMatch(
+ file ->
+ file.minSequenceNumber() == copySnapshot
+ && file.maxSequenceNumber() ==
copySnapshot);
+ updateColumn(target, 19, "updated");
+ assertThat(values(table(TARGET))).containsEntry(19,
"updated").containsEntry(18, "v18");
+ }
+
+ @Test
+ public void testCopiedFilesAreCommittedAsFilesWrittenForTheTarget() throws
Exception {
+ createTable(SOURCE, Options.DATA_EVOLUTION, false);
+ write(table(SOURCE), row(1, "a", "p1"));
+ write(table(SOURCE), row(2, "b", "p1"));
+ createTable(TARGET, Options.ROW_TRACKING, false);
+ List<DataFileMeta> source = liveFiles(table(SOURCE));
+ List<DataFileMeta> copied = new ArrayList<>();
+ for (DataFileMeta file : source) {
+ copied.add(file.withWriteColsSequences(new long[] {7L, 7L, 7L}));
+ }
+
+ CopiedDataFiles.adaptToTarget(table(TARGET),
Collections.singletonList(copied));
+
+ assertThat(copied).hasSize(source.size());
+ for (DataFileMeta file : copied) {
+ assertThat(file.firstRowId()).isNull();
+ assertThat(file.fileSource()).contains(FileSource.APPEND);
+ assertThat(file.minSequenceNumber()).isZero();
+ assertThat(file.maxSequenceNumber()).isZero();
+ assertThat(file.writeColsSequences()).isNull();
+ }
+ // in row id order
+
assertThat(copied.get(0).fileName()).isEqualTo(fileWithFirstRowId(source, 0L));
+ }
+
+ @Test
+ public void testTableWithoutRowTrackingDropsCopiedRowIds() throws
Exception {
+ createTable(SOURCE, Options.ROW_TRACKING, false);
+ write(table(SOURCE), row(1, "a", "p1"), row(2, "b", "p1"));
+ createTable(TARGET, Options.PLAIN, false);
+
+ copy(SOURCE, TARGET, null);
+
+ assertThat(values(table(TARGET))).containsOnlyKeys(1, 2);
+ assertThat(liveFiles(table(TARGET))).allMatch(file ->
file.firstRowId() == null);
+ }
+
+ @Test
+ public void testCopyBetweenTablesWithoutRowTrackingChangesNothing() throws
Exception {
+ createTable(SOURCE, Options.PLAIN, false);
+ write(table(SOURCE), row(1, "a", "p1"));
+ createTable(TARGET, Options.PLAIN, false);
+ write(table(TARGET), row(10, "x", "p1"));
+ List<DataFileMeta> before = liveFiles(table(SOURCE));
+
+ List<DataFileMeta> copied = new ArrayList<>(before);
+ CopiedDataFiles.adaptToTarget(table(TARGET),
Collections.singletonList(copied));
+
+ assertThat(copied).isEqualTo(before);
+ }
+
+ //
---------------------------------------------------------------------------------------------
+ // helpers
+ //
---------------------------------------------------------------------------------------------
+
+ private static DataFileMeta file(
+ String name, long firstRowId, long rowCount, String... writeCols) {
+ return DataFileMeta.forAppend(
+ name,
+ 1L,
+ rowCount,
+ SimpleStats.EMPTY_STATS,
+ 0L,
+ 0L,
+ 0L,
+ Collections.emptyList(),
+ null,
+ FileSource.APPEND,
+ null,
+ null,
+ firstRowId,
+ writeCols.length == 0 ? null : Arrays.asList(writeCols));
+ }
+
+ private static List<DataFileMeta> files(DataFileMeta... files) {
+ return new ArrayList<>(Arrays.asList(files));
+ }
+
+ @SafeVarargs
+ private final void assertRefused(String fileName, List<DataFileMeta>...
buckets)
+ throws Exception {
+ FileStoreTable target = table(TARGET);
+ assertThatThrownBy(() -> CopiedDataFiles.adaptToTarget(target,
Arrays.asList(buckets)))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Cannot copy data file " + fileName)
+ .hasMessageContaining("row ids of other rows");
+ }
+
+ private enum Options {
+ PLAIN,
+ ROW_TRACKING,
+ DATA_EVOLUTION
+ }
+
+ private void createTable(Identifier identifier, Options options, boolean
partitioned)
+ throws Exception {
+ createTable(identifier, options, partitioned, Collections.emptyMap());
+ }
+
+ private void createTable(
+ Identifier identifier,
+ Options options,
+ boolean partitioned,
+ Map<String, String> extraOptions)
+ throws Exception {
+ Schema.Builder builder =
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("v", DataTypes.STRING())
+ .column("pt", DataTypes.STRING())
+ .options(extraOptions);
+ if (partitioned) {
+ builder.partitionKeys("pt");
+ }
+ if (options != Options.PLAIN) {
+ builder.option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+ }
+ if (options == Options.DATA_EVOLUTION) {
+ builder.option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true");
+ }
+ catalog.createTable(identifier, builder.build(), false);
+ }
+
+ private void createBlobTable(Identifier identifier) throws Exception {
+ catalog.createTable(
+ identifier,
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("b", DataTypes.BLOB())
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .build(),
+ false);
+ }
+
+ private static byte[] blob(int id) {
+ return ("blob-" + id).getBytes();
+ }
+
+ private static void writeBlobs(FileStoreTable table, int... ids) throws
Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (int id : ids) {
+ write.write(GenericRow.of(id, new BlobData(blob(id))));
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ private FileStoreTable table(Identifier identifier) throws Exception {
+ return (FileStoreTable) catalog.getTable(identifier);
+ }
+
+ private static GenericRow row(int id, String v, String pt) {
+ return GenericRow.of(id, BinaryString.fromString(v),
BinaryString.fromString(pt));
+ }
+
+ private static void write(FileStoreTable table, GenericRow... rows) throws
Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (GenericRow row : rows) {
+ write.write(row);
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ /** A data-evolution update of column {@code v} of the rows from {@code
firstRowId} on. */
+ private static void updateColumn(FileStoreTable table, long firstRowId,
String... values)
+ throws Exception {
+ RowType writeType =
table.rowType().project(Collections.singletonList("v"));
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write =
builder.newWrite().withWriteType(writeType);
+ BatchTableCommit commit = builder.newCommit()) {
+ for (String value : values) {
+ write.write(GenericRow.of(BinaryString.fromString(value)));
+ }
+ List<CommitMessage> messages = write.prepareCommit();
+ for (CommitMessage message : messages) {
+ CommitMessageImpl impl = (CommitMessageImpl) message;
+ List<DataFileMeta> files = new
ArrayList<>(impl.newFilesIncrement().newFiles());
+ impl.newFilesIncrement().newFiles().clear();
+ files.forEach(
+ f ->
+ impl.newFilesIncrement()
+ .newFiles()
+ .add(f.assignFirstRowId(firstRowId)));
+ }
+ commit.commit(messages);
+ }
+ }
+
+ private static void compactDataEvolution(FileStoreTable table) throws
Exception {
+ DataEvolutionCompactCoordinator coordinator =
+ new DataEvolutionCompactCoordinator(
+ table, false, false,
table.snapshotManager().latestSnapshot());
+ List<CommitMessage> messages = new ArrayList<>();
+ for (DataEvolutionCompactTask task : coordinator.plan()) {
+ messages.add(task.doCompact(table, "compact"));
+ }
+ assertThat(messages).isNotEmpty();
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ /**
+ * Like a copy-on-write UPDATE of the first file of a row-tracking table:
rewrites it with the
+ * row ids of its rows stored in the new file.
+ */
+ private static void copyOnWriteUpdate(FileStoreTable table) throws
Exception {
+ DataFileMeta rewritten = null;
+ BinaryRow partition = null;
+ for (ManifestEntry entry : entries(table)) {
+ if (entry.file().firstRowId() != null && entry.file().firstRowId()
== 0L) {
+ rewritten = entry.file();
+ partition = entry.partition();
+ }
+ }
+ assertThat(rewritten).isNotNull();
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write =
+ builder.newWrite()
+ .withWriteType(
+ SpecialFields.rowTypeWithRowTracking(
+ table.rowType(), false, true));
+ BatchTableCommit commit = builder.newCommit()) {
+ write.write(
+ GenericRow.of(
+ 1,
+ BinaryString.fromString("A"),
+ BinaryString.fromString("p1"),
+ 0L,
+ null));
+ write.write(
+ GenericRow.of(
+ 2,
+ BinaryString.fromString("b"),
+ BinaryString.fromString("p1"),
+ 1L,
+ null));
+ CommitMessageImpl message = (CommitMessageImpl)
write.prepareCommit().get(0);
+ commit.commit(
+ Collections.singletonList(
+ new CommitMessageImpl(
+ partition,
+ message.bucket(),
+ message.totalBuckets(),
+ new DataIncrement(
+
message.newFilesIncrement().newFiles(),
+
Collections.singletonList(rewritten),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement())));
+ }
+ }
+
+ /** Copies and commits like {@code CopyFilesCommitOperator}, optionally
one partition only. */
+ private void copy(Identifier source, Identifier target, String partition)
throws Exception {
+ commitCopy(table(target), prepareCopy(source, target, partition));
+ }
+
+ private Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> prepareCopy(
+ Identifier source, Identifier target) throws Exception {
+ return prepareCopy(source, target, null);
+ }
+
+ /** Copies the data files physically and adapts their metadata to the
target. */
+ private Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> prepareCopy(
+ Identifier source, Identifier target, String partition) throws
Exception {
+ FileStoreTable from = table(source);
+ FileStoreTable to = table(target);
+ Map<Pair<BinaryRow, Integer>, List<DataFileMeta>> files = new
LinkedHashMap<>();
+ for (ManifestEntry entry : entries(from)) {
+ if (partition != null
+ && (entry.partition().getFieldCount() == 0
+ ||
!partition.equals(entry.partition().getString(0).toString()))) {
+ continue;
+ }
+ Path fromBucket =
+ from.store().pathFactory().bucketPath(entry.partition(),
entry.bucket());
+ Path toBucket =
to.store().pathFactory().bucketPath(entry.partition(), entry.bucket());
+ to.fileIO().mkdirs(toBucket);
+ to.fileIO()
+ .copyFile(
+ new Path(fromBucket, entry.file().fileName()),
+ new Path(toBucket, entry.file().fileName()),
+ false);
+ files.computeIfAbsent(
+ Pair.of(entry.partition().copy(), entry.bucket()),
+ k -> new ArrayList<>())
+ .add(entry.file());
+ }
+ CopiedDataFiles.adaptToTarget(to, files.values());
+ return files;
+ }
+
+ /** The overwrite commit of {@code CopyFilesCommitOperator}. */
+ private static void commitCopy(
+ FileStoreTable target, Map<Pair<BinaryRow, Integer>,
List<DataFileMeta>> files)
+ throws Exception {
+ List<CommitMessage> messages = new ArrayList<>();
+ for (Map.Entry<Pair<BinaryRow, Integer>, List<DataFileMeta>> entry :
files.entrySet()) {
+ messages.add(
+ new CommitMessageImpl(
+ entry.getKey().getLeft(),
+ entry.getKey().getRight(),
+ target.coreOptions().bucket(),
+ new DataIncrement(
+ entry.getValue(),
+ Collections.emptyList(),
+ Collections.emptyList()),
+ CompactIncrement.emptyIncrement()));
+ }
+ try (BatchTableCommit commit =
target.newBatchWriteBuilder().withOverwrite().newCommit()) {
+ commit.commit(messages);
+ }
+ }
+
+ private static String fileWithFirstRowId(List<DataFileMeta> files, long
firstRowId) {
+ for (DataFileMeta file : files) {
+ if (file.firstRowId() != null && file.firstRowId() == firstRowId) {
+ return file.fileName();
+ }
+ }
+ throw new AssertionError("no file with first row id " + firstRowId);
+ }
+
+ private static List<ManifestEntry> entries(FileStoreTable table) {
+ List<ManifestEntry> entries = new ArrayList<>();
+
table.newSnapshotReader().readFileIterator().forEachRemaining(entries::add);
+ return entries;
+ }
+
+ private static List<DataFileMeta> liveFiles(FileStoreTable table) {
+ List<DataFileMeta> files = new ArrayList<>();
+ entries(table).forEach(entry -> files.add(entry.file()));
+ return files;
+ }
+
+ private static boolean storesRowIds(DataFileMeta file) {
+ return file.writeCols() != null &&
file.writeCols().contains(SpecialFields.ROW_ID.name());
+ }
+
+ private static Map<Integer, String> values(FileStoreTable table) throws
Exception {
+ Map<Integer, String> result = new TreeMap<>();
+ for (InternalRow row : read(table, table.rowType())) {
+ result.put(row.getInt(0), row.getString(1).toString());
+ }
+ return result;
+ }
+
+ private static Map<Integer, Long> rowIds(FileStoreTable table) throws
Exception {
+ Map<Integer, Long> result = new TreeMap<>();
+ RowType readType =
SpecialFields.rowTypeWithRowTracking(table.rowType(), true, true);
+ for (InternalRow row : read(table, readType)) {
+ result.put(row.getInt(0), row.isNullAt(3) ? null : row.getLong(3));
+ }
+ return result;
+ }
+
+ private static void assertUniqueRowIds(FileStoreTable table) throws
Exception {
+ List<Long> ids = new ArrayList<>(rowIds(table).values());
+ assertThat(ids).doesNotContainNull();
+ assertThat(new HashSet<>(ids)).hasSameSizeAs(ids);
+ }
+
+ private static List<InternalRow> read(FileStoreTable table, RowType
readType) throws Exception {
+ ReadBuilder readBuilder =
table.newReadBuilder().withReadType(readType);
+ TableRead read = readBuilder.newRead();
+ InternalRowSerializer serializer = new InternalRowSerializer(readType);
+ List<InternalRow> rows = new ArrayList<>();
+ for (Split split : readBuilder.newScan().plan().splits()) {
+ try (RecordReader<InternalRow> reader = read.createReader(split)) {
+ reader.forEachRemaining(row -> rows.add(serializer.copy(row)));
+ }
+ }
+ return rows;
+ }
+
+ /** Pauses the committer thread at its first manifest write. */
+ private static class PausingFileIO extends LocalFileIO {
+
+ private final AtomicReference<Thread> committer;
+ private final CountDownLatch paused;
+ private final CountDownLatch released;
+ private final AtomicBoolean pausedOnce = new AtomicBoolean();
+
+ private PausingFileIO(
+ AtomicReference<Thread> committer, CountDownLatch paused,
CountDownLatch released) {
+ this.committer = committer;
+ this.paused = paused;
+ this.released = released;
+ }
+
+ @Override
+ public PositionOutputStream newOutputStream(Path path, boolean
overwrite)
+ throws IOException {
+ if (Thread.currentThread() == committer.get()
+ && path.getParent().getName().equals("manifest")
+ && pausedOnce.compareAndSet(false, true)) {
+ paused.countDown();
+ try {
+ released.await(60, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ throw new IOException(e);
+ }
+ }
+ return super.newOutputStream(path, overwrite);
+ }
+ }
+}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
index aa95263808..38dc6599cb 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
@@ -466,6 +466,8 @@ public class DataEvolutionTableTest extends
DataEvolutionTestBase {
BatchTableCommit commit = builder.newCommit();
List<CommitMessage> commitables = write1.prepareCommit();
+ // a column update of the rows written above
+ setFirstRowId(commitables, 0L);
commit.commit(commitables);
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/RowTrackingCopiedFilesTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/RowTrackingCopiedFilesTest.java
new file mode 100644
index 0000000000..3ab5c2b4d2
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/table/RowTrackingCopiedFilesTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.table;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.CompactIncrement;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableRead;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for committing files that already carry row ids into a row-tracking
table, as {@code
+ * sys.copy} does.
+ */
+public class RowTrackingCopiedFilesTest extends TableTestBase {
+
+ private static final Identifier SOURCE = new Identifier("default", "src");
+ private static final Identifier TARGET = new Identifier("default", "dst");
+
+ @Test
+ public void testRowsWrittenAfterCopiedFilesGetNewRowIds() throws Exception
{
+ FileStoreTable source = createRowTrackingTable(SOURCE);
+ writeRows(source, row(1, "a"));
+ writeRows(source, row(2, "b"));
+ FileStoreTable target = createRowTrackingTable(TARGET);
+
+ commitCopiedFiles(source, target, Collections.emptyList());
+ target = table(TARGET);
+ assertThat(rowIds(target)).containsEntry(1, 0L).containsEntry(2, 1L);
+ // the next row id moves past the row ids of the copied files
+
assertThat(target.snapshotManager().latestSnapshot().nextRowId()).isEqualTo(2L);
+
+ writeRows(target, row(3, "c"), row(4, "d"));
+ assertThat(rowIds(table(TARGET)))
+ .containsEntry(1, 0L)
+ .containsEntry(2, 1L)
+ .containsEntry(3, 2L)
+ .containsEntry(4, 3L);
+ }
+
+ @Test
+ public void testNewFilesCommittedWithCopiedFilesGetRowIdsAfterThem()
throws Exception {
+ FileStoreTable source = createRowTrackingTable(SOURCE);
+ writeRows(source, row(1, "a"), row(2, "b"));
+ FileStoreTable target = createRowTrackingTable(TARGET);
+
+ // a file written for the target, committed together with the copied
ones
+ List<DataFileMeta> newFiles;
+ try (BatchTableWrite write = target.newBatchWriteBuilder().newWrite())
{
+ write.write(row(3, "c"));
+ newFiles =
+ ((CommitMessageImpl) write.prepareCommit().get(0))
+ .newFilesIncrement()
+ .newFiles();
+ }
+ commitCopiedFiles(source, target, newFiles);
+
+ target = table(TARGET);
+ assertThat(rowIds(target)).containsEntry(1, 0L).containsEntry(2,
1L).containsEntry(3, 2L);
+
assertThat(target.snapshotManager().latestSnapshot().nextRowId()).isEqualTo(3L);
+ }
+
+ /**
+ * Copies the data files of {@code source} into {@code target} and commits
them, with {@code
+ * extraFiles}, as {@code CopyFilesCommitOperator} does: with the row ids
they have.
+ */
+ private void commitCopiedFiles(
+ FileStoreTable source, FileStoreTable target, List<DataFileMeta>
extraFiles)
+ throws Exception {
+ List<DataFileMeta> files = new ArrayList<>();
+ source.newSnapshotReader().readFileIterator().forEachRemaining(e ->
files.add(e.file()));
+ Path sourceBucket =
source.store().pathFactory().bucketPath(BinaryRow.EMPTY_ROW, 0);
+ Path targetBucket =
target.store().pathFactory().bucketPath(BinaryRow.EMPTY_ROW, 0);
+ target.fileIO().mkdirs(targetBucket);
+ for (DataFileMeta file : files) {
+ target.fileIO()
+ .copyFile(
+ new Path(sourceBucket, file.fileName()),
+ new Path(targetBucket, file.fileName()),
+ false);
+ }
+ files.addAll(extraFiles);
+ CommitMessage message =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ target.coreOptions().bucket(),
+ new DataIncrement(files, Collections.emptyList(),
Collections.emptyList()),
+ CompactIncrement.emptyIncrement());
+ try (BatchTableCommit commit =
target.newBatchWriteBuilder().withOverwrite().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ }
+
+ private FileStoreTable createRowTrackingTable(Identifier identifier)
throws Exception {
+ catalog.createTable(
+ identifier,
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("v", DataTypes.STRING())
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .build(),
+ false);
+ return table(identifier);
+ }
+
+ private FileStoreTable table(Identifier identifier) throws Exception {
+ return (FileStoreTable) catalog.getTable(identifier);
+ }
+
+ private static GenericRow row(int id, String v) {
+ return GenericRow.of(id, BinaryString.fromString(v));
+ }
+
+ private static void writeRows(FileStoreTable table, GenericRow... rows)
throws Exception {
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = builder.newWrite();
+ BatchTableCommit commit = builder.newCommit()) {
+ for (GenericRow row : rows) {
+ write.write(row);
+ }
+ commit.commit(write.prepareCommit());
+ }
+ }
+
+ private static Map<Integer, Long> rowIds(FileStoreTable table) throws
Exception {
+ RowType readType =
SpecialFields.rowTypeWithRowTracking(table.rowType(), true, true);
+ ReadBuilder readBuilder =
table.newReadBuilder().withReadType(readType);
+ TableRead read = readBuilder.newRead();
+ InternalRowSerializer serializer = new InternalRowSerializer(readType);
+ Map<Integer, Long> result = new HashMap<>();
+ for (Split split : readBuilder.newScan().plan().splits()) {
+ try (RecordReader<InternalRow> reader = read.createReader(split)) {
+ reader.forEachRemaining(
+ r -> {
+ InternalRow row = serializer.copy(r);
+ result.put(row.getInt(0), row.isNullAt(2) ? null :
row.getLong(2));
+ });
+ }
+ }
+ return result;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesCommitOperator.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesCommitOperator.java
index d2284e5e55..e2dd5d65cb 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesCommitOperator.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesCommitOperator.java
@@ -25,6 +25,7 @@ import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexFileMetaSerializer;
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.io.DataFileMetaSerializer;
+import org.apache.paimon.migrate.CopiedDataFiles;
import org.apache.paimon.migrate.FileMetaUtils;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.sink.BatchTableCommit;
@@ -71,6 +72,12 @@ public class CopyFilesCommitOperator extends
CopyFilesOperator {
Map<Tuple2<BinaryRow, Integer>, IndexFileInfo> indexFileMetaMap =
deserializeIndexFileMeta(indexCopyFileInfoRdd);
+ // the copied files keep the row ids and sequence numbers of the
source: adapt them to the
+ // target before committing them
+ List<List<DataFileMeta>> copiedFiles = new ArrayList<>();
+ dataFileMetaMap.values().forEach(info ->
copiedFiles.add(info.dataFileMetas()));
+ CopiedDataFiles.adaptToTarget(targetTable, copiedFiles);
+
// construct commit messages
List<CommitMessage> commitMessages = new ArrayList<>();
for (Map.Entry<Tuple2<BinaryRow, Integer>, DataFileInfo> entry :
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesUtil.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesUtil.java
index d9243cf823..5ec69b7501 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesUtil.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/CopyFilesUtil.java
@@ -24,6 +24,7 @@ import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.fs.SeekableInputStream;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
import org.apache.paimon.io.PojoDataFileMeta;
import org.apache.paimon.schema.TableSchema;
@@ -35,6 +36,9 @@ import java.io.IOException;
import java.util.List;
import java.util.Optional;
+import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
+import static org.apache.paimon.types.VectorType.isVectorStoreFile;
+
/** Utils for copy files. */
public class CopyFilesUtil {
@@ -51,6 +55,22 @@ public class CopyFilesUtil {
}
}
+ /**
+ * A new path for the copy of the data file {@code sourceFileName}. A blob
or vector file keeps
+ * its kind in its name: that is how readers and commits tell it from the
normal file holding
+ * the same rows.
+ */
+ public static Path newDataFilePath(DataFilePathFactory pathFactory, String
sourceFileName) {
+ if (isVectorStoreFile(sourceFileName)) {
+ return
pathFactory.newVectorPath(DataFilePathFactory.formatIdentifier(sourceFileName));
+ }
+ if (isBlobFile(sourceFileName)) {
+ return pathFactory.newPathFromExtension(
+ sourceFileName.substring(sourceFileName.lastIndexOf('.')));
+ }
+ return pathFactory.newPath();
+ }
+
public static DataFileMeta toNewDataFileMeta(
DataFileMeta oldFileMeta, String newFileName, long newSchemaId) {
return toNewDataFileMeta(oldFileMeta, newFileName, newSchemaId, null);
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/ListDataFilesOperator.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/ListDataFilesOperator.java
index 366fbc6cc6..cf144438fb 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/ListDataFilesOperator.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/copy/ListDataFilesOperator.java
@@ -109,10 +109,10 @@ public class ListDataFilesOperator extends
CopyFilesOperator {
manifestEntry.partition(),
manifestEntry.bucket())
.toPath(manifestEntry);
Path targetDataFilePath =
- targetFileStorePathFactory
- .createDataFilePathFactory(
- manifestEntry.partition(),
manifestEntry.bucket())
- .newPath();
+ CopyFilesUtil.newDataFilePath(
+ targetFileStorePathFactory.createDataFilePathFactory(
+ manifestEntry.partition(),
manifestEntry.bucket()),
+ manifestEntry.fileName());
DataFileMeta fileMeta = manifestEntry.file();
DataFileMeta targetFileMeta =
CopyFilesUtil.toNewDataFileMeta(
diff --git
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
index 08d529725a..67fa687311 100644
---
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
+++
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/copy/CopyFilesUtilTest.java
@@ -19,7 +19,9 @@
package org.apache.paimon.spark.copy;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.fs.Path;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.TableSchema;
import org.apache.paimon.stats.SimpleStats;
@@ -35,6 +37,32 @@ import static org.assertj.core.api.Assertions.assertThat;
/** Tests for {@link CopyFilesUtil}. */
public class CopyFilesUtilTest {
+ @Test
+ void testNewDataFilePathKeepsTheKindOfTheFile() {
+ DataFilePathFactory pathFactory =
+ new DataFilePathFactory(
+ new Path("/target/bucket-0"),
+ "parquet",
+ "data-",
+ "changelog-",
+ false,
+ null,
+ null);
+
+ assertThat(newName(pathFactory, "data-a-0.parquet"))
+ .endsWith(".parquet")
+ .doesNotContain(".vector.");
+ assertThat(newName(pathFactory,
"data-a-0.vector.lance")).endsWith(".vector.lance");
+ assertThat(newName(pathFactory, "data-a-0.blob")).endsWith(".blob");
+ assertThat(newName(pathFactory, "data-a-0.video")).endsWith(".video");
+ }
+
+ private static String newName(DataFilePathFactory pathFactory, String
sourceFileName) {
+ String name = CopyFilesUtil.newDataFilePath(pathFactory,
sourceFileName).getName();
+ assertThat(name).startsWith("data-").doesNotStartWith("data-a-");
+ return name;
+ }
+
@Test
void testClearWriteColsSequencesWhenChangingSchemaId() {
DataFileMeta source =
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CopyFilesProcedureTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CopyFilesProcedureTest.scala
index 6c35af37e8..e9d43b81fd 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CopyFilesProcedureTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/procedure/CopyFilesProcedureTest.scala
@@ -19,6 +19,7 @@
package org.apache.paimon.spark.procedure
import org.apache.paimon.spark.PaimonSparkTestBase
+import org.apache.paimon.utils.ExceptionUtils
import org.apache.spark.sql.Row
@@ -225,4 +226,291 @@ class CopyFilesProcedureTest extends PaimonSparkTestBase {
}
}
}
+
+ test("Paimon copy files procedure: rows written after the copy get new row
ids") {
+ withTable("src", "dst") {
+ sql("CREATE TABLE src (id INT, v STRING) TBLPROPERTIES
('row-tracking.enabled' = 'true')")
+ sql("INSERT INTO src VALUES (1, 'a')")
+ sql("INSERT INTO src VALUES (2, 'b')")
+
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ // the copied rows keep their row ids
+ checkAnswer(sql("SELECT id, _ROW_ID FROM dst"), Seq(Row(1, 0), Row(2,
1)))
+
+ // rows written afterwards continue after them instead of reusing them
+ sql("INSERT INTO dst VALUES (3, 'c'), (4, 'd')")
+ checkAnswer(
+ sql("SELECT id, _ROW_ID FROM dst"),
+ Seq(Row(1, 0), Row(2, 1), Row(3, 2), Row(4, 3)))
+ }
+ }
+
+ test("Paimon copy files procedure: copied rows follow the row ids of rows
the copy keeps") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, v STRING, pt STRING) PARTITIONED BY (pt)
+ |TBLPROPERTIES ('row-tracking.enabled' = 'true')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (1, 'a', 'p1'), (2, 'b', 'p1')")
+ sql("""
+ |CREATE TABLE dst (id INT, v STRING, pt STRING) PARTITIONED BY (pt)
+ |TBLPROPERTIES ('row-tracking.enabled' = 'true')
+ |""".stripMargin)
+ sql("INSERT INTO dst VALUES (10, 'x', 'p2'), (11, 'y', 'p2')")
+
+ // only partition p1 is overwritten: the rows of p2 keep row ids 0 and 1
+ checkAnswer(
+ sql(
+ """CALL sys.copy(source_table => 'src', target_table => 'dst', where
=> "pt = 'p1'")"""),
+ Row(true) :: Nil)
+ checkAnswer(
+ sql("SELECT id, v, _ROW_ID FROM dst"),
+ Seq(Row(1, "a", 2), Row(2, "b", 3), Row(10, "x", 0), Row(11, "y", 1)))
+
+ sql("INSERT INTO dst VALUES (3, 'c', 'p1')")
+ checkAnswer(sql("SELECT _ROW_ID FROM dst WHERE id = 3"), Row(4) :: Nil)
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID), count(*) FROM dst"),
Row(5, 5) :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: updates after copying a data-evolution
table take effect") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, v STRING) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'compaction.min.file-num' = '2')
+ |""".stripMargin)
+ // many snapshots: the source files carry sequence numbers above the
snapshots of dst
+ (1 to 10).foreach(i => sql(s"INSERT INTO src VALUES ($i, 'v$i')"))
+ sql("UPDATE src SET v = 'u1' WHERE id = 1")
+
+ // the update is a file holding the same rows as another one, which
cannot be copied
+ val error = intercept[Exception] {
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')")
+ }
+ val trace = ExceptionUtils.stringifyException(error)
+ assert(trace.contains("Compact the source table first"), trace)
+ checkAnswer(sql("SELECT count(*) FROM dst"), Row(0) :: Nil)
+
+ sql("CALL sys.compact(table => 'src')")
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT id, v FROM dst"), sql("SELECT id, v FROM src"))
+ checkAnswer(sql("SELECT v FROM dst WHERE id = 1"), Row("u1") :: Nil)
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID), count(*) FROM dst"),
Row(10, 10) :: Nil)
+
+ sql("UPDATE dst SET v = 'u2' WHERE id = 2")
+ sql("""
+ |MERGE INTO dst USING (SELECT _ROW_ID AS rid FROM dst WHERE id IN
(1, 10)) s
+ |ON dst._ROW_ID = s.rid
+ |WHEN MATCHED THEN UPDATE SET v = 'm'
+ |""".stripMargin)
+ checkAnswer(
+ sql("SELECT id, v FROM dst WHERE id IN (1, 2, 3, 10) ORDER BY id"),
+ Seq(Row(1, "m"), Row(2, "u2"), Row(3, "v3"), Row(10, "m")))
+ }
+ }
+
+ test("Paimon copy files procedure: files that store their row ids are
refused") {
+ withTable("src", "dst") {
+ sql("CREATE TABLE src (id INT, v STRING) TBLPROPERTIES
('row-tracking.enabled' = 'true')")
+ sql("INSERT INTO src VALUES (1, 'a'), (2, 'b')")
+ // a copy-on-write update stores the row ids of the rewritten rows in
the new file
+ sql("UPDATE src SET v = 'A' WHERE id = 1")
+ assert(
+ sql("SELECT write_cols FROM `src$files`")
+ .collect()
+ .exists(row => !row.isNullAt(0) &&
row.getSeq[String](0).contains("_ROW_ID")))
+
+ val error = intercept[Exception] {
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')")
+ }
+ val trace = ExceptionUtils.stringifyException(error)
+ assert(trace.contains("its rows store their row ids"), trace)
+ }
+ }
+
+ test("Paimon copy files procedure: data-evolution table with a vector
field") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, embedding ARRAY<FLOAT>) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'file.format' = 'parquet',
+ | 'vector.file.format' = 'parquet',
+ | 'vector-field' = 'embedding',
+ | 'field.embedding.vector-dim' = '2')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (1, array(1.0f, 0.0f)), (2, array(2.0f,
0.0f))")
+ val vectorFiles = fileNames("src").count(_.contains(".vector."))
+ assert(vectorFiles > 0, fileNames("src"))
+
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ // the vector file is copied as a vector file
+ assert(fileNames("dst").count(_.contains(".vector.")) == vectorFiles,
fileNames("dst"))
+ checkAnswer(
+ sql("SELECT id, embedding, _ROW_ID FROM dst"),
+ sql("SELECT id, embedding, _ROW_ID FROM src"))
+
+ sql("INSERT INTO dst VALUES (3, array(3.0f, 0.0f))")
+ checkAnswer(
+ sql("SELECT id, embedding, _ROW_ID FROM dst WHERE id = 3"),
+ Row(3, Seq(3.0f, 0.0f), 2) :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: vector field added after the first rows")
{
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'file.format' = 'parquet',
+ | 'vector.file.format' = 'parquet')
+ |""".stripMargin)
+ // these rows have no vector file
+ sql("INSERT INTO src VALUES (1), (2)")
+ sql("ALTER TABLE src ADD COLUMN embedding ARRAY<FLOAT> COMMENT
'__VECTOR_FIELD;2'")
+ sql("INSERT INTO src VALUES (3, array(3.0f, 0.0f)), (4, array(4.0f,
0.0f))")
+ assert(fileNames("src").exists(_.contains(".vector.")), fileNames("src"))
+
+ assertCopyRefused("src", "dst")
+ // rewriting the rows gives each of them its vector
+ sql("INSERT OVERWRITE src SELECT * FROM src")
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT id, embedding FROM dst"), sql("SELECT id,
embedding FROM src"))
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID) = count(*) FROM dst"),
Row(true) :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: vector column updated in the source") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, embedding ARRAY<FLOAT>) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'file.format' = 'parquet',
+ | 'vector.file.format' = 'parquet',
+ | 'vector-field' = 'embedding',
+ | 'field.embedding.vector-dim' = '2')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (1, array(1.0f, 0.0f)), (2, array(2.0f,
0.0f))")
+ sql("UPDATE src SET embedding = array(9.0f, 0.0f) WHERE id = 1")
+
+ assertCopyRefused("src", "dst")
+ // rewriting the rows gives each of them its vector
+ sql("INSERT OVERWRITE src SELECT * FROM src")
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT id, embedding FROM dst"), sql("SELECT id,
embedding FROM src"))
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID) = count(*) FROM dst"),
Row(true) :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: data-evolution table with a blob field") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, picture BINARY) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'blob-field' = 'picture')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (1, CAST('p1' AS BINARY)), (2, CAST('p2' AS
BINARY))")
+ val blobFiles = fileNames("src").count(_.endsWith(".blob"))
+ assert(blobFiles > 0, fileNames("src"))
+
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ // the blob file is copied as a blob file
+ assert(fileNames("dst").count(_.endsWith(".blob")) == blobFiles,
fileNames("dst"))
+ checkAnswer(
+ sql("SELECT id, CAST(picture AS STRING), _ROW_ID FROM dst"),
+ sql("SELECT id, CAST(picture AS STRING), _ROW_ID FROM src"))
+ checkAnswer(sql("SELECT CAST(picture AS STRING) FROM dst WHERE id = 2"),
Row("p2") :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: partitioned data-evolution table with a
vector field") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, embedding ARRAY<FLOAT>, pt STRING)
+ |PARTITIONED BY (pt) TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'file.format' = 'parquet',
+ | 'vector.file.format' = 'parquet',
+ | 'vector-field' = 'embedding',
+ | 'field.embedding.vector-dim' = '2')
+ |""".stripMargin)
+ sql("""
+ |INSERT INTO src VALUES
+ |(1, array(1.0f, 0.0f), 'a'), (2, array(2.0f, 0.0f), 'b'), (3,
array(3.0f, 0.0f), 'c')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (4, array(4.0f, 0.0f), 'a'), (5, array(5.0f,
0.0f), 'c')")
+
+ // the commit may order the partitions in any way
+ checkAnswer(
+ sql("CALL sys.copy(source_table => 'src', target_table => 'dst')"),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT * FROM dst"), sql("SELECT * FROM src"))
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID) = count(*) FROM dst"),
Row(true) :: Nil)
+
+ // only partition b is overwritten
+ sql("INSERT INTO src VALUES (6, array(6.0f, 0.0f), 'b')")
+ checkAnswer(
+ sql("""CALL sys.copy(source_table => 'src', target_table => 'dst',
where => "pt = 'b'")"""),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT * FROM dst"), sql("SELECT * FROM src"))
+ checkAnswer(sql("SELECT count(DISTINCT _ROW_ID) = count(*) FROM dst"),
Row(true) :: Nil)
+ }
+ }
+
+ test("Paimon copy files procedure: partition written before the vector field
was added") {
+ withTable("src", "dst") {
+ sql("""
+ |CREATE TABLE src (id INT, pt STRING) PARTITIONED BY (pt)
TBLPROPERTIES (
+ | 'row-tracking.enabled' = 'true',
+ | 'data-evolution.enabled' = 'true',
+ | 'file.format' = 'parquet',
+ | 'vector.file.format' = 'parquet')
+ |""".stripMargin)
+ sql("INSERT INTO src VALUES (1, 'a'), (2, 'a')")
+ sql("ALTER TABLE src ADD COLUMN embedding ARRAY<FLOAT> COMMENT
'__VECTOR_FIELD;2'")
+ sql("INSERT INTO src VALUES (3, 'b', array(3.0f, 0.0f)), (4, 'b',
array(4.0f, 0.0f))")
+
+ // the rows of partition a have no vector file, which would move those
of partition b
+ assertCopyRefused("src", "dst")
+
+ // each partition alone has a vector file for all of its rows or for none
+ checkAnswer(
+ sql("""CALL sys.copy(source_table => 'src', target_table => 'dst',
where => "pt = 'b'")"""),
+ Row(true) :: Nil)
+ checkAnswer(
+ sql("""CALL sys.copy(source_table => 'src', target_table => 'dst',
where => "pt = 'a'")"""),
+ Row(true) :: Nil)
+ checkAnswer(sql("SELECT * FROM dst"), sql("SELECT * FROM src"))
+ }
+ }
+
+ private def assertCopyRefused(source: String, target: String): Unit = {
+ val error = intercept[Exception] {
+ sql(s"CALL sys.copy(source_table => '$source', target_table =>
'$target')")
+ }
+ val trace = ExceptionUtils.stringifyException(error)
+ assert(trace.contains("row ids of other rows"), trace)
+ checkAnswer(sql(s"SELECT count(*) FROM $target"), Row(0) :: Nil)
+ }
+
+ private def fileNames(table: String): Seq[String] =
+ sql(s"SELECT file_path FROM
`$table$$files`").collect().map(_.getString(0)).toSeq
}