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 209a1ba679 [core][flink] Record row ID mappings for clustering output 
(#10681)
209a1ba679 is described below

commit 209a1ba679d7d45dfbf877280a28e23be241ba39
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 12:46:20 2026 +0800

    [core][flink] Record row ID mappings for clustering output (#10681)
---
 .../dataevolution/ClusteringRowIdMapping.java      | 113 ++++++++++
 .../dataevolution/ClusteringRowIdMappingTest.java  | 139 ++++++++++++
 .../cluster/DataEvolutionClusterWriteOperator.java | 240 ++++++++++++++++++++
 .../DataEvolutionClusterWriteOperatorTest.java     | 250 +++++++++++++++++++++
 4 files changed, 742 insertions(+)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMapping.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMapping.java
new file mode 100644
index 0000000000..09a62c8eba
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMapping.java
@@ -0,0 +1,113 @@
+/*
+ * 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.append.dataevolution;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.Closeable;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Old row IDs in the physical row order of a clustering output file. */
+public final class ClusteringRowIdMapping {
+
+    private static final String PREFIX = "cluster-row-id-";
+    private static final String SUFFIX = ".mapping";
+    private static final int MAGIC = 0x43524944;
+    private static final int VERSION = 1;
+
+    private ClusteringRowIdMapping() {}
+
+    public static String fileName(String dataFileName) {
+        return PREFIX + dataFileName + SUFFIX;
+    }
+
+    public static boolean isMappingFile(String fileName) {
+        return fileName.startsWith(PREFIX) && fileName.endsWith(SUFFIX);
+    }
+
+    public static Writer createWriter(FileIO fileIO, Path path) throws 
IOException {
+        return new Writer(fileIO, path);
+    }
+
+    public static void read(FileIO fileIO, Path path, long rowCount, 
RowIdConsumer consumer)
+            throws IOException {
+        checkArgument(rowCount >= 0, "Invalid clustering row count: %s", 
rowCount);
+        try (DataInputStream in =
+                new DataInputStream(new 
BufferedInputStream(fileIO.newInputStream(path)))) {
+            checkArgument(in.readInt() == MAGIC, "Invalid clustering row ID 
mapping: %s", path);
+            checkArgument(
+                    in.readInt() == VERSION, "Unsupported clustering row ID 
mapping: %s", path);
+            for (long ordinal = 0; ordinal < rowCount; ordinal++) {
+                long oldRowId = in.readLong();
+                checkArgument(oldRowId >= 0, "Invalid old row ID: %s", 
oldRowId);
+                consumer.accept(ordinal, oldRowId);
+            }
+            checkArgument(in.read() == -1, "Unexpected trailing clustering row 
IDs: %s", path);
+        }
+    }
+
+    /** Streaming consumer; the ordinal is local to the output data file. */
+    @FunctionalInterface
+    public interface RowIdConsumer {
+        void accept(long ordinal, long oldRowId) throws IOException;
+    }
+
+    /**
+     * Streaming writer, also used when splitting a spilled sort output at 
actual file boundaries.
+     */
+    public static final class Writer implements Closeable {
+
+        private final DataOutputStream out;
+
+        private Writer(FileIO fileIO, Path path) throws IOException {
+            out =
+                    new DataOutputStream(
+                            new 
BufferedOutputStream(fileIO.newOutputStream(path, false)));
+            try {
+                out.writeInt(MAGIC);
+                out.writeInt(VERSION);
+            } catch (IOException | RuntimeException | Error failure) {
+                try {
+                    out.close();
+                } catch (IOException closeFailure) {
+                    failure.addSuppressed(closeFailure);
+                }
+                fileIO.deleteQuietly(path);
+                throw failure;
+            }
+        }
+
+        public void write(long oldRowId) throws IOException {
+            checkArgument(oldRowId >= 0, "Invalid old row ID: %s", oldRowId);
+            out.writeLong(oldRowId);
+        }
+
+        @Override
+        public void close() throws IOException {
+            out.close();
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMappingTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMappingTest.java
new file mode 100644
index 0000000000..78ba1a7a36
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/ClusteringRowIdMappingTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.append.dataevolution;
+
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests the output-file ordinal mapping and rejects incomplete or corrupt 
side files. */
+class ClusteringRowIdMappingTest {
+
+    private static final int MAGIC = 0x43524944;
+    private final FileIO fileIO = LocalFileIO.create();
+
+    @TempDir java.nio.file.Path tempPath;
+
+    @Test
+    void testRoundTripKeepsPhysicalOrder() throws Exception {
+        Path path = path("round-trip");
+        try (ClusteringRowIdMapping.Writer writer =
+                ClusteringRowIdMapping.createWriter(fileIO, path)) {
+            writer.write(10);
+            writer.write(0);
+            writer.write(Long.MAX_VALUE);
+        }
+        List<Long> ordinals = new ArrayList<>();
+        List<Long> oldRowIds = new ArrayList<>();
+        ClusteringRowIdMapping.read(
+                fileIO,
+                path,
+                3,
+                (ordinal, oldRowId) -> {
+                    ordinals.add(ordinal);
+                    oldRowIds.add(oldRowId);
+                });
+        assertThat(ordinals).containsExactly(0L, 1L, 2L);
+        assertThat(oldRowIds).containsExactly(10L, 0L, Long.MAX_VALUE);
+    }
+
+    @Test
+    void testRejectsTruncatedAndTrailingRows() throws Exception {
+        Path path = raw("row-count", MAGIC, 1, 10L);
+        assertThatThrownBy(
+                        () -> ClusteringRowIdMapping.read(fileIO, path, 2, 
(ordinal, oldId) -> {}))
+                .isInstanceOf(EOFException.class);
+        assertThatThrownBy(
+                        () -> ClusteringRowIdMapping.read(fileIO, path, 0, 
(ordinal, oldId) -> {}))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Unexpected trailing clustering row 
IDs");
+    }
+
+    @Test
+    void testRejectsInvalidHeaderAndOldRowIds() throws Exception {
+        Path magic = raw("magic", 0, 1);
+        assertThatThrownBy(
+                        () -> ClusteringRowIdMapping.read(fileIO, magic, 0, 
(ordinal, oldId) -> {}))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Invalid clustering row ID mapping");
+        Path version = raw("version", MAGIC, 2);
+        assertThatThrownBy(
+                        () ->
+                                ClusteringRowIdMapping.read(
+                                        fileIO, version, 0, (ordinal, oldId) 
-> {}))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Unsupported clustering row ID mapping");
+        Path negative = raw("negative", MAGIC, 1, -1);
+        assertThatThrownBy(
+                        () ->
+                                ClusteringRowIdMapping.read(
+                                        fileIO, negative, 1, (ordinal, oldId) 
-> {}))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Invalid old row ID");
+        assertThatThrownBy(
+                        () ->
+                                ClusteringRowIdMapping.read(
+                                        fileIO, negative, -1, (ordinal, oldId) 
-> {}))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Invalid clustering row count");
+    }
+
+    @Test
+    void testWriterRejectsNegativeRowIds() throws Exception {
+        Path path = path("invalid-write");
+        try (ClusteringRowIdMapping.Writer writer =
+                ClusteringRowIdMapping.createWriter(fileIO, path)) {
+            assertThatThrownBy(() -> writer.write(-1))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("Invalid old row ID");
+            writer.write(3);
+        }
+        List<Long> rows = new ArrayList<>();
+        ClusteringRowIdMapping.read(fileIO, path, 1, (ordinal, oldId) -> 
rows.add(oldId));
+        assertThat(rows).containsExactly(3L);
+    }
+
+    private Path raw(String name, int magic, int version, long... rows) throws 
IOException {
+        Path path = path(name);
+        try (DataOutputStream out = new 
DataOutputStream(fileIO.newOutputStream(path, false))) {
+            out.writeInt(magic);
+            out.writeInt(version);
+            for (long row : rows) {
+                out.writeLong(row);
+            }
+        }
+        return path;
+    }
+
+    private Path path(String name) {
+        return new Path(tempPath.resolve(name).toUri());
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperator.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperator.java
new file mode 100644
index 0000000000..4199c5d49e
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperator.java
@@ -0,0 +1,240 @@
+/*
+ * 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.flink.cluster;
+
+import org.apache.paimon.append.dataevolution.ClusteringRowIdMapping;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.disk.IOManager;
+import org.apache.paimon.flink.memory.FlinkMemorySegmentPool;
+import org.apache.paimon.flink.memory.MemorySegmentAllocator;
+import org.apache.paimon.flink.sink.Committable;
+import org.apache.paimon.flink.utils.BoundedOneInputOperator;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.io.DataIncrement;
+import org.apache.paimon.memory.HeapMemorySegmentPool;
+import org.apache.paimon.memory.MemoryPoolFactory;
+import org.apache.paimon.memory.MemorySegmentPool;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.utils.ProjectedRow;
+
+import org.apache.flink.runtime.memory.MemoryManager;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import static 
org.apache.paimon.flink.FlinkConnectorOptions.SINK_USE_MANAGED_MEMORY;
+import static 
org.apache.paimon.flink.utils.ManagedMemoryUtils.computeManagedMemory;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** Writes sorted rows and records their original row IDs in output-file 
order. */
+public class DataEvolutionClusterWriteOperator
+        extends BoundedOneInputOperator<InternalRow, Committable> {
+
+    private static final long serialVersionUID = 1L;
+
+    private final FileStoreTable table;
+    private final BinaryRow partition;
+    private final String commitUser;
+
+    private transient IOManager ioManager;
+    private transient BatchTableWrite write;
+    private transient ProjectedRow valueProjection;
+    private transient File rowIdSpool;
+    private transient DataOutputStream rowIds;
+    private transient List<Path> mappingPaths;
+    private transient List<Path> preparedDataPaths;
+    private transient MemorySegmentAllocator memoryAllocator;
+    private transient boolean emitted;
+
+    public DataEvolutionClusterWriteOperator(
+            FileStoreTable table, BinaryRow partition, String commitUser) {
+        this.table = table;
+        this.partition = partition;
+        this.commitUser = commitUser;
+    }
+
+    @Override
+    public void open() throws Exception {
+        super.open();
+        ioManager =
+                IOManager.create(
+                        getContainingTask()
+                                .getEnvironment()
+                                .getIOManager()
+                                .getSpillingDirectoriesPaths());
+        rowIdSpool = ioManager.createChannel("cluster-row-ids").getPathFile();
+        rowIds = new DataOutputStream(new BufferedOutputStream(new 
FileOutputStream(rowIdSpool)));
+        mappingPaths = new ArrayList<>();
+        preparedDataPaths = new ArrayList<>();
+        valueProjection =
+                ProjectedRow.from(IntStream.range(0, 
table.rowType().getFieldCount()).toArray());
+        // Unaware append writers use NoopCompactManager. Their data files, 
including files
+        // produced by buffer flushes or spills, retain each partition's input 
order.
+        write = table.newWrite(commitUser).withIOManager(ioManager);
+        MemorySegmentPool memoryPool;
+        if (Options.fromMap(table.options()).get(SINK_USE_MANAGED_MEMORY)) {
+            MemoryManager memoryManager = 
getContainingTask().getEnvironment().getMemoryManager();
+            memoryAllocator = new MemorySegmentAllocator(this, memoryManager);
+            memoryPool =
+                    new FlinkMemorySegmentPool(
+                            computeManagedMemory(this),
+                            memoryManager.getPageSize(),
+                            memoryAllocator);
+        } else {
+            memoryPool =
+                    new HeapMemorySegmentPool(
+                            table.coreOptions().writeBufferSize(), 
table.coreOptions().pageSize());
+        }
+        write.withMemoryPoolFactory(new MemoryPoolFactory(memoryPool));
+    }
+
+    @Override
+    public void processElement(StreamRecord<InternalRow> element) throws 
Exception {
+        InternalRow row = element.getValue();
+        int rowIdPosition = table.rowType().getFieldCount();
+        checkArgument(!row.isNullAt(rowIdPosition), "Clustering source row ID 
must not be null.");
+        long oldRowId = row.getLong(rowIdPosition);
+        InternalRow value = valueProjection.replaceRow(row);
+        checkArgument(
+                write.getPartition(value).equals(partition),
+                "Clustering writer received a row from another partition.");
+        write.write(value, 0);
+        rowIds.writeLong(oldRowId);
+    }
+
+    @Override
+    public void endInput() throws Exception {
+        rowIds.close();
+        rowIds = null;
+        List<CommitMessage> messages = write.prepareCommit();
+        // prepareCommit transfers these files out of the writer's cleanup 
ownership. Keep them
+        // until all mapping files and committables have been produced 
successfully.
+        for (CommitMessage message : messages) {
+            DataFilePathFactory preparedPathFactory =
+                    table.store()
+                            .pathFactory()
+                            .createDataFilePathFactory(message.partition(), 
message.bucket());
+            for (DataFileMeta file : ((CommitMessageImpl) 
message).newFilesIncrement().newFiles()) {
+                
preparedDataPaths.addAll(file.collectFiles(preparedPathFactory));
+            }
+        }
+        DataFilePathFactory pathFactory =
+                
table.store().pathFactory().createDataFilePathFactory(partition, 0);
+        List<Committable> committables = new ArrayList<>();
+        try (DataInputStream input =
+                new DataInputStream(new BufferedInputStream(new 
FileInputStream(rowIdSpool)))) {
+            for (CommitMessage message : messages) {
+                CommitMessageImpl impl = (CommitMessageImpl) message;
+                DataIncrement increment = impl.newFilesIncrement();
+                checkState(
+                        message.partition().equals(partition)
+                                && message.bucket() == 0
+                                && impl.compactIncrement().isEmpty()
+                                && increment.deletedFiles().isEmpty()
+                                && increment.changelogFiles().isEmpty()
+                                && increment.newIndexFiles().isEmpty()
+                                && increment.deletedIndexFiles().isEmpty(),
+                        "Clustering writer must only append files for its 
partition.");
+                List<DataFileMeta> files = new ArrayList<>();
+                for (DataFileMeta file : increment.newFiles()) {
+                    checkState(
+                            file.firstRowId() == null, "Clustering output must 
get new row IDs.");
+                    String mappingName = 
ClusteringRowIdMapping.fileName(file.fileName());
+                    Path mappingPath = pathFactory.toAlignedPath(mappingName, 
file);
+                    mappingPaths.add(mappingPath);
+                    try (ClusteringRowIdMapping.Writer mapping =
+                            
ClusteringRowIdMapping.createWriter(table.fileIO(), mappingPath)) {
+                        for (long i = 0; i < file.rowCount(); i++) {
+                            mapping.write(input.readLong());
+                        }
+                    }
+                    List<String> extraFiles = new 
ArrayList<>(file.extraFiles());
+                    extraFiles.add(mappingName);
+                    files.add(file.copy(extraFiles));
+                }
+                committables.add(
+                        new Committable(
+                                Long.MAX_VALUE,
+                                new CommitMessageImpl(
+                                        message.partition(),
+                                        message.bucket(),
+                                        message.totalBuckets(),
+                                        new DataIncrement(
+                                                files,
+                                                increment.deletedFiles(),
+                                                increment.changelogFiles()),
+                                        impl.compactIncrement())));
+            }
+            checkState(input.read() == -1, "Clustering output lost rows from 
its input.");
+        }
+        for (Committable committable : committables) {
+            output.collect(new StreamRecord<>(committable));
+        }
+        emitted = true;
+    }
+
+    @Override
+    public void close() throws Exception {
+        try {
+            if (rowIds != null) {
+                rowIds.close();
+            }
+        } finally {
+            try {
+                if (write != null) {
+                    write.close();
+                }
+            } finally {
+                try {
+                    if (memoryAllocator != null) {
+                        memoryAllocator.release();
+                    }
+                    if (!emitted && mappingPaths != null) {
+                        mappingPaths.forEach(table.fileIO()::deleteQuietly);
+                    }
+                    if (!emitted && preparedDataPaths != null) {
+                        
preparedDataPaths.forEach(table.fileIO()::deleteQuietly);
+                    }
+                    if (ioManager != null) {
+                        ioManager.close();
+                    }
+                } finally {
+                    super.close();
+                }
+            }
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperatorTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperatorTest.java
new file mode 100644
index 0000000000..bfaf23bfe7
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/cluster/DataEvolutionClusterWriteOperatorTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.flink.cluster;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.append.dataevolution.ClusteringRowIdMapping;
+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.flink.sink.Committable;
+import org.apache.paimon.flink.sink.CommittableTypeInfo;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataFilePathFactory;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.FileSystemSchemaManager;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+import org.apache.paimon.table.sink.CommitMessageImpl;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.utils.CloseableIterator;
+
+import org.apache.flink.api.common.ExecutionConfig;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests that old row IDs follow actual output boundaries, including buffered 
spills. */
+class DataEvolutionClusterWriteOperatorTest {
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @ParameterizedTest
+    @CsvSource({"false,false", "true,false", "true,true"})
+    void testMappingsFollowWrittenRowsAcrossFileRolls(boolean buffered, 
boolean spillable)
+            throws Exception {
+        FileStoreTable table = createTable(buffered, spillable);
+        DataEvolutionClusterWriteOperator operator =
+                new DataEvolutionClusterWriteOperator(table, 
BinaryRow.EMPTY_ROW, "cluster-test");
+        List<DataFileMeta> files;
+        int rowCount = 250;
+        char[] payload = new char[2048];
+        java.util.Arrays.fill(payload, 'x');
+        try (OneInputStreamOperatorTestHarness<InternalRow, Committable> 
harness =
+                new OneInputStreamOperatorTestHarness<>(operator)) {
+            harness.setup(new CommittableTypeInfo().createSerializer(new 
ExecutionConfig()));
+            harness.open();
+            for (int id = 0; id < rowCount; id++) {
+                harness.processElement(
+                        new StreamRecord<>(
+                                GenericRow.of(
+                                        id,
+                                        
BinaryString.fromString(String.valueOf(payload)),
+                                        oldRowId(id, rowCount))));
+            }
+            operator.endInput();
+            files =
+                    harness.extractOutputValues().stream()
+                            .flatMap(
+                                    committable ->
+                                            ((CommitMessageImpl) 
committable.commitMessage())
+                                                    
.newFilesIncrement().newFiles().stream())
+                            .collect(Collectors.toList());
+        }
+
+        assertThat(files).hasSizeGreaterThan(2);
+        assertThat(files).allSatisfy(file -> 
assertThat(file.firstRowId()).isNull());
+        assertThat(table.latestSnapshot()).isEmpty();
+        DataFilePathFactory pathFactory =
+                
table.store().pathFactory().createDataFilePathFactory(BinaryRow.EMPTY_ROW, 0);
+        List<Integer> writtenIds = new ArrayList<>();
+        for (DataFileMeta file : files) {
+            List<Integer> ids = readIds(table, file);
+            assertThat(ids).hasSize((int) file.rowCount());
+            writtenIds.addAll(ids);
+            String mappingName = 
ClusteringRowIdMapping.fileName(file.fileName());
+            assertThat(file.extraFiles()).contains(mappingName);
+            ClusteringRowIdMapping.read(
+                    table.fileIO(),
+                    pathFactory.toAlignedPath(mappingName, file),
+                    file.rowCount(),
+                    (ordinal, oldId) ->
+                            assertThat(oldId)
+                                    .isEqualTo(oldRowId(ids.get((int) 
ordinal), rowCount)));
+        }
+        assertThat(writtenIds)
+                .containsExactlyElementsOf(
+                        IntStream.range(0, 
rowCount).boxed().collect(Collectors.toList()));
+    }
+
+    @Test
+    void testEmptyWriterProducesNoMappingsOrCommit() throws Exception {
+        FileStoreTable table = createTable(true, true);
+        DataEvolutionClusterWriteOperator operator =
+                new DataEvolutionClusterWriteOperator(table, 
BinaryRow.EMPTY_ROW, "cluster-test");
+        try (OneInputStreamOperatorTestHarness<InternalRow, Committable> 
harness =
+                new OneInputStreamOperatorTestHarness<>(operator)) {
+            harness.setup(new CommittableTypeInfo().createSerializer(new 
ExecutionConfig()));
+            harness.open();
+            operator.endInput();
+            assertThat(harness.extractOutputValues()).isEmpty();
+        }
+        assertThat(table.latestSnapshot()).isEmpty();
+    }
+
+    @Test
+    void testMappingFailureCleansPreparedDataAndEarlierMappings() throws 
Exception {
+        FailingMappingFileIO fileIO = new FailingMappingFileIO();
+        FileStoreTable table = createTable(true, true, fileIO);
+        fileIO.recordOutputs = true;
+        DataEvolutionClusterWriteOperator operator =
+                new DataEvolutionClusterWriteOperator(table, 
BinaryRow.EMPTY_ROW, "cluster-test");
+        try (OneInputStreamOperatorTestHarness<InternalRow, Committable> 
harness =
+                new OneInputStreamOperatorTestHarness<>(operator)) {
+            harness.setup(new CommittableTypeInfo().createSerializer(new 
ExecutionConfig()));
+            harness.open();
+            for (int id = 0; id < 40; id++) {
+                harness.processElement(
+                        new StreamRecord<>(
+                                GenericRow.of(id, 
BinaryString.fromString("payload"), (long) id)));
+            }
+            assertThatThrownBy(operator::endInput)
+                    .isInstanceOf(IOException.class)
+                    .hasMessageContaining("injected mapping failure");
+            assertThat(harness.extractOutputValues()).isEmpty();
+            assertThat(fileIO.mappingCount).isEqualTo(2);
+            assertThat(
+                            fileIO.outputs.stream()
+                                    .filter(
+                                            path ->
+                                                    
!ClusteringRowIdMapping.isMappingFile(
+                                                            path.getName())))
+                    .isNotEmpty();
+        }
+        for (Path output : fileIO.outputs) {
+            assertThat(fileIO.exists(output)).as("aborted output %s", 
output).isFalse();
+        }
+        assertThat(table.latestSnapshot()).isEmpty();
+    }
+
+    private FileStoreTable createTable(boolean buffered, boolean spillable) 
throws Exception {
+        return createTable(buffered, spillable, LocalFileIO.create());
+    }
+
+    private FileStoreTable createTable(boolean buffered, boolean spillable, 
LocalFileIO fileIO)
+            throws Exception {
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("payload", DataTypes.STRING())
+                        .option(CoreOptions.BUCKET.key(), "-1")
+                        .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+                        .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), 
"true")
+                        .option(CoreOptions.WRITE_ONLY.key(), "true")
+                        .option(CoreOptions.WRITE_BUFFER_FOR_APPEND.key(), 
String.valueOf(buffered))
+                        .option(CoreOptions.WRITE_BUFFER_SPILLABLE.key(), 
String.valueOf(spillable))
+                        .option(CoreOptions.WRITE_BUFFER_SIZE.key(), "16 kb")
+                        .option(CoreOptions.PAGE_SIZE.key(), "4 kb")
+                        .option(CoreOptions.TARGET_FILE_ROW_NUM.key(), "17")
+                        .build();
+        Path path = new Path(tempDir.toString());
+        SchemaManager schemaManager = new FileSystemSchemaManager(fileIO, 
path);
+        return FileStoreTableFactory.create(fileIO, path, 
schemaManager.createTable(schema));
+    }
+
+    private List<Integer> readIds(FileStoreTable table, DataFileMeta file) 
throws Exception {
+        DataSplit split =
+                DataSplit.builder()
+                        .withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(0)
+                        .withTotalBuckets(1)
+                        .withBucketPath(
+                                table.store()
+                                        .pathFactory()
+                                        .bucketPath(BinaryRow.EMPTY_ROW, 0)
+                                        .toString())
+                        
.withDataFiles(Collections.singletonList(file.assignFirstRowId(0L)))
+                        .rawConvertible(false)
+                        .build();
+        List<Integer> result = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
table.newRead().createReader(split);
+                CloseableIterator<InternalRow> rows = 
reader.toCloseableIterator()) {
+            while (rows.hasNext()) {
+                InternalRow row = rows.next();
+                assertThat(row.getFieldCount()).isEqualTo(2);
+                result.add(row.getInt(0));
+            }
+        }
+        return result;
+    }
+
+    private static long oldRowId(int id, int rowCount) {
+        return 1000L + (rowCount - id - 1) * 3L;
+    }
+
+    private static class FailingMappingFileIO extends LocalFileIO {
+
+        private static final long serialVersionUID = 1L;
+        private final List<Path> outputs = new ArrayList<>();
+        private boolean recordOutputs;
+        private int mappingCount;
+
+        @Override
+        public PositionOutputStream newOutputStream(Path path, boolean 
overwrite)
+                throws IOException {
+            if (recordOutputs) {
+                outputs.add(path);
+                if (ClusteringRowIdMapping.isMappingFile(path.getName()) && 
++mappingCount == 2) {
+                    throw new IOException("injected mapping failure");
+                }
+            }
+            return super.newOutputStream(path, overwrite);
+        }
+    }
+}

Reply via email to