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 ba7e1991da [core] Support retaining the latest expired partition 
(#10766)
ba7e1991da is described below

commit ba7e1991da8845c0a25c70345c1608216226b667
Author: tzphh <[email protected]>
AuthorDate: Tue Oct 27 14:11:42 2026 +0800

    [core] Support retaining the latest expired partition (#10766)
---
 docs/docs/maintenance/manage-partitions.md         |  23 +++
 docs/generated/core_configuration.html             |   6 +
 .../main/java/org/apache/paimon/CoreOptions.java   |  15 ++
 .../paimon/partition/PartitionExpireStrategy.java  |  20 ++
 .../PartitionUpdateTimeExpireStrategy.java         |  19 +-
 .../PartitionValuesTimeExpireStrategy.java         |  23 ++-
 .../paimon/operation/PartitionExpireTest.java      | 207 +++++++++++++++++++++
 .../flink/action/ExpirePartitionsAction.java       |   3 +
 .../flink/action/ExpirePartitionsActionITCase.java |  58 ++++++
 9 files changed, 369 insertions(+), 5 deletions(-)

diff --git a/docs/docs/maintenance/manage-partitions.md 
b/docs/docs/maintenance/manage-partitions.md
index ab2bfc7922..9fe1172d18 100644
--- a/docs/docs/maintenance/manage-partitions.md
+++ b/docs/docs/maintenance/manage-partitions.md
@@ -100,6 +100,23 @@ insert into t values('pk', 'par-1');
 
 ```
 
+### Retaining the Latest Partition
+
+Set `'partition.expiration-retain-latest' = 'true'` (default: `false`) to 
retain the latest
+partition when all partitions have expired. It remains available until a newer 
partition
+is committed, after which the previous partition can expire normally.
+
+```sql
+CREATE TABLE t (...) PARTITIONED BY (dt) WITH (
+    'partition.expiration-time' = '7 d',
+    'partition.timestamp-formatter' = 'yyyyMMdd',
+    'partition.expiration-retain-latest' = 'true'
+);
+```
+
+This option applies to normal tables. The latest partition is determined by 
the parsed
+partition timestamp for `values-time`, or the last file creation time for 
`update-time`.
+
 More options:
 
 <table class="table table-bordered">
@@ -135,6 +152,12 @@ More options:
             <td>Duration</td>
             <td>The expiration interval of a partition. A partition will be 
expired if it's lifetime is over this value. Partition time is extracted from 
the partition value.</td>
         </tr>
+        <tr>
+            <td><h5>partition.expiration-retain-latest</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Retain the latest partition when all partitions are expired. 
Applies to one partition across the whole table with the built-in expiration 
strategies on normal tables.</td>
+        </tr>
         <tr>
             <td><h5>partition.timestamp-formatter</h5></td>
             <td style="word-wrap: break-word;">(none)</td>
diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index ef54534e46..0fc8c42fd2 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -1308,6 +1308,12 @@ For an internal format table in a REST catalog, it also 
makes the catalog own th
             <td>Integer</td>
             <td>The default deleted num of partition expiration.</td>
         </tr>
+        <tr>
+            <td><h5>partition.expiration-retain-latest</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to retain the latest partition when all partitions are 
expired. The latest partition is determined by the parsed partition time for 
values-time or the last file creation time for update-time. Retains one 
partition for the whole table. Supported by the built-in expiration strategies 
on normal tables. Explicit partition deletion and truncation are not 
affected.</td>
+        </tr>
         <tr>
             <td><h5>partition.expiration-strategy</h5></td>
             <td style="word-wrap: break-word;">"values-time"</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 87fc141839..c3202188dc 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -1477,6 +1477,17 @@ public class CoreOptions implements Serializable {
                     .defaultValue(100)
                     .withDescription("The default deleted num of partition 
expiration.");
 
+    public static final ConfigOption<Boolean> 
PARTITION_EXPIRATION_RETAIN_LATEST =
+            key("partition.expiration-retain-latest")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription(
+                            "Whether to retain the latest partition when all 
partitions are expired. "
+                                    + "The latest partition is determined by 
the parsed partition time for values-time "
+                                    + "or the last file creation time for 
update-time. Retains one partition for the whole table. "
+                                    + "Supported by the built-in expiration 
strategies on normal tables. "
+                                    + "Explicit partition deletion and 
truncation are not affected.");
+
     public static final ConfigOption<Integer> PARTITION_EXPIRATION_BATCH_SIZE =
             key("partition.expiration-batch-size")
                     .intType()
@@ -4331,6 +4342,10 @@ public class CoreOptions implements Serializable {
         return options.get(PARTITION_EXPIRATION_MAX_NUM);
     }
 
+    public boolean partitionExpireRetainLatest() {
+        return options.get(PARTITION_EXPIRATION_RETAIN_LATEST);
+    }
+
     public int partitionExpireBatchSize() {
         return options.getOptional(PARTITION_EXPIRATION_BATCH_SIZE)
                 .orElse(options.get(PARTITION_EXPIRATION_MAX_NUM));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionExpireStrategy.java
 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionExpireStrategy.java
index 26ca00f2f7..fc00691c9a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionExpireStrategy.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionExpireStrategy.java
@@ -28,12 +28,16 @@ import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.RowDataToObjectArrayConverter;
 
+import 
org.apache.paimon.shade.guava30.com.google.common.primitives.UnsignedBytes;
+
 import javax.annotation.Nullable;
 
 import java.io.Serializable;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -89,6 +93,22 @@ public abstract class PartitionExpireStrategy implements 
Serializable {
     public abstract List<PartitionEntry> selectExpiredPartitions(
             FileStoreScan scan, LocalDateTime expirationTime);
 
+    protected static void retainLatestPartition(
+            List<PartitionEntry> partitions,
+            List<PartitionEntry> expired,
+            Comparator<PartitionEntry> timeComparator) {
+        if (expired.isEmpty() || expired.size() != partitions.size()) {
+            return;
+        }
+
+        // Use the complete partition key to break ties independently of scan 
order.
+        Comparator<PartitionEntry> comparator =
+                timeComparator.thenComparing(
+                        entry -> entry.partition().toBytes(),
+                        UnsignedBytes.lexicographicalComparator());
+        expired.remove(Collections.max(expired, comparator));
+    }
+
     public static PartitionExpireStrategy createPartitionExpireStrategy(
             CoreOptions options,
             RowType partitionType,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionUpdateTimeExpireStrategy.java
 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionUpdateTimeExpireStrategy.java
index 628f4c3733..6c41d90055 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionUpdateTimeExpireStrategy.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionUpdateTimeExpireStrategy.java
@@ -25,6 +25,7 @@ import org.apache.paimon.types.RowType;
 
 import java.time.LocalDateTime;
 import java.time.ZoneId;
+import java.util.Comparator;
 import java.util.List;
 import java.util.stream.Collectors;
 
@@ -36,17 +37,29 @@ public class PartitionUpdateTimeExpireStrategy extends 
PartitionExpireStrategy {
 
     private static final long serialVersionUID = 1L;
 
+    private final boolean retainLatest;
+
     public PartitionUpdateTimeExpireStrategy(CoreOptions options, RowType 
partitionType) {
         super(partitionType, options.partitionDefaultName());
+        this.retainLatest = options.partitionExpireRetainLatest();
     }
 
     @Override
     public List<PartitionEntry> selectExpiredPartitions(
             FileStoreScan scan, LocalDateTime expirationTime) {
+        List<PartitionEntry> partitions = scan.readPartitionEntries();
         long expirationMilli =
                 
expirationTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
-        return scan.readPartitionEntries().stream()
-                .filter(partitionEntry -> expirationMilli > 
partitionEntry.lastFileCreationTime())
-                .collect(Collectors.toList());
+        List<PartitionEntry> expired =
+                partitions.stream()
+                        .filter(entry -> expirationMilli > 
entry.lastFileCreationTime())
+                        .collect(Collectors.toList());
+        if (retainLatest) {
+            retainLatestPartition(
+                    partitions,
+                    expired,
+                    
Comparator.comparingLong(PartitionEntry::lastFileCreationTime));
+        }
+        return expired;
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionValuesTimeExpireStrategy.java
 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionValuesTimeExpireStrategy.java
index 6238817da4..0291bcc6c9 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/partition/PartitionValuesTimeExpireStrategy.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/partition/PartitionValuesTimeExpireStrategy.java
@@ -32,6 +32,7 @@ import org.slf4j.LoggerFactory;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeParseException;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -51,25 +52,43 @@ public class PartitionValuesTimeExpireStrategy extends 
PartitionExpireStrategy {
             LoggerFactory.getLogger(PartitionValuesTimeExpireStrategy.class);
 
     private final PartitionTimeExtractor timeExtractor;
+    private final boolean retainLatest;
 
     public PartitionValuesTimeExpireStrategy(CoreOptions options, RowType 
partitionType) {
         super(partitionType, options.partitionDefaultName());
         String timePattern = options.partitionTimestampPattern();
         String timeFormatter = options.partitionTimestampFormatter();
         this.timeExtractor = new PartitionTimeExtractor(timePattern, 
timeFormatter);
+        this.retainLatest = options.partitionExpireRetainLatest();
     }
 
     @Override
     public List<PartitionEntry> selectExpiredPartitions(
             FileStoreScan scan, LocalDateTime expirationTime) {
-        return scan.withPartitionFilter(new 
PartitionValuesTimePredicate(expirationTime))
-                .readPartitionEntries();
+        PartitionValuesTimePredicate predicate = new 
PartitionValuesTimePredicate(expirationTime);
+        if (!retainLatest) {
+            return scan.withPartitionFilter(predicate).readPartitionEntries();
+        }
+
+        List<PartitionEntry> partitions = scan.readPartitionEntries();
+        List<PartitionEntry> expired =
+                partitions.stream()
+                        .filter(entry -> predicate.test(entry.partition()))
+                        .collect(Collectors.toList());
+        retainLatestPartition(
+                partitions, expired, 
Comparator.comparing(this::extractPartitionTime));
+        return expired;
     }
 
     public boolean isExpired(LocalDateTime expireDateTime, BinaryRow 
partition) {
         return new 
PartitionValuesTimePredicate(expireDateTime).test(partition);
     }
 
+    private LocalDateTime extractPartitionTime(PartitionEntry entry) {
+        return timeExtractor.extract(
+                partitionKeys, 
Arrays.asList(convertPartition(entry.partition())));
+    }
+
     /** The expired partition predicate uses the date-format value of the 
partition. */
     private class PartitionValuesTimePredicate implements PartitionPredicate {
 
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/PartitionExpireTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/operation/PartitionExpireTest.java
index 57dabaea4c..e0a2372287 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/PartitionExpireTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/PartitionExpireTest.java
@@ -30,8 +30,11 @@ 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.PartitionEntry;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionExpireStrategy;
 import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.partition.PartitionUpdateTimeExpireStrategy;
 import org.apache.paimon.partition.PartitionValuesTimeExpireStrategy;
 import org.apache.paimon.partition.actions.AddDonePartitionAction;
 import org.apache.paimon.schema.FileSystemSchemaManager;
@@ -61,12 +64,14 @@ import org.junit.jupiter.api.BeforeEach;
 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 org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
 import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.LocalTime;
+import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
@@ -85,6 +90,7 @@ import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
 import static org.apache.paimon.CoreOptions.METASTORE_PARTITIONED_TABLE;
 import static 
org.apache.paimon.CoreOptions.PARTITION_EXPIRATION_CHECK_INTERVAL;
+import static org.apache.paimon.CoreOptions.PARTITION_EXPIRATION_RETAIN_LATEST;
 import static org.apache.paimon.CoreOptions.PARTITION_EXPIRATION_TIME;
 import static org.apache.paimon.CoreOptions.PARTITION_TIMESTAMP_FORMATTER;
 import static org.apache.paimon.CoreOptions.PATH;
@@ -94,6 +100,8 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 /** Test for {@link NormalPartitionExpire}. */
 public class PartitionExpireTest {
@@ -694,6 +702,205 @@ public class PartitionExpireTest {
         assertThat(read()).containsExactlyInAnyOrder("us:fresh");
     }
 
+    @ParameterizedTest
+    @CsvSource({"false,false", "true,false", "false,true", "true,true"})
+    public void testRetainLatestPartitionLifecycle(boolean primaryKey, boolean 
metastore)
+            throws Exception {
+        newRetainLatestTable(primaryKey);
+        table =
+                table.copy(
+                        Collections.singletonMap(
+                                METASTORE_PARTITIONED_TABLE.key(), 
String.valueOf(metastore)));
+        NormalPartitionExpire expire =
+                (NormalPartitionExpire) 
table.store().newPartitionExpire("expire", table);
+        assertThat(expire.expire(date(10), Long.MAX_VALUE)).isEmpty();
+
+        write("20230101", "old");
+        assertThat(expire.expire(date(10), Long.MAX_VALUE)).isEmpty();
+        write("20230103", "latest");
+        if (metastore) {
+            AddDonePartitionAction doneAction =
+                    new 
AddDonePartitionAction(table.catalogEnvironment().partitionModification());
+            doneAction.markDone("f0=20230101");
+            doneAction.markDone("f0=20230103");
+        }
+        assertThat(expire.expire(date(10), Long.MAX_VALUE))
+                .containsExactly(Collections.singletonMap("f0", "20230101"));
+        assertThat(read()).containsExactly("20230103:latest");
+        long snapshotId = table.snapshotManager().latestSnapshotId();
+        assertThat(expire.expire(date(20), Long.MAX_VALUE)).isEmpty();
+        
assertThat(table.snapshotManager().latestSnapshotId()).isEqualTo(snapshotId);
+        if (metastore) {
+            assertThat(createdPartitions)
+                    .containsExactlyInAnyOrder(
+                            Collections.singletonMap("f0", "20230103"),
+                            Collections.singletonMap("f0", "20230103.done"));
+            assertThat(deletedPartitions)
+                    .containsExactlyInAnyOrder(
+                            Collections.singletonMap("f0", "20230101"),
+                            Collections.singletonMap("f0", "20230101.done"));
+        }
+
+        // Prepared files are not a replacement until their commit becomes 
visible.
+        try (StreamTableWrite write = table.newWrite("replacement");
+                TableCommitImpl commit = table.newCommit("replacement")) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("20230121"), 
BinaryString.fromString("new")));
+            List<CommitMessage> messages = write.prepareCommit(true, 0);
+            assertThat(expire.expire(date(22), Long.MAX_VALUE)).isEmpty();
+            assertThat(read()).containsExactly("20230103:latest");
+            commit.commit(0, messages);
+        }
+        assertThat(expire.expire(date(22), Long.MAX_VALUE))
+                .containsExactly(Collections.singletonMap("f0", "20230103"));
+        assertThat(expire.expire(date(30), Long.MAX_VALUE)).isEmpty();
+        assertThat(read()).containsExactly("20230121:new");
+        if (metastore) {
+            assertThat(createdPartitions)
+                    .containsExactly(Collections.singletonMap("f0", 
"20230121"));
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"1,1", "100,1", "100,0"})
+    public void testRetainLatestWithLimitsAndBatches(int limit, int batchSize) 
throws Exception {
+        newRetainLatestTable(false);
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PARTITION_EXPIRATION_MAX_NUM.key(), 
String.valueOf(limit));
+        options.put(CoreOptions.PARTITION_EXPIRATION_BATCH_SIZE.key(), 
String.valueOf(batchSize));
+        options.put(PARTITION_TIMESTAMP_FORMATTER.key(), "ddMMyyyy");
+        table = table.copy(options);
+        write("12012023", "old");
+        write("15012023", "old");
+        // Lexical order differs from timestamp order.
+        write("02022023", "latest");
+        NormalPartitionExpire expire =
+                (NormalPartitionExpire) 
table.store().newPartitionExpire("expire", table);
+        for (int i = 0; i < 4; i++) {
+            assertThat(expire.expire(LocalDateTime.of(2023, 3, 1, 0, 0), 
Long.MAX_VALUE))
+                    .hasSizeLessThanOrEqualTo(limit);
+        }
+        assertThat(read()).containsExactly("02022023:latest");
+    }
+
+    @Test
+    public void 
testRetainLatestDoesNotKeepExtraPartitionWhenUnparseablePartitionRemains()
+            throws Exception {
+        newRetainLatestTable(false);
+        write("20230101", "old");
+        write("invalid", "retained");
+        NormalPartitionExpire expire =
+                (NormalPartitionExpire) 
table.store().newPartitionExpire("expire", table);
+        assertThat(expire.expire(date(10), Long.MAX_VALUE)).hasSize(1);
+        assertThat(read()).containsExactly("invalid:retained");
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void testWriterTriggeredExpirationRetainsLatest(boolean primaryKey) 
throws Exception {
+        newRetainLatestTable(primaryKey);
+        table = table.copy(Collections.singletonMap(WRITE_ONLY.key(), 
"false"));
+        write("20230101", "old");
+        assertThat(read()).containsExactly("20230101:old");
+        write("20230103", "latest");
+        assertThat(read()).containsExactly("20230103:latest");
+    }
+
+    @Test
+    public void testRetainLatestDoesNotPreventExplicitDeletion() throws 
Exception {
+        newRetainLatestTable(false);
+        write("20230101", "old");
+        try (FileStoreCommit commit = table.store().newCommit("drop", table)) {
+            commit.dropPartitions(singletonList(Collections.singletonMap("f0", 
"20230101")), 0);
+            assertThat(read()).isEmpty();
+            write("20230103", "latest");
+            commit.truncateTable(1);
+            assertThat(read()).isEmpty();
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"values-time", "update-time"})
+    public void testRetainLatestOrderingAndStableTies(String 
expirationStrategy) {
+        RowType type = RowType.of(DataTypes.DATE(), DataTypes.STRING());
+        Options options = new Options();
+        options.set(PARTITION_EXPIRATION_RETAIN_LATEST, true);
+        boolean updateTime = "update-time".equals(expirationStrategy);
+        PartitionExpireStrategy strategy =
+                updateTime
+                        ? new PartitionUpdateTimeExpireStrategy(new 
CoreOptions(options), type)
+                        : new PartitionValuesTimeExpireStrategy(new 
CoreOptions(options), type);
+        InternalRowSerializer serializer = new InternalRowSerializer(type);
+        // For update-time, the latest partition may have an older partition 
timestamp.
+        PartitionEntry old = partitionEntry(serializer, updateTime ? 5 : 1, 
"z", 1);
+        PartitionEntry recent = partitionEntry(serializer, 3, "a", 3);
+        PartitionEntry tie = partitionEntry(serializer, 3, "b", 3);
+        FileStoreScan scan = mock(FileStoreScan.class);
+        when(scan.readPartitionEntries()).thenReturn(Arrays.asList(old, 
recent, tie));
+        List<PartitionEntry> expired = strategy.selectExpiredPartitions(scan, 
date(10));
+        assertThat(expired).hasSize(2).contains(old);
+        when(scan.readPartitionEntries()).thenReturn(Arrays.asList(tie, 
recent, old));
+        assertThat(strategy.selectExpiredPartitions(scan, date(10)))
+                .containsExactlyInAnyOrderElementsOf(expired);
+        when(scan.readPartitionEntries()).thenReturn(singletonList(recent));
+        assertThat(strategy.selectExpiredPartitions(scan, date(10))).isEmpty();
+
+        // Any live partition means there is no need to protect an expired one.
+        PartitionEntry live =
+                partitionEntry(
+                        serializer,
+                        20,
+                        "live",
+                        
date(20).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
+        when(scan.readPartitionEntries()).thenReturn(Arrays.asList(old, 
recent, live));
+        assertThat(strategy.selectExpiredPartitions(scan, 
date(10))).containsExactly(old, recent);
+        if (!updateTime) {
+            PartitionEntry nullTime = partitionEntry(serializer, null, "null", 
1);
+            when(scan.readPartitionEntries()).thenReturn(Arrays.asList(old, 
recent, nullTime));
+            assertThat(strategy.selectExpiredPartitions(scan, date(10)))
+                    .containsExactly(old, recent);
+        }
+        when(scan.readPartitionEntries()).thenReturn(emptyList());
+        assertThat(strategy.selectExpiredPartitions(scan, date(10))).isEmpty();
+    }
+
+    private PartitionEntry partitionEntry(
+            InternalRowSerializer serializer, Integer day, String group, long 
fileCreationTime) {
+        BinaryRow partition =
+                serializer
+                        .toBinaryRow(
+                                GenericRow.of(
+                                        day == null
+                                                ? null
+                                                : (int) 
date(day).toLocalDate().toEpochDay(),
+                                        BinaryString.fromString(group)))
+                        .copy();
+        return new PartitionEntry(partition, 1, 1, 1, fileCreationTime, 1);
+    }
+
+    private void newRetainLatestTable(boolean primaryKey) throws Exception {
+        Map<String, String> options = new HashMap<>();
+        options.put(PARTITION_EXPIRATION_RETAIN_LATEST.key(), "true");
+        options.put(PARTITION_EXPIRATION_TIME.key(), "2 d");
+        options.put(PARTITION_EXPIRATION_CHECK_INTERVAL.key(), "0 s");
+        options.put(PARTITION_TIMESTAMP_FORMATTER.key(), "yyyyMMdd");
+        options.put(WRITE_ONLY.key(), "true");
+        if (primaryKey) {
+            options.put(CoreOptions.BUCKET.key(), "1");
+        }
+        new FileSystemSchemaManager(LocalFileIO.create(), path)
+                .createTable(
+                        new Schema(
+                                RowType.of(VarCharType.STRING_TYPE, 
VarCharType.STRING_TYPE)
+                                        .getFields(),
+                                singletonList("f0"),
+                                primaryKey ? Arrays.asList("f0", "f1") : 
emptyList(),
+                                options,
+                                ""));
+        newTable();
+    }
+
     private List<String> read() throws IOException {
         List<String> ret = new ArrayList<>();
         table.newRead()
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/ExpirePartitionsAction.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/ExpirePartitionsAction.java
index 7efb65d2d6..3dfd15e45f 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/ExpirePartitionsAction.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/ExpirePartitionsAction.java
@@ -59,6 +59,9 @@ public class ExpirePartitionsAction extends TableActionBase 
implements LocalActi
         map.put(CoreOptions.PARTITION_EXPIRATION_STRATEGY.key(), 
expireStrategy);
         map.put(CoreOptions.PARTITION_TIMESTAMP_FORMATTER.key(), 
timestampFormatter);
         map.put(CoreOptions.PARTITION_TIMESTAMP_PATTERN.key(), 
timestampPattern);
+        map.put(
+                CoreOptions.PARTITION_EXPIRATION_RETAIN_LATEST.key(),
+                
table.options().get(CoreOptions.PARTITION_EXPIRATION_RETAIN_LATEST.key()));
     }
 
     @Override
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ExpirePartitionsActionITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ExpirePartitionsActionITCase.java
index 130d23d466..1a528c8d1f 100644
--- 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ExpirePartitionsActionITCase.java
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/action/ExpirePartitionsActionITCase.java
@@ -31,12 +31,15 @@ import org.apache.paimon.utils.SnapshotManager;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 import static org.apache.paimon.flink.util.ReadWriteTableTestUtil.init;
 import static org.assertj.core.api.Assertions.assertThat;
@@ -190,6 +193,61 @@ public class ExpirePartitionsActionITCase extends 
ActionITCaseBase {
         assertThat(actual).isEqualTo(expected);
     }
 
+    @ParameterizedTest
+    @CsvSource({"true,,true", ",true,true", "true,false,false", ",,false"})
+    public void testRetainLatestPartition(
+            String tableOption, String actionOption, boolean retainLatest) 
throws Exception {
+        Map<String, String> options = new HashMap<>();
+        if (tableOption != null) {
+            options.put("partition.expiration-retain-latest", tableOption);
+        }
+        // Explicit action arguments must still override the table's strategy 
and formatter.
+        options.put("partition.expiration-strategy", "update-time");
+        options.put("partition.timestamp-formatter", "yyyy-MM-dd");
+        FileStoreTable table =
+                createFileStoreTable(
+                        ROW_TYPE,
+                        Collections.singletonList("dt"),
+                        Arrays.asList("k", "dt"),
+                        Collections.singletonList("k"),
+                        options);
+        StreamWriteBuilder writeBuilder = 
table.newStreamWriteBuilder().withCommitUser(commitUser);
+        write = writeBuilder.newWrite();
+        commit = writeBuilder.newCommit();
+        writeData(
+                rowData(
+                        BinaryString.fromString("1"),
+                        BinaryString.fromString("20010101"),
+                        BinaryString.fromString("01:00")));
+        assertThat(getData(tableName)).hasSize(1);
+
+        List<String> args =
+                new ArrayList<>(
+                        Arrays.asList(
+                                "expire_partitions",
+                                "--warehouse",
+                                warehouse,
+                                "--database",
+                                database,
+                                "--table",
+                                tableName,
+                                "--expiration_time",
+                                "1 d",
+                                "--timestamp_formatter",
+                                "yyyyMMdd",
+                                "--expire_strategy",
+                                "values-time"));
+        if (actionOption != null) {
+            args.add("--table_conf");
+            args.add("partition.expiration-retain-latest=" + actionOption);
+        }
+        ExpirePartitionsAction action = 
createAction(ExpirePartitionsAction.class, args);
+        for (int i = 0; i < 2; i++) {
+            action.executeLocally();
+            assertThat(getData(tableName)).hasSize(retainLatest ? 1 : 0);
+        }
+    }
+
     private FileStoreTable prepareTable() throws Exception {
         init(warehouse);
 

Reply via email to