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 81c935edad [core] Add LocalKvDb-backed lookup states (#8880)
81c935edad is described below

commit 81c935edad7a2639a6d781338436dd644ed63369
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jul 28 11:28:58 2026 +0800

    [core] Add LocalKvDb-backed lookup states (#8880)
---
 .../apache/paimon/lookup/sort/db/LocalKvDb.java    |  11 +
 .../lookup/sort/db/RecordCombiningWriter.java      |   8 +-
 .../paimon/lookup/sort/db/LocalKvDbTest.java       |  48 ++
 .../paimon/lookup/local/LocalKvBulkLoader.java     |  63 +++
 .../paimon/lookup/local/LocalKvCompositeKey.java   |  86 ++++
 .../paimon/lookup/local/LocalKvListBulkLoader.java |  87 ++++
 .../lookup/local/LocalKvListMergeOperator.java     |  68 +++
 .../paimon/lookup/local/LocalKvListState.java      | 120 +++++
 .../paimon/lookup/local/LocalKvListValueCodec.java | 187 +++++++
 .../paimon/lookup/local/LocalKvSetState.java       | 109 ++++
 .../apache/paimon/lookup/local/LocalKvState.java   | 117 +++++
 .../paimon/lookup/local/LocalKvStateFactory.java   | 201 ++++++++
 .../paimon/lookup/local/LocalKvValueCodec.java     | 127 +++++
 .../paimon/lookup/local/LocalKvValueState.java     |  95 ++++
 .../lookup/local/LocalKvStateFactoryTest.java      | 562 +++++++++++++++++++++
 15 files changed, 1887 insertions(+), 2 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 f319ee9463..3b976a67b5 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
@@ -1158,6 +1158,17 @@ public class LocalKvDb implements Closeable {
 
         boolean canMerge(MemorySlice firstKey, MemorySlice nextKey);
 
+        /**
+         * Return whether a tombstone can be absorbed into the pending merge 
group.
+         *
+         * <p>Tombstones are merge boundaries by default. Operators which 
produce tombstones for
+         * consumed physical keys can opt in so later compactions can merge 
across those synthetic
+         * tombstones.
+         */
+        default boolean canMergeTombstone(MemorySlice firstKey, MemorySlice 
tombstoneKey) {
+            return false;
+        }
+
         byte[] merge(List<byte[]> values) throws IOException;
     }
 
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
index edf5eedd0d..224ef16aa8 100644
--- 
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
@@ -55,8 +55,12 @@ final class RecordCombiningWriter {
         }
 
         if (isTombstone(value)) {
-            flushPending();
-            consumer.accept(key, value);
+            if (pendingKey != null && 
mergeOperator.canMergeTombstone(pendingKey, key)) {
+                pendingKeys.add(MemorySlice.wrap(key.copyBytes()));
+            } else {
+                flushPending();
+                consumer.accept(key, value);
+            }
             return;
         }
 
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 a022cb2265..d2673e5be2 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
@@ -563,6 +563,54 @@ public class LocalKvDbTest {
         }
     }
 
+    @Test
+    public void testCompactionMergesAcrossAbsorbedTombstones() throws 
IOException {
+        File directory = new File(tempDir.toFile(), "tombstone-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 boolean canMergeTombstone(
+                            MemorySlice firstKey, MemorySlice tombstoneKey) {
+                        return canMerge(firstKey, tombstoneKey);
+                    }
+
+                    @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)
+                        .level0FileNumCompactTrigger(100)
+                        .compressOptions(new CompressOptions("none", 1))
+                        .mergeOperator(mergeOperator)
+                        .build()) {
+            putString(db, "a-0", "one");
+            putString(db, "a-1", "two");
+            db.flush();
+            putString(db, "a-2", "three");
+            db.flush();
+
+            db.compact();
+
+            Assertions.assertEquals("one+two+three", getString(db, "a-0"));
+            Assertions.assertNull(getString(db, "a-1"));
+            Assertions.assertNull(getString(db, "a-2"));
+        }
+    }
+
     @Test
     public void 
