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 e599991d83 [core] Support LocalKvDb merge and TTL compaction (#8872)
e599991d83 is described below
commit e599991d8341fec89618d3168342505ad15b8f3c
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 28 10:24:54 2026 +0800
[core] Support LocalKvDb merge and TTL compaction (#8872)
---
.../apache/paimon/lookup/sort/db/LocalKvDb.java | 88 ++++++-
.../lookup/sort/db/RecordCombiningWriter.java | 107 +++++++++
.../paimon/lookup/sort/db/UniversalCompactor.java | 264 +++++++++++++++------
.../paimon/lookup/sort/db/LocalKvDbTest.java | 169 +++++++++++++
4 files changed, 537 insertions(+), 91 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LocalKvDb.java
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LocalKvDb.java
index 1bf4aa8dfa..f319ee9463 100644
---
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LocalKvDb.java
+++
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LocalKvDb.java
@@ -53,6 +53,7 @@ import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongFunction;
+import java.util.function.Predicate;
import static org.apache.paimon.utils.Preconditions.checkArgument;
@@ -118,6 +119,7 @@ public class LocalKvDb implements Closeable {
private final long maxSstFileSize;
private final LsmLevels levels;
private final LsmCompactor compaction;
+ @Nullable private final MergeOperator mergeOperator;
/** Active MemTable: key -> value bytes (empty byte[] = tombstone). */
private TreeMap<MemorySlice, byte[]> memTable;
@@ -142,6 +144,8 @@ public class LocalKvDb implements Closeable {
long maxSstFileSize,
int level0FileNumCompactTrigger,
int sizeRatio,
+ @Nullable Predicate<byte[]> expiredValuePredicate,
+ @Nullable MergeOperator mergeOperator,
@Nullable ExecutorService compactionExecutor) {
this.dataDirectory = dataDirectory;
this.uuid = UUID.randomUUID().toString();
@@ -158,6 +162,7 @@ public class LocalKvDb implements Closeable {
this.activeBulkLoadWriter = null;
this.openRangeIterators = 0;
this.closed = false;
+ this.mergeOperator = mergeOperator;
LsmCompactor.CompactorFactory compactorFactory =
fileDeleter ->
new UniversalCompactor(
@@ -167,6 +172,8 @@ public class LocalKvDb implements Closeable {
maxSstFileSize,
level0FileNumCompactTrigger,
sizeRatio,
+ expiredValuePredicate,
+ mergeOperator,
fileDeleter);
this.compaction =
compactionExecutor == null
@@ -807,26 +814,23 @@ public class LocalKvDb implements Closeable {
throws IOException {
File sstFile = newSstFile();
SortLookupStoreWriter writer = null;
- MemorySlice minKey = null;
- MemorySlice maxKey = null;
- long tombstoneCount = 0;
try {
writer =
storeFactory.createWriter(
- sstFile,
bloomFilterBuilderFactory.apply(data.size()));
+ sstFile,
+ bloomFilterBuilderFactory.apply(
+ mergeOperator == null ? data.size() :
UNKNOWN_NUM_ENTRIES));
+ SstMetadataWriter output = new SstMetadataWriter(writer);
+ RecordCombiningWriter combiningWriter =
+ new RecordCombiningWriter(mergeOperator, output);
for (Map.Entry<MemorySlice, byte[]> entry : data.entrySet()) {
- writer.put(entry.getKey().copyBytes(), entry.getValue());
- if (minKey == null) {
- minKey = entry.getKey();
- }
- maxKey = entry.getKey();
- if (isTombstone(entry.getValue())) {
- tombstoneCount++;
- }
+ combiningWriter.put(entry.getKey(), entry.getValue());
}
+ combiningWriter.finish();
writer.close();
writer = null;
- return new SstFileMetadata(sstFile, minKey, maxKey,
tombstoneCount, 0);
+ return new SstFileMetadata(
+ sstFile, output.minKey, output.maxKey,
output.tombstoneCount, 0);
} catch (IOException | RuntimeException e) {
if (writer != null) {
try {
@@ -840,6 +844,31 @@ public class LocalKvDb implements Closeable {
}
}
+ private static final class SstMetadataWriter implements
RecordCombiningWriter.RecordConsumer {
+
+ private final SortLookupStoreWriter writer;
+
+ @Nullable private MemorySlice minKey;
+ @Nullable private MemorySlice maxKey;
+ private long tombstoneCount;
+
+ private SstMetadataWriter(SortLookupStoreWriter writer) {
+ this.writer = writer;
+ }
+
+ @Override
+ public void accept(MemorySlice key, byte[] value) throws IOException {
+ writer.put(key.copyBytes(), value);
+ if (minKey == null) {
+ minKey = key;
+ }
+ maxKey = key;
+ if (isTombstone(value)) {
+ tombstoneCount++;
+ }
+ }
+ }
+
private File newSstFile() {
long sequence = fileSequence.getAndIncrement();
return new File(dataDirectory, String.format("sst-%s-%06d.db", uuid,
sequence));
@@ -1119,6 +1148,19 @@ public class LocalKvDb implements Closeable {
void accept(MemorySlice key, MemorySlice value) throws IOException;
}
+ /**
+ * Operator for combining adjacent logical records while flushing and
compacting SST files.
+ *
+ * <p>MemTable writes remain independent so merge-heavy workloads do not
pay repeated
+ * read-modify-write costs. The first record's key is retained for the
combined value.
+ */
+ public interface MergeOperator {
+
+ boolean canMerge(MemorySlice firstKey, MemorySlice nextKey);
+
+ byte[] merge(List<byte[]> values) throws IOException;
+ }
+
//
-------------------------------------------------------------------------
// Builder
//
-------------------------------------------------------------------------
@@ -1137,6 +1179,8 @@ public class LocalKvDb implements Closeable {
private Comparator<MemorySlice> keyComparator = MemorySlice::compareTo;
private boolean bloomFilterEnabled = true;
private double bloomFilterFpp = 0.1;
+ @Nullable private Predicate<byte[]> expiredValuePredicate;
+ @Nullable private MergeOperator mergeOperator;
@Nullable private ExecutorService compactionExecutor;
Builder(File dataDirectory) {
@@ -1205,6 +1249,22 @@ public class LocalKvDb implements Closeable {
return this;
}
+ /**
+ * Set a predicate which identifies expired stored values during
compaction. Partial
+ * compaction converts matching values into tombstones to avoid
resurrecting older values;
+ * full compaction drops them.
+ */
+ public Builder expiredValuePredicate(@Nullable Predicate<byte[]>
expiredValuePredicate) {
+ this.expiredValuePredicate = expiredValuePredicate;
+ return this;
+ }
+
+ /** Set an operator for combining adjacent logical records in
generated SST files. */
+ public Builder mergeOperator(@Nullable MergeOperator mergeOperator) {
+ this.mergeOperator = mergeOperator;
+ return this;
+ }
+
/**
* Set the executor for asynchronous compaction. Compaction runs
synchronously when no
* executor is configured. The executor remains owned by the caller
and is not shut down
@@ -1259,6 +1319,8 @@ public class LocalKvDb implements Closeable {
maxSstFileSize,
level0FileNumCompactTrigger,
sizeRatio,
+ expiredValuePredicate,
+ mergeOperator,
compactionExecutor);
}
}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/RecordCombiningWriter.java
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/RecordCombiningWriter.java
new file mode 100644
index 0000000000..edf5eedd0d
--- /dev/null
+++
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/RecordCombiningWriter.java
@@ -0,0 +1,107 @@
+/*
+ * 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.lookup.sort.db;
+
+import org.apache.paimon.memory.MemorySlice;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.paimon.lookup.sort.db.LocalKvDb.isTombstone;
+
+/** Combines adjacent records before forwarding them to an SST writer. */
+final class RecordCombiningWriter {
+
+ private static final byte[] TOMBSTONE = new byte[0];
+
+ @Nullable private final LocalKvDb.MergeOperator mergeOperator;
+ private final RecordConsumer consumer;
+
+ @Nullable private MemorySlice pendingKey;
+ private final List<MemorySlice> pendingKeys;
+ private final List<byte[]> pendingValues;
+
+ RecordCombiningWriter(
+ @Nullable LocalKvDb.MergeOperator mergeOperator, RecordConsumer
consumer) {
+ this.mergeOperator = mergeOperator;
+ this.consumer = consumer;
+ this.pendingKeys = new ArrayList<>();
+ this.pendingValues = new ArrayList<>();
+ }
+
+ void put(MemorySlice key, byte[] value) throws IOException {
+ if (mergeOperator == null) {
+ consumer.accept(key, value);
+ return;
+ }
+
+ if (isTombstone(value)) {
+ flushPending();
+ consumer.accept(key, value);
+ return;
+ }
+
+ if (pendingKey == null) {
+ startGroup(key, value);
+ } else if (mergeOperator.canMerge(pendingKey, key)) {
+ pendingKeys.add(MemorySlice.wrap(key.copyBytes()));
+ pendingValues.add(value);
+ } else {
+ flushPending();
+ startGroup(key, value);
+ }
+ }
+
+ void finish() throws IOException {
+ flushPending();
+ }
+
+ private void startGroup(MemorySlice key, byte[] value) {
+ pendingKey = MemorySlice.wrap(key.copyBytes());
+ pendingKeys.add(pendingKey);
+ pendingValues.add(value);
+ }
+
+ private void flushPending() throws IOException {
+ if (pendingKey == null) {
+ return;
+ }
+
+ byte[] value =
+ pendingValues.size() == 1
+ ? pendingValues.get(0)
+ : mergeOperator.merge(pendingValues);
+ consumer.accept(pendingKey, value);
+ for (int i = 1; i < pendingKeys.size(); i++) {
+ consumer.accept(pendingKeys.get(i), TOMBSTONE);
+ }
+ pendingKey = null;
+ pendingKeys.clear();
+ pendingValues.clear();
+ }
+
+ /** Callback receiving one combined record. */
+ interface RecordConsumer {
+
+ void accept(MemorySlice key, byte[] value) throws IOException;
+ }
+}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/UniversalCompactor.java
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/UniversalCompactor.java
index fc6c8b8328..b099b3eb0e 100644
---
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/UniversalCompactor.java
+++
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/UniversalCompactor.java
@@ -29,6 +29,8 @@ import org.apache.paimon.utils.BloomFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.annotation.Nullable;
+
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
@@ -40,6 +42,7 @@ import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.function.LongFunction;
+import java.util.function.Predicate;
import static org.apache.paimon.lookup.sort.db.LocalKvDb.UNKNOWN_NUM_ENTRIES;
import static org.apache.paimon.lookup.sort.db.LocalKvDb.isTombstone;
@@ -69,6 +72,7 @@ import static
org.apache.paimon.lookup.sort.db.LocalKvDb.isTombstone;
public class UniversalCompactor {
private static final Logger LOG =
LoggerFactory.getLogger(UniversalCompactor.class);
+ private static final byte[] TOMBSTONE = new byte[0];
private final Comparator<MemorySlice> keyComparator;
private final SortLookupStoreFactory storeFactory;
@@ -76,6 +80,8 @@ public class UniversalCompactor {
private final long maxOutputFileSize;
private final int level0FileNumCompactTrigger;
private final int sizeRatioPercent;
+ @Nullable private final Predicate<byte[]> expiredValuePredicate;
+ @Nullable private final LocalKvDb.MergeOperator mergeOperator;
private final FileDeleter fileDeleter;
public UniversalCompactor(
@@ -85,6 +91,8 @@ public class UniversalCompactor {
long maxOutputFileSize,
int level0FileNumCompactTrigger,
int sizeRatioPercent,
+ @Nullable Predicate<byte[]> expiredValuePredicate,
+ @Nullable LocalKvDb.MergeOperator mergeOperator,
FileDeleter fileDeleter) {
this.keyComparator = keyComparator;
this.storeFactory = storeFactory;
@@ -92,6 +100,8 @@ public class UniversalCompactor {
this.maxOutputFileSize = maxOutputFileSize;
this.level0FileNumCompactTrigger = level0FileNumCompactTrigger;
this.sizeRatioPercent = sizeRatioPercent;
+ this.expiredValuePredicate = expiredValuePredicate;
+ this.mergeOperator = mergeOperator;
this.fileDeleter = fileDeleter;
}
@@ -178,7 +188,7 @@ public class UniversalCompactor {
List<List<SstFileMetadata>> levels, int maxLevels, FileSupplier
fileSupplier)
throws IOException {
List<SortedRun> sortedRuns = collectSortedRuns(levels, maxLevels);
- if (sortedRuns.size() <= 1) {
+ if (sortedRuns.isEmpty() || (sortedRuns.size() == 1 &&
expiredValuePredicate == null)) {
return;
}
@@ -314,24 +324,51 @@ public class UniversalCompactor {
int skippedGroupCount = 0;
int mergedGroupCount = 0;
- for (List<SstFileMetadata> group : mergedGroups) {
- if (group.size() == 1) {
- SstFileMetadata singleFile = group.get(0);
- boolean canSkip = !dropTombstones ||
!singleFile.hasTombstones();
- if (canSkip) {
- SstFileMetadata promoted =
singleFile.withLevel(outputLevel);
- outputFiles.add(promoted);
- skippedFileSet.add(promoted.getFile());
- skippedGroupCount++;
- continue;
+ if (mergeOperator == null) {
+ for (List<SstFileMetadata> group : mergedGroups) {
+ if (group.size() == 1) {
+ SstFileMetadata singleFile = group.get(0);
+ boolean canSkip =
+ expiredValuePredicate == null
+ && (!dropTombstones ||
!singleFile.hasTombstones());
+ if (canSkip) {
+ SstFileMetadata promoted =
singleFile.withLevel(outputLevel);
+ outputFiles.add(promoted);
+ skippedFileSet.add(promoted.getFile());
+ skippedGroupCount++;
+ continue;
+ }
}
- }
- mergedGroupCount++;
- List<SstFileMetadata> groupMerged =
+ mergedGroupCount++;
+ outputFiles.addAll(
+ mergeFileGroup(
+ group,
+ dropTombstones,
+ fileSupplier,
+ fileToRunSequence,
+ outputLevel,
+ null));
+ }
+ } else {
+ CompactionSstOutput output =
+ new CompactionSstOutput(outputFiles, fileSupplier,
outputLevel, dropTombstones);
+ try {
+ for (List<SstFileMetadata> group : mergedGroups) {
+ mergedGroupCount++;
mergeFileGroup(
- group, dropTombstones, fileSupplier,
fileToRunSequence, outputLevel);
- outputFiles.addAll(groupMerged);
+ group,
+ dropTombstones,
+ fileSupplier,
+ fileToRunSequence,
+ outputLevel,
+ output);
+ }
+ output.finish();
+ } catch (IOException | RuntimeException | Error e) {
+ output.abort(e);
+ throw e;
+ }
}
outputFiles.sort((a, b) -> keyComparator.compare(a.getMinKey(),
b.getMinKey()));
@@ -452,6 +489,7 @@ public class UniversalCompactor {
* @param fileSupplier supplier for new SST file paths
* @param fileToRunSequence maps each file to its run sequence number for
dedup ordering
* @param outputLevel the level to assign to output files
+ * @param sharedOutput optional output shared across file groups to
preserve merge state
* @return the list of merged output files
*/
private List<SstFileMetadata> mergeFileGroup(
@@ -459,7 +497,8 @@ public class UniversalCompactor {
boolean dropTombstones,
FileSupplier fileSupplier,
Map<File, Integer> fileToRunSequence,
- int outputLevel)
+ int outputLevel,
+ @Nullable CompactionSstOutput sharedOutput)
throws IOException {
// Sort files by run sequence (older first) for correct dedup ordering
@@ -483,7 +522,11 @@ public class UniversalCompactor {
return Integer.compare(b.sequence, a.sequence);
});
- SortLookupStoreWriter currentWriter = null;
+ CompactionSstOutput output =
+ sharedOutput == null
+ ? new CompactionSstOutput(result, fileSupplier,
outputLevel, dropTombstones)
+ : sharedOutput;
+ Throwable failure = null;
try {
for (int seq = 0; seq < orderedFiles.size(); seq++) {
SortLookupStoreReader reader =
@@ -495,11 +538,6 @@ public class UniversalCompactor {
minHeap.add(source.currentEntry());
}
}
- File currentSstFile = null;
- MemorySlice currentFileMinKey = null;
- MemorySlice currentFileMaxKey = null;
- long currentBatchSize = 0;
- long currentTombstoneCount = 0;
MemorySlice previousKey = null;
while (!minHeap.isEmpty()) {
@@ -518,67 +556,34 @@ public class UniversalCompactor {
minHeap.add(entry.source.currentEntry());
}
- if (dropTombstones && isTombstone(entry.value)) {
- continue;
- }
-
- if (currentWriter == null) {
- currentSstFile = fileSupplier.newSstFile();
- currentWriter =
- storeFactory.createWriter(
- currentSstFile,
-
bloomFilterBuilderFactory.apply(UNKNOWN_NUM_ENTRIES));
- currentFileMinKey = entry.key;
- currentBatchSize = 0;
- currentTombstoneCount = 0;
- }
-
- currentWriter.put(entry.key.copyBytes(), entry.value);
- currentFileMaxKey = entry.key;
- currentBatchSize += entry.key.length() + entry.value.length;
- if (isTombstone(entry.value)) {
- currentTombstoneCount++;
- }
-
- if (currentBatchSize >= maxOutputFileSize) {
- currentWriter.close();
- result.add(
- new SstFileMetadata(
- currentSstFile,
- currentFileMinKey,
- currentFileMaxKey,
- currentTombstoneCount,
- outputLevel));
- currentWriter = null;
- currentSstFile = null;
- currentFileMinKey = null;
- currentFileMaxKey = null;
- }
+ output.put(entry.key, entry.value);
}
- if (currentWriter != null) {
- currentWriter.close();
- result.add(
- new SstFileMetadata(
- currentSstFile,
- currentFileMinKey,
- currentFileMaxKey,
- currentTombstoneCount,
- outputLevel));
- }
- } catch (IOException | RuntimeException e) {
- // Close the in-progress writer on failure to avoid resource leak
- if (currentWriter != null) {
- try {
- currentWriter.close();
- } catch (IOException suppressed) {
- e.addSuppressed(suppressed);
- }
+ if (sharedOutput == null) {
+ output.finish();
}
+ } catch (IOException | RuntimeException | Error e) {
+ failure = e;
+ output.abort(e);
throw e;
} finally {
+ IOException closeFailure = null;
for (SortLookupStoreReader reader : openReaders) {
- reader.close();
+ try {
+ reader.close();
+ } catch (IOException e) {
+ if (closeFailure == null) {
+ closeFailure = e;
+ } else {
+ closeFailure.addSuppressed(e);
+ }
+ }
+ }
+ if (closeFailure != null) {
+ if (failure == null) {
+ throw closeFailure;
+ }
+ failure.addSuppressed(closeFailure);
}
}
@@ -625,6 +630,109 @@ public class UniversalCompactor {
}
}
+ /** Writes compacted records, combining adjacent values before enforcing
output file sizes. */
+ private final class CompactionSstOutput {
+
+ private final List<SstFileMetadata> result;
+ private final FileSupplier fileSupplier;
+ private final int outputLevel;
+ private final boolean dropTombstones;
+ private final RecordCombiningWriter combiningWriter;
+
+ @Nullable private SortLookupStoreWriter currentWriter;
+ @Nullable private File currentSstFile;
+ @Nullable private MemorySlice currentFileMinKey;
+ @Nullable private MemorySlice currentFileMaxKey;
+ private long currentBatchSize;
+ private long currentTombstoneCount;
+
+ private CompactionSstOutput(
+ List<SstFileMetadata> result,
+ FileSupplier fileSupplier,
+ int outputLevel,
+ boolean dropTombstones) {
+ this.result = result;
+ this.fileSupplier = fileSupplier;
+ this.outputLevel = outputLevel;
+ this.dropTombstones = dropTombstones;
+ this.combiningWriter =
+ new RecordCombiningWriter(mergeOperator,
this::writeCombinedRecord);
+ }
+
+ private void put(MemorySlice key, byte[] value) throws IOException {
+ combiningWriter.put(key, value);
+ }
+
+ private void finish() throws IOException {
+ combiningWriter.finish();
+ closeCurrentWriter();
+ }
+
+ private void abort(Throwable failure) {
+ if (currentWriter != null) {
+ try {
+ currentWriter.close();
+ } catch (IOException suppressed) {
+ failure.addSuppressed(suppressed);
+ }
+ currentWriter = null;
+ }
+ }
+
+ private void writeCombinedRecord(MemorySlice key, byte[] value) throws
IOException {
+ // Evaluate expiration after combining so a TTL-aware merge can
refresh the result.
+ boolean tombstone = isTombstone(value);
+ boolean expired =
+ !tombstone
+ && expiredValuePredicate != null
+ && expiredValuePredicate.test(value);
+ if (dropTombstones && (tombstone || expired)) {
+ return;
+ }
+ byte[] outputValue = expired ? TOMBSTONE : value;
+
+ if (currentWriter == null) {
+ currentSstFile = fileSupplier.newSstFile();
+ currentWriter =
+ storeFactory.createWriter(
+ currentSstFile,
+
bloomFilterBuilderFactory.apply(UNKNOWN_NUM_ENTRIES));
+ currentFileMinKey = key;
+ currentBatchSize = 0;
+ currentTombstoneCount = 0;
+ }
+
+ currentWriter.put(key.copyBytes(), outputValue);
+ currentFileMaxKey = key;
+ currentBatchSize += key.length() + outputValue.length;
+ if (isTombstone(outputValue)) {
+ currentTombstoneCount++;
+ }
+
+ if (currentBatchSize >= maxOutputFileSize) {
+ closeCurrentWriter();
+ }
+ }
+
+ private void closeCurrentWriter() throws IOException {
+ if (currentWriter == null) {
+ return;
+ }
+ currentWriter.close();
+ result.add(
+ new SstFileMetadata(
+ currentSstFile,
+ currentFileMinKey,
+ currentFileMaxKey,
+ currentTombstoneCount,
+ outputLevel));
+ currentWriter = null;
+ currentSstFile = null;
+ currentFileMinKey = null;
+ currentFileMaxKey = null;
+ }
+ }
+
/** Delete old SST files from the merged sorted runs, skipping files that
were preserved. */
private void deleteOldFiles(List<SortedRun> oldRuns, Set<File>
skippedFiles) {
for (SortedRun run : oldRuns) {
diff --git
a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbTest.java
b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbTest.java
index 0f49fba43b..a022cb2265 100644
---
a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbTest.java
@@ -489,6 +489,175 @@ public class LocalKvDbTest {
}
}
+ @Test
+ public void
testPartialCompactionDoesNotResurrectValueFilteredByExpiration()
+ throws IOException {
+ File directory = new File(tempDir.toFile(),
"expiration-compaction-db");
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .memTableFlushThreshold(1024)
+ .maxSstFileSize(1024)
+ .blockSize(128)
+ .level0FileNumCompactTrigger(2)
+ .compressOptions(new CompressOptions("none", 1))
+ .expiredValuePredicate(value -> "expired".equals(new
String(value, UTF_8)))
+ .build()) {
+ db.bulkLoad(Collections.singletonList(entry("key",
"old-value")).iterator(), 1);
+
+ putString(db, "key", "expired");
+ db.flush();
+ putString(db, "other-key", "other-value");
+ db.flush();
+
+ Assertions.assertNull(getString(db, "key"));
+ Assertions.assertEquals("other-value", getString(db, "other-key"));
+ Assertions.assertEquals(1,
db.getLevelFileCount(LocalKvDb.MAX_LEVELS - 1));
+
+ db.compact();
+ Assertions.assertNull(getString(db, "key"));
+ Assertions.assertEquals("other-value", getString(db, "other-key"));
+ }
+ }
+
+ @Test
+ public void testCompactionMergesBeforeFilteringExpiredValues() throws
IOException {
+ File directory = new File(tempDir.toFile(), "expiration-merge-db");
+ LocalKvDb.MergeOperator mergeOperator =
+ new LocalKvDb.MergeOperator() {
+ @Override
+ public boolean canMerge(MemorySlice firstKey, MemorySlice
nextKey) {
+ return firstKey.readByte(0) == nextKey.readByte(0);
+ }
+
+ @Override
+ public byte[] merge(List<byte[]> values) {
+ StringBuilder merged = new StringBuilder();
+ for (byte[] value : values) {
+ if (merged.length() > 0) {
+ merged.append('+');
+ }
+ merged.append(new String(value, UTF_8));
+ }
+ return merged.toString().getBytes(UTF_8);
+ }
+ };
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .memTableFlushThreshold(1024)
+ .maxSstFileSize(1024)
+ .blockSize(128)
+ .level0FileNumCompactTrigger(100)
+ .compressOptions(new CompressOptions("none", 1))
+ .expiredValuePredicate(value -> "expired".equals(new
String(value, UTF_8)))
+ .mergeOperator(mergeOperator)
+ .build()) {
+ putString(db, "a-0", "expired");
+ db.flush();
+ putString(db, "a-1", "live");
+ db.flush();
+
+ db.compact();
+
+ Assertions.assertEquals("expired+live", getString(db, "a-0"));
+ Assertions.assertNull(getString(db, "a-1"));
+ }
+ }
+
+ @Test
+ public void
testCompactionMergesAcrossFileGroupsBeforeFilteringExpiration() throws
IOException {
+ File directory = new File(tempDir.toFile(),
"cross-group-expiration-merge-db");
+ LocalKvDb.MergeOperator mergeOperator =
+ new LocalKvDb.MergeOperator() {
+ @Override
+ public boolean canMerge(MemorySlice firstKey, MemorySlice
nextKey) {
+ return firstKey.readByte(0) == nextKey.readByte(0);
+ }
+
+ @Override
+ public byte[] merge(List<byte[]> values) {
+ return "live".getBytes(UTF_8);
+ }
+ };
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .maxSstFileSize(1)
+ .level0FileNumCompactTrigger(100)
+ .compressOptions(new CompressOptions("none", 1))
+ .expiredValuePredicate(value -> "expired".equals(new
String(value, UTF_8)))
+ .mergeOperator(mergeOperator)
+ .build()) {
+ putString(db, "a-0", "expired");
+ db.flush();
+ putString(db, "a-1", "expired");
+ db.flush();
+
+ db.compact();
+
+ Assertions.assertEquals("live", getString(db, "a-0"));
+ Assertions.assertNull(getString(db, "a-1"));
+ }
+ }
+
+ @Test
+ public void testExpirationPredicateDoesNotReceiveTombstones() throws
IOException {
+ File directory = new File(tempDir.toFile(), "tombstone-expiration-db");
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .level0FileNumCompactTrigger(100)
+ .compressOptions(new CompressOptions("none", 1))
+ .expiredValuePredicate(value -> value[0] == 1)
+ .build()) {
+ db.put("key".getBytes(UTF_8), new byte[] {2});
+ db.flush();
+ db.delete("key".getBytes(UTF_8));
+ db.flush();
+
+ db.compact();
+
+ Assertions.assertNull(db.get("key".getBytes(UTF_8)));
+ }
+ }
+
+ @Test
+ public void testFlushMergeShadowsConsumedKeysInOlderRuns() throws
IOException {
+ File directory = new File(tempDir.toFile(), "flush-merge-shadow-db");
+ LocalKvDb.MergeOperator mergeOperator =
+ new LocalKvDb.MergeOperator() {
+ @Override
+ public boolean canMerge(MemorySlice firstKey, MemorySlice
nextKey) {
+ return firstKey.readByte(0) == nextKey.readByte(0);
+ }
+
+ @Override
+ public byte[] merge(List<byte[]> values) {
+ return (new String(values.get(0), UTF_8)
+ + "+"
+ + new String(values.get(1), UTF_8))
+ .getBytes(UTF_8);
+ }
+ };
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .level0FileNumCompactTrigger(100)
+ .compressOptions(new CompressOptions("none", 1))
+ .mergeOperator(mergeOperator)
+ .build()) {
+ List<Map.Entry<byte[], byte[]>> oldValues = new ArrayList<>();
+ oldValues.add(entry("a-1", "old-1"));
+ oldValues.add(entry("a-2", "old-2"));
+ db.bulkLoad(oldValues.iterator(), oldValues.size());
+
+ putString(db, "a-1", "new-1");
+ putString(db, "a-2", "new-2");
+ Assertions.assertEquals("new-2", getString(db, "a-2"));
+
+ db.flush();
+
+ Assertions.assertEquals("new-1+new-2", getString(db, "a-1"));
+ Assertions.assertNull(getString(db, "a-2"));
+ }
+ }
+
@Test
public void testManualCompaction() throws IOException {
try (LocalKvDb db = createDb()) {