This is an automated email from the ASF dual-hosted git repository.

leaves12138 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 ef01d08e7b [core] Persist row ID reassignment plans and mark snapshots 
(#10008)
ef01d08e7b is described below

commit ef01d08e7b5794cb9536a2a9518e10c392815e61
Author: YeJunHao <[email protected]>
AuthorDate: Wed Sep 23 11:23:30 2026 +0800

    [core] Persist row ID reassignment plans and mark snapshots (#10008)
---
 .../data-evolution-maintenance.mdx                 |  10 +
 .../DataEvolutionRowIdReassigner.java              |  12 +-
 .../append/dataevolution/RowRangeMappingIndex.java |  26 ++
 .../dataevolution/SerializationAssignment.java     | 205 ++++++++++
 .../apache/paimon/operation/FileDeletionBase.java  |  12 +
 .../paimon/operation/FileStoreCommitImpl.java      |  20 +-
 .../apache/paimon/operation/OrphanFilesClean.java  |   6 +
 .../DataEvolutionRowIdReassignerTest.java          | 424 +++++++++++++++++++++
 .../dataevolution/RowRangeMappingIndexTest.java    |  25 ++
 9 files changed, 738 insertions(+), 2 deletions(-)

diff --git a/docs/docs/multimodal-table/data-evolution-maintenance.mdx 
b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
index 66870446aa..7666899d42 100644
--- a/docs/docs/multimodal-table/data-evolution-maintenance.mdx
+++ b/docs/docs/multimodal-table/data-evolution-maintenance.mdx
@@ -187,6 +187,16 @@ are not permanent application identifiers. The 
`reassign_row_id` procedure is
 documented in the [Spark](../spark/procedures) and
 [Flink](../flink/procedures) procedure references.
 
+Each reassignment snapshot records a `row-id-reassign.plan` property 
referencing a
+versioned `snapshot-<snapshotId>-<uuid>.reassign-plan` file in the table's 
`manifest/`
+directory, together with a `reassign-snapshot-id` property identifying the 
snapshot
+that committed it. The plan is recognized only when that ID matches the current
+snapshot ID; inherited properties on later snapshots are ignored. The file also
+stores the reassignment snapshot ID and the row-ID mappings applied to each 
affected
+partition. It is retained while its
+owning snapshot or a tag referencing that snapshot is retained. Snapshot commit
+failures leave unused plans for orphan-file cleanup.
+
 ## File Sizing
 
 `target-file-size` controls normal-file sizing. `blob.target-file-size` and
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
index ec12b3fd21..e357decb6b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassigner.java
@@ -67,6 +67,7 @@ import java.util.function.Consumer;
 import java.util.function.Function;
 
 import static java.util.Collections.singletonList;
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.writeProperties;
 import static 
org.apache.paimon.utils.ManifestReadThreadPool.sequentialBatchedExecuteCloseable;
 import static org.apache.paimon.utils.Preconditions.checkArgument;
 import static org.apache.paimon.utils.Preconditions.checkState;
@@ -427,6 +428,14 @@ public class DataEvolutionRowIdReassigner {
         Pair<String, Long> deltaManifestList = 
manifestList.write(Collections.emptyList());
         RewrittenIndexManifest rewrittenIndexManifest = 
rewriteIndexManifest(assignment);
 
+        Map<String, String> properties =
+                writeProperties(
+                        table,
+                        assignment.snapshot,
+                        assignment.rowIdMappings,
+                        assignment.firstAssignedRowId,
+                        assignment.nextRowId);
+
         boolean success;
         try (FileStoreCommitImpl commit =
                 (FileStoreCommitImpl) table.store().newCommit(commitUser, 
table)) {
@@ -438,7 +447,8 @@ public class DataEvolutionRowIdReassigner {
                             baseManifestList,
                             deltaManifestList,
                             rewrittenIndexManifest.indexManifest,
-                            assignment.nextRowId);
+                            assignment.nextRowId,
+                            properties);
         }
         return new CommitAssignmentResult(
                 success, rewrittenDataManifests.fileCount, 
rewrittenIndexManifest.indexFileCount);
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndex.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndex.java
index c4c0f07b8c..6b02d5ea61 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndex.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndex.java
@@ -18,8 +18,11 @@
 
 package org.apache.paimon.append.dataevolution;
 
+import org.apache.paimon.io.DataInputView;
+import org.apache.paimon.io.DataOutputView;
 import org.apache.paimon.utils.Range;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
@@ -27,6 +30,7 @@ import java.util.List;
 import java.util.Optional;
 
 import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.SerializationUtils.readCount;
 
 /** Index for row-range mappings. */
 final class RowRangeMappingIndex {
@@ -166,6 +170,28 @@ final class RowRangeMappingIndex {
         return index < oldStarts.length && oldStarts[index] <= oldRange.to;
     }
 
+    void serialize(DataOutputView out) throws IOException {
+        out.writeInt(oldStarts.length);
+        for (int i = 0; i < oldStarts.length; i++) {
+            out.writeLong(oldStarts[i]);
+            out.writeLong(oldEnds[i]);
+            out.writeLong(Math.addExact(newStarts[i], newStartOffset));
+        }
+    }
+
+    static RowRangeMappingIndex deserialize(DataInputView in) throws 
IOException {
+        int size = readCount(in, "row-id mappings");
+        long[] oldStarts = new long[size];
+        long[] oldEnds = new long[size];
+        long[] newStarts = new long[size];
+        for (int i = 0; i < size; i++) {
+            oldStarts[i] = in.readLong();
+            oldEnds[i] = in.readLong();
+            newStarts[i] = in.readLong();
+        }
+        return createFromOwnedArrays(oldStarts, oldEnds, newStarts);
+    }
+
     private static int lowerBound(long[] sorted, long target) {
         int left = 0;
         int right = sorted.length;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
new file mode 100644
index 0000000000..667129e472
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/append/dataevolution/SerializationAssignment.java
@@ -0,0 +1,205 @@
+/*
+ * 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.Snapshot;
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputViewStreamWrapper;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.utils.FileStorePathFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.zip.CRC32;
+import java.util.zip.CheckedOutputStream;
+
+import static org.apache.paimon.utils.IOUtils.readFully;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
+import static org.apache.paimon.utils.SerializationUtils.readCount;
+import static org.apache.paimon.utils.SerializationUtils.serializeBinaryRow;
+
+/** Persisted row-id mappings and allocation bounds copied from a reassignment 
attempt. */
+public final class SerializationAssignment {
+
+    /** A snapshot-local marker and reference to the plan in the manifest 
directory. */
+    public static final String PLAN_FILE_PROPERTY = "row-id-reassign.plan";
+
+    public static final String REASSIGN_SNAPSHOT_ID = "reassign-snapshot-id";
+
+    private static final int VERSION = 1;
+    private static final String FILE_PREFIX = "snapshot-";
+
+    private final long snapshotId;
+    private final Map<BinaryRow, RowRangeMappingIndex> rowIdMappings;
+    private final long firstAssignedRowId;
+    private final long nextRowId;
+
+    private SerializationAssignment(
+            long snapshotId,
+            Map<BinaryRow, RowRangeMappingIndex> rowIdMappings,
+            long firstAssignedRowId,
+            long nextRowId) {
+        checkArgument(
+                snapshotId >= Snapshot.FIRST_SNAPSHOT_ID,
+                "Invalid reassignment snapshot ID: %s.",
+                snapshotId);
+        checkArgument(!rowIdMappings.isEmpty(), "Reassignment mappings must 
not be empty.");
+        checkArgument(
+                firstAssignedRowId >= 0 && nextRowId > firstAssignedRowId,
+                "Invalid assigned row-id range [%s, %s).",
+                firstAssignedRowId,
+                nextRowId);
+        this.snapshotId = snapshotId;
+        this.rowIdMappings = Collections.unmodifiableMap(new 
LinkedHashMap<>(rowIdMappings));
+        this.firstAssignedRowId = firstAssignedRowId;
+        this.nextRowId = nextRowId;
+    }
+
+    /** The reassignment snapshot that references this plan file. */
+    public long snapshotId() {
+        return snapshotId;
+    }
+
+    public long firstAssignedRowId() {
+        return firstAssignedRowId;
+    }
+
+    public long nextRowId() {
+        return nextRowId;
+    }
+
+    /** Returns a plan only for the snapshot that committed it, ignoring 
inherited properties. */
+    @Nullable
+    public static String planFile(Snapshot snapshot) {
+        Map<String, String> properties = snapshot.properties();
+        if (properties == null
+                || 
!Long.toString(snapshot.id()).equals(properties.get(REASSIGN_SNAPSHOT_ID))) {
+            return null;
+        }
+        return properties.get(PLAN_FILE_PROPERTY);
+    }
+
+    /** Persists the assignment and adds its reference to this commit's 
snapshot properties. */
+    static Map<String, String> writeProperties(
+            FileStoreTable table,
+            Snapshot snapshot,
+            Map<BinaryRow, RowRangeMappingIndex> rowIdMappings,
+            long firstAssignedRowId,
+            long nextRowId) {
+        long snapshotId = snapshot.id() + 1;
+        String planFile;
+        try {
+            planFile =
+                    new SerializationAssignment(
+                                    snapshotId, rowIdMappings, 
firstAssignedRowId, nextRowId)
+                            .write(table.fileIO(), 
table.store().pathFactory());
+        } catch (IOException e) {
+            throw new UncheckedIOException("Failed to persist row-id 
reassignment plan.", e);
+        }
+        Map<String, String> properties =
+                snapshot.properties() == null
+                        ? new HashMap<>()
+                        : new HashMap<>(snapshot.properties());
+        properties.put(PLAN_FILE_PROPERTY, planFile);
+        properties.put(REASSIGN_SNAPSHOT_ID, Long.toString(snapshotId));
+        return properties;
+    }
+
+    /** Streams the effective mappings without materializing another copy of 
the plan. */
+    private String write(FileIO fileIO, FileStorePathFactory pathFactory) 
throws IOException {
+        String fileName = FILE_PREFIX + snapshotId + "-" + UUID.randomUUID() + 
".reassign-plan";
+        Path path = pathFactory.toManifestFilePath(fileName);
+        // A failed create may mean another attempt owns this path. Do not 
delete its plan.
+        OutputStream fileOut = fileIO.newOutputStream(path, false);
+        try (DataOutputViewStreamWrapper out =
+                new DataOutputViewStreamWrapper(new 
BufferedOutputStream(fileOut))) {
+            CRC32 checksum = new CRC32();
+            DataOutputViewStreamWrapper payload =
+                    new DataOutputViewStreamWrapper(new 
CheckedOutputStream(out, checksum));
+            payload.writeInt(VERSION);
+            payload.writeLong(snapshotId);
+            payload.writeLong(firstAssignedRowId);
+            payload.writeLong(nextRowId);
+            payload.writeInt(rowIdMappings.size());
+            for (Map.Entry<BinaryRow, RowRangeMappingIndex> entry : 
rowIdMappings.entrySet()) {
+                serializeBinaryRow(entry.getKey(), payload);
+                entry.getValue().serialize(payload);
+            }
+            payload.flush();
+            out.writeLong(checksum.getValue());
+        } catch (IOException | RuntimeException e) {
+            fileIO.deleteQuietly(path);
+            throw e;
+        }
+        return fileName;
+    }
+
+    public static SerializationAssignment readPlan(
+            FileIO fileIO, FileStorePathFactory pathFactory, String fileName) 
throws IOException {
+        Path path = pathFactory.toManifestFilePath(fileName);
+        byte[] bytes = readFully(fileIO.newInputStream(path), true);
+        if (bytes.length < Long.BYTES) {
+            throw new IOException("Truncated row-id reassignment plan.");
+        }
+        int payloadSize = bytes.length - Long.BYTES;
+        CRC32 checksum = new CRC32();
+        checksum.update(bytes, 0, payloadSize);
+        if (ByteBuffer.wrap(bytes).getLong(payloadSize) != 
checksum.getValue()) {
+            throw new IOException("Row-id reassignment plan checksum 
mismatch.");
+        }
+        try {
+            DataInputDeserializer in = new DataInputDeserializer(bytes, 0, 
payloadSize);
+            int version = in.readInt();
+            if (version != VERSION) {
+                throw new IOException("Unsupported row-id reassignment plan 
version: " + version);
+            }
+            long snapshotId = in.readLong();
+            long firstAssignedRowId = in.readLong();
+            long nextRowId = in.readLong();
+            int partitions = readCount(in, "reassignment partitions");
+            Map<BinaryRow, RowRangeMappingIndex> mappings = new 
LinkedHashMap<>();
+            for (int i = 0; i < partitions; i++) {
+                BinaryRow partition = deserializeBinaryRow(in);
+                if (mappings.put(partition, 
RowRangeMappingIndex.deserialize(in)) != null) {
+                    throw new IOException("Duplicate partition in row-id 
reassignment plan.");
+                }
+            }
+            if (in.available() != 0) {
+                throw new IOException("Unexpected trailing bytes in row-id 
reassignment plan.");
+            }
+            return new SerializationAssignment(snapshotId, mappings, 
firstAssignedRowId, nextRowId);
+        } catch (IllegalArgumentException e) {
+            throw new IOException("Invalid row-id reassignment plan " + 
fileName, e);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
index 1b8b245e9b..4f74342c0a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileDeletionBase.java
@@ -69,6 +69,8 @@ import java.util.concurrent.Executor;
 import java.util.function.Consumer;
 import java.util.function.Predicate;
 
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.planFile;
+
 /**
  * Base class for file deletion including methods for clean data files, 
manifest files and empty
  * data directories.
@@ -381,6 +383,11 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
         collectUnusedIndexManifests(snapshot, skippingSet, indexFiles, 
indexManifests);
         collectUnusedStatisticsManifests(snapshot, skippingSet, statistics);
 
+        String reassignPlan = planFile(snapshot);
+        if (reassignPlan != null && skippingSet.add(reassignPlan)) {
+            manifests.add(reassignPlan);
+        }
+
         List<Runnable> tasks = new ArrayList<>();
         for (String manifest : manifests) {
             tasks.add(() -> manifestFile.delete(manifest));
@@ -617,6 +624,11 @@ public abstract class FileDeletionBase<T extends Snapshot> 
{
                     .forEach(skippingSet::add);
         }
 
+        String reassignPlan = planFile(skippingSnapshot);
+        if (reassignPlan != null) {
+            skippingSet.add(reassignPlan);
+        }
+
         // statistics
         if (skippingSnapshot.statistics() != null) {
             skippingSet.add(skippingSnapshot.statistics());
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
index 926e972cc7..e518c8f0bc 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/FileStoreCommitImpl.java
@@ -1433,6 +1433,24 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
             Pair<String, Long> deltaManifestList,
             @Nullable String indexManifest,
             @Nullable Long nextRowId) {
+        return replaceManifestList(
+                latest,
+                totalRecordCount,
+                baseManifestList,
+                deltaManifestList,
+                indexManifest,
+                nextRowId,
+                latest.properties());
+    }
+
+    public boolean replaceManifestList(
+            Snapshot latest,
+            long totalRecordCount,
+            Pair<String, Long> baseManifestList,
+            Pair<String, Long> deltaManifestList,
+            @Nullable String indexManifest,
+            @Nullable Long nextRowId,
+            @Nullable Map<String, String> properties) {
         Snapshot newSnapshot =
                 new Snapshot(
                         latest.id() + 1,
@@ -1455,7 +1473,7 @@ public class FileStoreCommitImpl implements 
FileStoreCommit {
                         latest.watermark(),
                         latest.statistics(),
                         // if empty properties, just set to null
-                        latest.properties(),
+                        properties == null || properties.isEmpty() ? null : 
properties,
                         nextRowId,
                         null);
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
index 04b4ae63ff..31d80bf519 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/OrphanFilesClean.java
@@ -61,6 +61,7 @@ import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import static java.util.Collections.emptyList;
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.planFile;
 import static org.apache.paimon.catalog.Identifier.DEFAULT_MAIN_BRANCH;
 import static org.apache.paimon.utils.ChangelogManager.CHANGELOG_PREFIX;
 import static org.apache.paimon.utils.FileStorePathFactory.BUCKET_PATH_PREFIX;
@@ -329,6 +330,11 @@ public abstract class OrphanFilesClean implements 
Serializable {
                     .forEach(name -> 
usedFileWithFlagConsumer.accept(Pair.of(name, false)));
         }
 
+        String reassignPlan = planFile(snapshot);
+        if (reassignPlan != null) {
+            usedFileWithFlagConsumer.accept(Pair.of(reassignPlan, false));
+        }
+
         // statistic file
         if (snapshot.statistics() != null) {
             usedFileWithFlagConsumer.accept(Pair.of(snapshot.statistics(), 
false));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index 3cde9a1b7c..178577375f 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -21,12 +21,15 @@ package org.apache.paimon.append.dataevolution;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.catalog.RenamingSnapshotCommit;
+import org.apache.paimon.catalog.SnapshotCommit;
 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.fs.local.LocalFileIO;
 import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
 import org.apache.paimon.globalindex.ScanResult;
 import org.apache.paimon.globalindex.btree.BTreeIndexOptions;
@@ -49,14 +52,20 @@ import org.apache.paimon.manifest.ManifestFile;
 import org.apache.paimon.manifest.ManifestFileMeta;
 import org.apache.paimon.manifest.ManifestList;
 import org.apache.paimon.operation.FileStoreCommitImpl;
+import org.apache.paimon.operation.LocalOrphanFilesClean;
+import org.apache.paimon.operation.Lock;
+import org.apache.paimon.options.ExpireConfig;
 import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.partition.PartitionStatistics;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.stats.SimpleStats;
 import org.apache.paimon.stats.Statistics;
+import org.apache.paimon.table.CatalogEnvironment;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
 import org.apache.paimon.table.TableTestBase;
 import org.apache.paimon.table.sink.BatchTableCommit;
 import org.apache.paimon.table.sink.BatchTableWrite;
@@ -67,6 +76,7 @@ import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.IOUtils;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.Range;
 import org.apache.paimon.utils.SegmentsCache;
@@ -74,8 +84,13 @@ import org.apache.paimon.utils.SnapshotManager;
 
 import org.junit.jupiter.api.Test;
 
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.OutputStream;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -90,13 +105,19 @@ import java.util.Random;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.zip.CRC32;
 
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.PLAN_FILE_PROPERTY;
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.REASSIGN_SNAPSHOT_ID;
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.planFile;
+import static 
org.apache.paimon.append.dataevolution.SerializationAssignment.readPlan;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -643,6 +664,403 @@ public class DataEvolutionRowIdReassignerTest extends 
TableTestBase {
         assertThat(rowIdsByPartition).containsEntry("pt=a/", Arrays.asList(5L, 
6L, 7L));
         assertThat(rowIdsByPartition).containsEntry("pt=b/", Arrays.asList(8L, 
9L));
         
assertThat(table.snapshotManager().latestSnapshot().nextRowId()).isEqualTo(10L);
+        assertPersistedPlan(table);
+    }
+
+    @Test
+    public void testInheritedPropertiesDoNotMarkReassignment() throws 
Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        new DataEvolutionRowIdReassigner(table).reassign();
+        Snapshot reassigned = table.snapshotManager().latestSnapshot();
+        assertThat(planFile(reassigned)).isNotNull();
+
+        compactManifests(table);
+        Snapshot compacted = table.snapshotManager().latestSnapshot();
+        assertThat(compacted.id()).isGreaterThan(reassigned.id());
+        assertThat(compacted.properties()).isEqualTo(reassigned.properties());
+        assertThat(planFile(compacted)).isNull();
+
+        try (FileStoreCommitImpl commit =
+                (FileStoreCommitImpl) 
table.store().newCommit("test-rollback-plan", table)) {
+            assertThat(commit.rollbackToAsLatest(reassigned)).isTrue();
+        }
+        Snapshot rolledBack = table.snapshotManager().latestSnapshot();
+        assertThat(rolledBack.properties()).isEqualTo(reassigned.properties());
+        assertThat(planFile(rolledBack)).isNull();
+        assertThat(planFile(reassigned)).isNotNull();
+        assertThat(
+                        readPlan(table.fileIO(), table.store().pathFactory(), 
planFile(reassigned))
+                                .snapshotId())
+                .isEqualTo(reassigned.id());
+
+        Path plan = 
table.store().pathFactory().toManifestFilePath(planFile(reassigned));
+        table.newExpireSnapshots()
+                
.config(ExpireConfig.builder().snapshotRetainMin(1).snapshotRetainMax(1).build())
+                .expire();
+        
assertThat(table.snapshotManager().snapshotExists(reassigned.id())).isFalse();
+        assertThat(table.fileIO().exists(plan)).isFalse();
+        
assertThat(planFile(table.snapshotManager().latestSnapshot())).isNull();
+    }
+
+    @Test
+    public void testReassignPlanCleanup() throws Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        new DataEvolutionRowIdReassigner(table).reassign();
+        Snapshot reassigned = table.snapshotManager().latestSnapshot();
+        Path plan = 
table.store().pathFactory().toManifestFilePath(planFile(reassigned));
+        Path orphan =
+                table.store()
+                        .pathFactory()
+                        .toManifestFilePath(
+                                
"snapshot-99999-00000000-0000-0000-0000-000000000000.reassign-plan");
+        table.fileIO().newOutputStream(orphan, false).close();
+
+        new LocalOrphanFilesClean(table, System.currentTimeMillis() + 
2000).clean();
+        assertThat(table.fileIO().exists(plan)).isTrue();
+        assertThat(table.fileIO().exists(orphan)).isFalse();
+
+        table.createTag("reassign", reassigned.id());
+        writeOneRow(table, "c", 100);
+        
assertThat(planFile(table.snapshotManager().latestSnapshot())).isNull();
+        table.newExpireSnapshots()
+                
.config(ExpireConfig.builder().snapshotRetainMin(1).snapshotRetainMax(1).build())
+                .expire();
+        
assertThat(table.snapshotManager().snapshotExists(reassigned.id())).isFalse();
+        assertThat(table.fileIO().exists(plan)).isTrue();
+        new LocalOrphanFilesClean(table, System.currentTimeMillis() + 
2000).clean();
+        assertThat(table.fileIO().exists(plan)).isTrue();
+
+        table.deleteTag("reassign");
+        assertThat(table.fileIO().exists(plan)).isFalse();
+    }
+
+    @Test
+    public void testReassignPlanExpiresWithSnapshot() throws Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        new DataEvolutionRowIdReassigner(table).reassign();
+        Path plan =
+                table.store()
+                        .pathFactory()
+                        
.toManifestFilePath(planFile(table.snapshotManager().latestSnapshot()));
+        writeOneRow(table, "c", 100);
+        table.newExpireSnapshots()
+                
.config(ExpireConfig.builder().snapshotRetainMin(1).snapshotRetainMax(1).build())
+                .expire();
+        assertThat(table.fileIO().exists(plan)).isFalse();
+    }
+
+    @Test
+    public void testReassignPlansAreIndependentAcrossBranches() throws 
Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        table.createTag("base", table.snapshotManager().latestSnapshot().id());
+        table.createBranch("other", "base");
+        FileStoreTable branch = table.switchToBranch("other");
+        assertThat(branch.store().pathFactory().manifestPath())
+                .isEqualTo(table.store().pathFactory().manifestPath());
+
+        new DataEvolutionRowIdReassigner(table).reassign("main-reassign");
+        new DataEvolutionRowIdReassigner(branch).reassign("branch-reassign");
+        Snapshot mainSnapshot = table.snapshotManager().latestSnapshot();
+        Snapshot branchSnapshot = branch.snapshotManager().latestSnapshot();
+        assertThat(mainSnapshot.id()).isEqualTo(branchSnapshot.id());
+        
assertThat(planFile(mainSnapshot)).isNotEqualTo(planFile(branchSnapshot));
+        assertPersistedPlan(table);
+        assertPersistedPlan(branch);
+    }
+
+    @Test
+    public void testReassignAfterRollbackUsesNewPlanFile() throws Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        long base = table.snapshotManager().latestSnapshot().id();
+        new DataEvolutionRowIdReassigner(table).reassign("before-rollback");
+        String oldPlan = planFile(table.snapshotManager().latestSnapshot());
+        table.rollbackTo(base);
+        
assertThat(table.snapshotManager().latestSnapshot().id()).isEqualTo(base);
+        
assertThat(table.fileIO().exists(table.store().pathFactory().toManifestFilePath(oldPlan)))
+                .isTrue();
+
+        new DataEvolutionRowIdReassigner(table).reassign("after-rollback");
+        Snapshot latest = table.snapshotManager().latestSnapshot();
+        assertThat(latest.id()).isEqualTo(base + 1);
+        assertThat(planFile(latest)).isNotEqualTo(oldPlan);
+        assertPersistedPlan(table);
+    }
+
+    @Test
+    public void testLosingReassignDoesNotDeleteCommittedPlan() throws 
Exception {
+        FileStoreTable original = createTableWithInterleavedPartitions();
+        long targetId = original.snapshotManager().latestSnapshot().id() + 1;
+        String planPrefix = "snapshot-" + targetId + "-";
+        CountDownLatch checkedAbsent = new CountDownLatch(2);
+        Set<String> planNames = Collections.synchronizedSet(new HashSet<>());
+        LocalFileIO racingIO =
+                new LocalFileIO() {
+                    @Override
+                    public boolean exists(Path path) throws IOException {
+                        boolean result = super.exists(path);
+                        if (!result
+                                && path.getName().startsWith(planPrefix)
+                                && path.getName().endsWith(".reassign-plan")) {
+                            // Both creates observe an absent path before 
either opens its stream.
+                            planNames.add(path.getName());
+                            checkedAbsent.countDown();
+                            awaitReassignLatch(checkedAbsent);
+                        }
+                        return result;
+                    }
+                };
+        FileStoreTable table =
+                FileStoreTableFactory.create(racingIO, original.location(), 
original.schema());
+        CountDownLatch plansWritten = new CountDownLatch(2);
+        CountDownLatch releaseLoser = new CountDownLatch(1);
+        ExecutorService executor = Executors.newFixedThreadPool(2);
+        try {
+            Future<DataEvolutionRowIdReassigner.Result> winner =
+                    executor.submit(
+                            () ->
+                                    new DataEvolutionRowIdReassigner(
+                                                    table,
+                                                    null,
+                                                    () -> {
+                                                        
plansWritten.countDown();
+                                                        
awaitReassignLatch(plansWritten);
+                                                    })
+                                            .reassign("winner"));
+            Future<DataEvolutionRowIdReassigner.Result> loser =
+                    executor.submit(
+                            () ->
+                                    new DataEvolutionRowIdReassigner(
+                                                    table,
+                                                    null,
+                                                    () -> {
+                                                        
plansWritten.countDown();
+                                                        
awaitReassignLatch(releaseLoser);
+                                                    })
+                                            .reassign("loser"));
+            assertThat(winner.get(30, TimeUnit.SECONDS).reassigned).isTrue();
+            Snapshot committed = table.snapshotManager().latestSnapshot();
+            assertThat(committed.commitUser()).isEqualTo("winner");
+            releaseLoser.countDown();
+            assertThatThrownBy(() -> loser.get(30, TimeUnit.SECONDS))
+                    .isInstanceOf(ExecutionException.class)
+                    .hasStackTraceContaining("OVERWRITE snapshot");
+            assertThat(planNames).hasSize(2);
+            for (String planName : planNames) {
+                assertThat(
+                                table.fileIO()
+                                        .exists(
+                                                table.store()
+                                                        .pathFactory()
+                                                        
.toManifestFilePath(planName)))
+                        .isTrue();
+            }
+            new LocalOrphanFilesClean(table, System.currentTimeMillis() + 
2000).clean();
+            for (String planName : planNames) {
+                assertThat(
+                                table.fileIO()
+                                        .exists(
+                                                table.store()
+                                                        .pathFactory()
+                                                        
.toManifestFilePath(planName)))
+                        .isEqualTo(planName.equals(planFile(committed)));
+            }
+            assertPersistedPlan(table);
+        } finally {
+            releaseLoser.countDown();
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    public void testCommittedPlanSurvivesFalseCommitResult() throws Exception {
+        FileStoreTable original = createTableWithInterleavedPartitions();
+        AtomicBoolean committedButReturnedFalse = new AtomicBoolean();
+        CatalogEnvironment environment =
+                new CatalogEnvironment(null, null, null, null, null, null, 
false, false) {
+                    @Override
+                    public SnapshotCommit snapshotCommit(SnapshotManager 
manager) {
+                        SnapshotCommit delegate = new 
RenamingSnapshotCommit(manager, Lock.empty());
+                        return new SnapshotCommit() {
+                            @Override
+                            public boolean commit(
+                                    @Nullable String baseUuid,
+                                    Snapshot snapshot,
+                                    String branch,
+                                    List<PartitionStatistics> statistics)
+                                    throws Exception {
+                                boolean committed =
+                                        delegate.commit(baseUuid, snapshot, 
branch, statistics);
+                                if (committed) {
+                                    committedButReturnedFalse.set(true);
+                                    return false;
+                                }
+                                return committed;
+                            }
+
+                            @Override
+                            public void close() throws Exception {
+                                delegate.close();
+                            }
+                        };
+                    }
+                };
+        FileStoreTable table =
+                FileStoreTableFactory.create(
+                        original.fileIO(), original.location(), 
original.schema(), environment);
+        // Reuse the false-success failure model covered by 
FileStoreCommitTest.
+        assertThatThrownBy(() -> new 
DataEvolutionRowIdReassigner(table).reassign("false-result"))
+                .hasMessageContaining("OVERWRITE snapshot");
+        assertThat(committedButReturnedFalse).isTrue();
+        Snapshot committed = table.snapshotManager().latestSnapshot();
+        assertThat(committed.commitUser()).isEqualTo("false-result");
+        new LocalOrphanFilesClean(table, System.currentTimeMillis() + 
2000).clean();
+        assertPersistedPlan(table);
+    }
+
+    private static void awaitReassignLatch(CountDownLatch latch) {
+        try {
+            assertThat(latch.await(20, TimeUnit.SECONDS))
+                    .as("Concurrent reassignment barrier")
+                    .isTrue();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    public void testValidatePlanChecksumBeforeDeserialization() throws 
Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        new DataEvolutionRowIdReassigner(table).reassign();
+        Snapshot snapshot = table.snapshotManager().latestSnapshot();
+        Path path = 
table.store().pathFactory().toManifestFilePath(planFile(snapshot));
+        byte[] bytes = IOUtils.readFully(table.fileIO().newInputStream(path), 
true);
+        // Version and the three long fields precede the partition count.
+        int partitionCountOffset = Integer.BYTES + 3 * Long.BYTES;
+        int partitionSizeOffset = partitionCountOffset + Integer.BYTES;
+        int partitionSize = ByteBuffer.wrap(bytes).getInt(partitionSizeOffset);
+        int arityOffset = partitionSizeOffset + Integer.BYTES;
+        int mappingCountOffset = arityOffset + partitionSize;
+        int[] offsets = {
+            partitionCountOffset, partitionSizeOffset, arityOffset, 
mappingCountOffset
+        };
+        for (int offset : offsets) {
+            for (int length : new int[] {-1, Integer.MAX_VALUE}) {
+                byte[] corrupted = bytes.clone();
+                ByteBuffer.wrap(corrupted).putInt(offset, length);
+                overwritePlan(table, path, corrupted);
+                assertThatThrownBy(
+                                () ->
+                                        readPlan(
+                                                table.fileIO(),
+                                                table.store().pathFactory(),
+                                                planFile(snapshot)))
+                        .as("Invalid length %s at offset %s", length, offset)
+                        .isInstanceOf(IOException.class)
+                        .hasMessageContaining("checksum");
+            }
+        }
+    }
+
+    @Test
+    public void testRejectInvalidReassignPlan() throws Exception {
+        FileStoreTable table = createTableWithInterleavedPartitions();
+        new DataEvolutionRowIdReassigner(table).reassign();
+        Snapshot snapshot = table.snapshotManager().latestSnapshot();
+        Path path = 
table.store().pathFactory().toManifestFilePath(planFile(snapshot));
+        byte[] bytes = IOUtils.readFully(table.fileIO().newInputStream(path), 
true);
+        byte[] corrupted = bytes.clone();
+        corrupted[corrupted.length - 1] ^= 1;
+        overwritePlan(table, path, corrupted);
+        assertThatThrownBy(
+                        () ->
+                                readPlan(
+                                        table.fileIO(),
+                                        table.store().pathFactory(),
+                                        planFile(snapshot)))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("checksum");
+
+        corrupted = bytes.clone();
+        corrupted[3] = 99;
+        CRC32 checksum = new CRC32();
+        checksum.update(corrupted, 0, corrupted.length - Long.BYTES);
+        ByteBuffer.wrap(corrupted).putLong(corrupted.length - Long.BYTES, 
checksum.getValue());
+        overwritePlan(table, path, corrupted);
+        assertThatThrownBy(
+                        () ->
+                                readPlan(
+                                        table.fileIO(),
+                                        table.store().pathFactory(),
+                                        planFile(snapshot)))
+                .isInstanceOf(IOException.class)
+                .hasMessageContaining("version: 99");
+
+        for (int length : new int[] {0, Long.BYTES - 1, bytes.length - 1}) {
+            overwritePlan(table, path, Arrays.copyOf(bytes, length));
+            assertThatThrownBy(
+                            () ->
+                                    readPlan(
+                                            table.fileIO(),
+                                            table.store().pathFactory(),
+                                            planFile(snapshot)))
+                    .isInstanceOf(IOException.class);
+        }
+    }
+
+    private long planFileCount(FileStoreTable table) throws IOException {
+        return 
Arrays.stream(table.fileIO().listStatus(table.store().pathFactory().manifestPath()))
+                .filter(
+                        file ->
+                                
file.getPath().getName().startsWith("snapshot-")
+                                        && 
file.getPath().getName().endsWith(".reassign-plan"))
+                .count();
+    }
+
+    private void overwritePlan(FileStoreTable table, Path path, byte[] bytes) 
throws IOException {
+        try (OutputStream out = table.fileIO().newOutputStream(path, true)) {
+            out.write(bytes);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private void assertPersistedPlan(FileStoreTable table) throws Exception {
+        Snapshot snapshot = 
Snapshot.fromJson(table.snapshotManager().latestSnapshot().toJson());
+        assertThat(snapshot.properties())
+                .containsEntry(REASSIGN_SNAPSHOT_ID, 
Long.toString(snapshot.id()))
+                .containsKey(PLAN_FILE_PROPERTY);
+        String prefix = "snapshot-" + snapshot.id() + "-";
+        String fileName = planFile(snapshot);
+        assertThat(fileName).startsWith(prefix).endsWith(".reassign-plan");
+        UUID.fromString(
+                fileName.substring(prefix.length(), fileName.length() - 
".reassign-plan".length()));
+        SerializationAssignment assignment =
+                readPlan(table.fileIO(), table.store().pathFactory(), 
planFile(snapshot));
+        assertThat(assignment.snapshotId()).isEqualTo(snapshot.id());
+        assertThat(assignment.firstAssignedRowId())
+                .isEqualTo(table.snapshotManager().snapshot(snapshot.id() - 
1).nextRowId());
+        assertThat(assignment.nextRowId()).isEqualTo(snapshot.nextRowId());
+        Map<BinaryRow, RowRangeMappingIndex> mappings =
+                (Map<BinaryRow, RowRangeMappingIndex>) fieldValue(assignment, 
"rowIdMappings");
+        Map<String, ManifestEntry> previous = new HashMap<>();
+        for (ManifestEntry entry :
+                table.store().newScan().withSnapshot(snapshot.id() - 
1).plan().files()) {
+            previous.put(entry.file().fileName(), entry);
+        }
+        for (ManifestEntry entry : currentEntries(table)) {
+            Range oldRange = 
previous.get(entry.file().fileName()).file().nonNullRowIdRange();
+            Range newRange = entry.file().nonNullRowIdRange();
+            RowRangeMappingIndex mapping = mappings.get(entry.partition());
+            if (oldRange.equals(newRange)) {
+                if (mapping != null) {
+                    assertThat(mapping.map(oldRange)).isEmpty();
+                    assertThat(mapping.overlaps(oldRange)).isFalse();
+                }
+            } else {
+                assertThat(mapping).isNotNull();
+                assertThat(mapping.map(oldRange)).hasValue(newRange);
+            }
+        }
     }
 
     @Test
@@ -679,6 +1097,7 @@ public class DataEvolutionRowIdReassignerTest extends 
TableTestBase {
                 new DataEvolutionRowIdReassigner(table)
                         .reassign("test-reassign-null-partition-row-id");
 
+        assertPersistedPlan(table);
         assertThat(result.firstAssignedRowId).isEqualTo(4L);
         assertThat(result.nextRowId).isEqualTo(6L);
         assertThat(result.fileCount).isEqualTo(2L);
@@ -728,6 +1147,11 @@ public class DataEvolutionRowIdReassignerTest extends 
TableTestBase {
                 .containsEntry("pt=c/", Collections.singletonList(5L));
         
assertThat(valueStatsByFile(table)).containsAllEntriesOf(valueStatsBefore);
         
assertThat(table.snapshotManager().latestSnapshot().nextRowId()).isEqualTo(11L);
+        assertPersistedPlan(table);
+        assertThat(planFileCount(table)).isEqualTo(2L);
+        new LocalOrphanFilesClean(table, System.currentTimeMillis() + 
2000).clean();
+        assertThat(planFileCount(table)).isEqualTo(1L);
+        assertPersistedPlan(table);
     }
 
     @Test
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndexTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndexTest.java
index 9ed385bca0..894be9d52d 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndexTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/RowRangeMappingIndexTest.java
@@ -18,6 +18,8 @@
 
 package org.apache.paimon.append.dataevolution;
 
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
 import org.apache.paimon.utils.Range;
 
 import org.junit.jupiter.api.Test;
@@ -86,6 +88,29 @@ public class RowRangeMappingIndexTest {
         assertThat(absolute.map(new Range(20, 24))).hasValue(new Range(105, 
109));
     }
 
+    @Test
+    public void testSerializeEffectiveMappingAfterMultipleShifts() throws 
Exception {
+        RowRangeMappingIndex original =
+                RowRangeMappingIndex.create(
+                                Arrays.asList(
+                                        RowRangeMappingIndex.mapping(10, 14, 
0),
+                                        RowRangeMappingIndex.mapping(15, 19, 
5),
+                                        RowRangeMappingIndex.mapping(30, 39, 
10)))
+                        .shiftNewStarts(100)
+                        .shiftNewStarts(20);
+        DataOutputSerializer out = new DataOutputSerializer(128);
+        original.serialize(out);
+        byte[] serialized = out.getCopyOfBuffer();
+        RowRangeMappingIndex restored =
+                RowRangeMappingIndex.deserialize(new 
DataInputDeserializer(serialized));
+
+        assertThat(restored.map(new Range(12, 17))).hasValue(new Range(122, 
127));
+        assertThat(restored.map(new Range(30, 39))).hasValue(new Range(130, 
139));
+        assertThat(restored.map(new Range(19, 30))).isEmpty();
+        assertThat(restored.overlaps(new Range(20, 29))).isFalse();
+        assertThat(restored.shiftNewStarts(5).map(new Range(12, 
17))).hasValue(new Range(127, 132));
+    }
+
     @Test
     public void testPrimitiveArrayMappingsCanBeShifted() {
         long[] oldStarts = {20, 30};

Reply via email to