testCompactionMergesAcrossFileGroupsBeforeFilteringExpiration() throws 
IOException {
         File directory = new File(tempDir.toFile(), 
"cross-group-expiration-merge-db");
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvBulkLoader.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvBulkLoader.java
new file mode 100644
index 0000000000..0c199f663d
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvBulkLoader.java
@@ -0,0 +1,63 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.lookup.ValueBulkLoader;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+
+import java.io.IOException;
+import java.util.function.Consumer;
+
+/** State bulk loader backed by a {@link LocalKvDb.BulkLoadWriter}. */
+final class LocalKvBulkLoader implements ValueBulkLoader {
+
+    private final LocalKvDb.BulkLoadWriter writer;
+    private final LocalKvValueCodec valueCodec;
+    private final Consumer<byte[]> cacheInvalidator;
+
+    LocalKvBulkLoader(
+            LocalKvDb db, LocalKvValueCodec valueCodec, Consumer<byte[]> 
cacheInvalidator) {
+        try {
+            this.writer = db.createBulkLoadWriter();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to create LocalKvDb bulk-load 
writer.", e);
+        }
+        this.valueCodec = valueCodec;
+        this.cacheInvalidator = cacheInvalidator;
+    }
+
+    @Override
+    public void write(byte[] key, byte[] value) throws WriteException {
+        try {
+            writer.put(key, valueCodec.encode(value));
+            cacheInvalidator.accept(key);
+        } catch (IOException | RuntimeException e) {
+            throw new WriteException(e);
+        }
+    }
+
+    @Override
+    public void finish() {
+        try {
+            writer.finish();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to finish LocalKvDb bulk 
load.", e);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvCompositeKey.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvCompositeKey.java
new file mode 100644
index 0000000000..dae7ac2097
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvCompositeKey.java
@@ -0,0 +1,86 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.memory.MemorySlice;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Composite-key helpers for prefix-free serialized logical keys.
+ *
+ * <p>This matches the existing RocksDB state layout and relies on the 
serialized logical key being
+ * prefix-free. Paimon's lookup states use length-delimited row keys, and 
fixed-width primitive
+ * serializers such as integers are prefix-free as well.
+ */
+final class LocalKvCompositeKey {
+
+    private LocalKvCompositeKey() {}
+
+    static byte[] prefix(byte[] key) {
+        return Arrays.copyOf(key, key.length);
+    }
+
+    static byte[] append(byte[] prefix, byte[] suffix) {
+        byte[] result = Arrays.copyOf(prefix, prefix.length + suffix.length);
+        System.arraycopy(suffix, 0, result, prefix.length, suffix.length);
+        return result;
+    }
+
+    static byte[] appendLong(byte[] prefix, long suffix) {
+        checkArgument(suffix >= 0, "Composite-key sequence must be 
non-negative.");
+        byte[] result = Arrays.copyOf(prefix, prefix.length + Long.BYTES);
+        for (int i = result.length - 1; i >= prefix.length; i--) {
+            result[i] = (byte) suffix;
+            suffix >>>= Byte.SIZE;
+        }
+        return result;
+    }
+
+    @Nullable
+    static byte[] upperBound(byte[] prefix) {
+        byte[] result = Arrays.copyOf(prefix, prefix.length);
+        for (int i = result.length - 1; i >= 0; i--) {
+            int value = result[i] & 0xff;
+            if (value != 0xff) {
+                result[i] = (byte) (value + 1);
+                return Arrays.copyOf(result, i + 1);
+            }
+        }
+        return null;
+    }
+
+    static byte[] suffix(byte[] compositeKey, int prefixLength) {
+        checkArgument(
+                prefixLength <= compositeKey.length,
+                "Composite key is shorter than its logical-key prefix.");
+        return Arrays.copyOfRange(compositeKey, prefixLength, 
compositeKey.length);
+    }
+
+    static byte[] suffix(MemorySlice compositeKey, int prefixLength) {
+        checkArgument(
+                prefixLength <= compositeKey.length(),
+                "Composite key is shorter than its logical-key prefix.");
+        return compositeKey.slice(prefixLength, compositeKey.length() - 
prefixLength).copyBytes();
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListBulkLoader.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListBulkLoader.java
new file mode 100644
index 0000000000..39c765b539
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListBulkLoader.java
@@ -0,0 +1,87 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.lookup.ListBulkLoader;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+import org.apache.paimon.utils.SortUtil;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/** List-state bulk loader which stores one packed initial list per logical 
key. */
+final class LocalKvListBulkLoader implements ListBulkLoader {
+
+    private final LocalKvDb.BulkLoadWriter writer;
+    private final LocalKvValueCodec valueCodec;
+    private final LocalKvListValueCodec listValueCodec;
+    private final Function<byte[], byte[]> compositeKeyFactory;
+    private final Consumer<byte[]> cacheInvalidator;
+
+    private byte[] previousKey;
+
+    LocalKvListBulkLoader(
+            LocalKvDb db,
+            LocalKvValueCodec valueCodec,
+            LocalKvListValueCodec listValueCodec,
+            Function<byte[], byte[]> compositeKeyFactory,
+            Consumer<byte[]> cacheInvalidator) {
+        try {
+            this.writer = db.createBulkLoadWriter();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to create LocalKvDb bulk-load 
writer.", e);
+        }
+        this.valueCodec = valueCodec;
+        this.listValueCodec = listValueCodec;
+        this.compositeKeyFactory = compositeKeyFactory;
+        this.cacheInvalidator = cacheInvalidator;
+    }
+
+    @Override
+    public void write(byte[] key, List<byte[]> values) throws WriteException {
+        try {
+            if (previousKey != null && SortUtil.compareBinary(previousKey, 
key) >= 0) {
+                throw new IllegalArgumentException(
+                        "Bulk-load keys must be sorted in strictly increasing 
order.");
+            }
+            previousKey = Arrays.copyOf(key, key.length);
+
+            byte[] prefix = LocalKvCompositeKey.prefix(key);
+            writer.put(
+                    compositeKeyFactory.apply(prefix),
+                    valueCodec.encode(listValueCodec.encodeList(values)));
+            cacheInvalidator.accept(key);
+        } catch (IOException | RuntimeException e) {
+            writer.close();
+            throw new WriteException(e);
+        }
+    }
+
+    @Override
+    public void finish() {
+        try {
+            writer.finish();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to finish LocalKvDb bulk 
load.", e);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListMergeOperator.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListMergeOperator.java
new file mode 100644
index 0000000000..7d9f33107f
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListMergeOperator.java
@@ -0,0 +1,68 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+import org.apache.paimon.memory.MemorySlice;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Combines ListState fragments with the same logical key when writing SST 
files.
+ *
+ * <p>When TTL is enabled, merging includes expired fragments and re-encodes 
the result with a new
+ * expiration time, matching RocksDB's TTL merge behavior.
+ */
+final class LocalKvListMergeOperator implements LocalKvDb.MergeOperator {
+
+    private final LocalKvValueCodec valueCodec;
+    private final ThreadLocal<LocalKvListValueCodec> listValueCodec =
+            ThreadLocal.withInitial(LocalKvListValueCodec::new);
+
+    LocalKvListMergeOperator(LocalKvValueCodec valueCodec) {
+        this.valueCodec = valueCodec;
+    }
+
+    @Override
+    public boolean canMerge(MemorySlice firstKey, MemorySlice nextKey) {
+        if (firstKey.length() < Long.BYTES || firstKey.length() != 
nextKey.length()) {
+            return false;
+        }
+
+        int logicalKeyLength = firstKey.length() - Long.BYTES;
+        for (int i = 0; i < logicalKeyLength; i++) {
+            if (firstKey.readByte(i) != nextKey.readByte(i)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    @Override
+    public boolean canMergeTombstone(MemorySlice firstKey, MemorySlice 
tombstoneKey) {
+        return canMerge(firstKey, tombstoneKey);
+    }
+
+    @Override
+    public byte[] merge(List<byte[]> values) throws IOException {
+        // Foreground flush and background compaction can invoke the operator 
concurrently.
+        return listValueCodec.get().merge(values, valueCodec);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListState.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListState.java
new file mode 100644
index 0000000000..ac293b5ba0
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListState.java
@@ -0,0 +1,120 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.lookup.ByteArray;
+import org.apache.paimon.lookup.ListBulkLoader;
+import org.apache.paimon.lookup.ListState;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+import org.apache.paimon.memory.MemorySlice;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Local KV state for an insertion-ordered list per key. */
+public class LocalKvListState<K, V> extends LocalKvState<K, V, List<V>> 
implements ListState<K, V> {
+
+    private final LocalKvListValueCodec listValueCodec;
+    private long nextSequence;
+
+    LocalKvListState(
+            LocalKvDb db,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize,
+            LocalKvValueCodec valueCodec) {
+        super(db, keySerializer, valueSerializer, lruCacheSize, valueCodec);
+        this.listValueCodec = new LocalKvListValueCodec();
+        this.nextSequence = 0;
+    }
+
+    @Override
+    public void add(K key, V value) throws IOException {
+        checkArgument(value != null, "Value must not be null.");
+        byte[] keyBytes = serializeKey(key);
+        byte[] valueBytes = serializeValue(value);
+        db.put(
+                nextCompositeKey(LocalKvCompositeKey.prefix(keyBytes)),
+                valueCodec.encode(listValueCodec.encodeSingle(valueBytes)));
+        ByteArray cacheKey = wrap(keyBytes);
+        if (cache.getIfPresent(cacheKey) != null) {
+            cache.invalidate(cacheKey);
+        }
+    }
+
+    @Override
+    public List<V> get(K key) throws IOException {
+        byte[] keyBytes = serializeKey(key);
+        ByteArray cacheKey = wrap(keyBytes);
+        List<V> values = getCached(cacheKey);
+        if (values == null) {
+            byte[] prefix = LocalKvCompositeKey.prefix(keyBytes);
+            List<V> scanned = new ArrayList<>();
+            db.forEachInRange(
+                    prefix,
+                    LocalKvCompositeKey.upperBound(prefix),
+                    (ignored, stored) -> decodeValues(stored, scanned));
+            values =
+                    scanned.isEmpty()
+                            ? Collections.emptyList()
+                            : Collections.unmodifiableList(scanned);
+            putCached(cacheKey, values);
+        }
+        return values;
+    }
+
+    private void decodeValues(MemorySlice storedSlice, List<V> target) throws 
IOException {
+        byte[] stored = storedSlice.getHeapMemory();
+        int storedOffset = storedSlice.offset();
+        if (stored == null) {
+            stored = storedSlice.copyBytes();
+            storedOffset = 0;
+        }
+
+        int valueOffset = valueCodec.valueOffset(stored, storedOffset, 
storedSlice.length());
+        listValueCodec.decode(
+                stored,
+                valueOffset,
+                storedOffset + storedSlice.length() - valueOffset,
+                valueSerializer,
+                target);
+    }
+
+    @Override
+    public ListBulkLoader createBulkLoader() {
+        return new LocalKvListBulkLoader(
+                db,
+                valueCodec,
+                listValueCodec,
+                this::nextCompositeKey,
+                key -> cache.invalidate(wrap(key)));
+    }
+
+    private byte[] nextCompositeKey(byte[] key) {
+        if (nextSequence < 0) {
+            throw new IllegalStateException("Local KV list sequence has 
overflowed.");
+        }
+        return LocalKvCompositeKey.appendLong(key, nextSequence++);
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListValueCodec.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListValueCodec.java
new file mode 100644
index 0000000000..3e238bb46b
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvListValueCodec.java
@@ -0,0 +1,187 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+
+import java.io.IOException;
+import java.util.List;
+
+/** Codec for individual list deltas and packed initial lists. */
+final class LocalKvListValueCodec {
+
+    private static final byte SINGLE_VALUE = 1;
+    private static final byte PACKED_VALUES = 2;
+
+    private final DataInputDeserializer input = new DataInputDeserializer();
+    private final DataOutputSerializer output = new DataOutputSerializer(128);
+
+    byte[] encodeSingle(byte[] value) {
+        byte[] result = new byte[value.length + 1];
+        result[0] = SINGLE_VALUE;
+        System.arraycopy(value, 0, result, 1, value.length);
+        return result;
+    }
+
+    byte[] encodeList(List<byte[]> values) throws IOException {
+        output.clear();
+        output.writeByte(PACKED_VALUES);
+        output.writeInt(values.size());
+        for (byte[] value : values) {
+            output.writeInt(value.length);
+            output.write(value);
+        }
+        return output.getCopyOfBuffer();
+    }
+
+    byte[] merge(List<byte[]> storedValues, LocalKvValueCodec valueCodec) 
throws IOException {
+        long[] stats = new long[2];
+        for (byte[] stored : storedValues) {
+            inspectStoredValue(stored, valueCodec, stats);
+        }
+        if (stats[0] > Integer.MAX_VALUE || stats[1] > Integer.MAX_VALUE - 5) {
+            throw new IOException("Merged local KV list value is too large.");
+        }
+
+        byte[] packed = new byte[5 + (int) stats[1]];
+        packed[0] = PACKED_VALUES;
+        writeInt(packed, 1, (int) stats[0]);
+        int outputOffset = 5;
+        for (byte[] stored : storedValues) {
+            int valueOffset = valueCodec.valueOffset(stored, 0, stored.length);
+            input.setBuffer(stored, valueOffset, stored.length - valueOffset);
+            int type = input.readUnsignedByte();
+            if (type == SINGLE_VALUE) {
+                int valueLength = input.available();
+                writeInt(packed, outputOffset, valueLength);
+                outputOffset += Integer.BYTES;
+                System.arraycopy(stored, input.getPosition(), packed, 
outputOffset, valueLength);
+                outputOffset += valueLength;
+            } else if (type == PACKED_VALUES) {
+                input.readInt();
+                int payloadLength = input.available();
+                System.arraycopy(stored, input.getPosition(), packed, 
outputOffset, payloadLength);
+                outputOffset += payloadLength;
+            } else {
+                throw new IOException("Corrupted local KV list value marker.");
+            }
+        }
+        return valueCodec.encode(packed);
+    }
+
+    <V> void decode(byte[] bytes, int offset, int length, Serializer<V> 
serializer, List<V> target)
+            throws IOException {
+        if (length <= 0) {
+            throw new IOException("Corrupted empty local KV list value.");
+        }
+        input.setBuffer(bytes, offset, length);
+        int type = input.readUnsignedByte();
+        if (type == SINGLE_VALUE) {
+            target.add(deserializeElement(input.available(), serializer));
+            return;
+        }
+        if (type != PACKED_VALUES) {
+            throw new IOException("Corrupted local KV list value marker.");
+        }
+
+        int size = input.readInt();
+        if (size < 0 || size > input.available() / Integer.BYTES) {
+            throw new IOException(
+                    "Corrupted local KV list size: "
+                            + size
+                            + ", remaining bytes: "
+                            + input.available());
+        }
+
+        for (int i = 0; i < size; i++) {
+            int elementLength = input.readInt();
+            if (elementLength < 0 || elementLength > input.available()) {
+                throw new IOException(
+                        "Corrupted local KV list element length: "
+                                + elementLength
+                                + ", remaining bytes: "
+                                + input.available());
+            }
+            target.add(deserializeElement(elementLength, serializer));
+        }
+        if (input.available() != 0) {
+            throw new IOException(
+                    "Corrupted local KV list with " + input.available() + " 
trailing bytes.");
+        }
+    }
+
+    private <V> V deserializeElement(int length, Serializer<V> serializer) 
throws IOException {
+        int start = input.getPosition();
+        V value = serializer.deserialize(input);
+        int consumed = input.getPosition() - start;
+        if (consumed != length) {
+            throw new IOException(
+                    "Corrupted local KV list element length: expected "
+                            + length
+                            + " bytes, consumed "
+                            + consumed
+                            + '.');
+        }
+        return value;
+    }
+
+    private void inspectStoredValue(byte[] stored, LocalKvValueCodec 
valueCodec, long[] stats)
+            throws IOException {
+        int valueOffset = valueCodec.valueOffset(stored, 0, stored.length);
+        input.setBuffer(stored, valueOffset, stored.length - valueOffset);
+        int type = input.readUnsignedByte();
+        if (type == SINGLE_VALUE) {
+            stats[0]++;
+            stats[1] += Integer.BYTES + input.available();
+            return;
+        }
+        if (type != PACKED_VALUES) {
+            throw new IOException("Corrupted local KV list value marker.");
+        }
+
+        int size = input.readInt();
+        if (size < 0 || size > input.available() / Integer.BYTES) {
+            throw new IOException("Corrupted local KV list size: " + size + 
'.');
+        }
+        int payloadLength = input.available();
+        for (int i = 0; i < size; i++) {
+            int elementLength = input.readInt();
+            if (elementLength < 0 || elementLength > input.available()) {
+                throw new IOException(
+                        "Corrupted local KV list element length: " + 
elementLength + '.');
+            }
+            input.skipBytesToRead(elementLength);
+        }
+        if (input.available() != 0) {
+            throw new IOException(
+                    "Corrupted local KV list with " + input.available() + " 
trailing bytes.");
+        }
+        stats[0] += size;
+        stats[1] += payloadLength;
+    }
+
+    private static void writeInt(byte[] bytes, int offset, int value) {
+        bytes[offset] = (byte) (value >>> 24);
+        bytes[offset + 1] = (byte) (value >>> 16);
+        bytes[offset + 2] = (byte) (value >>> 8);
+        bytes[offset + 3] = (byte) value;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvSetState.java 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvSetState.java
new file mode 100644
index 0000000000..5a74d939df
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvSetState.java
@@ -0,0 +1,109 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.lookup.ByteArray;
+import org.apache.paimon.lookup.SetState;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Local KV state for bytewise-sorted unique values per key. */
+public class LocalKvSetState<K, V> extends LocalKvState<K, V, List<byte[]>>
+        implements SetState<K, V> {
+
+    private static final byte[] PRESENT = new byte[0];
+
+    LocalKvSetState(
+            LocalKvDb db,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize,
+            LocalKvValueCodec valueCodec) {
+        super(db, keySerializer, valueSerializer, lruCacheSize, valueCodec);
+    }
+
+    @Override
+    public List<V> get(K key) throws IOException {
+        List<byte[]> values = getSerializedValues(serializeKey(key));
+        List<V> result = new ArrayList<>(values.size());
+        for (byte[] value : values) {
+            result.add(deserializeValue(value));
+        }
+        return result;
+    }
+
+    @Override
+    public void retract(K key, V value) throws IOException {
+        checkArgument(value != null, "Value must not be null.");
+        byte[] keyBytes = serializeKey(key);
+        byte[] compositeKey =
+                LocalKvCompositeKey.append(
+                        LocalKvCompositeKey.prefix(keyBytes), 
serializeValue(value));
+        if (db.get(compositeKey) != null) {
+            db.delete(compositeKey);
+        }
+        cache.invalidate(wrap(keyBytes));
+    }
+
+    @Override
+    public void add(K key, V value) throws IOException {
+        checkArgument(value != null, "Value must not be null.");
+        byte[] keyBytes = serializeKey(key);
+        byte[] compositeKey =
+                LocalKvCompositeKey.append(
+                        LocalKvCompositeKey.prefix(keyBytes), 
serializeValue(value));
+        db.put(compositeKey, valueCodec.encode(PRESENT));
+        cache.invalidate(wrap(keyBytes));
+    }
+
+    private List<byte[]> getSerializedValues(byte[] keyBytes) throws 
IOException {
+        ByteArray key = wrap(keyBytes);
+        List<byte[]> values = getCached(key);
+        if (values == null) {
+            byte[] prefix = LocalKvCompositeKey.prefix(keyBytes);
+            List<byte[]> scanned = new ArrayList<>();
+            db.forEachInRange(
+                    prefix,
+                    LocalKvCompositeKey.upperBound(prefix),
+                    (compositeKey, storedSlice) -> {
+                        byte[] stored = storedSlice.getHeapMemory();
+                        int storedOffset = storedSlice.offset();
+                        if (stored == null) {
+                            stored = storedSlice.copyBytes();
+                            storedOffset = 0;
+                        }
+                        valueCodec.valueOffset(stored, storedOffset, 
storedSlice.length());
+                        scanned.add(LocalKvCompositeKey.suffix(compositeKey, 
prefix.length));
+                    });
+            values =
+                    scanned.isEmpty()
+                            ? Collections.emptyList()
+                            : Collections.unmodifiableList(scanned);
+            putCached(key, values);
+        }
+        return values;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvState.java 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvState.java
new file mode 100644
index 0000000000..66fd993cae
--- /dev/null
+++ b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvState.java
@@ -0,0 +1,117 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
+import org.apache.paimon.lookup.ByteArray;
+import org.apache.paimon.lookup.State;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+
+import 
org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Cache;
+import 
org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Caffeine;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+/** Base class for states backed by {@link LocalKvDb}. */
+abstract class LocalKvState<K, V, CacheV> implements State<K, V> {
+
+    protected final LocalKvDb db;
+    protected final Serializer<K> keySerializer;
+    protected final Serializer<V> valueSerializer;
+    protected final DataOutputSerializer keyOutput;
+    protected final DataOutputSerializer valueOutput;
+    protected final DataInputDeserializer valueInput;
+    protected final Cache<ByteArray, CacheV> cache;
+    protected final LocalKvValueCodec valueCodec;
+
+    LocalKvState(
+            LocalKvDb db,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize,
+            LocalKvValueCodec valueCodec) {
+        this.db = db;
+        this.keySerializer = keySerializer;
+        this.valueSerializer = valueSerializer;
+        this.keyOutput = new DataOutputSerializer(32);
+        this.valueOutput = new DataOutputSerializer(32);
+        this.valueInput = new DataInputDeserializer();
+        this.valueCodec = valueCodec;
+        this.cache =
+                Caffeine.newBuilder()
+                        .softValues()
+                        .maximumSize(lruCacheSize)
+                        .executor(Runnable::run)
+                        .build();
+    }
+
+    @Override
+    public byte[] serializeKey(K key) throws IOException {
+        keyOutput.clear();
+        keySerializer.serialize(key, keyOutput);
+        return keyOutput.getCopyOfBuffer();
+    }
+
+    @Override
+    public byte[] serializeValue(V value) throws IOException {
+        valueOutput.clear();
+        valueSerializer.serialize(value, valueOutput);
+        return valueOutput.getCopyOfBuffer();
+    }
+
+    @Override
+    public V deserializeValue(byte[] valueBytes) throws IOException {
+        valueInput.setBuffer(valueBytes);
+        return valueSerializer.deserialize(valueInput);
+    }
+
+    @Nullable
+    protected byte[] getRaw(byte[] key) throws IOException {
+        byte[] stored = db.get(key);
+        if (stored == null) {
+            return null;
+        }
+        return valueCodec.decode(stored);
+    }
+
+    protected void putRaw(byte[] key, byte[] value) throws IOException {
+        db.put(key, valueCodec.encode(value));
+    }
+
+    protected ByteArray wrap(byte[] bytes) {
+        return new ByteArray(bytes);
+    }
+
+    @Nullable
+    protected CacheV getCached(ByteArray key) {
+        return valueCodec.ttlEnabled() ? null : cache.getIfPresent(key);
+    }
+
+    protected void putCached(ByteArray key, CacheV value) {
+        if (valueCodec.ttlEnabled()) {
+            cache.invalidate(key);
+        } else {
+            cache.put(key, value);
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvStateFactory.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvStateFactory.java
new file mode 100644
index 0000000000..f6f538ad0d
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvStateFactory.java
@@ -0,0 +1,201 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.io.cache.CacheManager;
+import org.apache.paimon.lookup.ListState;
+import org.apache.paimon.lookup.SetState;
+import org.apache.paimon.lookup.StateFactory;
+import org.apache.paimon.lookup.ValueState;
+import org.apache.paimon.lookup.rocksdb.RocksDBOptions;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.function.LongSupplier;
+
+import static org.apache.paimon.CoreOptions.LOOKUP_CACHE_BLOOM_FILTER_ENABLED;
+import static org.apache.paimon.CoreOptions.LOOKUP_CACHE_BLOOM_FILTER_FPP;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Factory for lookup states backed by {@link LocalKvDb}.
+ *
+ * <p>Each state uses a separate database so that its serialized keys keep 
their natural byte
+ * ordering and can be bulk-loaded independently. All databases share one 
block cache and,
+ * optionally, one caller-owned compaction executor.
+ */
+public class LocalKvStateFactory implements StateFactory {
+
+    private final File rootDirectory;
+    private final CoreOptions coreOptions;
+    private final Options options;
+    private final CacheManager cacheManager;
+    private final LocalKvValueCodec valueCodec;
+    @Nullable private final ExecutorService compactionExecutor;
+    private final Map<String, LocalKvDb> databases;
+
+    private boolean closed;
+
+    public LocalKvStateFactory(
+            String path,
+            Options options,
+            @Nullable Duration ttl,
+            @Nullable ExecutorService compactionExecutor,
+            boolean offHeapCache) {
+        this(path, options, ttl, compactionExecutor, offHeapCache, 
System::currentTimeMillis);
+    }
+
+    LocalKvStateFactory(
+            String path,
+            Options options,
+            @Nullable Duration ttl,
+            @Nullable ExecutorService compactionExecutor,
+            boolean offHeapCache,
+            LongSupplier currentTimeMillis) {
+        this.rootDirectory = new File(path);
+        if ((!rootDirectory.exists() && !rootDirectory.mkdirs()) || 
!rootDirectory.isDirectory()) {
+            throw new IllegalStateException(
+                    "Failed to create LocalKvStateFactory directory: " + 
rootDirectory);
+        }
+        this.coreOptions = new CoreOptions(options);
+        this.options = options;
+        MemorySize cacheMemory =
+                options.contains(CoreOptions.LOOKUP_CACHE_MAX_MEMORY_SIZE)
+                        ? coreOptions.lookupCacheMaxMemory()
+                        : options.get(RocksDBOptions.BLOCK_CACHE_SIZE);
+        this.cacheManager =
+                offHeapCache
+                        ? CacheManager.createOffHeap(
+                                cacheMemory, 
coreOptions.lookupCacheHighPrioPoolRatio())
+                        : new CacheManager(cacheMemory, 
coreOptions.lookupCacheHighPrioPoolRatio());
+        this.valueCodec = new LocalKvValueCodec(ttl, currentTimeMillis);
+        this.compactionExecutor = compactionExecutor;
+        this.databases = new LinkedHashMap<>();
+        this.closed = false;
+    }
+
+    @Override
+    public <K, V> ValueState<K, V> valueState(
+            String name,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize)
+            throws IOException {
+        return new LocalKvValueState<>(
+                createDatabase(name, null),
+                keySerializer,
+                valueSerializer,
+                lruCacheSize,
+                valueCodec);
+    }
+
+    @Override
+    public <K, V> SetState<K, V> setState(
+            String name,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize)
+            throws IOException {
+        return new LocalKvSetState<>(
+                createDatabase(name, null),
+                keySerializer,
+                valueSerializer,
+                lruCacheSize,
+                valueCodec);
+    }
+
+    @Override
+    public <K, V> ListState<K, V> listState(
+            String name,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize)
+            throws IOException {
+        return new LocalKvListState<>(
+                createDatabase(name, new LocalKvListMergeOperator(valueCodec)),
+                keySerializer,
+                valueSerializer,
+                lruCacheSize,
+                valueCodec);
+    }
+
+    @Override
+    public boolean preferBulkLoad() {
+        return true;
+    }
+
+    private LocalKvDb createDatabase(String name, @Nullable 
LocalKvDb.MergeOperator mergeOperator) {
+        checkArgument(!closed, "LocalKvStateFactory is already closed.");
+        checkArgument(!databases.containsKey(name), "State '%s' already 
exists.", name);
+
+        File stateDirectory =
+                new File(rootDirectory, String.format("state-%06d", 
databases.size()));
+        LocalKvDb db =
+                LocalKvDb.builder(stateDirectory)
+                        .cacheManager(cacheManager)
+                        .blockSize(coreOptions.localKvDbBlockSize())
+                        .compressOptions(coreOptions.lookupCompressOptions())
+                        
.bloomFilterEnabled(options.get(LOOKUP_CACHE_BLOOM_FILTER_ENABLED))
+                        
.bloomFilterFpp(options.get(LOOKUP_CACHE_BLOOM_FILTER_FPP))
+                        .expiredValuePredicate(
+                                valueCodec.ttlEnabled() ? 
valueCodec::isExpired : null)
+                        .mergeOperator(mergeOperator)
+                        .compactionExecutor(compactionExecutor)
+                        .build();
+        databases.put(name, db);
+        return db;
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (closed) {
+            return;
+        }
+        closed = true;
+
+        IOException failure = null;
+        for (LocalKvDb db : databases.values()) {
+            try {
+                db.close();
+            } catch (IOException e) {
+                if (failure == null) {
+                    failure = e;
+                } else {
+                    failure.addSuppressed(e);
+                }
+            }
+        }
+        databases.clear();
+        cacheManager.close();
+        if (failure != null) {
+            throw failure;
+        }
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueCodec.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueCodec.java
new file mode 100644
index 0000000000..e6ef3ca3e8
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueCodec.java
@@ -0,0 +1,127 @@
+/*
+ * 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.local;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.function.LongSupplier;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Value envelope which distinguishes LocalKvDb tombstones and optionally 
stores TTL.
+ *
+ * <p>TTL is intentionally not enforced while decoding. Like RocksDB TtlDB, 
LocalKvDb removes
+ * expired values during compaction, so reads may temporarily return an 
expired value.
+ */
+final class LocalKvValueCodec {
+
+    private static final byte VALUE_MARKER = 1;
+    private static final byte TTL_VALUE_MARKER = 2;
+    private static final int EXPIRATION_BYTES = Long.BYTES;
+
+    private final long ttlMillis;
+    private final LongSupplier currentTimeMillis;
+
+    LocalKvValueCodec(@Nullable Duration ttl) {
+        this(ttl, System::currentTimeMillis);
+    }
+
+    LocalKvValueCodec(@Nullable Duration ttl, LongSupplier currentTimeMillis) {
+        this.currentTimeMillis = currentTimeMillis;
+        if (ttl == null) {
+            this.ttlMillis = -1;
+        } else {
+            this.ttlMillis = ttl.toMillis();
+            checkArgument(ttlMillis > 0, "TTL must be greater than zero.");
+        }
+    }
+
+    byte[] encode(byte[] value) {
+        if (!ttlEnabled()) {
+            byte[] stored = new byte[value.length + 1];
+            stored[0] = VALUE_MARKER;
+            System.arraycopy(value, 0, stored, 1, value.length);
+            return stored;
+        }
+
+        byte[] stored = new byte[value.length + 1 + EXPIRATION_BYTES];
+        stored[0] = TTL_VALUE_MARKER;
+        long now = currentTimeMillis.getAsLong();
+        long expiration = Long.MAX_VALUE - now < ttlMillis ? Long.MAX_VALUE : 
now + ttlMillis;
+        writeLong(stored, 1, expiration);
+        System.arraycopy(value, 0, stored, 1 + EXPIRATION_BYTES, value.length);
+        return stored;
+    }
+
+    byte[] decode(byte[] stored) throws IOException {
+        int valueOffset = valueOffset(stored, 0, stored.length);
+        return Arrays.copyOfRange(stored, valueOffset, stored.length);
+    }
+
+    /** Return the encoded value offset. TTL expiration is enforced only 
during compaction. */
+    int valueOffset(byte[] stored, int offset, int length) throws IOException {
+        if (offset < 0
+                || length <= 0
+                || offset > stored.length
+                || length > stored.length - offset) {
+            throw new IOException("Corrupted LocalKvState value marker.");
+        }
+
+        if (!ttlEnabled()) {
+            if (stored[offset] != VALUE_MARKER) {
+                throw new IOException("Corrupted LocalKvState value marker.");
+            }
+            return offset + 1;
+        }
+
+        if (length < 1 + EXPIRATION_BYTES || stored[offset] != 
TTL_VALUE_MARKER) {
+            throw new IOException("Corrupted LocalKvState TTL value.");
+        }
+        return offset + 1 + EXPIRATION_BYTES;
+    }
+
+    boolean ttlEnabled() {
+        return ttlMillis > 0;
+    }
+
+    boolean isExpired(byte[] stored) {
+        return stored.length >= 1 + EXPIRATION_BYTES
+                && stored[0] == TTL_VALUE_MARKER
+                && currentTimeMillis.getAsLong() >= readLong(stored, 1);
+    }
+
+    private static void writeLong(byte[] bytes, int offset, long value) {
+        for (int i = Long.BYTES - 1; i >= 0; i--) {
+            bytes[offset + i] = (byte) value;
+            value >>>= Byte.SIZE;
+        }
+    }
+
+    private static long readLong(byte[] bytes, int offset) {
+        long value = 0;
+        for (int i = 0; i < Long.BYTES; i++) {
+            value = (value << Byte.SIZE) | (bytes[offset + i] & 0xffL);
+        }
+        return value;
+    }
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueState.java
 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueState.java
new file mode 100644
index 0000000000..68ade810e9
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/lookup/local/LocalKvValueState.java
@@ -0,0 +1,95 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.lookup.ByteArray;
+import org.apache.paimon.lookup.ValueBulkLoader;
+import org.apache.paimon.lookup.ValueState;
+import org.apache.paimon.lookup.sort.db.LocalKvDb;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Local KV state for one value per key. */
+public class LocalKvValueState<K, V> extends LocalKvState<K, V, 
LocalKvValueState.Reference>
+        implements ValueState<K, V> {
+
+    LocalKvValueState(
+            LocalKvDb db,
+            Serializer<K> keySerializer,
+            Serializer<V> valueSerializer,
+            long lruCacheSize,
+            LocalKvValueCodec valueCodec) {
+        super(db, keySerializer, valueSerializer, lruCacheSize, valueCodec);
+    }
+
+    @Nullable
+    @Override
+    public V get(K key) throws IOException {
+        Reference reference = getReference(wrap(serializeKey(key)));
+        return reference.value == null ? null : 
deserializeValue(reference.value);
+    }
+
+    private Reference getReference(ByteArray key) throws IOException {
+        Reference reference = getCached(key);
+        if (reference == null) {
+            reference = new Reference(getRaw(key.bytes));
+            putCached(key, reference);
+        }
+        return reference;
+    }
+
+    @Override
+    public void put(K key, V value) throws IOException {
+        checkArgument(value != null, "Value must not be null.");
+        byte[] keyBytes = serializeKey(key);
+        byte[] valueBytes = serializeValue(value);
+        putRaw(keyBytes, valueBytes);
+        putCached(wrap(keyBytes), new Reference(valueBytes));
+    }
+
+    @Override
+    public void delete(K key) throws IOException {
+        byte[] keyBytes = serializeKey(key);
+        ByteArray wrappedKey = wrap(keyBytes);
+        if (getReference(wrappedKey).value != null) {
+            db.delete(keyBytes);
+            putCached(wrappedKey, new Reference(null));
+        }
+    }
+
+    @Override
+    public ValueBulkLoader createBulkLoader() {
+        return new LocalKvBulkLoader(db, valueCodec, key -> 
cache.invalidate(wrap(key)));
+    }
+
+    /** Nullable value wrapper used for negative cache entries. */
+    static final class Reference {
+
+        @Nullable private final byte[] value;
+
+        private Reference(@Nullable byte[] value) {
+            this.value = value;
+        }
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/lookup/local/LocalKvStateFactoryTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/lookup/local/LocalKvStateFactoryTest.java
new file mode 100644
index 0000000000..e89fe40142
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/lookup/local/LocalKvStateFactoryTest.java
@@ -0,0 +1,562 @@
+/*
+ * 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.local;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.serializer.IntSerializer;
+import org.apache.paimon.data.serializer.Serializer;
+import org.apache.paimon.io.DataInputView;
+import org.apache.paimon.io.DataOutputView;
+import org.apache.paimon.lookup.BulkLoader;
+import org.apache.paimon.lookup.ListBulkLoader;
+import org.apache.paimon.lookup.ListState;
+import org.apache.paimon.lookup.SetState;
+import org.apache.paimon.lookup.ValueBulkLoader;
+import org.apache.paimon.lookup.ValueState;
+import org.apache.paimon.options.MemorySize;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link LocalKvStateFactory}. */
+class LocalKvStateFactoryTest {
+
+    @TempDir Path tempDir;
+
+    @Test
+    void testFactoryRejectsNonDirectoryPath() throws Exception {
+        Path file = Files.write(tempDir.resolve("not-a-directory"), new byte[] 
{1});
+
+        assertThatThrownBy(
+                        () ->
+                                new LocalKvStateFactory(
+                                        file.toString(), options(), null, 
null, false))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("LocalKvStateFactory directory");
+    }
+
+    @Test
+    void testValueStateAndStateIsolation() throws Exception {
+        try (LocalKvStateFactory factory = createFactory()) {
+            ValueState<Integer, Integer> first =
+                    factory.valueState("first", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            ValueState<Integer, Integer> second =
+                    factory.valueState(
+                            "second", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            assertThat(first.get(1)).isNull();
+            first.put(1, 10);
+            assertThat(first.get(1)).isEqualTo(10);
+            assertThat(second.get(1)).isNull();
+
+            first.put(1, 11);
+            assertThat(first.get(1)).isEqualTo(11);
+            first.delete(1);
+            assertThat(first.get(1)).isNull();
+
+            assertThat(factory.preferBulkLoad()).isTrue();
+            assertThatThrownBy(
+                            () ->
+                                    factory.valueState(
+                                            "first",
+                                            IntSerializer.INSTANCE,
+                                            IntSerializer.INSTANCE,
+                                            10))
+                    .isInstanceOf(IllegalArgumentException.class)
+                    .hasMessageContaining("first");
+        }
+    }
+
+    @Test
+    void testValueAndSetStateSupportEmptySerializedValues() throws Exception {
+        EmptyIntSerializer emptySerializer = new EmptyIntSerializer();
+        try (LocalKvStateFactory factory = createFactory()) {
+            @SuppressWarnings("unchecked")
+            LocalKvValueState<Integer, Integer> valueState =
+                    (LocalKvValueState<Integer, Integer>)
+                            factory.valueState(
+                                    "empty-value", IntSerializer.INSTANCE, 
emptySerializer, 10);
+            valueState.put(1, 10);
+            valueState.db.flush();
+            valueState.cache.invalidateAll();
+            assertThat(valueState.get(1)).isZero();
+
+            ValueState<Integer, Integer> bulkValueState =
+                    factory.valueState(
+                            "bulk-empty-value", IntSerializer.INSTANCE, 
emptySerializer, 10);
+            ValueBulkLoader valueLoader = bulkValueState.createBulkLoader();
+            valueLoader.write(bulkValueState.serializeKey(2), 
bulkValueState.serializeValue(20));
+            valueLoader.finish();
+            assertThat(bulkValueState.get(2)).isZero();
+
+            @SuppressWarnings("unchecked")
+            LocalKvSetState<Integer, Integer> setState =
+                    (LocalKvSetState<Integer, Integer>)
+                            factory.setState(
+                                    "empty-set", IntSerializer.INSTANCE, 
emptySerializer, 10);
+            setState.add(1, 30);
+            setState.db.flush();
+            assertThat(setState.get(1)).containsExactly(0);
+            setState.retract(1, 40);
+            assertThat(setState.get(1)).isEmpty();
+        }
+    }
+
+    @Test
+    void testListState() throws Exception {
+        try (LocalKvStateFactory factory = createFactory()) {
+            ListState<Integer, Integer> state =
+                    factory.listState("list", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            assertThat(state.get(1)).isEmpty();
+            state.add(1, 3);
+            state.add(1, 1);
+            state.add(1, 3);
+            assertThat(state.get(1)).containsExactly(3, 1, 3);
+
+            ListState<Integer, Integer> bulkState =
+                    factory.listState(
+                            "bulk-list", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            ListBulkLoader loader = bulkState.createBulkLoader();
+            loader.write(
+                    bulkState.serializeKey(1),
+                    Arrays.asList(
+                            bulkState.serializeValue(4),
+                            bulkState.serializeValue(2),
+                            bulkState.serializeValue(4)));
+            loader.write(
+                    bulkState.serializeKey(2),
+                    Collections.singletonList(bulkState.serializeValue(5)));
+            loader.finish();
+
+            assertThat(bulkState.get(1)).containsExactly(4, 2, 4);
+            assertThat(bulkState.get(2)).containsExactly(5);
+            bulkState.add(1, 6);
+            assertThat(bulkState.get(1)).containsExactly(4, 2, 4, 6);
+
+            ListState<Integer, Integer> duplicateState =
+                    factory.listState(
+                            "bulk-list-duplicate",
+                            IntSerializer.INSTANCE,
+                            IntSerializer.INSTANCE,
+                            10);
+            ListBulkLoader duplicateLoader = duplicateState.createBulkLoader();
+            duplicateLoader.write(
+                    duplicateState.serializeKey(1),
+                    
Collections.singletonList(duplicateState.serializeValue(1)));
+            assertThatThrownBy(
+                            () ->
+                                    duplicateLoader.write(
+                                            duplicateState.serializeKey(1),
+                                            Collections.singletonList(
+                                                    
duplicateState.serializeValue(2))))
+                    .isInstanceOf(BulkLoader.WriteException.class)
+                    .hasMessageContaining("strictly increasing");
+        }
+    }
+
+    @Test
+    void testListStateCachesDeserializedValuesAndInvalidatesOnAdd() throws 
Exception {
+        CountingIntSerializer serializer = new CountingIntSerializer();
+        try (LocalKvStateFactory factory = createFactory()) {
+            ListState<Integer, Integer> state =
+                    factory.listState("cached-list", IntSerializer.INSTANCE, 
serializer, 10);
+            ListBulkLoader loader = state.createBulkLoader();
+            loader.write(
+                    state.serializeKey(1),
+                    Arrays.asList(state.serializeValue(10), 
state.serializeValue(20)));
+            loader.finish();
+
+            assertThat(state.get(1)).containsExactly(10, 20);
+            assertThat(serializer.deserializationCount).isEqualTo(2);
+            assertThat(state.get(1)).containsExactly(10, 20);
+            assertThat(serializer.deserializationCount).isEqualTo(2);
+
+            state.add(1, 30);
+            assertThat(state.get(1)).containsExactly(10, 20, 30);
+            assertThat(serializer.deserializationCount).isEqualTo(5);
+            assertThat(state.get(1)).containsExactly(10, 20, 30);
+            assertThat(serializer.deserializationCount).isEqualTo(5);
+        }
+    }
+
+    @Test
+    void testListStateMergesFragmentsDuringFlushAndCompaction() throws 
Exception {
+        try (LocalKvStateFactory factory = createFactory()) {
+            @SuppressWarnings("unchecked")
+            LocalKvListState<Integer, Integer> state =
+                    (LocalKvListState<Integer, Integer>)
+                            factory.listState(
+                                    "merged-list",
+                                    IntSerializer.INSTANCE,
+                                    IntSerializer.INSTANCE,
+                                    10);
+            ListBulkLoader loader = state.createBulkLoader();
+            loader.write(
+                    state.serializeKey(1),
+                    Arrays.asList(state.serializeValue(10), 
state.serializeValue(20)));
+            loader.write(
+                    state.serializeKey(2), 
Collections.singletonList(state.serializeValue(40)));
+            loader.finish();
+
+            state.add(1, 30);
+            state.add(1, 31);
+            state.add(2, 41);
+            state.db.flush();
+            state.cache.invalidateAll();
+
+            assertThat(state.get(1)).containsExactly(10, 20, 30, 31);
+            assertThat(state.get(2)).containsExactly(40, 41);
+            assertThat(rawEntries(state, 1)).hasSize(2);
+            assertThat(rawEntries(state, 2)).hasSize(2);
+
+            state.db.compact();
+            state.cache.invalidateAll();
+            assertThat(state.get(1)).containsExactly(10, 20, 30, 31);
+            assertThat(state.get(2)).containsExactly(40, 41);
+            assertThat(rawEntries(state, 1)).hasSize(1);
+            assertThat(rawEntries(state, 2)).hasSize(1);
+        }
+    }
+
+    @Test
+    void testListStateWithTtlMergesFragmentsDuringFlush() throws Exception {
+        try (LocalKvStateFactory factory =
+                new LocalKvStateFactory(
+                        tempDir.resolve("list-ttl-merge").toString(),
+                        options(),
+                        Duration.ofHours(1),
+                        null,
+                        false)) {
+            @SuppressWarnings("unchecked")
+            LocalKvListState<Integer, Integer> state =
+                    (LocalKvListState<Integer, Integer>)
+                            factory.listState(
+                                    "list", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            state.add(1, 10);
+            state.add(1, 20);
+            state.db.flush();
+            state.cache.invalidateAll();
+
+            assertThat(rawEntries(state, 1)).hasSize(1);
+            assertThat(state.get(1)).containsExactly(10, 20);
+        }
+    }
+
+    @Test
+    void testListStateTtlIsRefreshedAcrossRepeatedFlushes() throws Exception {
+        AtomicLong clock = new AtomicLong(1_000);
+        try (LocalKvStateFactory factory =
+                new LocalKvStateFactory(
+                        tempDir.resolve("list-ttl-repeated-flush").toString(),
+                        options(),
+                        Duration.ofMillis(100),
+                        null,
+                        false,
+                        clock::get)) {
+            @SuppressWarnings("unchecked")
+            LocalKvListState<Integer, Integer> state =
+                    (LocalKvListState<Integer, Integer>)
+                            factory.listState(
+                                    "list", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            state.add(1, 10);
+            state.add(1, 20);
+            state.db.flush();
+            clock.addAndGet(50);
+            state.add(1, 30);
+            state.db.flush();
+            clock.addAndGet(50);
+
+            state.db.compact();
+            state.cache.invalidateAll();
+            assertThat(state.get(1)).containsExactly(10, 20, 30);
+        }
+    }
+
+    @Test
+    void testListMergeRefreshesTtlIncludingExpiredFragments() throws Exception 
{
+        AtomicLong clock = new AtomicLong(1_000);
+        LocalKvValueCodec valueCodec = new 
LocalKvValueCodec(Duration.ofMillis(100), clock::get);
+        LocalKvListValueCodec listValueCodec = new LocalKvListValueCodec();
+        LocalKvListMergeOperator mergeOperator = new 
LocalKvListMergeOperator(valueCodec);
+
+        byte[] first = valueCodec.encode(listValueCodec.encodeSingle(new 
byte[] {0, 0, 0, 10}));
+        clock.set(1_050);
+        byte[] second = valueCodec.encode(listValueCodec.encodeSingle(new 
byte[] {0, 0, 0, 20}));
+        clock.set(1_200);
+        assertThat(valueCodec.isExpired(first)).isTrue();
+        assertThat(valueCodec.isExpired(second)).isTrue();
+        assertThat(valueCodec.valueOffset(first, 0, 
first.length)).isPositive();
+        assertThat(valueCodec.valueOffset(second, 0, 
second.length)).isPositive();
+
+        byte[] merged = mergeOperator.merge(Arrays.asList(first, second));
+        int valueOffset = valueCodec.valueOffset(merged, 0, merged.length);
+        assertThat(valueOffset).isPositive();
+        assertThat(valueCodec.isExpired(merged)).isFalse();
+        List<Integer> values = new ArrayList<>();
+        listValueCodec.decode(
+                merged, valueOffset, merged.length - valueOffset, 
IntSerializer.INSTANCE, values);
+        assertThat(values).containsExactly(10, 20);
+
+        clock.set(1_299);
+        assertThat(valueCodec.isExpired(merged)).isFalse();
+        clock.set(1_300);
+        assertThat(valueCodec.isExpired(merged)).isTrue();
+        assertThat(valueCodec.valueOffset(merged, 0, 
merged.length)).isPositive();
+    }
+
+    @Test
+    void testSetState() throws Exception {
+        try (LocalKvStateFactory factory = createFactory()) {
+            SetState<Integer, Integer> state =
+                    factory.setState("set", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            assertThat(state.get(1)).isEmpty();
+            state.retract(1, 9);
+            state.add(1, 3);
+            state.add(1, 1);
+            state.add(1, 3);
+            state.add(1, 2);
+            assertThat(state.get(1)).containsExactly(1, 2, 3);
+
+            state.retract(1, 2);
+            assertThat(state.get(1)).containsExactly(1, 3);
+            for (int value = 4; value < 260; value++) {
+                state.add(1, value);
+            }
+            assertThat(state.get(1)).hasSize(258);
+            state.retract(1, 1);
+            state.retract(1, 3);
+            for (int value = 4; value < 260; value++) {
+                state.retract(1, value);
+            }
+            assertThat(state.get(1)).isEmpty();
+        }
+    }
+
+    @Test
+    void testValueBulkLoadAndStrictOrdering() throws Exception {
+        try (LocalKvStateFactory factory = createFactory()) {
+            ValueState<Integer, Integer> state =
+                    factory.valueState("bulk", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            assertThat(state.get(1)).isNull();
+            ValueBulkLoader loader = state.createBulkLoader();
+            loader.write(state.serializeKey(1), state.serializeValue(10));
+            loader.write(state.serializeKey(2), state.serializeValue(20));
+            loader.finish();
+
+            assertThat(state.get(1)).isEqualTo(10);
+            assertThat(state.get(2)).isEqualTo(20);
+
+            ValueState<Integer, Integer> duplicateState =
+                    factory.valueState(
+                            "bulk-duplicate", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            ValueBulkLoader duplicateLoader = 
duplicateState.createBulkLoader();
+            duplicateLoader.write(
+                    duplicateState.serializeKey(1), 
duplicateState.serializeValue(10));
+            assertThatThrownBy(
+                            () ->
+                                    duplicateLoader.write(
+                                            duplicateState.serializeKey(1),
+                                            duplicateState.serializeValue(11)))
+                    .isInstanceOf(BulkLoader.WriteException.class)
+                    .hasMessageContaining("strictly increasing");
+        }
+    }
+
+    @Test
+    void testValueStateTtlIsRemovedOnlyByCompaction() throws Exception {
+        Options options = options();
+        AtomicLong clock = new AtomicLong(1_000);
+        try (LocalKvStateFactory factory =
+                new LocalKvStateFactory(
+                        tempDir.resolve("ttl").toString(),
+                        options,
+                        Duration.ofMillis(100),
+                        null,
+                        false,
+                        clock::get)) {
+            @SuppressWarnings("unchecked")
+            LocalKvValueState<Integer, Integer> state =
+                    (LocalKvValueState<Integer, Integer>)
+                            factory.valueState(
+                                    "ttl", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            state.put(1, 10);
+            state.db.flush();
+            assertThat(state.get(1)).isEqualTo(10);
+
+            @SuppressWarnings("unchecked")
+            LocalKvValueState<Integer, Integer> bulkState =
+                    (LocalKvValueState<Integer, Integer>)
+                            factory.valueState(
+                                    "bulk-ttl", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            ValueBulkLoader loader = bulkState.createBulkLoader();
+            loader.write(bulkState.serializeKey(2), 
bulkState.serializeValue(20));
+            loader.finish();
+            assertThat(bulkState.get(2)).isEqualTo(20);
+
+            clock.addAndGet(100);
+            assertThat(state.get(1)).isEqualTo(10);
+            assertThat(bulkState.get(2)).isEqualTo(20);
+
+            state.db.compact();
+            bulkState.db.compact();
+            assertThat(state.get(1)).isNull();
+            assertThat(bulkState.get(2)).isNull();
+        }
+    }
+
+    @Test
+    void testUnmergedListAndSetStateTtlRemainVisibleBeforeCompaction() throws 
Exception {
+        AtomicLong clock = new AtomicLong(1_000);
+        try (LocalKvStateFactory factory =
+                new LocalKvStateFactory(
+                        tempDir.resolve("collection-ttl").toString(),
+                        options(),
+                        Duration.ofMillis(500),
+                        null,
+                        false,
+                        clock::get)) {
+            ListState<Integer, Integer> list =
+                    factory.listState("list", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+            SetState<Integer, Integer> set =
+                    factory.setState("set", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            list.add(1, 10);
+            set.add(1, 10);
+            clock.addAndGet(350);
+            list.add(1, 20);
+            set.add(1, 20);
+            clock.addAndGet(250);
+
+            assertThat(list.get(1)).containsExactly(10, 20);
+            assertThat(set.get(1)).containsExactly(10, 20);
+        }
+    }
+
+    @Test
+    void testSetStateTtlCompactionDropsOnlyExpiredValues() throws Exception {
+        AtomicLong clock = new AtomicLong(1_000);
+        try (LocalKvStateFactory factory =
+                new LocalKvStateFactory(
+                        tempDir.resolve("set-ttl-compaction").toString(),
+                        options(),
+                        Duration.ofMillis(100),
+                        null,
+                        false,
+                        clock::get)) {
+            @SuppressWarnings("unchecked")
+            LocalKvSetState<Integer, Integer> state =
+                    (LocalKvSetState<Integer, Integer>)
+                            factory.setState(
+                                    "set", IntSerializer.INSTANCE, 
IntSerializer.INSTANCE, 10);
+
+            state.add(1, 10);
+            state.db.flush();
+            clock.addAndGet(50);
+            state.add(1, 20);
+            state.db.flush();
+            clock.addAndGet(50);
+
+            assertThat(state.get(1)).containsExactly(10, 20);
+            state.db.compact();
+            assertThat(state.get(1)).containsExactly(20);
+        }
+    }
+
+    private LocalKvStateFactory createFactory() {
+        return new LocalKvStateFactory(tempDir.toString(), options(), null, 
null, false);
+    }
+
+    private Options options() {
+        Options options = new Options();
+        options.set(CoreOptions.LOOKUP_CACHE_MAX_MEMORY_SIZE, 
MemorySize.ofMebiBytes(8));
+        return options;
+    }
+
+    private static List<Map.Entry<byte[], byte[]>> rawEntries(
+            LocalKvListState<Integer, Integer> state, int key) throws 
IOException {
+        byte[] prefix = LocalKvCompositeKey.prefix(state.serializeKey(key));
+        return state.db.rangeScan(prefix, 
LocalKvCompositeKey.upperBound(prefix));
+    }
+
+    private static class CountingIntSerializer implements Serializer<Integer> {
+
+        private int deserializationCount;
+
+        @Override
+        public Serializer<Integer> duplicate() {
+            return this;
+        }
+
+        @Override
+        public Integer copy(Integer from) {
+            return from;
+        }
+
+        @Override
+        public void serialize(Integer record, DataOutputView target) throws 
IOException {
+            target.writeInt(record);
+        }
+
+        @Override
+        public Integer deserialize(DataInputView source) throws IOException {
+            deserializationCount++;
+            return source.readInt();
+        }
+    }
+
+    private static class EmptyIntSerializer implements Serializer<Integer> {
+
+        @Override
+        public Serializer<Integer> duplicate() {
+            return this;
+        }
+
+        @Override
+        public Integer copy(Integer from) {
+            return from;
+        }
+
+        @Override
+        public void serialize(Integer record, DataOutputView target) {}
+
+        @Override
+        public Integer deserialize(DataInputView source) {
+            return 0;
+        }
+    }
+}

Reply via email to