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 5c45919aef [core] Add version-aware LocalKvDb range iterator (#8871)
5c45919aef is described below
commit 5c45919aef07f049fba06fb1c97ab6a5cff5e684
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 28 10:14:57 2026 +0800
[core] Add version-aware LocalKvDb range iterator (#8871)
---
.../paimon/lookup/sort/SortLookupStoreReader.java | 9 +
.../apache/paimon/lookup/sort/db/LocalKvDb.java | 348 +++++++++++++++++++++
.../apache/paimon/lookup/sort/db/LsmLevels.java | 100 ++++++
.../sort/db/LocalKvDbAsyncCompactionTest.java | 54 ++++
.../paimon/lookup/sort/db/LocalKvDbTest.java | 200 ++++++++++++
5 files changed, 711 insertions(+)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/SortLookupStoreReader.java
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/SortLookupStoreReader.java
index 63b060fee9..ebaebd4be6 100644
---
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/SortLookupStoreReader.java
+++
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/SortLookupStoreReader.java
@@ -70,6 +70,15 @@ public class SortLookupStoreReader implements
LookupStoreReader {
return reader.createIterator();
}
+ /**
+ * Close the underlying input stream without invalidating blocks in the
shared cache.
+ *
+ * <p>The reader must not be used after this method returns.
+ */
+ public void closeInput() throws IOException {
+ input.close();
+ }
+
@Override
public void close() throws IOException {
reader.close();
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 2c73a87a95..1bf4aa8dfa 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
@@ -26,7 +26,10 @@ import org.apache.paimon.lookup.sort.SortLookupStoreReader;
import org.apache.paimon.lookup.sort.SortLookupStoreWriter;
import org.apache.paimon.memory.MemorySlice;
import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.sst.BlockIterator;
+import org.apache.paimon.sst.SstFileReader;
import org.apache.paimon.utils.BloomFilter;
+import org.apache.paimon.utils.KeyValueIterator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,6 +39,7 @@ import javax.annotation.Nullable;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
+import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
@@ -43,6 +47,7 @@ import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.PriorityQueue;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
@@ -125,6 +130,7 @@ public class LocalKvDb implements Closeable {
private final AtomicLong fileSequence;
@Nullable private BulkLoadWriter activeBulkLoadWriter;
+ private int openRangeIterators;
private boolean closed;
private LocalKvDb(
@@ -150,6 +156,7 @@ public class LocalKvDb implements Closeable {
this.readerCache = new HashMap<>();
this.fileSequence = new AtomicLong();
this.activeBulkLoadWriter = null;
+ this.openRangeIterators = 0;
this.closed = false;
LsmCompactor.CompactorFactory compactorFactory =
fileDeleter ->
@@ -218,6 +225,7 @@ public class LocalKvDb implements Closeable {
public void put(byte[] key, byte[] value) throws IOException {
ensureOpen();
ensureNoBulkLoad();
+ ensureNoRangeIterator();
checkCompactionFailure();
if (value.length == 0) {
throw new IllegalArgumentException(
@@ -244,6 +252,7 @@ public class LocalKvDb implements Closeable {
public void delete(byte[] key) throws IOException {
ensureOpen();
ensureNoBulkLoad();
+ ensureNoRangeIterator();
checkCompactionFailure();
MemorySlice wrappedKey = MemorySlice.wrap(key);
byte[] oldValue = memTable.put(wrappedKey, TOMBSTONE);
@@ -298,6 +307,7 @@ public class LocalKvDb implements Closeable {
private BulkLoadWriter createBulkLoadWriter(long expectedEntries) throws
IOException {
ensureOpen();
+ ensureNoRangeIterator();
checkCompactionFailure();
if (activeBulkLoadWriter != null) {
throw new IllegalStateException("Another bulk load is already in
progress.");
@@ -549,6 +559,77 @@ public class LocalKvDb implements Closeable {
return value == null || isTombstone(value) ? null : value;
}
+ /**
+ * Scan live entries in the half-open range [{@code fromInclusive}, {@code
toExclusive}).
+ *
+ * <p>The result is sorted by the configured key comparator. Newer values
and tombstones shadow
+ * older versions in the same way as {@link #get(byte[])}. A null upper
bound scans to the end
+ * of the database.
+ */
+ public List<Map.Entry<byte[], byte[]>> rangeScan(
+ byte[] fromInclusive, @Nullable byte[] toExclusive) throws
IOException {
+ List<Map.Entry<byte[], byte[]>> result = new ArrayList<>();
+ forEachInRange(
+ fromInclusive,
+ toExclusive,
+ (key, value) ->
+ result.add(
+ new AbstractMap.SimpleImmutableEntry<>(
+ key.copyBytes(), value.copyBytes())));
+ return result;
+ }
+
+ /**
+ * Visit live entries in the half-open range [{@code fromInclusive},
{@code toExclusive}).
+ *
+ * <p>Entries are visited in key order with the same shadowing rules as
{@link #get(byte[])}.
+ * The supplied slices are only valid for the duration of the callback and
must be copied if
+ * retained.
+ */
+ public void forEachInRange(
+ byte[] fromInclusive, @Nullable byte[] toExclusive,
RangeEntryConsumer consumer)
+ throws IOException {
+ try (RangeIterator iterator = rangeIterator(fromInclusive,
toExclusive)) {
+ while (iterator.advanceNext()) {
+ consumer.accept(iterator.getKey(), iterator.getValue());
+ }
+ }
+ }
+
+ /**
+ * Create a lazy iterator over live entries in the half-open range [{@code
fromInclusive},
+ * {@code toExclusive}).
+ *
+ * <p>The iterator merges the MemTable and all overlapping SST files in
key order. For duplicate
+ * keys, the newest source wins and tombstones suppress older values. The
iterator must be
+ * closed before modifying or closing the database. Closing releases the
levels read lock so a
+ * concurrent compaction can publish its result.
+ */
+ public RangeIterator rangeIterator(byte[] fromInclusive, @Nullable byte[]
toExclusive)
+ throws IOException {
+ ensureOpen();
+ ensureNoBulkLoad();
+ checkCompactionFailure();
+
+ MemorySlice from = MemorySlice.wrap(fromInclusive);
+ MemorySlice to = toExclusive == null ? null :
MemorySlice.wrap(toExclusive);
+ checkArgument(
+ to == null || keyComparator.compare(from, to) <= 0,
+ "Range start must not be greater than range end.");
+
+ Map<MemorySlice, byte[]> memoryEntries =
+ to == null ? memTable.tailMap(from, true) :
memTable.subMap(from, true, to, false);
+ LsmLevels.RangeSnapshot snapshot = levels.openRangeSnapshot(from, to,
keyComparator);
+ try {
+ RangeIterator iterator = new RangeIterator(snapshot,
memoryEntries, fromInclusive, to);
+ openRangeIterators++;
+ return iterator;
+ } catch (IOException | RuntimeException e) {
+ snapshot.close();
+ throw e;
+ }
+ }
+
//
-------------------------------------------------------------------------
// Flush & Compaction
//
-------------------------------------------------------------------------
@@ -563,6 +644,7 @@ public class LocalKvDb implements Closeable {
public void flush() throws IOException {
ensureOpen();
ensureNoBulkLoad();
+ ensureNoRangeIterator();
checkCompactionFailure();
if (memTable.isEmpty()) {
return;
@@ -594,6 +676,7 @@ public class LocalKvDb implements Closeable {
public void compact() throws IOException {
ensureOpen();
ensureNoBulkLoad();
+ ensureNoRangeIterator();
compaction.fullCompact();
}
@@ -606,6 +689,7 @@ public class LocalKvDb implements Closeable {
if (closed) {
return;
}
+ ensureNoRangeIterator();
closed = true;
IOException failure = null;
@@ -653,6 +737,12 @@ public class LocalKvDb implements Closeable {
return levels.fileCount();
}
+ /** Return the number of readers retained for point lookups. */
+ @VisibleForTesting
+ int getCachedReaderCount() {
+ return readerCache.size();
+ }
+
/** Return the number of SST files at a specific level. */
public int getLevelFileCount(int level) {
return levels.fileCount(level);
@@ -771,6 +861,264 @@ public class LocalKvDb implements Closeable {
return MemorySlice.wrap(Arrays.copyOf(key, key.length));
}
+ private void ensureNoRangeIterator() {
+ if (openRangeIterators > 0) {
+ throw new IllegalStateException(
+ "The database cannot be modified or closed while a range
iterator is open.");
+ }
+ }
+
+ /** Lazy range iterator with newest-version-wins semantics. */
+ public final class RangeIterator
+ implements KeyValueIterator<MemorySlice, MemorySlice>,
AutoCloseable {
+
+ private final LsmLevels.RangeSnapshot snapshot;
+ private final PriorityQueue<RangeSource> sources;
+
+ @Nullable private MemorySlice currentKey;
+ @Nullable private MemorySlice currentValue;
+ private boolean closed;
+
+ private RangeIterator(
+ LsmLevels.RangeSnapshot snapshot,
+ Map<MemorySlice, byte[]> memoryEntries,
+ byte[] fromInclusive,
+ @Nullable MemorySlice toExclusive)
+ throws IOException {
+ this.snapshot = snapshot;
+ this.sources =
+ new PriorityQueue<>(
+ (left, right) -> {
+ int compare =
keyComparator.compare(left.key(), right.key());
+ return compare != 0
+ ? compare
+ : Integer.compare(left.priority(),
right.priority());
+ });
+
+ int priority = 0;
+ advanceAndAdd(new MemoryRangeSource(priority++,
memoryEntries.entrySet().iterator()));
+ for (File file : snapshot.files()) {
+ advanceAndAdd(new SstRangeSource(priority++, file,
fromInclusive, toExclusive));
+ }
+ }
+
+ @Override
+ public boolean advanceNext() throws IOException {
+ if (closed) {
+ return false;
+ }
+
+ currentKey = null;
+ currentValue = null;
+ try {
+ while (!sources.isEmpty()) {
+ RangeSource newest = sources.poll();
+ MemorySlice key = newest.key();
+ MemorySlice value = newest.value();
+ advanceAndAdd(newest);
+
+ while (!sources.isEmpty()
+ && keyComparator.compare(sources.peek().key(),
key) == 0) {
+ advanceAndAdd(sources.poll());
+ }
+
+ if (!isTombstoneSlice(value)) {
+ currentKey = key;
+ currentValue = value;
+ return true;
+ }
+ }
+ close();
+ return false;
+ } catch (IOException | RuntimeException e) {
+ close();
+ throw e;
+ }
+ }
+
+ @Override
+ public MemorySlice getKey() {
+ if (currentKey == null) {
+ throw new IllegalStateException("Range iterator is not
positioned on an entry.");
+ }
+ return currentKey;
+ }
+
+ @Override
+ public MemorySlice getValue() {
+ if (currentValue == null) {
+ throw new IllegalStateException("Range iterator is not
positioned on an entry.");
+ }
+ return currentValue;
+ }
+
+ private void advanceAndAdd(RangeSource source) throws IOException {
+ if (source.advance()) {
+ sources.add(source);
+ }
+ }
+
+ @Override
+ public void close() {
+ if (!closed) {
+ closed = true;
+ sources.clear();
+ currentKey = null;
+ currentValue = null;
+ snapshot.close();
+ openRangeIterators--;
+ }
+ }
+ }
+
+ private interface RangeSource {
+
+ int priority();
+
+ boolean advance() throws IOException;
+
+ MemorySlice key();
+
+ MemorySlice value();
+ }
+
+ private static final class MemoryRangeSource implements RangeSource {
+
+ private final int priority;
+ private final Iterator<Map.Entry<MemorySlice, byte[]>> iterator;
+
+ @Nullable private Map.Entry<MemorySlice, byte[]> current;
+
+ private MemoryRangeSource(int priority,
Iterator<Map.Entry<MemorySlice, byte[]>> iterator) {
+ this.priority = priority;
+ this.iterator = iterator;
+ }
+
+ @Override
+ public int priority() {
+ return priority;
+ }
+
+ @Override
+ public boolean advance() {
+ current = iterator.hasNext() ? iterator.next() : null;
+ return current != null;
+ }
+
+ @Override
+ public MemorySlice key() {
+ return current.getKey();
+ }
+
+ @Override
+ public MemorySlice value() {
+ return MemorySlice.wrap(current.getValue());
+ }
+ }
+
+ private final class SstRangeSource implements RangeSource {
+
+ private final int priority;
+ private final File file;
+ private final byte[] fromInclusive;
+ @Nullable private final MemorySlice toExclusive;
+
+ @Nullable private BlockIterator block;
+ @Nullable private MemorySlice resumeAfterKey;
+ @Nullable private Map.Entry<MemorySlice, MemorySlice> current;
+ private boolean finished;
+
+ private SstRangeSource(
+ int priority, File file, byte[] fromInclusive, @Nullable
MemorySlice toExclusive) {
+ this.priority = priority;
+ this.file = file;
+ this.fromInclusive = Arrays.copyOf(fromInclusive,
fromInclusive.length);
+ this.toExclusive =
+ toExclusive == null ? null :
MemorySlice.wrap(toExclusive.copyBytes());
+ }
+
+ @Override
+ public int priority() {
+ return priority;
+ }
+
+ @Override
+ public boolean advance() throws IOException {
+ while (block == null || !block.hasNext()) {
+ block = loadNextBlock();
+ if (block == null) {
+ current = null;
+ return false;
+ }
+ }
+
+ current = block.next();
+ if (toExclusive != null && keyComparator.compare(current.getKey(),
toExclusive) >= 0) {
+ current = null;
+ finished = true;
+ return false;
+ }
+ if (!block.hasNext()) {
+ resumeAfterKey =
MemorySlice.wrap(current.getKey().copyBytes());
+ }
+ return true;
+ }
+
+ @Nullable
+ private BlockIterator loadNextBlock() throws IOException {
+ if (finished) {
+ return null;
+ }
+
+ SortLookupStoreReader reader = storeFactory.createReader(file);
+ try (Closeable ignored = reader::closeInput) {
+ SstFileReader.SstFileIterator iterator =
reader.createIterator();
+ byte[] seekKey =
+ resumeAfterKey == null ? fromInclusive :
resumeAfterKey.copyBytes();
+ iterator.seekTo(seekKey);
+ boolean skipResumeKey = resumeAfterKey != null;
+ while (true) {
+ BlockIterator nextBlock = iterator.readBatch();
+ if (nextBlock == null) {
+ finished = true;
+ return null;
+ }
+
+ if (skipResumeKey) {
+ if (nextBlock.seekTo(resumeAfterKey)) {
+ nextBlock.next();
+ }
+ skipResumeKey = false;
+ }
+ if (nextBlock.hasNext()) {
+ return nextBlock;
+ }
+ }
+ }
+ }
+
+ @Override
+ public MemorySlice key() {
+ return current.getKey();
+ }
+
+ @Override
+ public MemorySlice value() {
+ return current.getValue();
+ }
+ }
+
+ private static boolean isTombstoneSlice(MemorySlice value) {
+ return value.length() == 0;
+ }
+
+ /** Callback for visiting an entry during a range scan. */
+ @FunctionalInterface
+ public interface RangeEntryConsumer {
+
+ void accept(MemorySlice key, MemorySlice value) throws IOException;
+ }
+
//
-------------------------------------------------------------------------
// Builder
//
-------------------------------------------------------------------------
diff --git
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LsmLevels.java
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LsmLevels.java
index 6f13e08dff..dfd9bec755 100644
---
a/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LsmLevels.java
+++
b/paimon-common/src/main/java/org/apache/paimon/lookup/sort/db/LsmLevels.java
@@ -119,6 +119,56 @@ class LsmLevels {
}
}
+ /**
+ * Open a snapshot of all SST files which overlap the requested range.
+ *
+ * <p>Files are ordered from newer levels to older levels; Level-0 files
are additionally
+ * ordered newest first. The read lock remains held until the returned
snapshot is closed so a
+ * concurrent compaction cannot delete a file being iterated.
+ */
+ RangeSnapshot openRangeSnapshot(
+ MemorySlice fromInclusive,
+ @Nullable MemorySlice toExclusive,
+ Comparator<MemorySlice> keyComparator) {
+ lock.readLock().lock();
+ boolean success = false;
+ try {
+ List<File> files = new ArrayList<>();
+ for (int level = 0; level < maxLevels; level++) {
+ List<SstFileMetadata> levelFiles = levels.get(level);
+ if (levelFiles.isEmpty()) {
+ continue;
+ }
+
+ if (level == 0) {
+ for (SstFileMetadata metadata : levelFiles) {
+ if (overlapsRange(metadata, fromInclusive,
toExclusive, keyComparator)) {
+ files.add(metadata.getFile());
+ }
+ }
+ continue;
+ }
+
+ int firstFile = findFirstOverlappingFile(levelFiles,
fromInclusive, keyComparator);
+ for (int i = firstFile; i < levelFiles.size(); i++) {
+ SstFileMetadata metadata = levelFiles.get(i);
+ if (toExclusive != null
+ && keyComparator.compare(metadata.getMinKey(),
toExclusive) >= 0) {
+ break;
+ }
+ files.add(metadata.getFile());
+ }
+ }
+ RangeSnapshot snapshot = new RangeSnapshot(files);
+ success = true;
+ return snapshot;
+ } finally {
+ if (!success) {
+ lock.readLock().unlock();
+ }
+ }
+ }
+
List<List<SstFileMetadata>> snapshot() {
lock.readLock().lock();
try {
@@ -265,6 +315,56 @@ class LsmLevels {
return null;
}
+ private static int findFirstOverlappingFile(
+ List<SstFileMetadata> sortedFiles,
+ MemorySlice fromInclusive,
+ Comparator<MemorySlice> keyComparator) {
+ int low = 0;
+ int high = sortedFiles.size();
+ while (low < high) {
+ int mid = low + (high - low) / 2;
+ if (keyComparator.compare(sortedFiles.get(mid).getMaxKey(),
fromInclusive) < 0) {
+ low = mid + 1;
+ } else {
+ high = mid;
+ }
+ }
+ return low;
+ }
+
+ private static boolean overlapsRange(
+ SstFileMetadata metadata,
+ MemorySlice fromInclusive,
+ @Nullable MemorySlice toExclusive,
+ Comparator<MemorySlice> keyComparator) {
+ return keyComparator.compare(metadata.getMaxKey(), fromInclusive) >= 0
+ && (toExclusive == null
+ || keyComparator.compare(metadata.getMinKey(),
toExclusive) < 0);
+ }
+
+ /** SST files in a range protected from concurrent compaction until
closed. */
+ final class RangeSnapshot implements AutoCloseable {
+
+ private final List<File> files;
+ private boolean closed;
+
+ private RangeSnapshot(List<File> files) {
+ this.files = files;
+ }
+
+ List<File> files() {
+ return files;
+ }
+
+ @Override
+ public void close() {
+ if (!closed) {
+ closed = true;
+ lock.readLock().unlock();
+ }
+ }
+ }
+
/** Callback for reading one SST file. */
interface FileLookup {
diff --git
a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbAsyncCompactionTest.java
b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbAsyncCompactionTest.java
index 205b9e51fa..ab94fab9e0 100644
---
a/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbAsyncCompactionTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/lookup/sort/db/LocalKvDbAsyncCompactionTest.java
@@ -26,6 +26,9 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
import java.util.Queue;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.CountDownLatch;
@@ -54,12 +57,48 @@ public class LocalKvDbAsyncCompactionTest {
assertThat(compactionExecutor.numQueuedTasks()).isOne();
assertThat(db.getLevelFileCount(0)).isEqualTo(3);
assertThat(get(db, "shared")).isEqualTo("v3");
+ assertThat(scan(db, "sha", "shb")).containsExactly("shared=v3");
compactionExecutor.runNext();
db.awaitCompaction();
assertThat(db.getLevelFileCount(0)).isZero();
assertThat(get(db, "shared")).isEqualTo("v3");
+ assertThat(scan(db, "sha", "shb")).containsExactly("shared=v3");
+ }
+ }
+
+ @Test
+ void testRangeIteratorPreventsCompactionFromDeletingItsFiles() throws
Exception {
+ ManuallyTriggeredExecutor compactionExecutor = new
ManuallyTriggeredExecutor();
+ File directory = new File(tempDir.toFile(), "range-compaction");
+ try (LocalKvDb db = createDb("range-compaction", compactionExecutor)) {
+ putAndFlush(db, "shared", "v1");
+ putAndFlush(db, "shared", "v2");
+ putAndFlush(db, "shared", "v3");
+
+ LocalKvDb.RangeIterator iterator =
+ db.rangeIterator("sha".getBytes(UTF_8),
"shb".getBytes(UTF_8));
+ ExecutorService compactionRunner =
Executors.newSingleThreadExecutor();
+ Future<?> compactionFuture =
compactionRunner.submit(compactionExecutor::runNext);
+ try {
+ long deadline = System.nanoTime() +
TimeUnit.SECONDS.toNanos(10);
+ while (sstFileCount(directory) < 4 && System.nanoTime() <
deadline) {
+ Thread.yield();
+ }
+
+ assertThat(sstFileCount(directory)).isGreaterThanOrEqualTo(4);
+ assertThat(compactionFuture).isNotDone();
+ assertThat(iterator.advanceNext()).isTrue();
+ assertThat(new String(iterator.getValue().copyBytes(),
UTF_8)).isEqualTo("v3");
+ } finally {
+ iterator.close();
+ compactionFuture.get(10, TimeUnit.SECONDS);
+ compactionRunner.shutdownNow();
+ }
+
+ db.awaitCompaction();
+ assertThat(scan(db, "sha", "shb")).containsExactly("shared=v3");
}
}
@@ -208,6 +247,21 @@ public class LocalKvDbAsyncCompactionTest {
return value == null ? null : new String(value, UTF_8);
}
+ private static List<String> scan(LocalKvDb db, String from, String to)
throws IOException {
+ List<String> result = new ArrayList<>();
+ for (Map.Entry<byte[], byte[]> entry :
+ db.rangeScan(from.getBytes(UTF_8), to.getBytes(UTF_8))) {
+ result.add(
+ new String(entry.getKey(), UTF_8) + "=" + new
String(entry.getValue(), UTF_8));
+ }
+ return result;
+ }
+
+ private static int sstFileCount(File directory) {
+ File[] files = directory.listFiles((ignored, name) ->
name.endsWith(".db"));
+ return files == null ? 0 : files.length;
+ }
+
private static class ManuallyTriggeredExecutor extends
AbstractExecutorService {
private final Queue<Runnable> tasks = new ArrayDeque<>();
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 66174ccf5a..0f49fba43b 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
@@ -27,12 +27,16 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import javax.annotation.Nullable;
+
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.util.AbstractMap;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -130,6 +134,177 @@ public class LocalKvDbTest {
}
}
+ @Test
+ public void testRangeScanMergesMemTableAndLevels() throws IOException {
+ try (LocalKvDb db = createDb("range-scan-db")) {
+ List<Map.Entry<byte[], byte[]>> initial = new ArrayList<>();
+ initial.add(entry("a-1", "old-1"));
+ initial.add(entry("a-2", "old-2"));
+ initial.add(entry("b-1", "value-b"));
+ db.bulkLoad(initial.iterator(), initial.size());
+
+ putString(db, "a-1", "new-1");
+ db.flush();
+ deleteString(db, "a-2");
+ putString(db, "a-3", "new-3");
+
+ Assertions.assertEquals(
+ Arrays.asList("a-1=new-1", "a-3=new-3"), scanStrings(db,
"a", "b"));
+ Assertions.assertTrue(db.rangeScan("b".getBytes(UTF_8),
"b".getBytes(UTF_8)).isEmpty());
+ Assertions.assertEquals(
+ Collections.singletonList("b-1=value-b"), scanStrings(db,
"b", null));
+
+ db.flush();
+ Assertions.assertEquals(
+ Arrays.asList("a-1=new-1", "a-3=new-3"), scanStrings(db,
"a", "b"));
+ db.compact();
+ Assertions.assertEquals(
+ Arrays.asList("a-1=new-1", "a-3=new-3"), scanStrings(db,
"a", "b"));
+ }
+ }
+
+ @Test
+ public void testForEachInRangeAcrossMemTableAndSingleSst() throws
IOException {
+ try (LocalKvDb db = createDb("range-consumer-db")) {
+ putString(db, "a-1", "value-1");
+ putString(db, "a-2", "value-2");
+ putString(db, "b-1", "value-b");
+
+ Assertions.assertEquals(
+ Arrays.asList("a-1=value-1", "a-2=value-2"),
forEachStrings(db, "a", "b"));
+
+ db.flush();
+ Assertions.assertEquals(
+ Arrays.asList("a-1=value-1", "a-2=value-2"),
forEachStrings(db, "a", "b"));
+
+ putString(db, "a-1", "updated-1");
+ deleteString(db, "a-2");
+ putString(db, "a-3", "value-3");
+ putString(db, "c-1", "value-c");
+ Assertions.assertEquals(
+ Arrays.asList("a-1=updated-1", "a-3=value-3"),
forEachStrings(db, "a", "b"));
+ Assertions.assertEquals(
+ Collections.singletonList("c-1=value-c"),
forEachStrings(db, "c", "d"));
+ }
+ }
+
+ @Test
+ public void testRangeIteratorDeduplicatesVersionsAcrossAllSources() throws
IOException {
+ try (LocalKvDb db = createDb("range-iterator-db")) {
+ db.bulkLoad(
+ Arrays.asList(entry("a", "base-a"), entry("b", "base-b"),
entry("c", "base-c"))
+ .iterator(),
+ 3);
+
+ putString(db, "a", "level-zero-a-1");
+ deleteString(db, "b");
+ putString(db, "d", "level-zero-d");
+ db.flush();
+
+ putString(db, "a", "level-zero-a-2");
+ deleteString(db, "c");
+ putString(db, "e", "level-zero-e");
+ db.flush();
+
+ putString(db, "a", "memtable-a");
+ deleteString(db, "d");
+ putString(db, "f", "memtable-f");
+
+ List<String> result = new ArrayList<>();
+ try (LocalKvDb.RangeIterator iterator =
+ db.rangeIterator("a".getBytes(UTF_8),
"g".getBytes(UTF_8))) {
+ Assertions.assertThrows(
+ IllegalStateException.class, () -> putString(db, "x",
"blocked"));
+ while (iterator.advanceNext()) {
+ result.add(
+ new String(iterator.getKey().copyBytes(), UTF_8)
+ + "="
+ + new
String(iterator.getValue().copyBytes(), UTF_8));
+ }
+ }
+
+ Assertions.assertEquals(
+ Arrays.asList("a=memtable-a", "e=level-zero-e",
"f=memtable-f"), result);
+ }
+ }
+
+ @Test
+ public void testRangeIteratorRejectsBulkLoadWithoutLockUpgrade() throws
IOException {
+ try (LocalKvDb db = createDb("range-iterator-bulk-load-db");
+ LocalKvDb.RangeIterator ignored =
+ db.rangeIterator("a".getBytes(UTF_8),
"b".getBytes(UTF_8))) {
+ List<Map.Entry<byte[], byte[]>> entries =
+ Collections.singletonList(entry("a-1", "value-1"));
+
+ IllegalStateException exception =
+ Assertions.assertThrows(
+ IllegalStateException.class,
+ () -> db.bulkLoad(entries.iterator(),
entries.size()));
+ Assertions.assertTrue(exception.getMessage().contains("range
iterator"));
+ Assertions.assertEquals(0, db.getSstFileCount());
+ }
+ }
+
+ @Test
+ public void testRangeIteratorCrossesSstBlocks() throws IOException {
+ File directory = new File(tempDir.toFile(), "range-iterator-blocks");
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .memTableFlushThreshold(1024 * 1024)
+ .blockSize(128)
+ .level0FileNumCompactTrigger(100)
+ .compressOptions(new CompressOptions("none", 1))
+ .build()) {
+ List<Map.Entry<byte[], byte[]>> initial = new ArrayList<>();
+ for (int i = 0; i < 100; i++) {
+ initial.add(entry(String.format("key-%05d", i),
String.format("value-%05d", i)));
+ }
+ db.bulkLoad(initial.iterator(), initial.size());
+
+ putString(db, "key-00020", "updated-20");
+ deleteString(db, "key-00030");
+ db.flush();
+ putString(db, "key-00040", "updated-40");
+
+ List<String> result = scanStrings(db, "key-00010", "key-00050");
+ Assertions.assertEquals(39, result.size());
+ Assertions.assertEquals("key-00010=value-00010", result.get(0));
+ Assertions.assertEquals("key-00020=updated-20", result.get(10));
+ Assertions.assertEquals("key-00031=value-00031", result.get(20));
+ Assertions.assertEquals("key-00040=updated-40", result.get(29));
+ Assertions.assertEquals("key-00049=value-00049", result.get(38));
+ }
+ }
+
+ @Test
+ public void testRangeIteratorDoesNotCacheReadersForOverlappingSsts()
throws IOException {
+ int fileCount = 128;
+ File directory = new File(tempDir.toFile(),
"range-iterator-many-ssts");
+ try (LocalKvDb db =
+ LocalKvDb.builder(directory)
+ .level0FileNumCompactTrigger(fileCount + 1)
+ .compressOptions(new CompressOptions("none", 1))
+ .build()) {
+ for (int i = 0; i < fileCount; i++) {
+ putString(db, String.format("key-%05d", i),
String.format("value-%05d", i));
+ db.flush();
+ }
+ Assertions.assertEquals(fileCount, db.getLevelFileCount(0));
+
+ int entryCount = 0;
+ try (LocalKvDb.RangeIterator iterator =
+ db.rangeIterator("key-00000".getBytes(UTF_8),
"key-99999".getBytes(UTF_8))) {
+ Assertions.assertEquals(0, db.getCachedReaderCount());
+ while (iterator.advanceNext()) {
+ entryCount++;
+ }
+ }
+
+ Assertions.assertEquals(fileCount, entryCount);
+ Assertions.assertEquals(0, db.getCachedReaderCount());
+ }
+ }
+
@Test
public void testFlushToSst() throws IOException {
try (LocalKvDb db = createDb()) {
@@ -1795,6 +1970,31 @@ public class LocalKvDbTest {
return new String(bytes, UTF_8);
}
+ private static List<String> scanStrings(LocalKvDb db, String from,
@Nullable String to)
+ throws IOException {
+ List<String> result = new ArrayList<>();
+ for (Map.Entry<byte[], byte[]> entry :
+ db.rangeScan(from.getBytes(UTF_8), to == null ? null :
to.getBytes(UTF_8))) {
+ result.add(
+ new String(entry.getKey(), UTF_8) + "=" + new
String(entry.getValue(), UTF_8));
+ }
+ return result;
+ }
+
+ private static List<String> forEachStrings(LocalKvDb db, String from,
@Nullable String to)
+ throws IOException {
+ List<String> result = new ArrayList<>();
+ db.forEachInRange(
+ from.getBytes(UTF_8),
+ to == null ? null : to.getBytes(UTF_8),
+ (key, value) ->
+ result.add(
+ new String(key.copyBytes(), UTF_8)
+ + "="
+ + new String(value.copyBytes(),
UTF_8)));
+ return result;
+ }
+
private static void deleteString(LocalKvDb db, String key) throws
IOException {
db.delete(key.getBytes(UTF_8));
}