You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@fluss.apache.org by yu...@apache.org on 2026/08/26 13:23:41 UTC

(fluss) branch main updated: [server] Support primary-key writes to historical partitions in server (#4001)

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

luoyuxia pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new 9a915d9f7 [server] Support primary-key writes to historical partitions in server (#4001)
9a915d9f7 is described below

commit 9a915d9f73714d75d3e1b89b0027feddc6989ea4
Author: yuxia Luo <lu...@alumni.sjtu.edu.cn>
AuthorDate: Wed Aug 26 21:23:35 2026 +0800

    [server] Support primary-key writes to historical partitions in server (#4001)
---
 .../fluss/client/table/getter/PartitionGetter.java |  47 +-
 .../org/apache/fluss/row/RowPartitionGetter.java   |  16 +-
 .../fluss/rpc/entity/PutKvResultForBucket.java     |  45 +-
 .../fluss/rpc/netty/server/NettyServerHandler.java |   4 +
 .../fluss/rpc/netty/server/RequestsMetrics.java    |   5 +-
 .../org/apache/fluss/rpc/protocol/ApiKeys.java     |   3 +-
 .../fluss/rpc/util/CommonRpcMessageUtils.java      |  12 +
 fluss-rpc/src/main/proto/FlussApi.proto            |   4 +
 fluss-rust/crates/fluss/proto/FlussApi.proto       |   4 +
 fluss-rust/crates/fluss/src/client/write/sender.rs |   1 +
 fluss-rust/crates/fluss/src/proto/fluss.rs         |   6 +
 fluss-rust/crates/fluss/src/rpc/api_key.rs         |   5 +-
 fluss-rust/crates/fluss/src/rpc/message/put_kv.rs  |   1 +
 .../crates/fluss/src/rpc/server_connection.rs      |  16 +-
 .../fluss/server/entity/PutKvDataForBucket.java    |  59 ++
 .../java/org/apache/fluss/server/kv/KvManager.java |   2 -
 .../apache/fluss/server/kv/KvRecoverHelper.java    |  33 +-
 .../apache/fluss/server/kv/KvStateAccessor.java    | 121 +++
 .../fluss/server/kv/KvStateLookupResult.java       | 131 +++
 .../java/org/apache/fluss/server/kv/KvTablet.java  | 500 +++--------
 .../apache/fluss/server/kv/KvWriteProcessor.java   | 564 ++++++++++++
 .../kv/historical/HistoricalKvKeyEncoder.java      |  79 ++
 .../kv/historical/HistoricalValueLookup.java       |  37 +
 .../apache/fluss/server/kv/rocksdb/RocksDBKv.java  |  34 +-
 .../server/metrics/group/TableMetricGroup.java     |  35 +-
 .../org/apache/fluss/server/replica/Replica.java   | 176 +++-
 .../fluss/server/replica/ReplicaManager.java       | 186 +++-
 .../HistoricalLakeLookupManager.java               | 305 ++-----
 .../historical/HistoricalPartitionManager.java     | 353 ++++++++
 .../HistoricalPartitionTaskExecutor.java           | 210 +++++
 .../apache/fluss/server/tablet/TabletService.java  |  36 +-
 .../fluss/server/utils/ServerRpcMessageUtils.java  |  45 +-
 .../kv/historical/HistoricalKvKeyEncoderTest.java  |  80 ++
 .../fluss/server/kv/rocksdb/RocksDBKvTest.java     |  12 +
 .../HistoricalLakeLookupManagerTest.java           | 391 ++------
 .../historical/HistoricalPartitionManagerTest.java | 997 +++++++++++++++++++++
 .../HistoricalPartitionTaskExecutorTest.java       | 232 +++++
 .../server/utils/ServerRpcMessageUtilsTest.java    |  83 ++
 .../maintenance/observability/monitor-metrics.md   |  15 +-
 39 files changed, 3858 insertions(+), 1027 deletions(-)

diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java b/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java
index ef0766ed4..6d05c887e 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java
@@ -19,59 +19,28 @@ package org.apache.fluss.client.table.getter;
 
 import org.apache.fluss.metadata.ResolvedPartitionSpec;
 import org.apache.fluss.row.InternalRow;
-import org.apache.fluss.types.DataType;
+import org.apache.fluss.row.RowPartitionGetter;
 import org.apache.fluss.types.RowType;
-import org.apache.fluss.utils.PartitionUtils;
 
-import java.util.ArrayList;
 import java.util.List;
 
-import static org.apache.fluss.utils.Preconditions.checkArgument;
-import static org.apache.fluss.utils.Preconditions.checkNotNull;
-
 /** A getter to get partition name from a row. */
 public class PartitionGetter {
 
-    private final List<String> partitionKeys;
-    private final List<InternalRow.FieldGetter> partitionFieldGetters;
-    private final List<DataType> partitionTypes;
+    private final RowPartitionGetter delegate;
 
+    /** Creates a partition getter for the given row type and partition keys. */
     public PartitionGetter(RowType rowType, List<String> partitionKeys) {
-        // check the partition column
-        List<String> fieldNames = rowType.getFieldNames();
-        this.partitionKeys = partitionKeys;
-        partitionFieldGetters = new ArrayList<>();
-        partitionTypes = new ArrayList<>();
-        for (String partitionKey : partitionKeys) {
-            int partitionColumnIndex = fieldNames.indexOf(partitionKey);
-            checkArgument(
-                    partitionColumnIndex >= 0,
-                    "The partition column %s is not in the row %s.",
-                    partitionKey,
-                    rowType);
-
-            // check the data type of the partition column
-            DataType partitionColumnDataType = rowType.getTypeAt(partitionColumnIndex);
-            partitionTypes.add(partitionColumnDataType);
-            partitionFieldGetters.add(
-                    InternalRow.createFieldGetter(partitionColumnDataType, partitionColumnIndex));
-        }
+        this.delegate = new RowPartitionGetter(rowType, partitionKeys);
     }
 
+    /** Returns the partition name extracted from the given row. */
     public String getPartition(InternalRow row) {
-        return getResolvedPartitionSpec(row).getPartitionName();
+        return delegate.getPartition(row);
     }
 
+    /** Returns the resolved partition spec extracted from the given row. */
     public ResolvedPartitionSpec getResolvedPartitionSpec(InternalRow row) {
-        List<String> partitionValues = new ArrayList<>();
-        for (int i = 0; i < partitionFieldGetters.size(); i++) {
-            InternalRow.FieldGetter partitionFieldGetter = partitionFieldGetters.get(i);
-            DataType dataType = partitionTypes.get(i);
-            Object partitionValue = partitionFieldGetter.getFieldOrNull(row);
-            checkNotNull(partitionValue, "Partition value shouldn't be null.");
-            partitionValues.add(
-                    PartitionUtils.convertValueOfType(partitionValue, dataType.getTypeRoot()));
-        }
-        return new ResolvedPartitionSpec(partitionKeys, partitionValues);
+        return delegate.getResolvedPartitionSpec(row);
     }
 }
diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java b/fluss-common/src/main/java/org/apache/fluss/row/RowPartitionGetter.java
similarity index 86%
copy from fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java
copy to fluss-common/src/main/java/org/apache/fluss/row/RowPartitionGetter.java
index ef0766ed4..6d4f4728f 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/table/getter/PartitionGetter.java
+++ b/fluss-common/src/main/java/org/apache/fluss/row/RowPartitionGetter.java
@@ -15,10 +15,10 @@
  * limitations under the License.
  */
 
-package org.apache.fluss.client.table.getter;
+package org.apache.fluss.row;
 
+import org.apache.fluss.annotation.Internal;
 import org.apache.fluss.metadata.ResolvedPartitionSpec;
-import org.apache.fluss.row.InternalRow;
 import org.apache.fluss.types.DataType;
 import org.apache.fluss.types.RowType;
 import org.apache.fluss.utils.PartitionUtils;
@@ -29,15 +29,16 @@ import java.util.List;
 import static org.apache.fluss.utils.Preconditions.checkArgument;
 import static org.apache.fluss.utils.Preconditions.checkNotNull;
 
-/** A getter to get partition name from a row. */
-public class PartitionGetter {
+/** Extracts a partition from an {@link InternalRow}. */
+@Internal
+public class RowPartitionGetter {
 
     private final List<String> partitionKeys;
     private final List<InternalRow.FieldGetter> partitionFieldGetters;
     private final List<DataType> partitionTypes;
 
-    public PartitionGetter(RowType rowType, List<String> partitionKeys) {
-        // check the partition column
+    /** Creates a partition getter for the given row type and partition keys. */
+    public RowPartitionGetter(RowType rowType, List<String> partitionKeys) {
         List<String> fieldNames = rowType.getFieldNames();
         this.partitionKeys = partitionKeys;
         partitionFieldGetters = new ArrayList<>();
@@ -50,7 +51,6 @@ public class PartitionGetter {
                     partitionKey,
                     rowType);
 
-            // check the data type of the partition column
             DataType partitionColumnDataType = rowType.getTypeAt(partitionColumnIndex);
             partitionTypes.add(partitionColumnDataType);
             partitionFieldGetters.add(
@@ -58,10 +58,12 @@ public class PartitionGetter {
         }
     }
 
+    /** Returns the partition name extracted from the given row. */
     public String getPartition(InternalRow row) {
         return getResolvedPartitionSpec(row).getPartitionName();
     }
 
+    /** Returns the resolved partition spec extracted from the given row. */
     public ResolvedPartitionSpec getResolvedPartitionSpec(InternalRow row) {
         List<String> partitionValues = new ArrayList<>();
         for (int i = 0; i < partitionFieldGetters.size(); i++) {
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/entity/PutKvResultForBucket.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/entity/PutKvResultForBucket.java
index eb8de8677..3d6d343c7 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/entity/PutKvResultForBucket.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/entity/PutKvResultForBucket.java
@@ -23,6 +23,8 @@ import org.apache.fluss.rpc.messages.PutKvRequest;
 import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.rpc.protocol.Errors;
 
+import javax.annotation.Nullable;
+
 /** Result of {@link PutKvRequest} for each table bucket. */
 @Internal
 public class PutKvResultForBucket extends WriteResultForBucket {
@@ -30,27 +32,58 @@ public class PutKvResultForBucket extends WriteResultForBucket {
     /** Backpressure pressure value: 0=normal, (0,1)=DELAYED zone. */
     private final float pressure;
 
+    private final @Nullable String originalPartitionName;
+
     public PutKvResultForBucket(TableBucket tableBucket, long changeLogEndOffset) {
-        this(tableBucket, changeLogEndOffset, 0f);
+        this(tableBucket, changeLogEndOffset, ApiError.NONE, 0f, null);
     }
 
     public PutKvResultForBucket(TableBucket tableBucket, long changeLogEndOffset, float pressure) {
-        super(tableBucket, changeLogEndOffset, ApiError.NONE);
-        this.pressure = pressure;
+        this(tableBucket, changeLogEndOffset, ApiError.NONE, pressure, null);
     }
 
     public PutKvResultForBucket(TableBucket tableBucket, ApiError error) {
-        super(tableBucket, -1L, error);
-        this.pressure = 0f;
+        this(tableBucket, -1L, error, 0f, null);
+    }
+
+    public static PutKvResultForBucket historicalSuccess(
+            TableBucket tableBucket,
+            long changeLogEndOffset,
+            @Nullable String originalPartitionName) {
+        return new PutKvResultForBucket(
+                tableBucket, changeLogEndOffset, ApiError.NONE, 0f, originalPartitionName);
+    }
+
+    public static PutKvResultForBucket historicalFailure(
+            TableBucket tableBucket, ApiError error, @Nullable String originalPartitionName) {
+        return new PutKvResultForBucket(tableBucket, -1L, error, 0f, originalPartitionName);
+    }
+
+    private PutKvResultForBucket(
+            TableBucket tableBucket,
+            long changeLogEndOffset,
+            ApiError error,
+            float pressure,
+            @Nullable String originalPartitionName) {
+        super(tableBucket, changeLogEndOffset, error);
+        this.pressure = pressure;
+        this.originalPartitionName = originalPartitionName;
     }
 
     public float getPressure() {
         return pressure;
     }
 
+    /** Returns the original partition name for a historical write, or null for a normal write. */
+    public @Nullable String getOriginalPartitionName() {
+        return originalPartitionName;
+    }
+
     @Override
     public <T extends WriteResultForBucket> T copy(Errors newError) {
         //noinspection unchecked
-        return (T) new PutKvResultForBucket(tableBucket, newError.toApiError());
+        return (T)
+                new PutKvResultForBucket(
+                        tableBucket, -1L, newError.toApiError(), 0f, originalPartitionName);
     }
 }
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServerHandler.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServerHandler.java
index 25328fe44..b04722d5c 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServerHandler.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/NettyServerHandler.java
@@ -27,6 +27,7 @@ import org.apache.fluss.rpc.messages.AuthenticateRequest;
 import org.apache.fluss.rpc.messages.AuthenticateResponse;
 import org.apache.fluss.rpc.messages.FetchLogRequest;
 import org.apache.fluss.rpc.messages.LookupRequest;
+import org.apache.fluss.rpc.messages.PutKvRequest;
 import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.rpc.protocol.ApiKeys;
 import org.apache.fluss.rpc.protocol.ApiManager;
@@ -57,6 +58,7 @@ import static org.apache.fluss.rpc.protocol.MessageCodec.encodeErrorResponse;
 import static org.apache.fluss.rpc.protocol.MessageCodec.encodeServerFailure;
 import static org.apache.fluss.rpc.protocol.MessageCodec.encodeSuccessResponse;
 import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.hasHistoricalLookup;
+import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.hasHistoricalPut;
 
 /** Implementation of the channel handler to process inbound requests for RPC server. */
 public final class NettyServerHandler extends ChannelInboundHandlerAdapter {
@@ -308,6 +310,8 @@ public final class NettyServerHandler extends ChannelInboundHandlerAdapter {
             isFromFollower = fetchLogRequest.getFollowerServerId() >= 0;
         } else if (request.getApiKey() == ApiKeys.LOOKUP.id) {
             isHistorical = hasHistoricalLookup((LookupRequest) requestMessage);
+        } else if (request.getApiKey() == ApiKeys.PUT_KV.id) {
+            isHistorical = hasHistoricalPut((PutKvRequest) requestMessage);
         }
         return requestsMetrics.getMetrics(request.getApiKey(), isFromFollower, isHistorical);
     }
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/RequestsMetrics.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/RequestsMetrics.java
index 3fff71f70..d3aa345ca 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/RequestsMetrics.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/netty/server/RequestsMetrics.java
@@ -59,6 +59,9 @@ public class RequestsMetrics {
             if (apiKey == ApiKeys.LOOKUP) {
                 addMetrics(serverMetricsGroup, toRequestName(apiKey, false, true));
             }
+            if (apiKey == ApiKeys.PUT_KV) {
+                addMetrics(serverMetricsGroup, toRequestName(apiKey, false, true));
+            }
         }
         this.requestMetricGroup = serverMetricsGroup.addGroup("request");
     }
@@ -103,7 +106,7 @@ public class RequestsMetrics {
             case PRODUCE_LOG:
                 return "produceLog";
             case PUT_KV:
-                return "putKv";
+                return isHistorical ? "historicalPutKv" : "putKv";
             case LOOKUP:
                 return isHistorical ? "historicalLookup" : "lookup";
             case PREFIX_LOOKUP:
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
index e9f18b3d6..979674c25 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
@@ -51,7 +51,8 @@ public enum ApiKeys {
     // Version 2: Understands the STORAGE_BACKPRESSURE_EXCEPTION error code (72) returned when the
     //            KV storage engine rejects a write under pressure; older versions receive the
     //            retriable KV_STORAGE_EXCEPTION instead.
-    PUT_KV(1016, 0, 2, PUBLIC),
+    // Version 3: Supports original_partition_name in requests and responses for historical writes.
+    PUT_KV(1016, 0, 3, PUBLIC),
 
     // Version 0: Uses lake's encoder for primary key encoding (legacy behavior).
     // Version 1: Uses CompactedKeyEncoder for primary key encoding when bucket key differs from
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/util/CommonRpcMessageUtils.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/util/CommonRpcMessageUtils.java
index a5aa22cb3..219b51087 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/util/CommonRpcMessageUtils.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/util/CommonRpcMessageUtils.java
@@ -34,6 +34,7 @@ import org.apache.fluss.rpc.messages.PbKeyValue;
 import org.apache.fluss.rpc.messages.PbPartitionSpec;
 import org.apache.fluss.rpc.messages.PbRemoteLogFetchInfo;
 import org.apache.fluss.rpc.messages.PbRemoteLogSegment;
+import org.apache.fluss.rpc.messages.PutKvRequest;
 import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.security.acl.AccessControlEntry;
 import org.apache.fluss.security.acl.AccessControlEntryFilter;
@@ -71,6 +72,17 @@ public class CommonRpcMessageUtils {
                 && lookupRequest.getBucketsReqAt(0).hasOriginalPartitionName();
     }
 
+    /**
+     * Returns whether the put-KV request is for historical partition writes.
+     *
+     * <p>Normal and historical write buckets cannot be mixed in the same request, so the first
+     * bucket determines the request type.
+     */
+    public static boolean hasHistoricalPut(PutKvRequest putKvRequest) {
+        return putKvRequest.getBucketsReqsCount() > 0
+                && putKvRequest.getBucketsReqAt(0).hasOriginalPartitionName();
+    }
+
     public static List<PbAclInfo> toPbAclInfos(Collection<AclBinding> aclBindings) {
         return aclBindings.stream()
                 .map(CommonRpcMessageUtils::toPbAclInfo)
diff --git a/fluss-rpc/src/main/proto/FlussApi.proto b/fluss-rpc/src/main/proto/FlussApi.proto
index d03acb92f..9d5e05fbd 100644
--- a/fluss-rpc/src/main/proto/FlussApi.proto
+++ b/fluss-rpc/src/main/proto/FlussApi.proto
@@ -939,6 +939,8 @@ message PbPutKvReqForBucket {
   optional int64 partition_id = 1;
   required int32 bucket_id = 2;
   required bytes records = 3;
+  // The original partition name for historical PK writes. It is unset for normal writes.
+  optional string original_partition_name = 4;
 }
 
 message PbPutKvRespForBucket {
@@ -953,6 +955,8 @@ message PbPutKvRespForBucket {
   // 0 = normal (no pressure), (0,1) = DELAYED zone (normalized L0 ratio)
   // introduced in v1.0
   optional float pressure = 6;
+  // The original partition name for historical PK writes. It is unset for normal writes.
+  optional string original_partition_name = 7;
 }
 
 message PbLookupReqForBucket {
diff --git a/fluss-rust/crates/fluss/proto/FlussApi.proto b/fluss-rust/crates/fluss/proto/FlussApi.proto
index d03acb92f..9d5e05fbd 100644
--- a/fluss-rust/crates/fluss/proto/FlussApi.proto
+++ b/fluss-rust/crates/fluss/proto/FlussApi.proto
@@ -939,6 +939,8 @@ message PbPutKvReqForBucket {
   optional int64 partition_id = 1;
   required int32 bucket_id = 2;
   required bytes records = 3;
+  // The original partition name for historical PK writes. It is unset for normal writes.
+  optional string original_partition_name = 4;
 }
 
 message PbPutKvRespForBucket {
@@ -953,6 +955,8 @@ message PbPutKvRespForBucket {
   // 0 = normal (no pressure), (0,1) = DELAYED zone (normalized L0 ratio)
   // introduced in v1.0
   optional float pressure = 6;
+  // The original partition name for historical PK writes. It is unset for normal writes.
+  optional string original_partition_name = 7;
 }
 
 message PbLookupReqForBucket {
diff --git a/fluss-rust/crates/fluss/src/client/write/sender.rs b/fluss-rust/crates/fluss/src/client/write/sender.rs
index f264fac0e..d5f25e8dd 100644
--- a/fluss-rust/crates/fluss/src/client/write/sender.rs
+++ b/fluss-rust/crates/fluss/src/client/write/sender.rs
@@ -1209,6 +1209,7 @@ mod tests {
                 error_message: None,
                 log_end_offset: None,
                 pressure: Some(0.5),
+                original_partition_name: None,
             }],
         };
         sender
diff --git a/fluss-rust/crates/fluss/src/proto/fluss.rs b/fluss-rust/crates/fluss/src/proto/fluss.rs
index 6fe822a1a..95e7045c5 100644
--- a/fluss-rust/crates/fluss/src/proto/fluss.rs
+++ b/fluss-rust/crates/fluss/src/proto/fluss.rs
@@ -1249,6 +1249,9 @@ pub struct PbPutKvReqForBucket {
     pub bucket_id: i32,
     #[prost(bytes = "bytes", required, tag = "3")]
     pub records: ::prost::bytes::Bytes,
+    /// The original partition name for historical PK writes. It is unset for normal writes.
+    #[prost(string, optional, tag = "4")]
+    pub original_partition_name: ::core::option::Option<::prost::alloc::string::String>,
 }
 #[derive(Clone, PartialEq, ::prost::Message)]
 pub struct PbPutKvRespForBucket {
@@ -1269,6 +1272,9 @@ pub struct PbPutKvRespForBucket {
     /// introduced in v1.0
     #[prost(float, optional, tag = "6")]
     pub pressure: ::core::option::Option<f32>,
+    /// The original partition name for historical PK writes. It is unset for normal writes.
+    #[prost(string, optional, tag = "7")]
+    pub original_partition_name: ::core::option::Option<::prost::alloc::string::String>,
 }
 #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)]
 pub struct PbLookupReqForBucket {
diff --git a/fluss-rust/crates/fluss/src/rpc/api_key.rs b/fluss-rust/crates/fluss/src/rpc/api_key.rs
index 239b1d448..e9720a4e2 100644
--- a/fluss-rust/crates/fluss/src/rpc/api_key.rs
+++ b/fluss-rust/crates/fluss/src/rpc/api_key.rs
@@ -129,8 +129,9 @@ impl ApiKey {
             | ApiKey::GetClusterHealth
             | ApiKey::ListRemoteLogManifests
             | ApiKey::ListKvSnapshots => Some(ApiVersionRange::new(ApiVersion(0), ApiVersion(0))),
-            // PutKv v2 adds the storage backpressure error code.
-            ApiKey::PutKv => Some(ApiVersionRange::new(ApiVersion(0), ApiVersion(2))),
+            // PutKv v2 adds the storage backpressure error code; v3 adds historical partition
+            // context to requests and responses.
+            ApiKey::PutKv => Some(ApiVersionRange::new(ApiVersion(0), ApiVersion(3))),
             // Lookup / PrefixLookup support v0 (legacy key encoding) and v1
             // (Paimon BinaryRow key encoding for kv_format_version=2
             // non-default bucket keys). The Rust client encodes both.
diff --git a/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs b/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs
index 8ffdaeefe..c6ce9eb5c 100644
--- a/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs
+++ b/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs
@@ -51,6 +51,7 @@ impl PutKvRequest {
                 partition_id: ready_batch.table_bucket.partition_id(),
                 bucket_id: ready_batch.table_bucket.bucket_id(),
                 records: ready_batch.write_batch.build()?,
+                original_partition_name: None,
             })
         }
 
diff --git a/fluss-rust/crates/fluss/src/rpc/server_connection.rs b/fluss-rust/crates/fluss/src/rpc/server_connection.rs
index 885dcbbf6..a8b36cbec 100644
--- a/fluss-rust/crates/fluss/src/rpc/server_connection.rs
+++ b/fluss-rust/crates/fluss/src/rpc/server_connection.rs
@@ -1186,11 +1186,11 @@ mod tests {
 
         assert_eq!(
             resolve_api_version_for(None, ApiKey::PutKv).unwrap(),
-            ApiVersion(2)
+            ApiVersion(3)
         );
 
         let server_versions = vec![
-            // PutKv: server v0..v3, client v0..v2 → negotiated v2
+            // PutKv: server v0..v3, client v0..v3 → negotiated v3
             PbApiVersion {
                 api_key: 1016,
                 min_version: 0,
@@ -1220,6 +1220,18 @@ mod tests {
         // Successful negotiation cases
         assert_eq!(
             negotiated.highest_available_version(ApiKey::PutKv).unwrap(),
+            ApiVersion(3)
+        );
+
+        let old_server_versions = ServerApiVersions::new(&[PbApiVersion {
+            api_key: 1016,
+            min_version: 0,
+            max_version: 2,
+        }]);
+        assert_eq!(
+            old_server_versions
+                .highest_available_version(ApiKey::PutKv)
+                .unwrap(),
             ApiVersion(2)
         );
         assert_eq!(
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/entity/PutKvDataForBucket.java b/fluss-server/src/main/java/org/apache/fluss/server/entity/PutKvDataForBucket.java
new file mode 100644
index 000000000..af164d521
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/entity/PutKvDataForBucket.java
@@ -0,0 +1,59 @@
+/*
+ * 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.fluss.server.entity;
+
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.KvRecordBatch;
+
+import javax.annotation.Nullable;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/** Put KV request data and historical partition context for one table bucket. */
+public final class PutKvDataForBucket {
+
+    private final TableBucket tableBucket;
+    private final KvRecordBatch records;
+    private final @Nullable String originalPartitionName;
+
+    /** Creates decoded put-KV data for one table bucket. */
+    public PutKvDataForBucket(
+            TableBucket tableBucket,
+            KvRecordBatch records,
+            @Nullable String originalPartitionName) {
+        this.tableBucket = checkNotNull(tableBucket, "tableBucket must not be null.");
+        this.records = checkNotNull(records, "records must not be null.");
+        this.originalPartitionName = originalPartitionName;
+    }
+
+    /** Returns the physical table bucket targeted by this request data. */
+    public TableBucket tableBucket() {
+        return tableBucket;
+    }
+
+    /** Returns the encoded KV records for this table bucket. */
+    public KvRecordBatch records() {
+        return records;
+    }
+
+    /** Returns the original partition name for a historical write, or null. */
+    public @Nullable String originalPartitionName() {
+        return originalPartitionName;
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvManager.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvManager.java
index c1ee5f867..84273ab38 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvManager.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvManager.java
@@ -386,8 +386,6 @@ public final class KvManager extends TabletManagerBase implements ServerReconfig
                                 dropKvTablet.getKvTabletDir().getAbsolutePath()),
                         e);
             }
-        } else {
-            LOG.warn("Fail to delete kv bucket {}.", tableBucket.getBucket());
         }
     }
 
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
index 29c6cae8c..780533c68 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvRecoverHelper.java
@@ -31,11 +31,13 @@ import org.apache.fluss.record.LogRecords;
 import org.apache.fluss.record.MemoryLogRecords;
 import org.apache.fluss.row.BinaryRow;
 import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.RowPartitionGetter;
 import org.apache.fluss.row.encode.KeyEncoder;
 import org.apache.fluss.row.encode.RowEncoder;
 import org.apache.fluss.row.encode.ValueEncoder;
 import org.apache.fluss.row.indexed.IndexedRow;
 import org.apache.fluss.server.kv.autoinc.AutoIncIDRange;
+import org.apache.fluss.server.kv.historical.HistoricalKvKeyEncoder;
 import org.apache.fluss.server.log.FetchIsolation;
 import org.apache.fluss.server.log.LogTablet;
 import org.apache.fluss.server.zk.ZooKeeperClient;
@@ -53,6 +55,9 @@ import javax.annotation.Nullable;
 import java.util.List;
 
 import static org.apache.fluss.server.TabletManagerBase.getTableInfo;
+import static org.apache.fluss.server.kv.KvStateAccessor.HISTORICAL_TOMBSTONE;
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
 
 /** A helper for recovering Kv from log. */
 public class KvRecoverHelper {
@@ -67,6 +72,7 @@ public class KvRecoverHelper {
     private final KvFormat kvFormat;
     private final LogFormat logFormat;
     private final RemoteLogFetcher remoteLogFetcher;
+    private final boolean historicalPartition;
 
     // will be initialized when first encounter a log record during recovering from log
     private Integer currentSchemaId;
@@ -77,6 +83,7 @@ public class KvRecoverHelper {
     private final SchemaGetter schemaGetter;
 
     private InternalRow.FieldGetter[] currentFieldGetters;
+    private @Nullable RowPartitionGetter historicalPartitionGetter;
 
     public KvRecoverHelper(
             KvTablet kvTablet,
@@ -88,7 +95,8 @@ public class KvRecoverHelper {
             KvFormat kvFormat,
             LogFormat logFormat,
             SchemaGetter schemaGetter,
-            RemoteLogFetcher remoteLogFetcher) {
+            RemoteLogFetcher remoteLogFetcher,
+            boolean historicalPartition) {
         this.kvTablet = kvTablet;
         this.logTablet = logTablet;
         this.recoverPointOffset = recoverPointOffset;
@@ -99,6 +107,7 @@ public class KvRecoverHelper {
         this.logFormat = logFormat;
         this.schemaGetter = schemaGetter;
         this.remoteLogFetcher = remoteLogFetcher;
+        this.historicalPartition = historicalPartition;
     }
 
     public void recover() throws Exception {
@@ -134,7 +143,11 @@ public class KvRecoverHelper {
             ThrowingConsumer<KeyValueAndLogOffset, Exception> resumeRecordApplier =
                     (resumeRecord) -> {
                         if (resumeRecord.value == null) {
-                            kvBatchWriter.delete(resumeRecord.key);
+                            if (historicalPartition) {
+                                kvBatchWriter.put(resumeRecord.key, HISTORICAL_TOMBSTONE);
+                            } else {
+                                kvBatchWriter.delete(resumeRecord.key);
+                            }
                         } else {
                             kvBatchWriter.put(resumeRecord.key, resumeRecord.value);
                         }
@@ -263,6 +276,13 @@ public class KvRecoverHelper {
                 if (changeType != ChangeType.UPDATE_BEFORE) {
                     InternalRow logRow = logRecord.getRow();
                     byte[] key = keyEncoder.encodeKey(logRow);
+                    if (historicalPartition) {
+                        key =
+                                HistoricalKvKeyEncoder.encode(
+                                        checkNotNull(historicalPartitionGetter)
+                                                .getPartition(logRow),
+                                        key);
+                    }
                     byte[] value = null;
                     if (changeType != ChangeType.DELETE) {
                         // the log row format may not compatible with kv row format,
@@ -331,6 +351,15 @@ public class KvRecoverHelper {
                         tableInfo.getPhysicalPrimaryKeys(),
                         tableInfo.getTableConfig(),
                         tableInfo.isDefaultBucketKey());
+        if (historicalPartition) {
+            List<String> partitionKeys = tableInfo.getPartitionKeys();
+            checkArgument(
+                    !partitionKeys.isEmpty(),
+                    "Historical KV recovery requires at least one partition key.");
+            historicalPartitionGetter = new RowPartitionGetter(currentRowType, partitionKeys);
+        } else {
+            historicalPartitionGetter = null;
+        }
         rowEncoder = RowEncoder.create(kvFormat, dataTypes);
         currentFieldGetters = new InternalRow.FieldGetter[currentRowType.getFieldCount()];
         for (int i = 0; i < currentRowType.getFieldCount(); i++) {
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateAccessor.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateAccessor.java
new file mode 100644
index 000000000..1e0cd3996
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateAccessor.java
@@ -0,0 +1,121 @@
+/*
+ * 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.fluss.server.kv;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.server.kv.historical.HistoricalKvKeyEncoder;
+import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer;
+import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.Key;
+import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.TruncateReason;
+import org.apache.fluss.server.kv.rocksdb.RocksDBKv;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/** Accesses a KV tablet's local prewrite buffer and RocksDB state. */
+@Internal
+public final class KvStateAccessor {
+
+    /** Encoded RocksDB value marking a deleted key in historical KV state. */
+    static final byte[] HISTORICAL_TOMBSTONE = new byte[0];
+
+    private final KvPreWriteBuffer preWriteBuffer;
+    private final RocksDBKv rocksDBKv;
+    private final boolean historicalPartition;
+
+    KvStateAccessor(
+            KvPreWriteBuffer preWriteBuffer, RocksDBKv rocksDBKv, boolean historicalPartition) {
+        this.preWriteBuffer = preWriteBuffer;
+        this.rocksDBKv = rocksDBKv;
+        this.historicalPartition = historicalPartition;
+    }
+
+    /**
+     * Encodes the logical primary key into the physical key used by this state.
+     *
+     * <p>Normal KV state keeps the primary key unchanged. Historical KV state also encodes the
+     * original partition context because multiple original partitions share one historical KV
+     * tablet. {@code originalPartitionName} is null for normal state and must be present for
+     * historical state.
+     */
+    public Key encodeKey(byte[] primaryKey, @Nullable String originalPartitionName) {
+        if (!historicalPartition) {
+            checkArgument(
+                    originalPartitionName == null,
+                    "A normal KV state key must not carry an original partition name");
+            return Key.of(primaryKey);
+        }
+
+        String partitionName =
+                checkNotNull(originalPartitionName, "originalPartitionName must not be null");
+        checkArgument(!partitionName.isEmpty(), "originalPartitionName must not be empty");
+        return Key.of(HistoricalKvKeyEncoder.encode(partitionName, primaryKey));
+    }
+
+    /** Looks up an encoded key from the local prewrite buffer and RocksDB state. */
+    public KvStateLookupResult lookup(Key key) throws IOException {
+        KvPreWriteBuffer.Value bufferedValue = preWriteBuffer.get(key);
+        if (bufferedValue != null) {
+            byte[] value = bufferedValue.get();
+            return value == null
+                    ? KvStateLookupResult.deleted()
+                    : KvStateLookupResult.present(value);
+        }
+
+        byte[] value = rocksDBKv.get(key.get());
+        if (value == null) {
+            return KvStateLookupResult.notFound();
+        }
+        // Historical KV tablets persist deletes as empty values so that a local miss does not
+        // expose a stale value from lake storage after the buffered delete has been flushed.
+        return value.length == 0
+                ? KvStateLookupResult.deleted()
+                : KvStateLookupResult.present(value);
+    }
+
+    /** Adds an insert mutation to the prewrite buffer. */
+    public void insert(Key key, byte[] value, long logOffset) {
+        if (historicalPartition) {
+            checkArgument(value.length > 0, "Historical KV insert value must not be empty");
+        }
+        preWriteBuffer.insert(key, value, logOffset);
+    }
+
+    /** Adds an update mutation to the prewrite buffer. */
+    public void update(Key key, @Nullable byte[] value, long logOffset) {
+        if (historicalPartition) {
+            checkNotNull(value, "Historical KV update value must not be null");
+            checkArgument(value.length > 0, "Historical KV update value must not be empty");
+        }
+        preWriteBuffer.update(key, value, logOffset);
+    }
+
+    /** Adds a delete mutation to the prewrite buffer. */
+    public void delete(Key key, long logOffset) {
+        preWriteBuffer.delete(key, logOffset);
+    }
+
+    /** Truncates pending mutations to the given log offset. */
+    public void truncateTo(long logOffset, TruncateReason reason) {
+        preWriteBuffer.truncateTo(logOffset, reason);
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateLookupResult.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateLookupResult.java
new file mode 100644
index 000000000..fd21586a6
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvStateLookupResult.java
@@ -0,0 +1,131 @@
+/*
+ * 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.fluss.server.kv;
+
+import org.apache.fluss.annotation.Internal;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Result of looking up a key from a KV state source.
+ *
+ * <p>{@link Status#NOT_FOUND} means that the queried source has no state for the key, so the caller
+ * may consult another source. {@link Status#DELETED} confirms that the key has been deleted and
+ * must stop fallback from exposing an older value.
+ */
+@Internal
+public final class KvStateLookupResult {
+
+    /** Status of a KV state lookup. */
+    public enum Status {
+        /** The key was not found in the queried state source. */
+        NOT_FOUND,
+
+        /** The queried state source contains a non-empty encoded value. */
+        PRESENT,
+
+        /** The key is known to have been deleted. */
+        DELETED
+    }
+
+    private static final KvStateLookupResult NOT_FOUND =
+            new KvStateLookupResult(Status.NOT_FOUND, null);
+    private static final KvStateLookupResult DELETED =
+            new KvStateLookupResult(Status.DELETED, null);
+
+    private final Status status;
+    private final @Nullable byte[] value;
+
+    private KvStateLookupResult(Status status, @Nullable byte[] value) {
+        this.status = status;
+        this.value = value;
+    }
+
+    /** Returns a result indicating that the key was not found in the queried state source. */
+    public static KvStateLookupResult notFound() {
+        return NOT_FOUND;
+    }
+
+    /** Returns a result containing a non-empty encoded value. */
+    public static KvStateLookupResult present(byte[] value) {
+        checkNotNull(value, "value must not be null");
+        checkArgument(value.length > 0, "value must not be empty");
+        return new KvStateLookupResult(Status.PRESENT, value);
+    }
+
+    /** Returns a result indicating that the key is known to have been deleted. */
+    public static KvStateLookupResult deleted() {
+        return DELETED;
+    }
+
+    /** Returns the lookup status. */
+    public Status status() {
+        return status;
+    }
+
+    /** Returns whether this result contains an encoded value. */
+    public boolean isPresent() {
+        return status == Status.PRESENT;
+    }
+
+    /** Returns whether the key is known to have been deleted. */
+    public boolean isDeleted() {
+        return status == Status.DELETED;
+    }
+
+    /**
+     * Returns the encoded value, or null when the key is absent or deleted.
+     *
+     * <p>The returned byte array is owned by the underlying state and must not be modified.
+     */
+    public @Nullable byte[] value() {
+        return value;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        KvStateLookupResult that = (KvStateLookupResult) o;
+        return status == that.status && Arrays.equals(value, that.value);
+    }
+
+    @Override
+    public int hashCode() {
+        return 31 * status.hashCode() + Arrays.hashCode(value);
+    }
+
+    @Override
+    public String toString() {
+        return "KvStateLookupResult{"
+                + "status="
+                + status
+                + ", value="
+                + Arrays.toString(value)
+                + '}';
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvTablet.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvTablet.java
index b43bd93e0..8fae46559 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvTablet.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvTablet.java
@@ -21,44 +21,29 @@ import org.apache.fluss.annotation.VisibleForTesting;
 import org.apache.fluss.compression.ArrowCompressionInfo;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
-import org.apache.fluss.exception.DeletionDisabledException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.KvStorageException;
-import org.apache.fluss.exception.SchemaNotExistException;
 import org.apache.fluss.exception.StorageBackpressureException;
 import org.apache.fluss.memory.MemorySegmentPool;
 import org.apache.fluss.metadata.ChangelogImage;
-import org.apache.fluss.metadata.DeleteBehavior;
 import org.apache.fluss.metadata.KvFormat;
-import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.PhysicalTablePath;
-import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.SchemaGetter;
-import org.apache.fluss.metadata.SchemaInfo;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TablePath;
-import org.apache.fluss.record.BinaryValue;
 import org.apache.fluss.record.ChangeType;
-import org.apache.fluss.record.KvRecord;
 import org.apache.fluss.record.KvRecordBatch;
-import org.apache.fluss.record.KvRecordReadContext;
-import org.apache.fluss.row.BinaryRow;
-import org.apache.fluss.row.PaddingRow;
 import org.apache.fluss.row.arrow.ArrowWriterPool;
-import org.apache.fluss.row.arrow.ArrowWriterProvider;
-import org.apache.fluss.row.encode.ValueDecoder;
 import org.apache.fluss.rpc.protocol.MergeMode;
 import org.apache.fluss.server.kv.autoinc.AutoIncIDRange;
 import org.apache.fluss.server.kv.autoinc.AutoIncrementManager;
-import org.apache.fluss.server.kv.autoinc.AutoIncrementUpdater;
+import org.apache.fluss.server.kv.historical.HistoricalValueLookup;
 import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer;
 import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.PreparedFlush;
-import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.TruncateReason;
 import org.apache.fluss.server.kv.rocksdb.RocksDBKv;
 import org.apache.fluss.server.kv.rocksdb.RocksDBKvBuilder;
 import org.apache.fluss.server.kv.rocksdb.RocksDBResourceContainer;
 import org.apache.fluss.server.kv.rocksdb.RocksDBStatistics;
-import org.apache.fluss.server.kv.rowmerger.DefaultRowMerger;
 import org.apache.fluss.server.kv.rowmerger.RowMerger;
 import org.apache.fluss.server.kv.scan.OpenScanResult;
 import org.apache.fluss.server.kv.scan.ScannerContext;
@@ -66,18 +51,12 @@ import org.apache.fluss.server.kv.snapshot.KvFileHandleAndLocalPath;
 import org.apache.fluss.server.kv.snapshot.KvSnapshotDataUploader;
 import org.apache.fluss.server.kv.snapshot.RocksIncrementalSnapshot;
 import org.apache.fluss.server.kv.snapshot.TabletState;
-import org.apache.fluss.server.kv.wal.ArrowWalBuilder;
-import org.apache.fluss.server.kv.wal.CompactedWalBuilder;
-import org.apache.fluss.server.kv.wal.IndexWalBuilder;
-import org.apache.fluss.server.kv.wal.WalBuilder;
 import org.apache.fluss.server.log.LogAppendInfo;
 import org.apache.fluss.server.log.LogTablet;
 import org.apache.fluss.server.metrics.group.TabletServerMetricGroup;
 import org.apache.fluss.server.utils.FatalErrorHandler;
 import org.apache.fluss.server.utils.ResourceGuard;
 import org.apache.fluss.shaded.arrow.org.apache.arrow.memory.BufferAllocator;
-import org.apache.fluss.types.RowType;
-import org.apache.fluss.utils.BytesUtils;
 import org.apache.fluss.utils.FileUtils;
 import org.apache.fluss.utils.IOUtils;
 
@@ -102,6 +81,9 @@ import java.util.concurrent.Executor;
 import java.util.concurrent.locks.ReadWriteLock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
+import static org.apache.fluss.server.kv.KvStateAccessor.HISTORICAL_TOMBSTONE;
+import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
 import static org.apache.fluss.utils.Preconditions.checkState;
 import static org.apache.fluss.utils.concurrent.LockUtils.inReadLock;
 import static org.apache.fluss.utils.concurrent.LockUtils.inWriteLock;
@@ -135,36 +117,24 @@ public final class KvTablet {
 
     private final PhysicalTablePath physicalPath;
     private final TableBucket tableBucket;
+    private final boolean historicalPartition;
 
     private final LogTablet logTablet;
-    private final ArrowWriterProvider arrowWriterProvider;
-    private final MemorySegmentPool memorySegmentPool;
 
     private final File kvTabletDir;
     private final long writeBatchSize;
     private final RocksDBKv rocksDBKv;
     private final KvPreWriteBuffer kvPreWriteBuffer;
+    private final KvStateAccessor kvStateAccessor;
+    private final KvWriteProcessor kvWriteProcessor;
     private final TabletServerMetricGroup serverMetricGroup;
     private final KvFlushScheduler kvFlushScheduler;
     private final boolean closeFlushScheduler;
 
     // A lock that guards all modifications to the kv.
     private final ReadWriteLock kvLock = new ReentrantReadWriteLock();
-    private final LogFormat logFormat;
-    private final KvFormat kvFormat;
-    // defines how to merge rows on the same primary key
-    private final RowMerger rowMerger;
-    // Pre-created DefaultRowMerger for OVERWRITE mode (undo recovery scenarios)
-    // This avoids creating a new instance on every putAsLeader call
-    private final RowMerger overwriteRowMerger;
-    private final ArrowCompressionInfo arrowCompressionInfo;
     private final AutoIncrementManager autoIncrementManager;
 
-    private final SchemaGetter schemaGetter;
-
-    // the changelog image mode for this tablet
-    private final ChangelogImage changelogImage;
-
     // RocksDB statistics accessor for this tablet
     @Nullable private final RocksDBStatistics rocksDBStatistics;
 
@@ -201,7 +171,6 @@ public final class KvTablet {
             TabletServerMetricGroup serverMetricGroup,
             RocksDBKv rocksDBKv,
             long writeBatchSize,
-            LogFormat logFormat,
             BufferAllocator arrowBufferAllocator,
             MemorySegmentPool memorySegmentPool,
             KvFormat kvFormat,
@@ -216,6 +185,8 @@ public final class KvTablet {
             AutoIncrementManager autoIncrementManager) {
         this.physicalPath = physicalPath;
         this.tableBucket = tableBucket;
+        this.historicalPartition =
+                HISTORICAL_PARTITION_VALUE.equals(physicalPath.getPartitionName());
         this.logTablet = logTablet;
         this.kvTabletDir = kvTabletDir;
         this.rocksDBKv = rocksDBKv;
@@ -224,22 +195,30 @@ public final class KvTablet {
         this.kvFlushScheduler = kvFlushScheduler;
         this.closeFlushScheduler = closeFlushScheduler;
         this.kvPreWriteBuffer = new KvPreWriteBuffer(serverMetricGroup);
-        this.logFormat = logFormat;
-        this.arrowWriterProvider = new ArrowWriterPool(arrowBufferAllocator);
-        this.memorySegmentPool = memorySegmentPool;
-        this.kvFormat = kvFormat;
-        this.rowMerger = rowMerger;
-        // Pre-create DefaultRowMerger for OVERWRITE mode to avoid creating new instances
-        // on every putAsLeader call. Used for undo recovery scenarios.
-        this.overwriteRowMerger = new DefaultRowMerger(kvFormat, DeleteBehavior.ALLOW);
-        this.arrowCompressionInfo = arrowCompressionInfo;
-        this.schemaGetter = schemaGetter;
-        this.changelogImage = changelogImage;
+        this.kvStateAccessor =
+                new KvStateAccessor(kvPreWriteBuffer, rocksDBKv, historicalPartition);
+        this.kvWriteProcessor =
+                new KvWriteProcessor(
+                        tableBucket,
+                        logTablet,
+                        new ArrowWriterPool(arrowBufferAllocator),
+                        memorySegmentPool,
+                        kvFormat,
+                        rowMerger,
+                        arrowCompressionInfo,
+                        schemaGetter,
+                        changelogImage,
+                        autoIncrementManager);
         this.rocksDBStatistics = rocksDBStatistics;
         this.autoIncrementManager = autoIncrementManager;
         this.flushCompleteListener = flushCompleteListener;
-        // disable row count for WAL image mode.
-        this.rowCount = changelogImage == ChangelogImage.WAL ? ROW_COUNT_DISABLED : 0L;
+        // TODO: Support row count for historical partitions.
+        // Historical state only contains the WAL tail that has not been tiered to lake, so it
+        // cannot maintain a table-level row count.
+        this.rowCount =
+                historicalPartition || changelogImage == ChangelogImage.WAL
+                        ? ROW_COUNT_DISABLED
+                        : 0L;
     }
 
     /**
@@ -371,7 +350,6 @@ public final class KvTablet {
                 serverMetricGroup,
                 kv,
                 serverConf.get(ConfigOptions.KV_WRITE_BATCH_SIZE).getBytes(),
-                logTablet.getLogFormat(),
                 arrowBufferAllocator,
                 memorySegmentPool,
                 kvFormat,
@@ -428,6 +406,11 @@ public final class KvTablet {
         return kvTabletDir;
     }
 
+    /** Returns the total size in bytes of the live RocksDB SST files. */
+    public long liveSstFilesSize() {
+        return rocksDBKv.liveSstFilesSize();
+    }
+
     /**
      * Get RocksDB statistics accessor for this tablet.
      *
@@ -514,6 +497,72 @@ public final class KvTablet {
     public LogAppendInfo putAsLeader(
             KvRecordBatch kvRecords, @Nullable int[] targetColumns, MergeMode mergeMode)
             throws Exception {
+        checkState(
+                !historicalPartition,
+                "putAsLeader is not supported for historical KV tablet %s",
+                tableBucket);
+        return putAsLeader(kvRecords, targetColumns, mergeMode, null, null);
+    }
+
+    /**
+     * Puts records for one original partition into this historical KV tablet.
+     *
+     * <p>The original partition name namespaces the physical primary keys because one historical
+     * bucket can contain records from multiple original partitions. The supplied fallback may only
+     * read lake results already resolved for this request; it must not perform lake I/O while the
+     * tablet lock is held.
+     */
+    public LogAppendInfo putHistoricalAsLeader(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            String originalPartitionName,
+            HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        checkState(historicalPartition, "%s is not a historical KV tablet", tableBucket);
+        return putAsLeader(
+                kvRecords,
+                targetColumns,
+                mergeMode,
+                checkNotNull(originalPartitionName, "originalPartitionName must not be null"),
+                checkNotNull(memoizedLakeLookup, "memoizedLakeLookup must not be null"));
+    }
+
+    /**
+     * Finds keys whose historical write requires an old value that is absent from local state.
+     *
+     * <p>This method only reads KV entries and uses the tablet read lock. Lake I/O must be
+     * performed by the caller after this method releases the tablet lock.
+     */
+    public List<byte[]> findKeysRequiringLakeLookup(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            String originalPartitionName)
+            throws Exception {
+        checkState(historicalPartition, "%s is not a historical KV tablet", tableBucket);
+        return inReadLock(
+                kvLock,
+                () -> {
+                    rocksDBKv.checkIfRocksDBClosed();
+                    return kvWriteProcessor.findKeysRequiringLakeLookup(
+                            kvRecords,
+                            targetColumns,
+                            mergeMode,
+                            kvStateAccessor,
+                            checkNotNull(
+                                    originalPartitionName,
+                                    "originalPartitionName must not be null"));
+                });
+    }
+
+    private LogAppendInfo putAsLeader(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            @Nullable String originalPartitionName,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
         return inWriteLock(
                 kvLock,
                 () -> {
@@ -535,297 +584,16 @@ public final class KvTablet {
                                         tableBucket));
                     }
 
-                    SchemaInfo schemaInfo = schemaGetter.getLatestSchemaInfo();
-                    Schema latestSchema = schemaInfo.getSchema();
-                    short latestSchemaId = (short) schemaInfo.getSchemaId();
-                    validateSchemaId(kvRecords.schemaId(), latestSchemaId);
-
-                    AutoIncrementUpdater currentAutoIncrementUpdater =
-                            autoIncrementManager.getUpdaterForSchema(kvFormat, latestSchemaId);
-
-                    // Validate targetColumns doesn't contain auto-increment column
-                    currentAutoIncrementUpdater.validateTargetColumns(targetColumns);
-
-                    // Determine the row merger based on mergeMode:
-                    // - DEFAULT: Use the configured merge engine (rowMerger)
-                    // - OVERWRITE: Bypass merge engine, use pre-created overwriteRowMerger
-                    //   to directly replace values (for undo recovery scenarios)
-                    // We only support ADD COLUMN, so targetColumns is fine to be used directly.
-                    RowMerger currentMerger =
-                            (mergeMode == MergeMode.OVERWRITE)
-                                    ? overwriteRowMerger.configureTargetColumns(
-                                            targetColumns, latestSchemaId, latestSchema)
-                                    : rowMerger.configureTargetColumns(
-                                            targetColumns, latestSchemaId, latestSchema);
-
-                    RowType latestRowType = latestSchema.getRowType();
-                    WalBuilder walBuilder = createWalBuilder(latestSchemaId, latestRowType);
-                    walBuilder.setWriterState(kvRecords.writerId(), kvRecords.batchSequence());
-                    // we only support ADD COLUMN LAST, so the BinaryRow after RowMerger is
-                    // only has fewer ending columns than latest schema, so we pad nulls to
-                    // the end of the BinaryRow to get the latest schema row.
-                    PaddingRow latestSchemaRow = new PaddingRow(latestRowType.getFieldCount());
-                    // get offset to track the offset corresponded to the kv record
-                    long logEndOffsetOfPrevBatch = logTablet.localLogEndOffset();
-
-                    try {
-                        processKvRecords(
-                                kvRecords,
-                                kvRecords.schemaId(),
-                                currentMerger,
-                                currentAutoIncrementUpdater,
-                                walBuilder,
-                                latestSchemaRow,
-                                logEndOffsetOfPrevBatch);
-
-                        // There will be a situation that these batches of kvRecordBatch have not
-                        // generated any CDC logs, for example, when client attempts to delete
-                        // some non-existent keys or MergeEngineType set to FIRST_ROW. In this case,
-                        // we cannot simply return, as doing so would cause a
-                        // OutOfOrderSequenceException problem. Therefore, here we will build an
-                        // empty batch with lastLogOffset to 0L as the baseLogOffset is 0L. As doing
-                        // that, the logOffsetDelta in logRecordBatch will be set to 0L. So, we will
-                        // put a batch into file with recordCount 0 and offset plus 1L, it will
-                        // update the batchSequence corresponding to the writerId and also increment
-                        // the CDC log offset by 1.
-                        LogAppendInfo logAppendInfo = logTablet.appendAsLeader(walBuilder.build());
-
-                        // if the batch is duplicated, we should truncate the kvPreWriteBuffer
-                        // already written.
-                        if (logAppendInfo.duplicated()) {
-                            kvPreWriteBuffer.truncateTo(
-                                    logEndOffsetOfPrevBatch, TruncateReason.DUPLICATED);
-                        }
-                        return logAppendInfo;
-                    } catch (Throwable t) {
-                        // While encounter error here, the CDC logs may fail writing to disk,
-                        // and the client probably will resend the batch. If we do not remove the
-                        // values generated by the erroneous batch from the kvPreWriteBuffer, the
-                        // retry-send batch will produce incorrect CDC logs.
-                        // TODO for some errors, the cdc logs may already be written to disk, for
-                        //  those errors, we should not truncate the kvPreWriteBuffer.
-                        kvPreWriteBuffer.truncateTo(logEndOffsetOfPrevBatch, TruncateReason.ERROR);
-                        throw t;
-                    } finally {
-                        // deallocate the memory and arrow writer used by the wal builder
-                        walBuilder.deallocate();
-                    }
+                    return kvWriteProcessor.putAsLeader(
+                            kvRecords,
+                            targetColumns,
+                            mergeMode,
+                            kvStateAccessor,
+                            originalPartitionName,
+                            memoizedLakeLookup);
                 });
     }
 
-    private void validateSchemaId(short schemaIdOfNewData, short latestSchemaId) {
-        if (schemaIdOfNewData > latestSchemaId || schemaIdOfNewData < 0) {
-            throw new SchemaNotExistException(
-                    "Invalid schema id: "
-                            + schemaIdOfNewData
-                            + ", latest schema id: "
-                            + latestSchemaId);
-        }
-    }
-
-    private void processKvRecords(
-            KvRecordBatch kvRecords,
-            short schemaIdOfNewData,
-            RowMerger currentMerger,
-            AutoIncrementUpdater autoIncrementUpdater,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long startLogOffset)
-            throws Exception {
-        long logOffset = startLogOffset;
-
-        // TODO: reuse the read context and decoder
-        KvRecordBatch.ReadContext readContext =
-                KvRecordReadContext.createReadContext(kvFormat, schemaGetter);
-        ValueDecoder valueDecoder = new ValueDecoder(schemaGetter, kvFormat);
-
-        for (KvRecord kvRecord : kvRecords.records(readContext)) {
-            byte[] keyBytes = BytesUtils.toArray(kvRecord.getKey());
-            KvPreWriteBuffer.Key key = KvPreWriteBuffer.Key.of(keyBytes);
-            BinaryRow row = kvRecord.getRow();
-            BinaryValue currentValue = row == null ? null : new BinaryValue(schemaIdOfNewData, row);
-
-            if (currentValue == null) {
-                logOffset =
-                        processDeletion(
-                                key,
-                                currentMerger,
-                                valueDecoder,
-                                walBuilder,
-                                latestSchemaRow,
-                                logOffset);
-            } else {
-                logOffset =
-                        processUpsert(
-                                key,
-                                currentValue,
-                                currentMerger,
-                                autoIncrementUpdater,
-                                valueDecoder,
-                                walBuilder,
-                                latestSchemaRow,
-                                logOffset);
-            }
-        }
-    }
-
-    private long processDeletion(
-            KvPreWriteBuffer.Key key,
-            RowMerger currentMerger,
-            ValueDecoder valueDecoder,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long logOffset)
-            throws Exception {
-        DeleteBehavior deleteBehavior = currentMerger.deleteBehavior();
-        if (deleteBehavior == DeleteBehavior.IGNORE) {
-            // skip delete rows if the merger doesn't support yet
-            return logOffset;
-        } else if (deleteBehavior == DeleteBehavior.DISABLE) {
-            throw new DeletionDisabledException(
-                    "Delete operations are disabled for this table. "
-                            + "The table.delete.behavior is set to 'disable'.");
-        }
-
-        byte[] oldValueBytes = getFromBufferOrKv(key);
-        if (oldValueBytes == null) {
-            LOG.debug(
-                    "The specific key can't be found in kv tablet although the kv record is for deletion, "
-                            + "ignore it directly as it doesn't exist in the kv tablet yet.");
-            return logOffset;
-        }
-
-        BinaryValue oldValue = valueDecoder.decodeValue(oldValueBytes);
-        BinaryValue newValue = currentMerger.delete(oldValue);
-
-        // if newValue is null, it means the row should be deleted
-        if (newValue == null) {
-            return applyDelete(key, oldValue, walBuilder, latestSchemaRow, logOffset);
-        } else {
-            return applyUpdate(key, oldValue, newValue, walBuilder, latestSchemaRow, logOffset);
-        }
-    }
-
-    private long processUpsert(
-            KvPreWriteBuffer.Key key,
-            BinaryValue currentValue,
-            RowMerger currentMerger,
-            AutoIncrementUpdater autoIncrementUpdater,
-            ValueDecoder valueDecoder,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long logOffset)
-            throws Exception {
-        // Optimization: IN WAL mode,when using DefaultRowMerger (full update, not partial update)
-        // and there is no auto-increment column, we can skip fetching old value for better
-        // performance since the result always reflects the new value. In this case, both INSERT and
-        // UPDATE will produce UPDATE_AFTER.
-        if (changelogImage == ChangelogImage.WAL
-                && !autoIncrementUpdater.hasAutoIncrement()
-                && currentMerger instanceof DefaultRowMerger) {
-            return applyUpdate(key, null, currentValue, walBuilder, latestSchemaRow, logOffset);
-        }
-
-        byte[] oldValueBytes = getFromBufferOrKv(key);
-        if (oldValueBytes == null) {
-            BinaryValue valueToInsert = currentMerger.merge(null, currentValue);
-            return applyInsert(
-                    key,
-                    valueToInsert,
-                    walBuilder,
-                    latestSchemaRow,
-                    logOffset,
-                    autoIncrementUpdater);
-        }
-
-        BinaryValue oldValue = valueDecoder.decodeValue(oldValueBytes);
-        BinaryValue newValue = currentMerger.merge(oldValue, currentValue);
-
-        if (newValue == oldValue) {
-            // no actual change, skip this record
-            return logOffset;
-        }
-
-        return applyUpdate(key, oldValue, newValue, walBuilder, latestSchemaRow, logOffset);
-    }
-
-    private long applyDelete(
-            KvPreWriteBuffer.Key key,
-            BinaryValue oldValue,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long logOffset)
-            throws Exception {
-        walBuilder.append(ChangeType.DELETE, latestSchemaRow.replaceRow(oldValue.row));
-        kvPreWriteBuffer.delete(key, logOffset);
-        return logOffset + 1;
-    }
-
-    private long applyInsert(
-            KvPreWriteBuffer.Key key,
-            BinaryValue currentValue,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long logOffset,
-            AutoIncrementUpdater autoIncrementUpdater)
-            throws Exception {
-        BinaryValue newValue = autoIncrementUpdater.updateAutoIncrementColumns(currentValue);
-        walBuilder.append(ChangeType.INSERT, latestSchemaRow.replaceRow(newValue.row));
-        kvPreWriteBuffer.insert(key, newValue.encodeValue(), logOffset);
-        return logOffset + 1;
-    }
-
-    private long applyUpdate(
-            KvPreWriteBuffer.Key key,
-            BinaryValue oldValue,
-            BinaryValue newValue,
-            WalBuilder walBuilder,
-            PaddingRow latestSchemaRow,
-            long logOffset)
-            throws Exception {
-        if (changelogImage == ChangelogImage.WAL) {
-            walBuilder.append(ChangeType.UPDATE_AFTER, latestSchemaRow.replaceRow(newValue.row));
-            kvPreWriteBuffer.update(key, newValue.encodeValue(), logOffset);
-            return logOffset + 1;
-        } else {
-            walBuilder.append(ChangeType.UPDATE_BEFORE, latestSchemaRow.replaceRow(oldValue.row));
-            walBuilder.append(ChangeType.UPDATE_AFTER, latestSchemaRow.replaceRow(newValue.row));
-            kvPreWriteBuffer.update(key, newValue.encodeValue(), logOffset + 1);
-            return logOffset + 2;
-        }
-    }
-
-    private WalBuilder createWalBuilder(int schemaId, RowType rowType) throws Exception {
-        switch (logFormat) {
-            case INDEXED:
-                if (kvFormat == KvFormat.COMPACTED) {
-                    // convert from compacted row to indexed row is time cost, and gain
-                    // less benefits, currently we won't support compacted as kv format and
-                    // indexed as cdc log format.
-                    // so in here we throw exception directly
-                    throw new IllegalArgumentException(
-                            "Primary Key Table with COMPACTED kv format doesn't support INDEXED cdc log format.");
-                }
-                return new IndexWalBuilder(schemaId, memorySegmentPool);
-            case COMPACTED:
-                return new CompactedWalBuilder(schemaId, rowType, memorySegmentPool);
-            case ARROW:
-                return new ArrowWalBuilder(
-                        schemaId,
-                        arrowWriterProvider.getOrCreateWriter(
-                                tableBucket.getTableId(),
-                                schemaId,
-                                // we don't limit size of the arrow batch, because all the
-                                // changelogs should be in a single batch
-                                Integer.MAX_VALUE,
-                                rowType,
-                                arrowCompressionInfo),
-                        memorySegmentPool);
-            default:
-                throw new IllegalArgumentException("Unsupported log format: " + logFormat);
-        }
-    }
-
     @VisibleForTesting
     long localLogEndOffset() {
         return logTablet.localLogEndOffset();
@@ -988,7 +756,13 @@ public final class KvTablet {
                 for (KvPreWriteBuffer.KvEntry entry : segment.entries()) {
                     KvPreWriteBuffer.Value value = entry.getValue();
                     if (value.get() == null) {
-                        kvBatchWriter.delete(entry.getKey().get());
+                        if (historicalPartition) {
+                            // A physical delete would turn a local miss into a lake lookup and
+                            // could expose the stale value that this mutation deleted.
+                            kvBatchWriter.put(entry.getKey().get(), HISTORICAL_TOMBSTONE);
+                        } else {
+                            kvBatchWriter.delete(entry.getKey().get());
+                        }
                     } else {
                         kvBatchWriter.put(entry.getKey().get(), value.get());
                     }
@@ -1126,11 +900,11 @@ public final class KvTablet {
             ChangeType changeType, byte[] key, @Nullable byte[] value, long logOffset) {
         KvPreWriteBuffer.Key wrapKey = KvPreWriteBuffer.Key.of(key);
         if (changeType == ChangeType.DELETE && value == null) {
-            kvPreWriteBuffer.delete(wrapKey, logOffset);
+            kvStateAccessor.delete(wrapKey, logOffset);
         } else if (changeType == ChangeType.INSERT) {
-            kvPreWriteBuffer.insert(wrapKey, value, logOffset);
+            kvStateAccessor.insert(wrapKey, value, logOffset);
         } else if (changeType == ChangeType.UPDATE_AFTER) {
-            kvPreWriteBuffer.update(wrapKey, value, logOffset);
+            kvStateAccessor.update(wrapKey, value, logOffset);
         } else {
             throw new IllegalArgumentException(
                     "Unsupported change type for putToPreWriteBuffer: " + changeType);
@@ -1148,15 +922,6 @@ public final class KvTablet {
         return runnable -> inWriteLock(kvLock, runnable::run);
     }
 
-    // get from kv pre-write buffer first, if can't find, get from rocksdb
-    private byte[] getFromBufferOrKv(KvPreWriteBuffer.Key key) throws IOException {
-        KvPreWriteBuffer.Value value = kvPreWriteBuffer.get(key);
-        if (value == null) {
-            return rocksDBKv.get(key.get());
-        }
-        return value.get();
-    }
-
     public List<byte[]> multiGet(List<byte[]> keys) throws IOException {
         return inReadLock(
                 kvLock,
@@ -1175,18 +940,45 @@ public final class KvTablet {
      * #multiGet} so that clients only observe flushed data.
      */
     public List<byte[]> multiGetFromBufferOrKv(List<byte[]> keys) throws IOException {
+        checkState(
+                !historicalPartition,
+                "multiGetFromBufferOrKv is not supported for historical KV tablet %s",
+                tableBucket);
         return inReadLock(
                 kvLock,
                 () -> {
                     rocksDBKv.checkIfRocksDBClosed();
                     List<byte[]> values = new ArrayList<>(keys.size());
                     for (byte[] key : keys) {
-                        values.add(getFromBufferOrKv(KvPreWriteBuffer.Key.of(key)));
+                        values.add(
+                                kvStateAccessor
+                                        .lookup(kvStateAccessor.encodeKey(key, null))
+                                        .value());
                     }
                     return values;
                 });
     }
 
+    /** Looks up one key from the flushed local state for an original historical partition. */
+    public KvStateLookupResult lookupHistoricalLocal(String originalPartitionName, byte[] key)
+            throws IOException {
+        checkState(historicalPartition, "%s is not a historical KV tablet", tableBucket);
+        return inReadLock(
+                kvLock,
+                () -> {
+                    rocksDBKv.checkIfRocksDBClosed();
+                    byte[] value =
+                            rocksDBKv.get(
+                                    kvStateAccessor.encodeKey(key, originalPartitionName).get());
+                    if (value == null) {
+                        return KvStateLookupResult.notFound();
+                    }
+                    return value.length == 0
+                            ? KvStateLookupResult.deleted()
+                            : KvStateLookupResult.present(value);
+                });
+    }
+
     public List<byte[]> prefixLookup(byte[] prefixKey) throws IOException {
         return inReadLock(
                 kvLock,
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/KvWriteProcessor.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvWriteProcessor.java
new file mode 100644
index 000000000..76fc94e5c
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/KvWriteProcessor.java
@@ -0,0 +1,564 @@
+/*
+ * 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.fluss.server.kv;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.compression.ArrowCompressionInfo;
+import org.apache.fluss.exception.DeletionDisabledException;
+import org.apache.fluss.exception.SchemaNotExistException;
+import org.apache.fluss.memory.MemorySegmentPool;
+import org.apache.fluss.metadata.ChangelogImage;
+import org.apache.fluss.metadata.DeleteBehavior;
+import org.apache.fluss.metadata.KvFormat;
+import org.apache.fluss.metadata.LogFormat;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.SchemaGetter;
+import org.apache.fluss.metadata.SchemaInfo;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.BinaryValue;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.KvRecord;
+import org.apache.fluss.record.KvRecordBatch;
+import org.apache.fluss.record.KvRecordReadContext;
+import org.apache.fluss.row.BinaryRow;
+import org.apache.fluss.row.PaddingRow;
+import org.apache.fluss.row.arrow.ArrowWriterProvider;
+import org.apache.fluss.row.encode.ValueDecoder;
+import org.apache.fluss.rpc.protocol.MergeMode;
+import org.apache.fluss.server.kv.autoinc.AutoIncrementManager;
+import org.apache.fluss.server.kv.autoinc.AutoIncrementUpdater;
+import org.apache.fluss.server.kv.historical.HistoricalValueLookup;
+import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer;
+import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.TruncateReason;
+import org.apache.fluss.server.kv.rowmerger.DefaultRowMerger;
+import org.apache.fluss.server.kv.rowmerger.RowMerger;
+import org.apache.fluss.server.kv.wal.ArrowWalBuilder;
+import org.apache.fluss.server.kv.wal.CompactedWalBuilder;
+import org.apache.fluss.server.kv.wal.IndexWalBuilder;
+import org.apache.fluss.server.kv.wal.WalBuilder;
+import org.apache.fluss.server.log.LogAppendInfo;
+import org.apache.fluss.server.log.LogTablet;
+import org.apache.fluss.types.RowType;
+import org.apache.fluss.utils.ByteArrayWrapper;
+import org.apache.fluss.utils.BytesUtils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.NotThreadSafe;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Processes a KV record batch into local state mutations and the corresponding WAL records.
+ *
+ * <p>For each input record, this processor reads the current value through {@link KvStateAccessor},
+ * applies the configured row-merge semantics, stages the resulting mutation in the state pre-write
+ * buffer, and appends the generated changelog to the {@link LogTablet}. Staged mutations are
+ * truncated when the append fails or is detected as a duplicate.
+ *
+ * <p>The supplied {@link KvStateAccessor} defines how keys and state are accessed. Normal writes
+ * use the original primary key and local state, while historical writes use partition-scoped keys.
+ * On a historical local miss, the processor can consult a lake result already memoized for the
+ * current request. Resolving that result from lake storage remains the caller's responsibility and
+ * must happen outside the tablet lock. The merge and WAL generation path is shared by both write
+ * kinds.
+ */
+@Internal
+@NotThreadSafe
+public final class KvWriteProcessor {
+
+    private static final Logger LOG = LoggerFactory.getLogger(KvWriteProcessor.class);
+
+    private final TableBucket tableBucket;
+    private final LogTablet logTablet;
+    private final ArrowWriterProvider arrowWriterProvider;
+    private final MemorySegmentPool memorySegmentPool;
+    private final LogFormat logFormat;
+    private final KvFormat kvFormat;
+    // defines how to merge rows on the same primary key
+    private final RowMerger rowMerger;
+    // Pre-created DefaultRowMerger for OVERWRITE mode (undo recovery scenarios)
+    // This avoids creating a new instance on every putAsLeader call
+    private final RowMerger overwriteRowMerger;
+    private final ArrowCompressionInfo arrowCompressionInfo;
+    private final SchemaGetter schemaGetter;
+    // the changelog image mode for this tablet
+    private final ChangelogImage changelogImage;
+    private final AutoIncrementManager autoIncrementManager;
+
+    /** Creates a KV write processor. */
+    public KvWriteProcessor(
+            TableBucket tableBucket,
+            LogTablet logTablet,
+            ArrowWriterProvider arrowWriterProvider,
+            MemorySegmentPool memorySegmentPool,
+            KvFormat kvFormat,
+            RowMerger rowMerger,
+            ArrowCompressionInfo arrowCompressionInfo,
+            SchemaGetter schemaGetter,
+            ChangelogImage changelogImage,
+            AutoIncrementManager autoIncrementManager) {
+        this.tableBucket = tableBucket;
+        this.logTablet = logTablet;
+        this.arrowWriterProvider = arrowWriterProvider;
+        this.memorySegmentPool = memorySegmentPool;
+        this.logFormat = logTablet.getLogFormat();
+        this.kvFormat = kvFormat;
+        this.rowMerger = rowMerger;
+        // Pre-create DefaultRowMerger for OVERWRITE mode to avoid creating new instances
+        // on every putAsLeader call. Used for undo recovery scenarios.
+        this.overwriteRowMerger = new DefaultRowMerger(kvFormat, DeleteBehavior.ALLOW);
+        this.arrowCompressionInfo = arrowCompressionInfo;
+        this.schemaGetter = schemaGetter;
+        this.changelogImage = changelogImage;
+        this.autoIncrementManager = autoIncrementManager;
+    }
+
+    /** Processes a KV batch against the supplied state and appends its WAL. */
+    public LogAppendInfo putAsLeader(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            KvStateAccessor stateAccessor,
+            @Nullable String originalPartitionName,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        WriteContext writeContext = createWriteContext(kvRecords, targetColumns, mergeMode);
+        RowType latestRowType = writeContext.latestSchema.getRowType();
+        WalBuilder walBuilder = createWalBuilder(writeContext.latestSchemaId, latestRowType);
+        walBuilder.setWriterState(kvRecords.writerId(), kvRecords.batchSequence());
+        // we only support ADD COLUMN LAST, so the BinaryRow after RowMerger is
+        // only has fewer ending columns than latest schema, so we pad nulls to
+        // the end of the BinaryRow to get the latest schema row.
+        PaddingRow latestSchemaRow = new PaddingRow(latestRowType.getFieldCount());
+        // get offset to track the offset corresponded to the kv record
+        long logEndOffsetOfPrevBatch = logTablet.localLogEndOffset();
+
+        try {
+            processKvRecords(
+                    kvRecords,
+                    kvRecords.schemaId(),
+                    writeContext.rowMerger,
+                    writeContext.autoIncrementUpdater,
+                    walBuilder,
+                    latestSchemaRow,
+                    logEndOffsetOfPrevBatch,
+                    stateAccessor,
+                    originalPartitionName,
+                    memoizedLakeLookup);
+
+            // There will be a situation that these batches of kvRecordBatch have not
+            // generated any CDC logs, for example, when client attempts to delete
+            // some non-existent keys or MergeEngineType set to FIRST_ROW. In this case,
+            // we cannot simply return, as doing so would cause a
+            // OutOfOrderSequenceException problem. Therefore, here we will build an
+            // empty batch with lastLogOffset to 0L as the baseLogOffset is 0L. As doing
+            // that, the logOffsetDelta in logRecordBatch will be set to 0L. So, we will
+            // put a batch into file with recordCount 0 and offset plus 1L, it will
+            // update the batchSequence corresponding to the writerId and also increment
+            // the CDC log offset by 1.
+            LogAppendInfo logAppendInfo = logTablet.appendAsLeader(walBuilder.build());
+
+            // if the batch is duplicated, we should truncate the state pre-write
+            // buffer already written.
+            if (logAppendInfo.duplicated()) {
+                stateAccessor.truncateTo(logEndOffsetOfPrevBatch, TruncateReason.DUPLICATED);
+            }
+            return logAppendInfo;
+        } catch (Throwable t) {
+            // While encounter error here, the CDC logs may fail writing to disk,
+            // and the client probably will resend the batch. If we do not remove the
+            // values generated by the erroneous batch from the state pre-write buffer,
+            // the retry-send batch will produce incorrect CDC logs.
+            // TODO for some errors, the cdc logs may already be written to disk, for
+            //  those errors, we should not truncate the state pre-write buffer.
+            stateAccessor.truncateTo(logEndOffsetOfPrevBatch, TruncateReason.ERROR);
+            throw t;
+        } finally {
+            // deallocate the memory and arrow writer used by the wal builder
+            walBuilder.deallocate();
+        }
+    }
+
+    /**
+     * Finds the original primary keys whose previous values must be loaded from lake storage before
+     * applying a historical write batch.
+     *
+     * <p>A key is returned only when its previous value is required and its partition-scoped key is
+     * absent from local state. Records that can establish their result without a previous value are
+     * skipped, and each key is returned at most once.
+     */
+    List<byte[]> findKeysRequiringLakeLookup(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            KvStateAccessor stateAccessor,
+            String originalPartitionName)
+            throws Exception {
+        WriteContext writeContext = createWriteContext(kvRecords, targetColumns, mergeMode);
+
+        List<byte[]> keysRequiringLakeLookup = new ArrayList<>();
+        // Track keys whose lake-lookup requirement has already been evaluated.
+        Set<ByteArrayWrapper> keysEvaluatedForLakeLookup = new HashSet<>();
+        KvRecordBatch.ReadContext readContext =
+                KvRecordReadContext.createReadContext(kvFormat, schemaGetter);
+        for (KvRecord kvRecord : kvRecords.records(readContext)) {
+            byte[] primaryKey = BytesUtils.toArray(kvRecord.getKey());
+            ByteArrayWrapper wrappedKey = new ByteArrayWrapper(primaryKey);
+            if (kvRecord.getRow() == null) {
+                if (shouldIgnoreDeletion(writeContext.rowMerger)) {
+                    continue;
+                }
+            } else if (canSkipOldValueLookup(
+                    writeContext.rowMerger, writeContext.autoIncrementUpdater)) {
+                // A full-row WAL upsert establishes the state without reading its previous value.
+                keysEvaluatedForLakeLookup.add(wrappedKey);
+                continue;
+            }
+
+            // Probe local state only for the first record that needs a previous value. A true local
+            // miss schedules one lake lookup shared by all records for this key in the batch.
+            if (keysEvaluatedForLakeLookup.add(wrappedKey)
+                    && stateAccessor
+                                    .lookup(
+                                            stateAccessor.encodeKey(
+                                                    primaryKey, originalPartitionName))
+                                    .status()
+                            == KvStateLookupResult.Status.NOT_FOUND) {
+                keysRequiringLakeLookup.add(primaryKey);
+            }
+        }
+        return keysRequiringLakeLookup;
+    }
+
+    private WriteContext createWriteContext(
+            KvRecordBatch kvRecords, @Nullable int[] targetColumns, MergeMode mergeMode) {
+        SchemaInfo schemaInfo = schemaGetter.getLatestSchemaInfo();
+        Schema latestSchema = schemaInfo.getSchema();
+        short latestSchemaId = (short) schemaInfo.getSchemaId();
+        validateSchemaId(kvRecords.schemaId(), latestSchemaId);
+
+        AutoIncrementUpdater autoIncrementUpdater =
+                autoIncrementManager.getUpdaterForSchema(kvFormat, latestSchemaId);
+        autoIncrementUpdater.validateTargetColumns(targetColumns);
+
+        // OVERWRITE bypasses the configured merge engine for undo recovery.
+        RowMerger configuredRowMerger =
+                (mergeMode == MergeMode.OVERWRITE)
+                        ? overwriteRowMerger.configureTargetColumns(
+                                targetColumns, latestSchemaId, latestSchema)
+                        : rowMerger.configureTargetColumns(
+                                targetColumns, latestSchemaId, latestSchema);
+        return new WriteContext(
+                latestSchema, latestSchemaId, autoIncrementUpdater, configuredRowMerger);
+    }
+
+    private void validateSchemaId(short schemaIdOfNewData, short latestSchemaId) {
+        if (schemaIdOfNewData > latestSchemaId || schemaIdOfNewData < 0) {
+            throw new SchemaNotExistException(
+                    "Invalid schema id: "
+                            + schemaIdOfNewData
+                            + ", latest schema id: "
+                            + latestSchemaId);
+        }
+    }
+
+    private void processKvRecords(
+            KvRecordBatch kvRecords,
+            short schemaIdOfNewData,
+            RowMerger currentMerger,
+            AutoIncrementUpdater autoIncrementUpdater,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long startLogOffset,
+            KvStateAccessor stateAccessor,
+            @Nullable String originalPartitionName,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        long logOffset = startLogOffset;
+
+        // TODO: reuse the read context and decoder
+        KvRecordBatch.ReadContext readContext =
+                KvRecordReadContext.createReadContext(kvFormat, schemaGetter);
+        ValueDecoder valueDecoder = new ValueDecoder(schemaGetter, kvFormat);
+
+        for (KvRecord kvRecord : kvRecords.records(readContext)) {
+            byte[] keyBytes = BytesUtils.toArray(kvRecord.getKey());
+            KvPreWriteBuffer.Key key = stateAccessor.encodeKey(keyBytes, originalPartitionName);
+            BinaryRow row = kvRecord.getRow();
+            BinaryValue currentValue = row == null ? null : new BinaryValue(schemaIdOfNewData, row);
+
+            if (currentValue == null) {
+                logOffset =
+                        processDeletion(
+                                key,
+                                currentMerger,
+                                valueDecoder,
+                                walBuilder,
+                                latestSchemaRow,
+                                logOffset,
+                                stateAccessor,
+                                keyBytes,
+                                memoizedLakeLookup);
+            } else {
+                logOffset =
+                        processUpsert(
+                                key,
+                                currentValue,
+                                currentMerger,
+                                autoIncrementUpdater,
+                                valueDecoder,
+                                walBuilder,
+                                latestSchemaRow,
+                                logOffset,
+                                stateAccessor,
+                                keyBytes,
+                                memoizedLakeLookup);
+            }
+        }
+    }
+
+    private long processDeletion(
+            KvPreWriteBuffer.Key key,
+            RowMerger currentMerger,
+            ValueDecoder valueDecoder,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long logOffset,
+            KvStateAccessor stateAccessor,
+            byte[] primaryKey,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        if (shouldIgnoreDeletion(currentMerger)) {
+            return logOffset;
+        }
+
+        byte[] oldValueBytes = getFromState(key, primaryKey, stateAccessor, memoizedLakeLookup);
+        if (oldValueBytes == null) {
+            LOG.debug(
+                    "The specific key can't be found in kv tablet although the kv record is for deletion, "
+                            + "ignore it directly as it doesn't exist in the kv tablet yet.");
+            return logOffset;
+        }
+
+        BinaryValue oldValue = valueDecoder.decodeValue(oldValueBytes);
+        BinaryValue newValue = currentMerger.delete(oldValue);
+
+        // if newValue is null, it means the row should be deleted
+        if (newValue == null) {
+            return applyDelete(
+                    key, oldValue, walBuilder, latestSchemaRow, logOffset, stateAccessor);
+        } else {
+            return applyUpdate(
+                    key, oldValue, newValue, walBuilder, latestSchemaRow, logOffset, stateAccessor);
+        }
+    }
+
+    private long processUpsert(
+            KvPreWriteBuffer.Key key,
+            BinaryValue currentValue,
+            RowMerger currentMerger,
+            AutoIncrementUpdater autoIncrementUpdater,
+            ValueDecoder valueDecoder,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long logOffset,
+            KvStateAccessor stateAccessor,
+            byte[] primaryKey,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        if (canSkipOldValueLookup(currentMerger, autoIncrementUpdater)) {
+            return applyUpdate(
+                    key, null, currentValue, walBuilder, latestSchemaRow, logOffset, stateAccessor);
+        }
+
+        byte[] oldValueBytes = getFromState(key, primaryKey, stateAccessor, memoizedLakeLookup);
+        if (oldValueBytes == null) {
+            BinaryValue valueToInsert = currentMerger.merge(null, currentValue);
+            return applyInsert(
+                    key,
+                    valueToInsert,
+                    walBuilder,
+                    latestSchemaRow,
+                    logOffset,
+                    autoIncrementUpdater,
+                    stateAccessor);
+        }
+
+        BinaryValue oldValue = valueDecoder.decodeValue(oldValueBytes);
+        BinaryValue newValue = currentMerger.merge(oldValue, currentValue);
+
+        if (newValue == oldValue) {
+            // no actual change, skip this record
+            return logOffset;
+        }
+
+        return applyUpdate(
+                key, oldValue, newValue, walBuilder, latestSchemaRow, logOffset, stateAccessor);
+    }
+
+    private long applyDelete(
+            KvPreWriteBuffer.Key key,
+            BinaryValue oldValue,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long logOffset,
+            KvStateAccessor stateAccessor)
+            throws Exception {
+        walBuilder.append(ChangeType.DELETE, latestSchemaRow.replaceRow(oldValue.row));
+        stateAccessor.delete(key, logOffset);
+        return logOffset + 1;
+    }
+
+    private long applyInsert(
+            KvPreWriteBuffer.Key key,
+            BinaryValue currentValue,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long logOffset,
+            AutoIncrementUpdater autoIncrementUpdater,
+            KvStateAccessor stateAccessor)
+            throws Exception {
+        BinaryValue newValue = autoIncrementUpdater.updateAutoIncrementColumns(currentValue);
+        walBuilder.append(ChangeType.INSERT, latestSchemaRow.replaceRow(newValue.row));
+        stateAccessor.insert(key, newValue.encodeValue(), logOffset);
+        return logOffset + 1;
+    }
+
+    private long applyUpdate(
+            KvPreWriteBuffer.Key key,
+            @Nullable BinaryValue oldValue,
+            BinaryValue newValue,
+            WalBuilder walBuilder,
+            PaddingRow latestSchemaRow,
+            long logOffset,
+            KvStateAccessor stateAccessor)
+            throws Exception {
+        if (changelogImage == ChangelogImage.WAL) {
+            walBuilder.append(ChangeType.UPDATE_AFTER, latestSchemaRow.replaceRow(newValue.row));
+            stateAccessor.update(key, newValue.encodeValue(), logOffset);
+            return logOffset + 1;
+        } else {
+            walBuilder.append(ChangeType.UPDATE_BEFORE, latestSchemaRow.replaceRow(oldValue.row));
+            walBuilder.append(ChangeType.UPDATE_AFTER, latestSchemaRow.replaceRow(newValue.row));
+            stateAccessor.update(key, newValue.encodeValue(), logOffset + 1);
+            return logOffset + 2;
+        }
+    }
+
+    /**
+     * Returns the previous value from local state, falling back to the memoized lake result only on
+     * a genuine local miss.
+     *
+     * @param localStateKey the key used by the local prewrite buffer and RocksDB; it wraps {@code
+     *     primaryKey} for a normal partition and adds the original partition namespace for a
+     *     historical partition
+     * @param primaryKey the encoded bytes of the logical primary key, without the historical
+     *     partition namespace; used by the lake lookup
+     * @return the previous value, or null if the key is absent or locally marked as deleted
+     */
+    private byte[] getFromState(
+            KvPreWriteBuffer.Key localStateKey,
+            byte[] primaryKey,
+            KvStateAccessor stateAccessor,
+            @Nullable HistoricalValueLookup memoizedLakeLookup)
+            throws Exception {
+        KvStateLookupResult localResult = stateAccessor.lookup(localStateKey);
+        if (localResult.status() != KvStateLookupResult.Status.NOT_FOUND
+                || memoizedLakeLookup == null) {
+            return localResult.value();
+        }
+        return memoizedLakeLookup.lookup(primaryKey);
+    }
+
+    private boolean canSkipOldValueLookup(
+            RowMerger currentMerger, AutoIncrementUpdater autoIncrementUpdater) {
+        // A full-row WAL upsert does not need the old value: its result is always the incoming row,
+        // and WAL mode emits only UPDATE_AFTER. Partial updates and auto-increment writes still
+        // need the old value to construct the final row or distinguish an insert from an update.
+        return changelogImage == ChangelogImage.WAL
+                && !autoIncrementUpdater.hasAutoIncrement()
+                && currentMerger instanceof DefaultRowMerger;
+    }
+
+    private static boolean shouldIgnoreDeletion(RowMerger currentMerger) {
+        DeleteBehavior deleteBehavior = currentMerger.deleteBehavior();
+        if (deleteBehavior == DeleteBehavior.IGNORE) {
+            return true;
+        } else if (deleteBehavior == DeleteBehavior.DISABLE) {
+            throw new DeletionDisabledException(
+                    "Delete operations are disabled for this table. "
+                            + "The table.delete.behavior is set to 'disable'.");
+        }
+        return false;
+    }
+
+    private WalBuilder createWalBuilder(int schemaId, RowType rowType) throws Exception {
+        switch (logFormat) {
+            case INDEXED:
+                if (kvFormat == KvFormat.COMPACTED) {
+                    // convert from compacted row to indexed row is time cost, and gain
+                    // less benefits, currently we won't support compacted as kv format and
+                    // indexed as cdc log format.
+                    // so in here we throw exception directly
+                    throw new IllegalArgumentException(
+                            "Primary Key Table with COMPACTED kv format doesn't support INDEXED cdc log format.");
+                }
+                return new IndexWalBuilder(schemaId, memorySegmentPool);
+            case COMPACTED:
+                return new CompactedWalBuilder(schemaId, rowType, memorySegmentPool);
+            case ARROW:
+                return new ArrowWalBuilder(
+                        schemaId,
+                        arrowWriterProvider.getOrCreateWriter(
+                                tableBucket.getTableId(),
+                                schemaId,
+                                // we don't limit size of the arrow batch, because all the
+                                // changelogs should be in a single batch
+                                Integer.MAX_VALUE,
+                                rowType,
+                                arrowCompressionInfo),
+                        memorySegmentPool);
+            default:
+                throw new IllegalArgumentException("Unsupported log format: " + logFormat);
+        }
+    }
+
+    private static final class WriteContext {
+        private final Schema latestSchema;
+        private final short latestSchemaId;
+        private final AutoIncrementUpdater autoIncrementUpdater;
+        private final RowMerger rowMerger;
+
+        private WriteContext(
+                Schema latestSchema,
+                short latestSchemaId,
+                AutoIncrementUpdater autoIncrementUpdater,
+                RowMerger rowMerger) {
+            this.latestSchema = latestSchema;
+            this.latestSchemaId = latestSchemaId;
+            this.autoIncrementUpdater = autoIncrementUpdater;
+            this.rowMerger = rowMerger;
+        }
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoder.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoder.java
new file mode 100644
index 000000000..b52e93588
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoder.java
@@ -0,0 +1,79 @@
+/*
+ * 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.fluss.server.kv.historical;
+
+import org.apache.fluss.annotation.Internal;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Encoder for keys stored in historical KV state.
+ *
+ * <p>A historical bucket can store local state for multiple original partitions. Prefixing each
+ * physical primary key with its original partition name preserves the partition identity in the
+ * shared RocksDB key space.
+ */
+@Internal
+public final class HistoricalKvKeyEncoder {
+
+    private HistoricalKvKeyEncoder() {}
+
+    /**
+     * Encodes an original partition name and primary key into one unambiguous storage key.
+     *
+     * <p>The key layout is a four-byte big-endian UTF-8 partition-name length, followed by the
+     * partition-name bytes and original primary-key bytes.
+     */
+    public static byte[] encode(String originalPartitionName, byte[] originalPrimaryKey) {
+        checkNotNull(originalPartitionName, "originalPartitionName must not be null");
+        checkArgument(!originalPartitionName.isEmpty(), "originalPartitionName must not be empty");
+        checkNotNull(originalPrimaryKey, "originalPrimaryKey must not be null");
+
+        byte[] partitionNameBytes = originalPartitionName.getBytes(StandardCharsets.UTF_8);
+        long encodedLength =
+                Integer.BYTES + (long) partitionNameBytes.length + originalPrimaryKey.length;
+        checkArgument(encodedLength <= Integer.MAX_VALUE, "The encoded historical key is too long");
+
+        return ByteBuffer.allocate((int) encodedLength)
+                .putInt(partitionNameBytes.length)
+                .put(partitionNameBytes)
+                .put(originalPrimaryKey)
+                .array();
+    }
+
+    /** Returns a copy of the original primary key from an encoded historical KV key. */
+    public static byte[] extractOriginalPrimaryKey(byte[] encodedPrimaryKey) {
+        checkNotNull(encodedPrimaryKey, "encodedPrimaryKey must not be null");
+        checkArgument(
+                encodedPrimaryKey.length >= Integer.BYTES,
+                "The encoded historical key is shorter than its partition-name length prefix");
+
+        int partitionNameLength = ByteBuffer.wrap(encodedPrimaryKey).getInt();
+        checkArgument(
+                partitionNameLength >= 0
+                        && partitionNameLength <= encodedPrimaryKey.length - Integer.BYTES,
+                "The encoded historical key contains an invalid partition-name length");
+        int primaryKeyOffset = Integer.BYTES + partitionNameLength;
+        return Arrays.copyOfRange(encodedPrimaryKey, primaryKeyOffset, encodedPrimaryKey.length);
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalValueLookup.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalValueLookup.java
new file mode 100644
index 000000000..bdc723df9
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/historical/HistoricalValueLookup.java
@@ -0,0 +1,37 @@
+/*
+ * 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.fluss.server.kv.historical;
+
+import org.apache.fluss.annotation.Internal;
+
+import javax.annotation.Nullable;
+
+/** Resolves a lake value already memoized for the current historical write request. */
+@Internal
+@FunctionalInterface
+public interface HistoricalValueLookup {
+
+    /**
+     * Returns the encoded value for the primary key, or null when it does not exist.
+     *
+     * <p>This method is invoked while the KV write lock is held and must not perform lake or file
+     * I/O.
+     */
+    @Nullable
+    byte[] lookup(byte[] primaryKey);
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/kv/rocksdb/RocksDBKv.java b/fluss-server/src/main/java/org/apache/fluss/server/kv/rocksdb/RocksDBKv.java
index f672240b9..ce8ef903c 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/kv/rocksdb/RocksDBKv.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/kv/rocksdb/RocksDBKv.java
@@ -57,6 +57,9 @@ public class RocksDBKv implements AutoCloseable {
     /** RocksDB property name for the number of SST files at level 0 (column-family scoped). */
     private static final String NUM_FILES_AT_LEVEL0 = "rocksdb.num-files-at-level0";
 
+    /** RocksDB property name for the total size of SST files in the current LSM tree. */
+    private static final String LIVE_SST_FILES_SIZE = "rocksdb.live-sst-files-size";
+
     private static final float DELAYED_WRITE_PRESSURE_FLOOR = 0.8f;
 
     private static final long L0_REFRESH_INTERVAL_NANOS = TimeUnit.MILLISECONDS.toNanos(100);
@@ -270,6 +273,11 @@ public class RocksDBKv implements AutoCloseable {
         }
     }
 
+    /** Returns the total size in bytes of the live SST files. */
+    public long liveSstFilesSize() {
+        return readLongProperty(LIVE_SST_FILES_SIZE);
+    }
+
     @Override
     public void close() throws Exception {
         close(KvCloseMode.PRESERVE_LOCAL_STATE);
@@ -406,21 +414,7 @@ public class RocksDBKv implements AutoCloseable {
     }
 
     private long sampleL0FileCount() {
-        if (closed) {
-            return 0L;
-        }
-        ResourceGuard.Lease lease = null;
-        try {
-            lease = rocksDBResourceGuard.acquireResource();
-            return readLongProperty(NUM_FILES_AT_LEVEL0);
-        } catch (IOException acquireFailed) {
-            // RocksDB is already closed (or being closed); treat as no pressure.
-            return 0L;
-        } finally {
-            if (lease != null) {
-                lease.close();
-            }
-        }
+        return readLongProperty(NUM_FILES_AT_LEVEL0);
     }
 
     /**
@@ -443,14 +437,20 @@ public class RocksDBKv implements AutoCloseable {
     }
 
     private long readLongProperty(String propertyName) {
-        try {
+        if (closed) {
+            return 0L;
+        }
+        try (ResourceGuard.Lease ignored = rocksDBResourceGuard.acquireResource()) {
             String value = db.getProperty(defaultColumnFamilyHandle, propertyName);
             if (value == null || value.isEmpty()) {
                 return 0L;
             }
             return Long.parseLong(value);
+        } catch (IOException ignored) {
+            // RocksDB is already closed or being closed.
+            return 0L;
         } catch (RocksDBException | NumberFormatException e) {
-            LOG.warn("Failed to query RocksDB property {} for backpressure", propertyName, e);
+            LOG.warn("Failed to query RocksDB property {}", propertyName, e);
             return 0L;
         }
     }
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metrics/group/TableMetricGroup.java b/fluss-server/src/main/java/org/apache/fluss/server/metrics/group/TableMetricGroup.java
index 303ed5805..02be54ed9 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/metrics/group/TableMetricGroup.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/metrics/group/TableMetricGroup.java
@@ -221,6 +221,24 @@ public class TableMetricGroup extends AbstractMetricGroup {
         }
     }
 
+    /** Returns the counter for historical put-KV requests received by this table. */
+    public Counter totalHistoricalPutKvRequests() {
+        if (kvMetrics == null) {
+            return NoOpCounter.INSTANCE;
+        } else {
+            return kvMetrics.totalHistoricalPutKvRequests;
+        }
+    }
+
+    /** Returns the counter for failed historical put-KV requests for this table. */
+    public Counter failedHistoricalPutKvRequests() {
+        if (kvMetrics == null) {
+            return NoOpCounter.INSTANCE;
+        } else {
+            return kvMetrics.failedHistoricalPutKvRequests;
+        }
+    }
+
     /**
      * Records a historical lake table point lookup.
      *
@@ -578,6 +596,8 @@ public class TableMetricGroup extends AbstractMetricGroup {
         private final Counter failedLookupRequests;
         private final Counter totalHistoricalLookupRequests;
         private final Counter failedHistoricalLookupRequests;
+        private final Counter totalHistoricalPutKvRequests;
+        private final Counter failedHistoricalPutKvRequests;
         private final LookupFileDownloadedMetricGroup downloadedHistoricalLookupMetrics;
         private final LookupFileDownloadedMetricGroup nonDownloadedHistoricalLookupMetrics;
         private final Counter totalPutKvRequests;
@@ -596,15 +616,24 @@ public class TableMetricGroup extends AbstractMetricGroup {
             failedLookupRequests = new ThreadSafeSimpleCounter();
             meter(MetricNames.FAILED_LOOKUP_REQUESTS_RATE, new MeterView(failedLookupRequests));
             // for historical lookup request
-            MetricGroup historicalLookupMetrics = addGroup("historical");
+            MetricGroup historicalMetrics = addGroup("historical");
             totalHistoricalLookupRequests = new ThreadSafeSimpleCounter();
-            historicalLookupMetrics.meter(
+            historicalMetrics.meter(
                     MetricNames.TOTAL_LOOKUP_REQUESTS_RATE,
                     new MeterView(totalHistoricalLookupRequests));
             failedHistoricalLookupRequests = new ThreadSafeSimpleCounter();
-            historicalLookupMetrics.meter(
+            historicalMetrics.meter(
                     MetricNames.FAILED_LOOKUP_REQUESTS_RATE,
                     new MeterView(failedHistoricalLookupRequests));
+            // for historical put kv request
+            totalHistoricalPutKvRequests = new ThreadSafeSimpleCounter();
+            historicalMetrics.meter(
+                    MetricNames.TOTAL_PUT_KV_REQUESTS_RATE,
+                    new MeterView(totalHistoricalPutKvRequests));
+            failedHistoricalPutKvRequests = new ThreadSafeSimpleCounter();
+            historicalMetrics.meter(
+                    MetricNames.FAILED_PUT_KV_REQUESTS_RATE,
+                    new MeterView(failedHistoricalPutKvRequests));
             // Separate groups expose the same metric names with different downloaded-file labels
             // without adding the label key to the logical metric scope.
             downloadedHistoricalLookupMetrics =
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
index 0d30b8381..5e6036849 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java
@@ -24,6 +24,7 @@ import org.apache.fluss.config.ConfigurationUtils;
 import org.apache.fluss.config.TableConfig;
 import org.apache.fluss.exception.FencedLeaderEpochException;
 import org.apache.fluss.exception.InvalidColumnProjectionException;
+import org.apache.fluss.exception.InvalidPartitionException;
 import org.apache.fluss.exception.InvalidTableException;
 import org.apache.fluss.exception.InvalidTimestampException;
 import org.apache.fluss.exception.InvalidUpdateVersionException;
@@ -39,6 +40,7 @@ import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.PhysicalTablePath;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.SchemaGetter;
+import org.apache.fluss.metadata.SchemaInfo;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
@@ -59,9 +61,11 @@ import org.apache.fluss.server.coordinator.CoordinatorContext;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.kv.KvManager;
 import org.apache.fluss.server.kv.KvRecoverHelper;
+import org.apache.fluss.server.kv.KvStateLookupResult;
 import org.apache.fluss.server.kv.KvTablet;
 import org.apache.fluss.server.kv.RemoteLogFetcher;
 import org.apache.fluss.server.kv.autoinc.AutoIncIDRange;
+import org.apache.fluss.server.kv.historical.HistoricalValueLookup;
 import org.apache.fluss.server.kv.rocksdb.RocksDBKvBuilder;
 import org.apache.fluss.server.kv.scan.OpenScanResult;
 import org.apache.fluss.server.kv.scan.ScannerContext;
@@ -141,7 +145,9 @@ import java.util.function.IntSupplier;
 import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
+import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE;
 import static org.apache.fluss.utils.Preconditions.checkNotNull;
+import static org.apache.fluss.utils.Preconditions.checkState;
 import static org.apache.fluss.utils.concurrent.LockUtils.inReadLock;
 import static org.apache.fluss.utils.concurrent.LockUtils.inWriteLock;
 
@@ -186,6 +192,7 @@ public final class Replica {
 
     private final SchemaGetter schemaGetter;
     private volatile TableInfo tableInfo;
+    private final boolean historicalPartition;
     // logFormat and arrowCompressionInfo are immutable and used in hot-path, so cache them here.
     private final LogFormat logFormat;
     private final ArrowCompressionInfo arrowCompressionInfo;
@@ -272,6 +279,8 @@ public final class Replica {
                         tableInfo.getSchema());
         this.tableInfo = tableInfo;
         TableConfig tableConfig = tableInfo.getTableConfig();
+        String partitionName = physicalPath.getPartitionName();
+        this.historicalPartition = HISTORICAL_PARTITION_VALUE.equals(partitionName);
         this.logFormat = tableConfig.getLogFormat();
         this.arrowCompressionInfo = tableConfig.getArrowCompressionInfo();
         this.snapshotContext = snapshotContext;
@@ -316,6 +325,12 @@ public final class Replica {
 
     public long logicalStorageKvSize() {
         if (isLeader() && isKvTable()) {
+            if (isHistoricalPartition()) {
+                // Historical KV tablets do not create snapshots, so account for the local overlay
+                // using live SST files instead.
+                KvTablet currentKvTablet = kvTablet;
+                return currentKvTablet == null ? 0L : currentKvTablet.liveSstFilesSize();
+            }
             checkNotNull(kvSnapshotManager, "kvSnapshotManager is null");
             return kvSnapshotManager.getSnapshotSize();
         } else {
@@ -396,6 +411,19 @@ public final class Replica {
         return kvTablet;
     }
 
+    SchemaGetter schemaGetter() {
+        return schemaGetter;
+    }
+
+    /** Returns the latest schema used by historical partition operations. */
+    public SchemaInfo getLatestSchemaInfo() {
+        return schemaGetter.getLatestSchemaInfo();
+    }
+
+    boolean isHistoricalPartition() {
+        return historicalPartition;
+    }
+
     public TablePath getTablePath() {
         return physicalPath.getTablePath();
     }
@@ -723,8 +751,14 @@ public final class Replica {
                         e);
             }
         }
-        // start periodic kv snapshot
-        startPeriodicKvSnapshot(snapshotUsed.orElse(null));
+        // A historical KV tablet is a disposable overlay over the lake snapshot. It is recovered
+        // by replaying WAL from the lake log end offset and does not create its own KV snapshots.
+        if (isHistoricalPartition()) {
+            // TODO: Clean up historical KV state after the corresponding WAL is fully tiered to
+            // lake storage.
+        } else {
+            startPeriodicKvSnapshot(snapshotUsed.orElse(null));
+        }
     }
 
     private void dropKv() {
@@ -797,8 +831,11 @@ public final class Replica {
         // when replica become follower, we'll always delete the kv files.
 
         // get the offset from which, we should restore from. default is 0
-        long restoreStartOffset = 0;
-        Optional<CompletedSnapshot> optCompletedSnapshot = getLatestSnapshot(tableBucket);
+        long restoreStartOffset = isHistoricalPartition() ? historicalRecoveryStartOffset() : 0;
+        // The lake snapshot is the durable base for a historical overlay. Historical replicas
+        // therefore never restore a normal KV snapshot, even if one exists from older code.
+        Optional<CompletedSnapshot> optCompletedSnapshot =
+                isHistoricalPartition() ? Optional.empty() : getLatestSnapshot(tableBucket);
         try {
             Long rowCount;
             AutoIncIDRange autoIncIDRange;
@@ -844,7 +881,11 @@ public final class Replica {
                                 this::onKvFlushComplete);
 
                 // we don't support rowCount
-                rowCount = tableConfig.getChangelogImage() == ChangelogImage.WAL ? null : 0L;
+                rowCount =
+                        isHistoricalPartition()
+                                        || tableConfig.getChangelogImage() == ChangelogImage.WAL
+                                ? null
+                                : 0L;
                 // TODO: it is possible that this is a recovered kv tablet without kv snapshot but
                 //  with changelogs, in this case, the kv tablet should also have the
                 //  autoIncIDRange, we may need to get it from the changelog in the future.
@@ -977,7 +1018,8 @@ public final class Replica {
                                 tableConfig.getKvFormat(),
                                 tableConfig.getLogFormat(),
                                 schemaGetter,
-                                remoteLogFetcher);
+                                remoteLogFetcher,
+                                isHistoricalPartition());
                 kvRecoverHelper.recover();
             } finally {
                 remoteLogFetcher.close();
@@ -998,6 +1040,19 @@ public final class Replica {
                 end - start);
     }
 
+    private long historicalRecoveryStartOffset() {
+        long lakeLogEndOffset = logTablet.getLakeLogEndOffset();
+        long logStartOffset = logTablet.logStartOffset();
+        long recoveryStartOffset = lakeLogEndOffset >= 0 ? lakeLogEndOffset : 0L;
+        checkState(
+                recoveryStartOffset >= logStartOffset,
+                "Cannot recover historical KV state: recovery start offset %s is before the "
+                        + "available log start offset %s.",
+                recoveryStartOffset,
+                logStartOffset);
+        return recoveryStartOffset;
+    }
+
     private void startPeriodicKvSnapshot(@Nullable CompletedSnapshot completedSnapshot) {
         checkNotNull(kvTablet);
         KvTabletSnapshotTarget kvTabletSnapshotTarget;
@@ -1104,6 +1159,10 @@ public final class Replica {
                                         "Leader not local for bucket %s on tabletServer %d",
                                         tableBucket, localTabletServerId));
                     }
+                    if (isHistoricalPartition()) {
+                        throw new InvalidPartitionException(
+                                "Normal write request must not target a historical partition.");
+                    }
 
                     validateInSyncReplicaSize(requiredAcks);
 
@@ -1164,6 +1223,10 @@ public final class Replica {
                                         "Leader not local for bucket %s on tabletServer %d",
                                         tableBucket, localTabletServerId));
                     }
+                    if (isHistoricalPartition()) {
+                        throw new InvalidPartitionException(
+                                "Normal write request must not target a historical partition.");
+                    }
 
                     validateInSyncReplicaSize(requiredAcks);
                     KvTablet kv = this.kvTablet;
@@ -1184,6 +1247,107 @@ public final class Replica {
                 });
     }
 
+    /**
+     * Finds historical write keys that require lake fallback without mutating local KV state.
+     *
+     * <p>The caller must keep historical writes for this table bucket ordered until the subsequent
+     * {@link #putHistoricalRecordsToLeader} call completes.
+     */
+    public List<byte[]> findKeysRequiringLakeLookup(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            String originalPartitionName,
+            int expectedLeaderEpoch,
+            int requiredAcks)
+            throws Exception {
+        return inReadLock(
+                leaderIsrUpdateLock,
+                () -> {
+                    validateHistoricalWrite(expectedLeaderEpoch, requiredAcks);
+                    KvTablet kv = this.kvTablet;
+                    checkNotNull(kv, "KvTablet for the historical replica shouldn't be null.");
+                    return kv.findKeysRequiringLakeLookup(
+                            kvRecords, targetColumns, mergeMode, originalPartitionName);
+                });
+    }
+
+    /** Writes records to the local historical KV overlay of the leader replica. */
+    public LogAppendInfo putHistoricalRecordsToLeader(
+            KvRecordBatch kvRecords,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            String originalPartitionName,
+            HistoricalValueLookup memoizedLakeLookup,
+            int expectedLeaderEpoch,
+            int requiredAcks)
+            throws Exception {
+        return inReadLock(
+                leaderIsrUpdateLock,
+                () -> {
+                    validateHistoricalWrite(expectedLeaderEpoch, requiredAcks);
+                    KvTablet kv = this.kvTablet;
+                    checkNotNull(kv, "KvTablet for the historical replica shouldn't be null.");
+                    LogAppendInfo appendInfo =
+                            kv.putHistoricalAsLeader(
+                                    kvRecords,
+                                    targetColumns,
+                                    mergeMode,
+                                    originalPartitionName,
+                                    memoizedLakeLookup);
+                    maybeIncrementLeaderHW(logTablet, clock.milliseconds());
+                    return appendInfo;
+                });
+    }
+
+    private void validateHistoricalWrite(int expectedLeaderEpoch, int requiredAcks) {
+        if (!isLeader()) {
+            throw new NotLeaderOrFollowerException(
+                    String.format(
+                            "Leader not local for bucket %s on tabletServer %d",
+                            tableBucket, localTabletServerId));
+        }
+        if (!isHistoricalPartition()) {
+            throw new InvalidPartitionException(
+                    "Historical write request must target a historical partition.");
+        }
+        if (leaderEpoch != expectedLeaderEpoch) {
+            throw new FencedLeaderEpochException(
+                    String.format(
+                            "Historical write for %s was prepared at leader epoch %s, "
+                                    + "but the current leader epoch is %s.",
+                            tableBucket, expectedLeaderEpoch, leaderEpoch));
+        }
+        validateInSyncReplicaSize(requiredAcks);
+    }
+
+    /** Looks up keys from the local historical KV overlay of the leader replica. */
+    public List<KvStateLookupResult> lookupHistoricalLocal(
+            String originalPartitionName, List<byte[]> keys) throws Exception {
+        return inReadLock(
+                leaderIsrUpdateLock,
+                () -> {
+                    if (!isLeader()) {
+                        throw new NotLeaderOrFollowerException(
+                                String.format(
+                                        "Leader not local for bucket %s on tabletServer %d",
+                                        tableBucket, localTabletServerId));
+                    }
+                    if (!isHistoricalPartition()) {
+                        throw new InvalidPartitionException(
+                                "Historical lookup request must target a historical partition.");
+                    }
+
+                    KvTablet kv = this.kvTablet;
+                    checkNotNull(kv, "KvTablet for the historical replica shouldn't be null.");
+                    List<KvStateLookupResult> results = new ArrayList<>(keys.size());
+                    for (byte[] key : keys) {
+                        results.add(kv.lookupHistoricalLocal(originalPartitionName, key));
+                    }
+                    return results;
+                });
+    }
+
     public LogReadInfo fetchRecords(FetchParams fetchParams) throws IOException {
         if (fetchParams.projection() != null && logFormat != LogFormat.ARROW) {
             throw new InvalidColumnProjectionException(
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
index db8b2e8ed..ef3b16b2c 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java
@@ -42,13 +42,13 @@ import org.apache.fluss.fs.FsPath;
 import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.PhysicalTablePath;
 import org.apache.fluss.metadata.Schema;
-import org.apache.fluss.metadata.SchemaInfo;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.metrics.MetricNames;
 import org.apache.fluss.metrics.groups.MetricGroup;
 import org.apache.fluss.plugin.PluginManager;
+import org.apache.fluss.record.DefaultKvRecordBatch;
 import org.apache.fluss.record.KeyRecordBatch;
 import org.apache.fluss.record.KvRecordBatch;
 import org.apache.fluss.record.MemoryLogRecords;
@@ -82,6 +82,7 @@ import org.apache.fluss.server.entity.NotifyLakeTableOffsetData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
 import org.apache.fluss.server.entity.NotifyRemoteLogOffsetsData;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
 import org.apache.fluss.server.entity.StopReplicaData;
 import org.apache.fluss.server.entity.StopReplicaResultForBucket;
 import org.apache.fluss.server.entity.UserContext;
@@ -114,6 +115,7 @@ import org.apache.fluss.server.replica.delay.DelayedTableBucketKey;
 import org.apache.fluss.server.replica.delay.DelayedWrite;
 import org.apache.fluss.server.replica.fetcher.InitialFetchStatus;
 import org.apache.fluss.server.replica.fetcher.ReplicaFetcherManager;
+import org.apache.fluss.server.replica.historical.HistoricalPartitionManager;
 import org.apache.fluss.server.storage.DiskUsageMonitor;
 import org.apache.fluss.server.storage.LocalDiskManager;
 import org.apache.fluss.server.utils.FatalErrorHandler;
@@ -138,6 +140,7 @@ import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -158,7 +161,6 @@ import java.util.stream.Stream;
 
 import static org.apache.fluss.config.ConfigOptions.KV_FORMAT_VERSION_2;
 import static org.apache.fluss.server.TabletManagerBase.getTableInfo;
-import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE;
 import static org.apache.fluss.utils.Preconditions.checkArgument;
 import static org.apache.fluss.utils.Preconditions.checkNotNull;
 import static org.apache.fluss.utils.Preconditions.checkState;
@@ -237,7 +239,7 @@ public class ReplicaManager implements ServerReconfigurable {
 
     private final ScannerManager scannerManager;
 
-    private final HistoricalLakeLookupManager historicalLakeLookupManager;
+    private final HistoricalPartitionManager historicalPartitionManager;
 
     public ReplicaManager(
             Configuration conf,
@@ -363,8 +365,8 @@ public class ReplicaManager implements ServerReconfigurable {
         // Historical lookup cache capacity currently uses only the first data volume.
         File dataDir = localDiskManager.dataDirs().get(0);
         long dataDirVolumeBytes = Files.getFileStore(dataDir.toPath()).getTotalSpace();
-        this.historicalLakeLookupManager =
-                new HistoricalLakeLookupManager(
+        this.historicalPartitionManager =
+                new HistoricalPartitionManager(
                         conf,
                         pluginManager,
                         localDiskManager,
@@ -376,7 +378,7 @@ public class ReplicaManager implements ServerReconfigurable {
     }
 
     public void startup() {
-        historicalLakeLookupManager.startup(scheduler);
+        historicalPartitionManager.startup(scheduler);
 
         // start up ISR expiration thread.
         // A follower can log behind leader for up tp configOptions#LOG_REPLICA_MAX_LAG_TIME x 1.5
@@ -427,7 +429,7 @@ public class ReplicaManager implements ServerReconfigurable {
 
     @Override
     public void reconfigure(Configuration newConfig) {
-        historicalLakeLookupManager.reconfigure(newConfig);
+        historicalPartitionManager.reconfigure(newConfig);
         int newMinInSyncReplicas =
                 newConfig.get(ConfigOptions.LOG_REPLICA_MIN_IN_SYNC_REPLICAS_NUMBER);
         if (newMinInSyncReplicas == minInSyncReplicas) {
@@ -448,16 +450,16 @@ public class ReplicaManager implements ServerReconfigurable {
         MetricGroup historicalMetrics = serverMetricGroup.addGroup("historical");
         historicalMetrics.gauge(
                 MetricNames.HISTORICAL_INFLIGHT_REQUESTS,
-                historicalLakeLookupManager::numInflightRequests);
+                historicalPartitionManager::numInflightRequests);
         historicalMetrics.gauge(
                 MetricNames.HISTORICAL_LOOKUP_CACHE_DISK_SIZE,
-                historicalLakeLookupManager::lookupCacheDiskSize);
+                historicalPartitionManager::lookupCacheDiskSize);
         historicalMetrics.gauge(
                 MetricNames.HISTORICAL_LOOKUP_CACHE_TABLE_COUNT,
-                historicalLakeLookupManager::cachedTableCount);
+                historicalPartitionManager::cachedTableCount);
         historicalMetrics.counter(
                 MetricNames.HISTORICAL_LOOKUP_CACHE_CAPACITY_EVICTIONS,
-                historicalLakeLookupManager.capacityEvictions());
+                historicalPartitionManager.capacityEvictions());
 
         serverMetricGroup.gauge(
                 MetricNames.REPLICA_LEADER_COUNT,
@@ -617,7 +619,7 @@ public class ReplicaManager implements ServerReconfigurable {
                         ioExecutor.execute(
                                 () ->
                                         deletedTableIds.forEach(
-                                                historicalLakeLookupManager
+                                                historicalPartitionManager
                                                         ::invalidateTableLookuper));
                     }
                 });
@@ -742,6 +744,118 @@ public class ReplicaManager implements ServerReconfigurable {
                 timeoutMs, requiredAcks, entriesPerBucket.size(), kvPutResult, responseCallback);
     }
 
+    /** Puts records to historical partition leaders. */
+    public void putHistoricalRecordsToKv(
+            int timeoutMs,
+            int requiredAcks,
+            Collection<PutKvDataForBucket> entriesPerBucket,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            short apiVersion,
+            Consumer<List<PutKvResultForBucket>> responseCallback) {
+        if (isRequiredAcksInvalid(requiredAcks)) {
+            throw new InvalidRequiredAcksException("Invalid required acks: " + requiredAcks);
+        }
+        localDiskManager.ensureWritable();
+
+        if (entriesPerBucket.isEmpty()) {
+            responseCallback.accept(Collections.emptyList());
+            return;
+        }
+
+        List<PutKvResultForBucket> results = Collections.synchronizedList(new ArrayList<>());
+        AtomicInteger remaining = new AtomicInteger(entriesPerBucket.size());
+        entriesPerBucket.forEach(
+                putData ->
+                        historicalPutKv(putData, targetColumns, mergeMode, requiredAcks, apiVersion)
+                                .whenComplete(
+                                        (result, error) -> {
+                                            PutKvResultForBucket completedResult =
+                                                    error == null
+                                                            ? result
+                                                            : PutKvResultForBucket
+                                                                    .historicalFailure(
+                                                                            putData.tableBucket(),
+                                                                            ApiError.fromThrowable(
+                                                                                    error),
+                                                                            putData
+                                                                                    .originalPartitionName());
+                                            maybeAddDelayedWrite(
+                                                    timeoutMs,
+                                                    requiredAcks,
+                                                    1,
+                                                    Collections.singletonMap(
+                                                            putData.tableBucket(), completedResult),
+                                                    delayedResult -> {
+                                                        results.add(delayedResult.get(0));
+                                                        if (remaining.decrementAndGet() == 0) {
+                                                            responseCallback.accept(
+                                                                    new ArrayList<>(results));
+                                                        }
+                                                    });
+                                        }));
+    }
+
+    private CompletableFuture<PutKvResultForBucket> historicalPutKv(
+            PutKvDataForBucket putData,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            int requiredAcks,
+            short apiVersion) {
+        TableMetricGroup tableMetrics = null;
+        try {
+            Replica replica = getReplicaOrException(putData.tableBucket());
+            tableMetrics = replica.tableMetrics();
+            tableMetrics.totalHistoricalPutKvRequests().inc();
+            if (!replica.isKvTable()) {
+                throw new NonPrimaryKeyTableException(
+                        "Historical writes are only supported for primary key tables.");
+            }
+            if (!replica.isHistoricalPartition()) {
+                throw new InvalidPartitionException(
+                        "Historical write request must target a historical partition.");
+            }
+            validateClientVersionForPkTable(apiVersion, replica.getTableInfo());
+
+            KvRecordBatch records = putData.records();
+            checkArgument(
+                    records instanceof DefaultKvRecordBatch,
+                    "Historical RPC write requires DefaultKvRecordBatch, but found %s.",
+                    records.getClass().getName());
+            PutKvDataForBucket copiedData =
+                    new PutKvDataForBucket(
+                            putData.tableBucket(),
+                            copyToHeap((DefaultKvRecordBatch) records),
+                            putData.originalPartitionName());
+            TableMetricGroup historicalPutMetrics = tableMetrics;
+            return historicalPartitionManager
+                    .putKv(replica, copiedData, targetColumns, mergeMode, requiredAcks)
+                    .thenApply(
+                            result -> {
+                                if (result.failed()
+                                        && isUnexpectedHistoricalException(
+                                                result.getError().exception())) {
+                                    historicalPutMetrics.failedHistoricalPutKvRequests().inc();
+                                }
+                                return result;
+                            });
+        } catch (Throwable t) {
+            ApiError error = ApiError.fromThrowable(t);
+            if (tableMetrics != null && isUnexpectedHistoricalException(error.exception())) {
+                tableMetrics.failedHistoricalPutKvRequests().inc();
+            }
+            return CompletableFuture.completedFuture(
+                    PutKvResultForBucket.historicalFailure(
+                            putData.tableBucket(), error, putData.originalPartitionName()));
+        }
+    }
+
+    private static DefaultKvRecordBatch copyToHeap(DefaultKvRecordBatch records) {
+        byte[] bytes = new byte[records.sizeInBytes()];
+        records.getMemorySegment().get(records.getPosition(), bytes);
+        return DefaultKvRecordBatch.pointToBytes(bytes);
+    }
+
     /** Context for tracking missing keys that need to be inserted. */
     public static class MissingKeysContext {
         final List<Integer> missingIndexes;
@@ -840,17 +954,13 @@ public class ReplicaManager implements ServerReconfigurable {
                                     + replica.getTablePath()
                                     + " is not a primary key table.");
                 }
-                if (!isHistoricalPartitionReplica(replica)) {
+                if (!replica.isHistoricalPartition()) {
                     throw new InvalidPartitionException(
                             "Historical lookup request must target a historical partition.");
                 }
-                SchemaInfo latestSchemaInfo = replica.getSchemaGetter().getLatestSchemaInfo();
                 lookupFuture =
-                        historicalLakeLookupManager.lookup(
-                                data,
-                                replica.getTableInfo(),
-                                latestSchemaInfo,
-                                replica.tableMetrics()::recordHistoricalLakeLookup);
+                        historicalPartitionManager.lookup(
+                                replica, data, replica.tableMetrics()::recordHistoricalLakeLookup);
             } catch (Exception e) {
                 result.add(
                         new LookupResultForBucket(
@@ -874,7 +984,7 @@ public class ReplicaManager implements ServerReconfigurable {
                                                 data.originalPartitionName(),
                                                 ApiError.fromThrowable(error));
                         if (completedResult.failed()
-                                && isUnexpectedHistoricalLookupException(
+                                && isUnexpectedHistoricalException(
                                         completedResult.getError().exception())) {
                             replica.tableMetrics().failedHistoricalLookupRequests().inc();
                         }
@@ -975,12 +1085,6 @@ public class ReplicaManager implements ServerReconfigurable {
         LOG.debug("Lookup from local kv in {}ms", System.currentTimeMillis() - startTime);
     }
 
-    private boolean isHistoricalPartitionReplica(Replica replica) {
-        String partitionName = replica.getPhysicalTablePath().getPartitionName();
-        return replica.getTableInfo().getPartitionKeys().size() == 1
-                && HISTORICAL_PARTITION_VALUE.equals(partitionName);
-    }
-
     /**
      * Re-lookup missing keys after insert and merge results back into the original lookup result
      * map.
@@ -1149,7 +1253,7 @@ public class ReplicaManager implements ServerReconfigurable {
                                 try {
                                     boolean deletingHistoricalPartition =
                                             data.isDeleteRemote()
-                                                    && isHistoricalPartitionReplica(replica);
+                                                    && replica.isHistoricalPartition();
                                     result.add(
                                             stopReplica(
                                                     tb,
@@ -1181,7 +1285,7 @@ public class ReplicaManager implements ServerReconfigurable {
                 });
 
         deletedHistoricalPartitionTableIds.forEach(
-                historicalLakeLookupManager::invalidateTableLookuper);
+                historicalPartitionManager::invalidateTableLookuper);
         responseCallback.accept(result);
     }
 
@@ -1291,10 +1395,13 @@ public class ReplicaManager implements ServerReconfigurable {
                 // register replica to remote log manager first.
                 remoteLogManager.registerReplica(replica);
 
-                replica.makeLeader(data);
+                // Load the latest lake progress before leader activation. Historical KV recovery
+                // requires its lake log end offset, while failures remain best effort for normal
+                // replicas.
                 if (replica.isDataLakeEnabled()) {
                     updateWithLakeTableSnapshot(replica);
                 }
+                replica.makeLeader(data);
 
                 // start the remote log tiering tasks for leaders
                 remoteLogManager.startLogTiering(replica);
@@ -1308,7 +1415,7 @@ public class ReplicaManager implements ServerReconfigurable {
     }
 
     // NOTE: This method can be removed when fetchFromLake is deprecated
-    private void updateWithLakeTableSnapshot(Replica replica) {
+    private void updateWithLakeTableSnapshot(Replica replica) throws Exception {
         TableBucket tb = replica.getTableBucket();
         try {
             Optional<LakeTableSnapshot> optLakeTableSnapshot =
@@ -1317,14 +1424,27 @@ public class ReplicaManager implements ServerReconfigurable {
                 LakeTableSnapshot lakeTableSnapshot = optLakeTableSnapshot.get();
                 long snapshotId = optLakeTableSnapshot.get().getSnapshotId();
                 replica.getLogTablet().updateLakeTableSnapshotId(snapshotId);
+                if (replica.isHistoricalPartition()) {
+                    // The historical overlay will be rebuilt from this snapshot's lake offset.
+                    // Refresh a cached lookuper before it becomes the fallback for data omitted
+                    // from the rebuilt overlay.
+                    historicalPartitionManager.requireLakeSnapshot(
+                            replica.getTableBucket().getTableId(), snapshotId);
+                }
                 lakeTableSnapshot
                         .getLogEndOffset(tb)
                         .ifPresent(replica.getLogTablet()::updateLakeLogEndOffset);
             }
         } catch (Exception e) {
+            if (replica.isHistoricalPartition()) {
+                // Historical recovery uses the lake offset as its durable base and replays the
+                // retained WAL from that offset. Reject leader activation if the latest lake
+                // progress cannot be loaded, instead of rebuilding the overlay from stale state.
+                throw e;
+            }
             // Lake commit cleanup can race with this best-effort refresh and remove the
             // referenced offsets file. A failure only leaves the snapshot/offset state stale
-            // until the next synchronization, so it must not fail the leader transition.
+            // until the next synchronization, so it must not fail a normal leader transition.
             LOG.warn("Failed to update replica {} with lake table snapshot.", tb, e);
         }
     }
@@ -1838,7 +1958,7 @@ public class ReplicaManager implements ServerReconfigurable {
                 || e instanceof StorageBackpressureException);
     }
 
-    private boolean isUnexpectedHistoricalLookupException(Exception e) {
+    private boolean isUnexpectedHistoricalException(Exception e) {
         return isUnexpectedException(e)
                 && !(e instanceof HistoricalPartitionThrottledException
                         || e instanceof InvalidPartitionException
@@ -2430,7 +2550,7 @@ public class ReplicaManager implements ServerReconfigurable {
     public void shutdown() throws InterruptedException {
         // Close the resources for snapshot kv
         kvSnapshotResource.close();
-        historicalLakeLookupManager.close();
+        historicalPartitionManager.close();
         replicaFetcherManager.shutdown();
         delayedWriteManager.shutdown();
         delayedFetchLogManager.shutdown();
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/HistoricalLakeLookupManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java
similarity index 67%
rename from fluss-server/src/main/java/org/apache/fluss/server/replica/HistoricalLakeLookupManager.java
rename to fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java
index 977b60840..af86581e4 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/replica/HistoricalLakeLookupManager.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java
@@ -15,15 +15,13 @@
  * limitations under the License.
  */
 
-package org.apache.fluss.server.replica;
+package org.apache.fluss.server.replica.historical;
 
 import org.apache.fluss.annotation.VisibleForTesting;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.config.TableConfig;
 import org.apache.fluss.exception.FlussRuntimeException;
-import org.apache.fluss.exception.HistoricalPartitionThrottledException;
-import org.apache.fluss.exception.InvalidPartitionException;
 import org.apache.fluss.exception.LakeStorageNotConfiguredException;
 import org.apache.fluss.lake.lakestorage.LakeStorage;
 import org.apache.fluss.lake.lakestorage.LakeStoragePlugin;
@@ -38,15 +36,11 @@ import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.metrics.Counter;
 import org.apache.fluss.metrics.ThreadSafeSimpleCounter;
 import org.apache.fluss.plugin.PluginManager;
-import org.apache.fluss.rpc.entity.LookupResultForBucket;
-import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.server.entity.LookupDataForBucket;
 import org.apache.fluss.server.storage.LocalDiskManager;
-import org.apache.fluss.utils.ExecutorUtils;
 import org.apache.fluss.utils.FileUtils;
 import org.apache.fluss.utils.FlussPaths;
 import org.apache.fluss.utils.IOUtils;
-import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
 import org.apache.fluss.utils.concurrent.Scheduler;
 
 import com.github.benmanes.caffeine.cache.Cache;
@@ -68,13 +62,8 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.Semaphore;
-import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 
@@ -86,10 +75,6 @@ import static org.apache.fluss.utils.Preconditions.checkState;
 /**
  * Handles server-side point lookup for historical partitions stored in lake storage.
  *
- * <p>Accepted requests run on a dedicated executor whose threads are started lazily and released
- * when idle. A semaphore bounds the total number of accepted historical lookup tasks so slow lake
- * storage cannot create an unbounded request backlog.
- *
  * <p>Creating a lake table lookuper may initialize catalog, table, and query state and allocate
  * local lookup files, so lookupers are cached and reused. The cache is keyed by table ID rather
  * than table path to prevent a deleted and recreated table from reusing the old table's lookuper. A
@@ -118,10 +103,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
     private static final String LOOKUP_CACHE_DISK_SIZE_TASK_NAME =
             "historical-lookup-cache-disk-size";
     private static final Duration LOOKUP_CACHE_DISK_SIZE_CHECK_INTERVAL = Duration.ofMinutes(3);
-    private static final Duration HISTORICAL_PARTITION_THREAD_KEEP_ALIVE = Duration.ofMinutes(10);
-    private static final Duration HISTORICAL_PARTITION_EXECUTOR_SHUTDOWN_TIMEOUT =
-            Duration.ofSeconds(10);
-    private static final String HISTORICAL_PARTITION_THREAD_NAME_PREFIX = "historical-partition-io";
     // TODO: Share one Paimon IOManager disk budget across all table lookupers and evict cached
     // entries by data file instead of reserving fixed per-table capacity. See
     // https://github.com/apache/fluss/issues/3955.
@@ -131,12 +112,8 @@ class HistoricalLakeLookupManager implements AutoCloseable {
     private volatile long lakeConfigVersion;
     private final @Nullable PluginManager pluginManager;
     private final Counter capacityEvictions;
-    private final int maxQueuedHistoricalRequests;
-    private final Semaphore lookupPermits;
-    // Accepted lookup futures tracked so close() can cancel tasks left after executor shutdown.
-    private final Set<CompletableFuture<LookupResultForBucket>> pendingLookups;
     private final Cache<Long, CachedLakeTableLookuper> lakeTableLookupers;
-    private final ExecutorService historicalPartitionExecutor;
+    private final ConcurrentMap<Long, Long> requiredLakeSnapshotIds = new ConcurrentHashMap<>();
     private final File historicalLookupCacheRootDir;
     private final long dataDirVolumeBytes;
     // TODO: Introduce a minimum lookup cache disk ratio (default 0.01). When disk usage is high,
@@ -160,7 +137,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
         this(
                 conf,
                 pluginManager,
-                null,
                 dataDir,
                 dataDirVolumeBytes,
                 Ticker.systemTicker(),
@@ -173,7 +149,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
     HistoricalLakeLookupManager(
             Configuration conf,
             @Nullable PluginManager pluginManager,
-            @Nullable ExecutorService historicalPartitionExecutor,
             File dataDir,
             long dataDirVolumeBytes,
             Ticker ticker,
@@ -193,22 +168,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
                                 ConfigOptions
                                         .SERVER_HISTORICAL_PARTITION_LOOKUP_CACHE_MAX_DISK_RATIO));
         this.capacityEvictions = new ThreadSafeSimpleCounter();
-        this.maxQueuedHistoricalRequests =
-                conf.get(ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS);
-        checkArgument(
-                maxQueuedHistoricalRequests > 0,
-                "%s must be greater than 0.",
-                ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS.key());
-        int maxThreadPoolSize =
-                conf.get(ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE);
-        checkArgument(
-                maxThreadPoolSize > 0,
-                "%s must be greater than 0.",
-                ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE.key());
-        this.historicalPartitionExecutor =
-                historicalPartitionExecutor == null
-                        ? createHistoricalPartitionExecutor(maxThreadPoolSize)
-                        : historicalPartitionExecutor;
         this.lakeTableLookupers =
                 Caffeine.newBuilder()
                         .maximumSize(MAX_CACHED_TABLES)
@@ -221,8 +180,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
                         .executor(Runnable::run)
                         .removalListener(this::onLookuperRemoved)
                         .build();
-        this.lookupPermits = new Semaphore(maxQueuedHistoricalRequests);
-        this.pendingLookups = ConcurrentHashMap.newKeySet();
     }
 
     private static com.github.benmanes.caffeine.cache.Scheduler createCacheScheduler(
@@ -273,92 +230,53 @@ class HistoricalLakeLookupManager implements AutoCloseable {
     }
 
     /** Looks up a batch of keys from one historical lake partition. */
-    CompletableFuture<LookupResultForBucket> lookup(
+    List<byte[]> lookup(
             LookupDataForBucket lookupData,
             TableInfo tableInfo,
             SchemaInfo schemaInfo,
-            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
+            ResolvedPartitionSpec originalPartitionSpec,
+            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder)
+            throws Exception {
+        LakeTableLookuper.LookupMetricRecorder checkedMetricRecorder =
+                checkNotNull(lookupMetricRecorder, "lookupMetricRecorder must not be null.");
         checkState(started, "Historical lake lookup manager has not been started.");
-        TableBucket tableBucket = lookupData.tableBucket();
-        if (!lookupPermits.tryAcquire()) {
-            return CompletableFuture.completedFuture(
-                    new LookupResultForBucket(
-                            tableBucket,
-                            null,
-                            lookupData.originalPartitionName(),
-                            ApiError.fromThrowable(
-                                    new HistoricalPartitionThrottledException(
-                                            "Historical lookup is throttled for "
-                                                    + tableBucket
-                                                    + "."))));
-        }
-
-        CompletableFuture<LookupResultForBucket> future;
+        LookupContext context =
+                createLookupContext(
+                        lookupData,
+                        tableInfo,
+                        schemaInfo,
+                        originalPartitionSpec,
+                        checkedMetricRecorder);
+        CachedLakeTableLookuper cachedLookuper = acquireLookuper(context, tableInfo);
         try {
-            future =
-                    submitLookup(
-                            lookupData,
-                            tableInfo,
-                            schemaInfo,
-                            checkNotNull(
-                                    lookupMetricRecorder,
-                                    "lookupMetricRecorder must not be null."));
-        } catch (RuntimeException e) {
-            lookupPermits.release();
-            throw e;
+            List<byte[]> values = new ArrayList<>(lookupData.keys().size());
+            for (byte[] key : lookupData.keys()) {
+                values.add(cachedLookuper.lookuper.lookup(key, context.lookupContext));
+            }
+            return values;
+        } finally {
+            cachedLookuper.release();
         }
-        future.whenComplete(
-                (ignored, error) -> {
-                    pendingLookups.remove(future);
-                    lookupPermits.release();
-                });
-        return future;
     }
 
     @Override
     public void close() {
-        ExecutorUtils.gracefulShutdown(
-                HISTORICAL_PARTITION_EXECUTOR_SHUTDOWN_TIMEOUT.toMillis(),
-                TimeUnit.MILLISECONDS,
-                historicalPartitionExecutor);
-        pendingLookups.forEach(future -> future.cancel(true));
         lakeTableLookupers.invalidateAll();
         lakeTableLookupers.cleanUp();
-    }
-
-    private CompletableFuture<LookupResultForBucket> submitLookup(
-            LookupDataForBucket lookupData,
-            TableInfo tableInfo,
-            SchemaInfo schemaInfo,
-            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
-        CompletableFuture<LookupResultForBucket> future =
-                CompletableFuture.supplyAsync(
-                        () ->
-                                lookupInternal(
-                                        lookupData, tableInfo, schemaInfo, lookupMetricRecorder),
-                        historicalPartitionExecutor);
-        pendingLookups.add(future);
-        return future;
-    }
-
-    private ExecutorService createHistoricalPartitionExecutor(int maxThreadPoolSize) {
-        ThreadPoolExecutor executor =
-                new ThreadPoolExecutor(
-                        maxThreadPoolSize,
-                        maxThreadPoolSize,
-                        HISTORICAL_PARTITION_THREAD_KEEP_ALIVE.toMillis(),
-                        TimeUnit.MILLISECONDS,
-                        new LinkedBlockingQueue<>(),
-                        new ExecutorThreadFactory(HISTORICAL_PARTITION_THREAD_NAME_PREFIX));
-        executor.allowCoreThreadTimeOut(true);
-        return executor;
+        requiredLakeSnapshotIds.clear();
     }
 
     /** Invalidates the cached lake lookuper for the given table. */
     void invalidateTableLookuper(long tableId) {
+        requiredLakeSnapshotIds.remove(tableId);
         lakeTableLookupers.invalidate(tableId);
     }
 
+    /** Records the required opaque lake snapshot ID, which is compared only by equality. */
+    void requireLakeSnapshot(long tableId, long snapshotId) {
+        requiredLakeSnapshotIds.put(tableId, snapshotId);
+    }
+
     /** Returns the number of table lookupers currently cached. */
     int cachedTableCount() {
         return lakeTableLookupers.asMap().size();
@@ -369,11 +287,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
         return capacityEvictions;
     }
 
-    /** Returns the number of accepted historical lookup requests that have not completed. */
-    int numInflightRequests() {
-        return maxQueuedHistoricalRequests - lookupPermits.availablePermits();
-    }
-
     /** Applies dynamic historical lookup configuration changes. */
     void reconfigure(Configuration newConf) {
         checkNotNull(newConf, "newConf must not be null.");
@@ -424,83 +337,6 @@ class HistoricalLakeLookupManager implements AutoCloseable {
         }
     }
 
-    private LookupResultForBucket lookupInternal(
-            LookupDataForBucket lookupData,
-            TableInfo tableInfo,
-            SchemaInfo schemaInfo,
-            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
-        TableBucket tableBucket = lookupData.tableBucket();
-        CachedLakeTableLookuper cachedLookuper = null;
-        try {
-            LookupContext context =
-                    createLookupContext(lookupData, tableInfo, schemaInfo, lookupMetricRecorder);
-            long currentLakeConfigVersion = lakeConfigVersion;
-            Configuration currentConf = conf;
-            long cacheSizeBytes = lookupCacheMaxDiskBytesPerTable;
-            cachedLookuper =
-                    lakeTableLookupers
-                            .asMap()
-                            .compute(
-                                    context.tableId,
-                                    (ignored, currentLookuper) -> {
-                                        CachedLakeTableLookuper selectedLookuper = currentLookuper;
-                                        // Create the lookuper lazily, and recreate it after schema,
-                                        // lake configuration, or server cache size changes so it
-                                        // reloads lake table/query state and uses the current
-                                        // settings.
-                                        if (selectedLookuper == null
-                                                || selectedLookuper.schemaId != context.schemaId
-                                                || selectedLookuper.lakeConfigVersion
-                                                        != currentLakeConfigVersion
-                                                || selectedLookuper.cacheSizeBytes
-                                                        != cacheSizeBytes) {
-                                            File tableLookupDir =
-                                                    FlussPaths.historicalLookupTableDir(
-                                                            historicalLookupCacheRootDir,
-                                                            context.tablePath,
-                                                            context.tableId);
-                                            LakeTableLookuper lookuper =
-                                                    createLakeTableLookuper(
-                                                            context.tablePath,
-                                                            tableLookupDir.getAbsolutePath(),
-                                                            tableInfo.getTableConfig(),
-                                                            cacheSizeBytes,
-                                                            currentConf);
-                                            selectedLookuper =
-                                                    new CachedLakeTableLookuper(
-                                                            context.tableId,
-                                                            context.tablePath,
-                                                            context.schemaId,
-                                                            currentLakeConfigVersion,
-                                                            cacheSizeBytes,
-                                                            tableLookupDir,
-                                                            lookuper);
-                                        }
-                                        // Pin the lookuper before leaving the atomic cache update.
-                                        // Eviction or invalidation can then defer closing it until
-                                        // this lookup releases it.
-                                        selectedLookuper.acquire();
-                                        return selectedLookuper;
-                                    });
-            List<byte[]> values = new ArrayList<>(lookupData.keys().size());
-            for (byte[] key : lookupData.keys()) {
-                values.add(cachedLookuper.lookuper.lookup(key, context.lookupContext));
-            }
-            return new LookupResultForBucket(
-                    tableBucket, values, lookupData.originalPartitionName(), ApiError.NONE);
-        } catch (Exception e) {
-            return new LookupResultForBucket(
-                    tableBucket,
-                    null,
-                    lookupData.originalPartitionName(),
-                    ApiError.fromThrowable(e));
-        } finally {
-            if (cachedLookuper != null) {
-                cachedLookuper.release();
-            }
-        }
-    }
-
     private void onLookuperRemoved(
             Long ignored, @Nullable CachedLakeTableLookuper cachedLookuper, RemovalCause cause) {
         if (cachedLookuper == null) {
@@ -521,28 +357,10 @@ class HistoricalLakeLookupManager implements AutoCloseable {
             LookupDataForBucket lookupData,
             TableInfo tableInfo,
             SchemaInfo schemaInfo,
+            ResolvedPartitionSpec originalPartitionSpec,
             LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
         TableBucket tableBucket = lookupData.tableBucket();
-        String originalPartitionName = lookupData.originalPartitionName();
-        if (originalPartitionName == null) {
-            throw new InvalidPartitionException(
-                    "Historical lookup request must carry the original partition name.");
-        }
-
         TablePath tablePath = tableInfo.getTablePath();
-
-        ResolvedPartitionSpec originalPartitionSpec;
-        try {
-            originalPartitionSpec =
-                    ResolvedPartitionSpec.fromPartitionName(
-                            tableInfo.getPartitionKeys(), originalPartitionName);
-        } catch (RuntimeException e) {
-            throw new InvalidPartitionException(
-                    String.format(
-                            "Invalid original partition name %s for historical lookup on table %s.",
-                            originalPartitionName, tablePath));
-        }
-
         LakeTableLookuper.LookupContext lookupContext =
                 new LakeTableLookuper.LookupContext(
                         originalPartitionSpec,
@@ -659,6 +477,60 @@ class HistoricalLakeLookupManager implements AutoCloseable {
         }
     }
 
+    private CachedLakeTableLookuper acquireLookuper(LookupContext context, TableInfo tableInfo) {
+        long currentLakeConfigVersion = lakeConfigVersion;
+        Configuration currentConf = conf;
+        long cacheSizeBytes = lookupCacheMaxDiskBytesPerTable;
+        Long requiredLakeSnapshotId = requiredLakeSnapshotIds.get(context.tableId);
+        return lakeTableLookupers
+                .asMap()
+                .compute(
+                        context.tableId,
+                        (ignored, currentLookuper) -> {
+                            CachedLakeTableLookuper selectedLookuper = currentLookuper;
+                            // Create the lookuper lazily, and recreate it after schema,
+                            // lake configuration, or server cache size changes so it
+                            // reloads lake table/query state and uses the current
+                            // settings.
+                            if (selectedLookuper == null
+                                    || selectedLookuper.schemaId != context.schemaId
+                                    || selectedLookuper.lakeConfigVersion
+                                            != currentLakeConfigVersion
+                                    || selectedLookuper.cacheSizeBytes != cacheSizeBytes
+                                    || !Objects.equals(
+                                            selectedLookuper.lakeSnapshotId,
+                                            requiredLakeSnapshotId)) {
+                                File tableLookupDir =
+                                        FlussPaths.historicalLookupTableDir(
+                                                historicalLookupCacheRootDir,
+                                                context.tablePath,
+                                                context.tableId);
+                                LakeTableLookuper lookuper =
+                                        createLakeTableLookuper(
+                                                context.tablePath,
+                                                tableLookupDir.getAbsolutePath(),
+                                                tableInfo.getTableConfig(),
+                                                cacheSizeBytes,
+                                                currentConf);
+                                selectedLookuper =
+                                        new CachedLakeTableLookuper(
+                                                context.tableId,
+                                                context.tablePath,
+                                                context.schemaId,
+                                                currentLakeConfigVersion,
+                                                cacheSizeBytes,
+                                                requiredLakeSnapshotId,
+                                                tableLookupDir,
+                                                lookuper);
+                            }
+                            // Pin the lookuper before leaving the atomic cache update.
+                            // Eviction or invalidation can then defer closing it until
+                            // this lookup releases it.
+                            selectedLookuper.acquire();
+                            return selectedLookuper;
+                        });
+    }
+
     private static final class LookupContext {
         private final long tableId;
         private final int schemaId;
@@ -683,6 +555,9 @@ class HistoricalLakeLookupManager implements AutoCloseable {
         private final int schemaId;
         private final long lakeConfigVersion;
         private final long cacheSizeBytes;
+        /** The required opaque lake snapshot ID when this lookuper was created, or null if none. */
+        private final @Nullable Long lakeSnapshotId;
+
         private final File tableLookupDir;
         private final LakeTableLookuper lookuper;
         private int activeLookups;
@@ -695,6 +570,7 @@ class HistoricalLakeLookupManager implements AutoCloseable {
                 int schemaId,
                 long lakeConfigVersion,
                 long cacheSizeBytes,
+                @Nullable Long lakeSnapshotId,
                 File tableLookupDir,
                 LakeTableLookuper lookuper) {
             this.tableId = tableId;
@@ -702,6 +578,7 @@ class HistoricalLakeLookupManager implements AutoCloseable {
             this.schemaId = schemaId;
             this.lakeConfigVersion = lakeConfigVersion;
             this.cacheSizeBytes = cacheSizeBytes;
+            this.lakeSnapshotId = lakeSnapshotId;
             this.tableLookupDir = tableLookupDir;
             this.lookuper = lookuper;
         }
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManager.java
new file mode 100644
index 000000000..f54aef0c0
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManager.java
@@ -0,0 +1,353 @@
+/*
+ * 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.fluss.server.replica.historical;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.HistoricalPartitionThrottledException;
+import org.apache.fluss.exception.InvalidPartitionException;
+import org.apache.fluss.lake.lakestorage.LakeTableLookuper;
+import org.apache.fluss.metadata.ResolvedPartitionSpec;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metrics.Counter;
+import org.apache.fluss.plugin.PluginManager;
+import org.apache.fluss.rpc.entity.LookupResultForBucket;
+import org.apache.fluss.rpc.entity.PutKvResultForBucket;
+import org.apache.fluss.rpc.protocol.ApiError;
+import org.apache.fluss.rpc.protocol.MergeMode;
+import org.apache.fluss.server.entity.LookupDataForBucket;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
+import org.apache.fluss.server.kv.KvStateLookupResult;
+import org.apache.fluss.server.kv.KvStateLookupResult.Status;
+import org.apache.fluss.server.kv.historical.HistoricalValueLookup;
+import org.apache.fluss.server.log.LogAppendInfo;
+import org.apache.fluss.server.replica.Replica;
+import org.apache.fluss.server.storage.LocalDiskManager;
+import org.apache.fluss.utils.ByteArrayWrapper;
+import org.apache.fluss.utils.concurrent.Scheduler;
+
+import javax.annotation.Nullable;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/** Coordinates lookup, write, and lifecycle operations for historical partitions. */
+@Internal
+public final class HistoricalPartitionManager implements AutoCloseable {
+
+    private final HistoricalPartitionTaskExecutor taskExecutor;
+    private final HistoricalLakeLookupManager lakeLookupManager;
+
+    /** Creates a historical partition manager from the tablet server dependencies. */
+    public HistoricalPartitionManager(
+            Configuration conf,
+            @Nullable PluginManager pluginManager,
+            LocalDiskManager localDiskManager,
+            File dataDir,
+            long dataDirVolumeBytes,
+            Scheduler scheduler) {
+        this(
+                new HistoricalPartitionTaskExecutor(conf),
+                new HistoricalLakeLookupManager(
+                        conf,
+                        pluginManager,
+                        localDiskManager,
+                        dataDir,
+                        dataDirVolumeBytes,
+                        scheduler));
+    }
+
+    @VisibleForTesting
+    HistoricalPartitionManager(
+            HistoricalPartitionTaskExecutor taskExecutor,
+            HistoricalLakeLookupManager lakeLookupManager) {
+        this.taskExecutor = checkNotNull(taskExecutor, "taskExecutor must not be null");
+        this.lakeLookupManager =
+                checkNotNull(lakeLookupManager, "lakeLookupManager must not be null");
+    }
+
+    /** Starts the resources used by historical partition operations. */
+    public void startup(Scheduler scheduler) {
+        lakeLookupManager.startup(scheduler);
+    }
+
+    /** Looks up historical keys from the local overlay and then lake storage. */
+    public CompletableFuture<LookupResultForBucket> lookup(
+            Replica replica,
+            LookupDataForBucket lookupData,
+            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
+        TableBucket tableBucket = lookupData.tableBucket();
+        try {
+            LakeTableLookuper.LookupMetricRecorder checkedMetricRecorder =
+                    checkNotNull(lookupMetricRecorder, "lookupMetricRecorder must not be null.");
+            return taskExecutor.submit(
+                    () -> lookupInternal(replica, lookupData, checkedMetricRecorder),
+                    () ->
+                            new LookupResultForBucket(
+                                    tableBucket,
+                                    null,
+                                    lookupData.originalPartitionName(),
+                                    ApiError.fromThrowable(
+                                            new HistoricalPartitionThrottledException(
+                                                    "Historical lookup is throttled for "
+                                                            + tableBucket
+                                                            + " (original partition "
+                                                            + lookupData.originalPartitionName()
+                                                            + ")."))));
+        } catch (RuntimeException e) {
+            return CompletableFuture.completedFuture(
+                    new LookupResultForBucket(
+                            tableBucket,
+                            null,
+                            lookupData.originalPartitionName(),
+                            ApiError.fromThrowable(e)));
+        }
+    }
+
+    /** Writes records to the local overlay of a historical partition. */
+    public CompletableFuture<PutKvResultForBucket> putKv(
+            Replica replica,
+            PutKvDataForBucket putData,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            int requiredAcks) {
+        try {
+            String originalPartitionName =
+                    checkNotNull(
+                            putData.originalPartitionName(),
+                            "originalPartitionName must not be null");
+            return taskExecutor.submitOrdered(
+                    putData.tableBucket(),
+                    () -> {
+                        try {
+                            LogAppendInfo appendInfo =
+                                    processPut(
+                                            replica,
+                                            putData,
+                                            targetColumns,
+                                            mergeMode,
+                                            requiredAcks);
+                            return PutKvResultForBucket.historicalSuccess(
+                                    putData.tableBucket(),
+                                    appendInfo.lastOffset() + 1,
+                                    originalPartitionName);
+                        } catch (Throwable t) {
+                            return PutKvResultForBucket.historicalFailure(
+                                    putData.tableBucket(),
+                                    ApiError.fromThrowable(t),
+                                    originalPartitionName);
+                        }
+                    },
+                    () ->
+                            PutKvResultForBucket.historicalFailure(
+                                    putData.tableBucket(),
+                                    ApiError.fromThrowable(
+                                            new HistoricalPartitionThrottledException(
+                                                    "Historical write is throttled for "
+                                                            + putData.tableBucket()
+                                                            + " (original partition "
+                                                            + originalPartitionName
+                                                            + ").")),
+                                    originalPartitionName));
+        } catch (RuntimeException e) {
+            return CompletableFuture.completedFuture(
+                    PutKvResultForBucket.historicalFailure(
+                            putData.tableBucket(),
+                            ApiError.fromThrowable(e),
+                            putData.originalPartitionName()));
+        }
+    }
+
+    /** Applies dynamic historical lookup configuration changes. */
+    public void reconfigure(Configuration newConf) {
+        lakeLookupManager.reconfigure(newConf);
+    }
+
+    /** Invalidates the cached lake lookuper for the given table. */
+    public void invalidateTableLookuper(long tableId) {
+        lakeLookupManager.invalidateTableLookuper(tableId);
+    }
+
+    /** Requires future fallback lookups to reload after the given lake snapshot notification. */
+    public void requireLakeSnapshot(long tableId, long lakeSnapshotId) {
+        lakeLookupManager.requireLakeSnapshot(tableId, lakeSnapshotId);
+    }
+
+    /** Returns the number of accepted historical operations that have not completed. */
+    public int numInflightRequests() {
+        return taskExecutor.numInflightRequests();
+    }
+
+    /** Returns the current disk usage of the historical lake lookup cache. */
+    public long lookupCacheDiskSize() {
+        return lakeLookupManager.lookupCacheDiskSize();
+    }
+
+    /** Returns the number of cached historical lake table lookupers. */
+    public int cachedTableCount() {
+        return lakeLookupManager.cachedTableCount();
+    }
+
+    /** Returns the counter for lookuper evictions caused by the table cache capacity. */
+    public Counter capacityEvictions() {
+        return lakeLookupManager.capacityEvictions();
+    }
+
+    @VisibleForTesting
+    LogAppendInfo processPut(
+            Replica replica,
+            PutKvDataForBucket putData,
+            @Nullable int[] targetColumns,
+            MergeMode mergeMode,
+            int requiredAcks)
+            throws Exception {
+        TableInfo tableInfo = replica.getTableInfo();
+        String originalPartitionName =
+                checkNotNull(
+                        putData.originalPartitionName(), "originalPartitionName must not be null");
+        ResolvedPartitionSpec originalPartitionSpec =
+                ResolvedPartitionSpec.fromPartitionName(
+                        tableInfo.getPartitionKeys(), originalPartitionName);
+        // The public put path holds the TableBucket ordering slot until processPut returns, so
+        // local state cannot be changed by a later historical write between resolve and apply.
+        int expectedLeaderEpoch = replica.getLeaderEpoch();
+        List<byte[]> keysRequiringLakeLookup =
+                replica.findKeysRequiringLakeLookup(
+                        putData.records(),
+                        targetColumns,
+                        mergeMode,
+                        originalPartitionName,
+                        expectedLeaderEpoch,
+                        requiredAcks);
+
+        Map<ByteArrayWrapper, KvStateLookupResult> lakeResults = new HashMap<>();
+        if (!keysRequiringLakeLookup.isEmpty()) {
+            List<byte[]> lakeValues =
+                    lakeLookupManager.lookup(
+                            new LookupDataForBucket(
+                                    putData.tableBucket(),
+                                    keysRequiringLakeLookup,
+                                    originalPartitionName),
+                            tableInfo,
+                            replica.getLatestSchemaInfo(),
+                            originalPartitionSpec,
+                            replica.tableMetrics()::recordHistoricalLakeLookup);
+            for (int i = 0; i < keysRequiringLakeLookup.size(); i++) {
+                byte[] lakeValue = lakeValues.get(i);
+                lakeResults.put(
+                        new ByteArrayWrapper(keysRequiringLakeLookup.get(i)),
+                        lakeValue == null
+                                ? KvStateLookupResult.notFound()
+                                : KvStateLookupResult.present(lakeValue));
+            }
+        }
+
+        HistoricalValueLookup memoizedLakeLookup =
+                primaryKey -> {
+                    KvStateLookupResult result =
+                            checkNotNull(
+                                    lakeResults.get(new ByteArrayWrapper(primaryKey)),
+                                    "No resolved lake value for a historical write key");
+                    return result.value();
+                };
+
+        return replica.putHistoricalRecordsToLeader(
+                putData.records(),
+                targetColumns,
+                mergeMode,
+                originalPartitionName,
+                memoizedLakeLookup,
+                expectedLeaderEpoch,
+                requiredAcks);
+    }
+
+    @Override
+    public void close() {
+        taskExecutor.close();
+        lakeLookupManager.close();
+    }
+
+    private LookupResultForBucket lookupInternal(
+            Replica replica,
+            LookupDataForBucket lookupData,
+            LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
+        TableBucket tableBucket = lookupData.tableBucket();
+        String originalPartitionName = lookupData.originalPartitionName();
+        try {
+            TableInfo tableInfo = replica.getTableInfo();
+            if (originalPartitionName == null) {
+                throw new InvalidPartitionException(
+                        "Historical lookup request must carry the original partition name.");
+            }
+            ResolvedPartitionSpec originalPartitionSpec =
+                    ResolvedPartitionSpec.fromPartitionName(
+                            tableInfo.getPartitionKeys(), originalPartitionName);
+
+            List<KvStateLookupResult> localResults =
+                    replica.lookupHistoricalLocal(originalPartitionName, lookupData.keys());
+            List<byte[]> missingKeys = new ArrayList<>();
+            for (int i = 0; i < localResults.size(); i++) {
+                KvStateLookupResult localResult = localResults.get(i);
+                // Only a true local miss falls back to lake. A local value or tombstone is
+                // authoritative and must not be overwritten by an older lake value.
+                if (localResult.status() == Status.NOT_FOUND) {
+                    missingKeys.add(lookupData.keys().get(i));
+                }
+            }
+
+            List<byte[]> lakeValues = Collections.emptyList();
+            if (!missingKeys.isEmpty()) {
+                // Look up all local misses together. Results preserve the order of missingKeys.
+                lakeValues =
+                        lakeLookupManager.lookup(
+                                new LookupDataForBucket(
+                                        tableBucket, missingKeys, originalPartitionName),
+                                tableInfo,
+                                replica.getLatestSchemaInfo(),
+                                originalPartitionSpec,
+                                lookupMetricRecorder);
+            }
+
+            Iterator<byte[]> lakeValueIterator = lakeValues.iterator();
+            List<byte[]> values = new ArrayList<>(localResults.size());
+            for (KvStateLookupResult localResult : localResults) {
+                // Consume one lake value for each NOT_FOUND result; local values and tombstones
+                // keep their original positions without advancing the lake iterator.
+                values.add(
+                        localResult.status() == Status.NOT_FOUND
+                                ? lakeValueIterator.next()
+                                : localResult.value());
+            }
+            return new LookupResultForBucket(
+                    tableBucket, values, originalPartitionName, ApiError.NONE);
+        } catch (Exception e) {
+            return new LookupResultForBucket(
+                    tableBucket, null, originalPartitionName, ApiError.fromThrowable(e));
+        }
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutor.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutor.java
new file mode 100644
index 000000000..9bf66848f
--- /dev/null
+++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutor.java
@@ -0,0 +1,210 @@
+/*
+ * 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.fluss.server.replica.historical;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.utils.ExecutorUtils;
+import org.apache.fluss.utils.concurrent.ExecutorThreadFactory;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.GuardedBy;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Executes lookup and write tasks for historical partitions.
+ *
+ * <p>Lookups and writes share one executor and one request limit. A permit is held from task
+ * acceptance until completion, so the limit covers both queued and running work.
+ *
+ * <p>Normal submissions may run concurrently. Ordered submissions with the same key are chained in
+ * acceptance order, while submissions with different keys can still run in parallel.
+ */
+@Internal
+public final class HistoricalPartitionTaskExecutor implements AutoCloseable {
+
+    private static final Duration THREAD_KEEP_ALIVE = Duration.ofMinutes(10);
+    private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);
+    private static final String THREAD_NAME_PREFIX = "historical-partition-io";
+
+    private final int maxQueuedHistoricalRequests;
+    // Shared by lookup and write tasks, including tasks waiting in the executor queue.
+    private final Semaphore requestPermits;
+    // Keep accepted requests so close() can cancel work that remains after executor shutdown.
+    private final Set<CompletableFuture<?>> pendingRequests;
+    private final ExecutorService executor;
+    private final Object orderedTasksLock = new Object();
+
+    // The latest accepted task for each ordering key. A completed tail is removed only when it is
+    // still the current tail, so an older completion cannot remove a newer task from the chain.
+    @GuardedBy("orderedTasksLock")
+    private final Map<Object, CompletableFuture<Void>> orderedTaskTails;
+
+    /** Creates a historical-partition task executor from the server configuration. */
+    public HistoricalPartitionTaskExecutor(Configuration conf) {
+        this(conf, null);
+    }
+
+    /** Creates a historical-partition task executor backed by the supplied executor. */
+    @VisibleForTesting
+    public HistoricalPartitionTaskExecutor(
+            Configuration conf, @Nullable ExecutorService historicalPartitionExecutor) {
+        checkNotNull(conf, "conf must not be null.");
+        this.maxQueuedHistoricalRequests =
+                conf.get(ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS);
+        checkArgument(
+                maxQueuedHistoricalRequests > 0,
+                "%s must be greater than 0.",
+                ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS.key());
+        int maxThreadPoolSize =
+                conf.get(ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE);
+        checkArgument(
+                maxThreadPoolSize > 0,
+                "%s must be greater than 0.",
+                ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE.key());
+        this.executor =
+                historicalPartitionExecutor == null
+                        ? createHistoricalPartitionExecutor(maxThreadPoolSize)
+                        : historicalPartitionExecutor;
+        this.requestPermits = new Semaphore(maxQueuedHistoricalRequests);
+        this.pendingRequests = ConcurrentHashMap.newKeySet();
+        this.orderedTaskTails = new HashMap<>();
+    }
+
+    /**
+     * Submits a task or returns the supplied throttled result when the shared request limit is
+     * full.
+     *
+     * <p>The throttled result is completed by the caller thread without entering the executor.
+     */
+    public <T> CompletableFuture<T> submit(Supplier<T> task, Supplier<T> throttledResult) {
+        checkNotNull(task, "task must not be null.");
+        checkNotNull(throttledResult, "throttledResult must not be null.");
+        if (!requestPermits.tryAcquire()) {
+            return CompletableFuture.completedFuture(throttledResult.get());
+        }
+
+        CompletableFuture<T> future;
+        try {
+            future = CompletableFuture.supplyAsync(task, executor);
+        } catch (RuntimeException e) {
+            requestPermits.release();
+            throw e;
+        }
+        return trackAcceptedRequest(future);
+    }
+
+    /**
+     * Submits a task after all previously accepted tasks with the same ordering key have finished.
+     *
+     * <p>A failed task does not break the chain: its completion still releases the next task for
+     * that key.
+     */
+    public <T> CompletableFuture<T> submitOrdered(
+            Object orderingKey, Supplier<T> task, Supplier<T> throttledResult) {
+        checkNotNull(orderingKey, "orderingKey must not be null.");
+        checkNotNull(task, "task must not be null.");
+        checkNotNull(throttledResult, "throttledResult must not be null.");
+        if (!requestPermits.tryAcquire()) {
+            return CompletableFuture.completedFuture(throttledResult.get());
+        }
+
+        CompletableFuture<T> future;
+        CompletableFuture<Void> tail;
+        try {
+            synchronized (orderedTasksLock) {
+                CompletableFuture<Void> previousTail = orderedTaskTails.get(orderingKey);
+                if (previousTail == null) {
+                    future = CompletableFuture.supplyAsync(task, executor);
+                } else {
+                    future = previousTail.thenApplyAsync(ignored -> task.get(), executor);
+                }
+                // Convert success or failure into a normal completion used only for sequencing.
+                tail = future.handle((ignored, error) -> null);
+                orderedTaskTails.put(orderingKey, tail);
+            }
+        } catch (RuntimeException e) {
+            requestPermits.release();
+            throw e;
+        }
+
+        CompletableFuture<Void> currentTail = tail;
+        tail.whenComplete(
+                (ignored, error) -> {
+                    synchronized (orderedTasksLock) {
+                        orderedTaskTails.remove(orderingKey, currentTail);
+                    }
+                });
+        return trackAcceptedRequest(future);
+    }
+
+    private <T> CompletableFuture<T> trackAcceptedRequest(CompletableFuture<T> future) {
+        pendingRequests.add(future);
+        future.whenComplete(
+                (ignored, error) -> {
+                    // Release the permit exactly once when the accepted task reaches a terminal
+                    // state, including exceptional completion and cancellation.
+                    pendingRequests.remove(future);
+                    requestPermits.release();
+                });
+        return future;
+    }
+
+    /** Returns the number of accepted historical requests that have not completed. */
+    @VisibleForTesting
+    public int numInflightRequests() {
+        return maxQueuedHistoricalRequests - requestPermits.availablePermits();
+    }
+
+    @Override
+    public void close() {
+        ExecutorUtils.gracefulShutdown(
+                SHUTDOWN_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS, executor);
+        pendingRequests.forEach(future -> future.cancel(true));
+    }
+
+    private static ExecutorService createHistoricalPartitionExecutor(int maxThreadPoolSize) {
+        ThreadPoolExecutor executor =
+                new ThreadPoolExecutor(
+                        maxThreadPoolSize,
+                        maxThreadPoolSize,
+                        THREAD_KEEP_ALIVE.toMillis(),
+                        TimeUnit.MILLISECONDS,
+                        new LinkedBlockingQueue<>(),
+                        new ExecutorThreadFactory(THREAD_NAME_PREFIX));
+        executor.allowCoreThreadTimeOut(true);
+        return executor;
+    }
+}
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
index f7a004de3..ba31a143d 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
@@ -91,6 +91,7 @@ import org.apache.fluss.server.entity.NotifyKvSnapshotOffsetData;
 import org.apache.fluss.server.entity.NotifyLakeTableOffsetData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyRemoteLogOffsetsData;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
 import org.apache.fluss.server.entity.StopReplicaData;
 import org.apache.fluss.server.entity.UserContext;
 import org.apache.fluss.server.kv.scan.OpenScanResult;
@@ -123,6 +124,7 @@ import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.hasHistoricalLookup;
+import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.hasHistoricalPut;
 import static org.apache.fluss.security.acl.OperationType.DESCRIBE;
 import static org.apache.fluss.security.acl.OperationType.READ;
 import static org.apache.fluss.security.acl.OperationType.WRITE;
@@ -135,7 +137,6 @@ import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getNotifyLeade
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getNotifyRemoteLogOffsetsData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getNotifySnapshotOffsetData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getProduceLogData;
-import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getPutKvData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getStopReplicaData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getTableFilterInfoMap;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.getTableStatsRequestData;
@@ -155,6 +156,7 @@ import static org.apache.fluss.server.utils.ServerRpcMessageUtils.makeStopReplic
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.toHistoricalLookupData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.toLookupData;
 import static org.apache.fluss.server.utils.ServerRpcMessageUtils.toPrefixLookupData;
+import static org.apache.fluss.server.utils.ServerRpcMessageUtils.toPutKvDataForBuckets;
 
 /** An RPC Gateway service for tablet server. */
 public final class TabletService extends RpcServiceBase implements TabletServerGateway {
@@ -283,21 +285,35 @@ public final class TabletService extends RpcServiceBase implements TabletServerG
     public CompletableFuture<PutKvResponse> putKv(PutKvRequest request) {
         authorizeTable(WRITE, request.getTableId());
 
-        Map<TableBucket, KvRecordBatch> putKvData = getPutKvData(request);
+        List<PutKvDataForBucket> putKvData = toPutKvDataForBuckets(request);
         // Get mergeMode from request, default to DEFAULT if not set
         MergeMode mergeMode =
                 request.hasAggMode()
                         ? MergeMode.fromValue(request.getAggMode())
                         : MergeMode.DEFAULT;
         CompletableFuture<PutKvResponse> response = new CompletableFuture<>();
-        replicaManager.putRecordsToKv(
-                request.getTimeoutMs(),
-                request.getAcks(),
-                putKvData,
-                getTargetColumns(request),
-                mergeMode,
-                currentSession().getApiVersion(),
-                bucketResponse -> response.complete(makePutKvResponse(bucketResponse)));
+        if (hasHistoricalPut(request)) {
+            replicaManager.putHistoricalRecordsToKv(
+                    request.getTimeoutMs(),
+                    request.getAcks(),
+                    putKvData,
+                    getTargetColumns(request),
+                    mergeMode,
+                    currentSession().getApiVersion(),
+                    bucketResponse -> response.complete(makePutKvResponse(bucketResponse)));
+        } else {
+            Map<TableBucket, KvRecordBatch> recordsByBucket = new HashMap<>();
+            putKvData.forEach(
+                    putData -> recordsByBucket.put(putData.tableBucket(), putData.records()));
+            replicaManager.putRecordsToKv(
+                    request.getTimeoutMs(),
+                    request.getAcks(),
+                    recordsByBucket,
+                    getTargetColumns(request),
+                    mergeMode,
+                    currentSession().getApiVersion(),
+                    bucketResponse -> response.complete(makePutKvResponse(bucketResponse)));
+        }
         return response;
     }
 
diff --git a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
index 89ae6f713..81131f1bc 100644
--- a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
+++ b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java
@@ -50,7 +50,6 @@ import org.apache.fluss.record.DefaultKvRecordBatch;
 import org.apache.fluss.record.DefaultValueRecordBatch;
 import org.apache.fluss.record.FileChannelChunk;
 import org.apache.fluss.record.FileLogRecords;
-import org.apache.fluss.record.KvRecordBatch;
 import org.apache.fluss.record.LogRecords;
 import org.apache.fluss.record.MemoryLogRecords;
 import org.apache.fluss.remote.RemoteLogFetchInfo;
@@ -188,6 +187,7 @@ import org.apache.fluss.server.entity.NotifyLakeTableOffsetData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
 import org.apache.fluss.server.entity.NotifyRemoteLogOffsetsData;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
 import org.apache.fluss.server.entity.StopReplicaData;
 import org.apache.fluss.server.entity.StopReplicaResultForBucket;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
@@ -1101,22 +1101,52 @@ public class ServerRpcMessageUtils {
         return fetchLogResponse;
     }
 
-    public static Map<TableBucket, KvRecordBatch> getPutKvData(PutKvRequest putKvRequest) {
+    /**
+     * Converts put-KV bucket requests while preserving their historical partition context.
+     *
+     * <p>The returned original partition name is null for normal writes. Normal and historical
+     * writes cannot be mixed in one request. Historical target and partition eligibility are
+     * validated by the historical write path.
+     */
+    public static List<PutKvDataForBucket> toPutKvDataForBuckets(PutKvRequest putKvRequest) {
         long tableId = putKvRequest.getTableId();
-        Map<TableBucket, KvRecordBatch> produceEntryData = new HashMap<>();
+        List<PutKvDataForBucket> putKvData = new ArrayList<>(putKvRequest.getBucketsReqsCount());
+        Map<TableBucket, Set<String>> originalPartitionsByBucket = new HashMap<>();
+        boolean historicalWriteRequest =
+                putKvRequest.getBucketsReqsCount() > 0
+                        && putKvRequest.getBucketsReqAt(0).hasOriginalPartitionName();
         for (PbPutKvReqForBucket putKvReqForBucket : putKvRequest.getBucketsReqsList()) {
+            if (putKvReqForBucket.hasOriginalPartitionName() != historicalWriteRequest) {
+                throw new IllegalArgumentException(
+                        "Normal and historical writes cannot be mixed in the same request.");
+            }
             ByteBuffer recordsBuffer = toByteBuffer(putKvReqForBucket.getRecordsSlice());
             DefaultKvRecordBatch kvRecords = DefaultKvRecordBatch.pointToByteBuffer(recordsBuffer);
-            TableBucket tb =
+            TableBucket tableBucket =
                     new TableBucket(
                             tableId,
                             putKvReqForBucket.hasPartitionId()
                                     ? putKvReqForBucket.getPartitionId()
                                     : null,
                             putKvReqForBucket.getBucketId());
-            produceEntryData.put(tb, kvRecords);
+            String originalPartitionName =
+                    putKvReqForBucket.hasOriginalPartitionName()
+                            ? putKvReqForBucket.getOriginalPartitionName()
+                            : null;
+            Set<String> originalPartitions =
+                    originalPartitionsByBucket.computeIfAbsent(
+                            tableBucket, ignored -> new HashSet<>());
+            if (!originalPartitions.add(originalPartitionName)) {
+                throw new IllegalArgumentException(
+                        "A PutKv request contains duplicate table bucket "
+                                + tableBucket
+                                + " and original partition "
+                                + originalPartitionName
+                                + '.');
+            }
+            putKvData.add(new PutKvDataForBucket(tableBucket, kvRecords, originalPartitionName));
         }
-        return produceEntryData;
+        return putKvData;
     }
 
     public static Map<TableBucket, List<byte[]>> toLookupData(LookupRequest lookupRequest) {
@@ -1214,6 +1244,9 @@ public class ServerRpcMessageUtils {
             if (tableBucket.getPartitionId() != null) {
                 putKvBucket.setPartitionId(tableBucket.getPartitionId());
             }
+            if (bucketResult.getOriginalPartitionName() != null) {
+                putKvBucket.setOriginalPartitionName(bucketResult.getOriginalPartitionName());
+            }
 
             if (bucketResult.failed()) {
                 putKvBucket.setError(bucketResult.getErrorCode(), bucketResult.getErrorMessage());
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoderTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoderTest.java
new file mode 100644
index 000000000..557863f94
--- /dev/null
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/historical/HistoricalKvKeyEncoderTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.fluss.server.kv.historical;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link HistoricalKvKeyEncoder}. */
+class HistoricalKvKeyEncoderTest {
+
+    @Test
+    void testEncodeKey() {
+        assertEncodedKey("dt=2026-07-12", new byte[] {1, 2, 3});
+        assertEncodedKey("地区=杭州", new byte[] {0, 1, -1});
+        assertEncodedKey("dt=2026-07-12/region=cn", new byte[0]);
+    }
+
+    @Test
+    void testEncodingHasUnambiguousPartitionBoundary() {
+        byte[] first = HistoricalKvKeyEncoder.encode("ab", "c".getBytes(StandardCharsets.UTF_8));
+        byte[] second = HistoricalKvKeyEncoder.encode("a", "bc".getBytes(StandardCharsets.UTF_8));
+
+        assertThat(first).isNotEqualTo(second);
+        assertThat(HistoricalKvKeyEncoder.encode("p1", new byte[] {1}))
+                .isNotEqualTo(HistoricalKvKeyEncoder.encode("p2", new byte[] {1}));
+        assertThat(HistoricalKvKeyEncoder.encode("p1", new byte[] {1}))
+                .isEqualTo(HistoricalKvKeyEncoder.encode("p1", new byte[] {1}));
+    }
+
+    @Test
+    void testRejectInvalidInput() {
+        assertThatThrownBy(() -> HistoricalKvKeyEncoder.encode(null, new byte[0]))
+                .isInstanceOf(NullPointerException.class);
+        assertThatThrownBy(() -> HistoricalKvKeyEncoder.encode("", new byte[0]))
+                .isInstanceOf(IllegalArgumentException.class);
+        assertThatThrownBy(() -> HistoricalKvKeyEncoder.encode("p", null))
+                .isInstanceOf(NullPointerException.class);
+    }
+
+    private static void assertEncodedKey(String partitionName, byte[] primaryKey) {
+        byte[] partitionNameBytes = partitionName.getBytes(StandardCharsets.UTF_8);
+        byte[] encoded = HistoricalKvKeyEncoder.encode(partitionName, primaryKey);
+        ByteBuffer buffer = ByteBuffer.wrap(encoded);
+
+        int partitionNameLength = buffer.getInt();
+        byte[] actualPartitionName = new byte[partitionNameLength];
+        buffer.get(actualPartitionName);
+        byte[] actualPrimaryKey = new byte[buffer.remaining()];
+        buffer.get(actualPrimaryKey);
+
+        assertThat(partitionNameLength).isEqualTo(partitionNameBytes.length);
+        assertThat(actualPartitionName).isEqualTo(partitionNameBytes);
+        assertThat(actualPrimaryKey).isEqualTo(primaryKey);
+        assertThat(HistoricalKvKeyEncoder.extractOriginalPrimaryKey(encoded)).isEqualTo(primaryKey);
+        assertThat(encoded).hasSize(Integer.BYTES + partitionNameBytes.length + primaryKey.length);
+        assertThat(Arrays.copyOfRange(encoded, Integer.BYTES, encoded.length))
+                .startsWith(partitionNameBytes);
+    }
+}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/kv/rocksdb/RocksDBKvTest.java b/fluss-server/src/test/java/org/apache/fluss/server/kv/rocksdb/RocksDBKvTest.java
index c25dd8e35..7add36047 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/rocksdb/RocksDBKvTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/rocksdb/RocksDBKvTest.java
@@ -68,6 +68,18 @@ class RocksDBKvTest {
         }
     }
 
+    @Test
+    void testLiveSstFilesSize(@TempDir Path tempDir) throws Exception {
+        try (RocksDBKv rocksDBKv = buildRocksDBKv(tempDir.toFile())) {
+            rocksDBKv.put(new byte[] {1}, new byte[] {2});
+            try (FlushOptions flushOptions = new FlushOptions().setWaitForFlush(true)) {
+                rocksDBKv.db.flush(flushOptions);
+            }
+
+            assertThat(rocksDBKv.liveSstFilesSize()).isPositive();
+        }
+    }
+
     @Test
     void testClosePreservesUnflushedStateByDefault(@TempDir Path tempDir) throws Exception {
         byte[] key = new byte[] {1};
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/HistoricalLakeLookupManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManagerTest.java
similarity index 55%
rename from fluss-server/src/test/java/org/apache/fluss/server/replica/HistoricalLakeLookupManagerTest.java
rename to fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManagerTest.java
index afa53e551..364ec46be 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/replica/HistoricalLakeLookupManagerTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManagerTest.java
@@ -15,24 +15,22 @@
  * limitations under the License.
  */
 
-package org.apache.fluss.server.replica;
+package org.apache.fluss.server.replica.historical;
 
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.config.MemorySize;
 import org.apache.fluss.config.TableConfig;
-import org.apache.fluss.exception.HistoricalPartitionThrottledException;
 import org.apache.fluss.lake.lakestorage.LakeTableLookuper;
 import org.apache.fluss.metadata.DataLakeFormat;
 import org.apache.fluss.metadata.KvFormat;
+import org.apache.fluss.metadata.ResolvedPartitionSpec;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.SchemaInfo;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
-import org.apache.fluss.rpc.entity.LookupResultForBucket;
-import org.apache.fluss.rpc.protocol.Errors;
 import org.apache.fluss.server.entity.LookupDataForBucket;
 import org.apache.fluss.types.DataTypes;
 import org.apache.fluss.utils.FlussPaths;
@@ -41,8 +39,6 @@ import com.github.benmanes.caffeine.cache.Scheduler;
 import com.github.benmanes.caffeine.cache.Ticker;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
 import java.io.RandomAccessFile;
@@ -50,27 +46,19 @@ import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.AbstractExecutorService;
-import java.util.concurrent.BlockingQueue;
-import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.FutureTask;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ScheduledFuture;
-import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.concurrent.atomic.AtomicReference;
 
 import static org.apache.fluss.record.TestData.PARTITION_TABLE_ID;
 import static org.apache.fluss.record.TestData.PARTITION_TABLE_INFO;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Tests for {@link HistoricalLakeLookupManager}. */
 class HistoricalLakeLookupManagerTest {
 
     private static final long DATA_DIR_VOLUME_BYTES = MemorySize.parse("800gb").getBytes();
-    private static final TableBucket HISTORICAL_BUCKET = new TableBucket(PARTITION_TABLE_ID, 1L, 0);
     private static final LakeTableLookuper.LookupMetricRecorder NO_OP_LOOKUP_METRIC_RECORDER =
             (lookupTimeNanos, lookupFileDownloaded) -> {};
     private static final Runnable NO_OP_DISK_WRITE_GUARD = () -> {};
@@ -79,141 +67,6 @@ class HistoricalLakeLookupManagerTest {
 
     @TempDir private File ioTmpDir;
 
-    @Test
-    void testHistoricalLookupThrottledWhenPermitsExhausted() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        HistoricalLakeLookupManager manager = createManager(1, executor);
-        assertThat(manager.numInflightRequests()).isZero();
-
-        CompletableFuture<LookupResultForBucket> first =
-                manager.lookup(
-                        lookupData(HISTORICAL_BUCKET),
-                        PARTITION_TABLE_INFO,
-                        PARTITION_TABLE_INFO.getSchemaInfo(),
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        assertThat(first).isNotDone();
-        assertThat(executor.numQueuedTasks()).isEqualTo(1);
-        assertThat(manager.numInflightRequests()).isOne();
-
-        TableBucket secondBucket = new TableBucket(PARTITION_TABLE_ID, 2L, 0);
-        LookupResultForBucket second =
-                manager.lookup(
-                                lookupData(secondBucket),
-                                PARTITION_TABLE_INFO,
-                                PARTITION_TABLE_INFO.getSchemaInfo(),
-                                NO_OP_LOOKUP_METRIC_RECORDER)
-                        .get(1, TimeUnit.SECONDS);
-
-        assertThat(second.failed()).isTrue();
-        assertThat(second.getError().error()).isEqualTo(Errors.HISTORICAL_PARTITION_THROTTLED);
-        assertThat(second.getError().exception())
-                .isInstanceOf(HistoricalPartitionThrottledException.class);
-        assertThat(executor.numQueuedTasks()).isEqualTo(1);
-        assertThat(manager.numInflightRequests()).isOne();
-    }
-
-    @Test
-    void testHistoricalLookupReleasesPermitOnFailure() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        HistoricalLakeLookupManager manager = createManager(1, executor);
-
-        CompletableFuture<LookupResultForBucket> first =
-                manager.lookup(
-                        lookupData(HISTORICAL_BUCKET),
-                        PARTITION_TABLE_INFO,
-                        PARTITION_TABLE_INFO.getSchemaInfo(),
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        executor.runNext();
-        LookupResultForBucket firstResult = first.get(1, TimeUnit.SECONDS);
-        assertThat(firstResult.failed()).isTrue();
-        assertThat(firstResult.getError().error())
-                .isNotEqualTo(Errors.HISTORICAL_PARTITION_THROTTLED);
-        assertThat(manager.numInflightRequests()).isZero();
-
-        CompletableFuture<LookupResultForBucket> second =
-                manager.lookup(
-                        lookupData(HISTORICAL_BUCKET),
-                        PARTITION_TABLE_INFO,
-                        PARTITION_TABLE_INFO.getSchemaInfo(),
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        assertThat(second).isNotDone();
-        assertThat(executor.numQueuedTasks()).isEqualTo(1);
-    }
-
-    @Test
-    void testHistoricalLookupMaxQueuedRequestsUsesExplicitConfig() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        HistoricalLakeLookupManager manager = createManager(2, executor);
-
-        CompletableFuture<LookupResultForBucket> first =
-                manager.lookup(
-                        lookupData(new TableBucket(PARTITION_TABLE_ID, 1L, 0)),
-                        PARTITION_TABLE_INFO,
-                        PARTITION_TABLE_INFO.getSchemaInfo(),
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        CompletableFuture<LookupResultForBucket> second =
-                manager.lookup(
-                        lookupData(new TableBucket(PARTITION_TABLE_ID, 2L, 0)),
-                        PARTITION_TABLE_INFO,
-                        PARTITION_TABLE_INFO.getSchemaInfo(),
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        LookupResultForBucket third =
-                manager.lookup(
-                                lookupData(new TableBucket(PARTITION_TABLE_ID, 3L, 0)),
-                                PARTITION_TABLE_INFO,
-                                PARTITION_TABLE_INFO.getSchemaInfo(),
-                                NO_OP_LOOKUP_METRIC_RECORDER)
-                        .get(1, TimeUnit.SECONDS);
-
-        assertThat(first).isNotDone();
-        assertThat(second).isNotDone();
-        assertThat(executor.numQueuedTasks()).isEqualTo(2);
-        assertThat(third.getError().error()).isEqualTo(Errors.HISTORICAL_PARTITION_THROTTLED);
-    }
-
-    @Test
-    void testRejectNonPositiveHistoricalLookupMaxQueuedRequests() {
-        Configuration conf = conf(0);
-        ManualExecutor executor = new ManualExecutor();
-
-        assertThatThrownBy(
-                        () ->
-                                new HistoricalLakeLookupManager(
-                                        conf,
-                                        null,
-                                        executor,
-                                        ioTmpDir,
-                                        DATA_DIR_VOLUME_BYTES,
-                                        Ticker.systemTicker(),
-                                        Scheduler.disabledScheduler(),
-                                        NO_OP_DISK_WRITE_GUARD))
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining(
-                        ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS.key());
-    }
-
-    @ParameterizedTest
-    @ValueSource(ints = {0, -1})
-    void testRejectNonPositiveHistoricalPartitionThreadPoolMaxSize(int maxThreadPoolSize) {
-        Configuration conf = conf(1);
-        conf.set(ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE, maxThreadPoolSize);
-
-        assertThatThrownBy(
-                        () ->
-                                new HistoricalLakeLookupManager(
-                                        conf,
-                                        null,
-                                        null,
-                                        ioTmpDir,
-                                        DATA_DIR_VOLUME_BYTES,
-                                        Ticker.systemTicker(),
-                                        Scheduler.disabledScheduler(),
-                                        NO_OP_DISK_WRITE_GUARD))
-                .isInstanceOf(IllegalArgumentException.class)
-                .hasMessageContaining(
-                        ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE.key());
-    }
-
     @Test
     void testCleansAndCreatesLookupCacheDirectoryOnStartup() throws Exception {
         File serverLookupDir = FlussPaths.historicalLookupRootDir(ioTmpDir);
@@ -221,15 +74,13 @@ class HistoricalLakeLookupManagerTest {
         File staleLookupFile = new File(serverLookupDir, "stale-lookup-file");
         assertThat(staleLookupFile.createNewFile()).isTrue();
 
-        ManualExecutor executor = new ManualExecutor();
-        TestingHistoricalLakeLookupManager manager =
-                new TestingHistoricalLakeLookupManager(conf(1), executor);
+        TestingHistoricalLakeLookupManager manager = new TestingHistoricalLakeLookupManager(conf());
 
         assertThat(staleLookupFile).exists();
         manager.startup(NO_OP_SCHEDULER);
         assertThat(staleLookupFile).doesNotExist();
         assertThat(serverLookupDir).isDirectory();
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         assertThat(manager.createdIoTmpDirs.get(0)).startsWith(serverLookupDir.getAbsolutePath());
 
         File liveLookupFile = new File(serverLookupDir, "live-lookup-file");
@@ -240,8 +91,7 @@ class HistoricalLakeLookupManagerTest {
 
     @Test
     void testCreatesLookuperWithTableKvConfig() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        TestingHistoricalLakeLookupManager manager = createTestingManager(executor);
+        TestingHistoricalLakeLookupManager manager = createTestingManager();
         TableDescriptor indexedDescriptor =
                 TableDescriptor.builder(PARTITION_TABLE_INFO.toTableDescriptor())
                         .kvFormat(KvFormat.INDEXED)
@@ -259,7 +109,7 @@ class HistoricalLakeLookupManagerTest {
                         PARTITION_TABLE_INFO.getCreatedTime(),
                         PARTITION_TABLE_INFO.getModifiedTime());
 
-        lookupAndRun(manager, executor, indexedTableInfo);
+        lookup(manager, indexedTableInfo);
 
         assertThat(manager.createdTableConfigs).hasSize(1);
         TableConfig createdTableConfig = manager.createdTableConfigs.get(0);
@@ -270,23 +120,21 @@ class HistoricalLakeLookupManagerTest {
 
     @Test
     void testDoesNotReuseLookuperForRecreatedTable() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        TestingHistoricalLakeLookupManager manager = createTestingManager(executor);
+        TestingHistoricalLakeLookupManager manager = createTestingManager();
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         TableInfo recreatedTableInfo =
                 tableInfo(PARTITION_TABLE_ID + 1, PARTITION_TABLE_INFO.getSchemaId());
-        lookupAndRun(manager, executor, recreatedTableInfo);
+        lookup(manager, recreatedTableInfo);
 
         assertThat(manager.createdLookupers).hasSize(2);
     }
 
     @Test
     void testInvalidatesLookuperOnSchemaAndLifecycleChanges() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        TestingHistoricalLakeLookupManager manager = createTestingManager(executor);
+        TestingHistoricalLakeLookupManager manager = createTestingManager();
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         TestingLakeTableLookuper initialLookuper = manager.createdLookupers.get(0);
 
         Schema evolvedSchema =
@@ -296,7 +144,7 @@ class HistoricalLakeLookupManagerTest {
                         .build();
         SchemaInfo evolvedSchemaInfo =
                 new SchemaInfo(evolvedSchema, PARTITION_TABLE_INFO.getSchemaId() + 1);
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO, evolvedSchemaInfo);
+        lookup(manager, PARTITION_TABLE_INFO, evolvedSchemaInfo);
         assertThat(initialLookuper.closed).isTrue();
         assertThat(manager.createdLookupers).hasSize(2);
 
@@ -309,16 +157,41 @@ class HistoricalLakeLookupManagerTest {
         manager.invalidateTableLookuper(PARTITION_TABLE_ID);
         assertThat(evolvedLookuper.closed).isTrue();
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO, evolvedSchemaInfo);
+        lookup(manager, PARTITION_TABLE_INFO, evolvedSchemaInfo);
+        assertThat(manager.createdLookupers).hasSize(3);
+    }
+
+    @Test
+    void testRefreshesLookuperWhenLakeSnapshotChanges() throws Exception {
+        TestingHistoricalLakeLookupManager manager = createTestingManager();
+
+        lookup(manager, PARTITION_TABLE_INFO);
+        TestingLakeTableLookuper initialLookuper = manager.createdLookupers.get(0);
+
+        manager.requireLakeSnapshot(PARTITION_TABLE_ID, 10L);
+        lookup(manager, PARTITION_TABLE_INFO);
+        assertThat(initialLookuper.closed).isTrue();
+        assertThat(manager.createdLookupers).hasSize(2);
+
+        TestingLakeTableLookuper snapshotTenLookuper = manager.createdLookupers.get(1);
+        // Snapshot IDs are opaque; a numerically smaller ID may identify a newer snapshot.
+        manager.requireLakeSnapshot(PARTITION_TABLE_ID, 9L);
+        lookup(manager, PARTITION_TABLE_INFO);
+        assertThat(snapshotTenLookuper.closed).isTrue();
+        assertThat(manager.createdLookupers).hasSize(3);
+
+        TestingLakeTableLookuper snapshotNineLookuper = manager.createdLookupers.get(2);
+        manager.requireLakeSnapshot(PARTITION_TABLE_ID, 9L);
+        lookup(manager, PARTITION_TABLE_INFO);
+        assertThat(snapshotNineLookuper.closed).isFalse();
         assertThat(manager.createdLookupers).hasSize(3);
     }
 
     @Test
     void testDoesNotReplaceLookuperForUnrelatedTableConfigChange() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        TestingHistoricalLakeLookupManager manager = createTestingManager(executor);
+        TestingHistoricalLakeLookupManager manager = createTestingManager();
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         TestingLakeTableLookuper initialLookuper = manager.createdLookupers.get(0);
 
         TableDescriptor changedDescriptor =
@@ -336,7 +209,7 @@ class HistoricalLakeLookupManagerTest {
                         PARTITION_TABLE_INFO.getRemoteDataDir(),
                         PARTITION_TABLE_INFO.getCreatedTime(),
                         PARTITION_TABLE_INFO.getModifiedTime());
-        lookupAndRun(manager, executor, changedTableInfo);
+        lookup(manager, changedTableInfo);
 
         assertThat(manager.createdLookupers).hasSize(1);
         assertThat(initialLookuper.closed).isFalse();
@@ -344,7 +217,6 @@ class HistoricalLakeLookupManagerTest {
 
     @Test
     void testDynamicallyUpdatesExpirationAndExpiresIdleLookuper() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
         AtomicLong tickerNanos = new AtomicLong();
         AtomicReference<FutureTask<Void>> expirationTask = new AtomicReference<>();
         Scheduler cacheScheduler =
@@ -360,13 +232,10 @@ class HistoricalLakeLookupManagerTest {
                 };
         TestingHistoricalLakeLookupManager manager =
                 new TestingHistoricalLakeLookupManager(
-                        confWithExpiration(Duration.ofHours(1)),
-                        executor,
-                        tickerNanos::get,
-                        cacheScheduler);
+                        confWithExpiration(Duration.ofHours(1)), tickerNanos::get, cacheScheduler);
         manager.startup(NO_OP_SCHEDULER);
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         TestingLakeTableLookuper expiredLookuper = manager.createdLookupers.get(0);
 
         manager.reconfigure(confWithExpiration(Duration.ofMinutes(30)));
@@ -375,30 +244,21 @@ class HistoricalLakeLookupManagerTest {
         expirationTask.get().run();
 
         assertThat(expiredLookuper.closed).isTrue();
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         assertThat(manager.createdLookupers).hasSize(2);
     }
 
     @Test
     void testEvictsLookuperWhenCachedTableLimitIsExceeded() throws Exception {
-        ManualExecutor executor = new ManualExecutor();
-        Configuration conf = conf(1);
+        Configuration conf = conf();
         conf.set(ConfigOptions.SERVER_HISTORICAL_PARTITION_LOOKUP_CACHE_MAX_DISK_RATIO, 0.20);
         TestingHistoricalLakeLookupManager manager =
                 new TestingHistoricalLakeLookupManager(
-                        conf,
-                        executor,
-                        Ticker.systemTicker(),
-                        Scheduler.disabledScheduler(),
-                        100,
-                        0);
+                        conf, Ticker.systemTicker(), Scheduler.disabledScheduler(), 100, 0);
         manager.startup(NO_OP_SCHEDULER);
 
         for (int i = 0; i < 11; i++) {
-            lookupAndRun(
-                    manager,
-                    executor,
-                    tableInfo(PARTITION_TABLE_ID + i, PARTITION_TABLE_INFO.getSchemaId()));
+            lookup(manager, tableInfo(PARTITION_TABLE_ID + i, PARTITION_TABLE_INFO.getSchemaId()));
         }
 
         assertThat(manager.createdLookupers).hasSize(11);
@@ -410,15 +270,14 @@ class HistoricalLakeLookupManagerTest {
 
     @Test
     void testReconfiguresLakePropertiesAndInvalidatesLookuper() throws Exception {
-        Configuration initialConf = conf(1);
+        Configuration initialConf = conf();
         initialConf.set(ConfigOptions.DATALAKE_FORMAT, DataLakeFormat.PAIMON);
         initialConf.setString("datalake.paimon.warehouse", "old-warehouse");
-        ManualExecutor executor = new ManualExecutor();
         TestingHistoricalLakeLookupManager manager =
-                new TestingHistoricalLakeLookupManager(initialConf, executor);
+                new TestingHistoricalLakeLookupManager(initialConf);
         manager.startup(NO_OP_SCHEDULER);
 
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         TestingLakeTableLookuper initialLookuper = manager.createdLookupers.get(0);
 
         Configuration newConf = new Configuration(initialConf);
@@ -427,46 +286,26 @@ class HistoricalLakeLookupManagerTest {
 
         assertThat(initialLookuper.closed).isTrue();
         assertThat(manager.cachedTableCount()).isZero();
-        lookupAndRun(manager, executor, PARTITION_TABLE_INFO);
+        lookup(manager, PARTITION_TABLE_INFO);
         assertThat(manager.createdLookupers).hasSize(2);
         assertThat(manager.createdClusterConfigs.get(1).toMap())
                 .containsEntry("datalake.paimon.warehouse", "new-warehouse");
     }
 
-    private HistoricalLakeLookupManager createManager(
-            int maxQueuedHistoricalRequests, ManualExecutor executor) {
-        HistoricalLakeLookupManager manager =
-                new HistoricalLakeLookupManager(
-                        conf(maxQueuedHistoricalRequests),
-                        null,
-                        executor,
-                        ioTmpDir,
-                        DATA_DIR_VOLUME_BYTES,
-                        Ticker.systemTicker(),
-                        Scheduler.disabledScheduler(),
-                        NO_OP_DISK_WRITE_GUARD);
+    private TestingHistoricalLakeLookupManager createTestingManager() {
+        TestingHistoricalLakeLookupManager manager = new TestingHistoricalLakeLookupManager(conf());
         manager.startup(NO_OP_SCHEDULER);
         return manager;
     }
 
-    private TestingHistoricalLakeLookupManager createTestingManager(ManualExecutor executor) {
-        TestingHistoricalLakeLookupManager manager =
-                new TestingHistoricalLakeLookupManager(conf(1), executor);
-        manager.startup(NO_OP_SCHEDULER);
-        return manager;
-    }
-
-    private Configuration conf(int maxQueuedHistoricalRequests) {
+    private Configuration conf() {
         Configuration conf = new Configuration();
-        conf.set(
-                ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS,
-                maxQueuedHistoricalRequests);
         conf.set(ConfigOptions.DATA_DIR, ioTmpDir.getAbsolutePath());
         return conf;
     }
 
     private Configuration confWithExpiration(Duration expiration) {
-        Configuration conf = conf(1);
+        Configuration conf = conf();
         conf.set(
                 ConfigOptions.SERVER_HISTORICAL_PARTITION_LOOKUPER_CACHE_EXPIRE_AFTER_ACCESS,
                 expiration);
@@ -478,13 +317,9 @@ class HistoricalLakeLookupManagerTest {
                 tableBucket, Collections.singletonList(new byte[] {1}), "2024");
     }
 
-    private static CompletableFuture<LookupResultForBucket> lookup(
-            HistoricalLakeLookupManager manager, TableInfo tableInfo) {
-        return manager.lookup(
-                lookupData(new TableBucket(tableInfo.getTableId(), 1L, 0)),
-                tableInfo,
-                tableInfo.getSchemaInfo(),
-                NO_OP_LOOKUP_METRIC_RECORDER);
+    private static void lookup(HistoricalLakeLookupManager manager, TableInfo tableInfo)
+            throws Exception {
+        lookup(manager, tableInfo, tableInfo.getSchemaInfo());
     }
 
     private static TableInfo tableInfo(long tableId, int schemaId) {
@@ -498,44 +333,18 @@ class HistoricalLakeLookupManagerTest {
                 PARTITION_TABLE_INFO.getModifiedTime());
     }
 
-    private static void lookupAndRun(
-            HistoricalLakeLookupManager manager, ManualExecutor executor, TableInfo tableInfo)
-            throws Exception {
-        lookupAndRun(manager, executor, tableInfo, tableInfo.getSchemaInfo());
-    }
-
-    private static void lookupAndRun(
-            HistoricalLakeLookupManager manager,
-            ManualExecutor executor,
-            TableInfo tableInfo,
-            SchemaInfo schemaInfo)
-            throws Exception {
-        LookupResultForBucket result = lookupResultAndRun(manager, executor, tableInfo, schemaInfo);
-        assertThat(result.failed()).isFalse();
-        assertThat(result.originalPartitionName()).isEqualTo("2024");
-    }
-
-    private static LookupResultForBucket lookupResultAndRun(
-            HistoricalLakeLookupManager manager, ManualExecutor executor, TableInfo tableInfo)
-            throws Exception {
-        return lookupResultAndRun(manager, executor, tableInfo, tableInfo.getSchemaInfo());
-    }
-
-    private static LookupResultForBucket lookupResultAndRun(
-            HistoricalLakeLookupManager manager,
-            ManualExecutor executor,
-            TableInfo tableInfo,
-            SchemaInfo schemaInfo)
+    private static void lookup(
+            HistoricalLakeLookupManager manager, TableInfo tableInfo, SchemaInfo schemaInfo)
             throws Exception {
         TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), 1L, 0);
-        CompletableFuture<LookupResultForBucket> future =
-                manager.lookup(
-                        lookupData(tableBucket),
-                        tableInfo,
-                        schemaInfo,
-                        NO_OP_LOOKUP_METRIC_RECORDER);
-        executor.runNext();
-        return future.get(1, TimeUnit.SECONDS);
+        LookupDataForBucket lookupData = lookupData(tableBucket);
+        manager.lookup(
+                lookupData,
+                tableInfo,
+                schemaInfo,
+                ResolvedPartitionSpec.fromPartitionName(
+                        tableInfo.getPartitionKeys(), lookupData.originalPartitionName()),
+                NO_OP_LOOKUP_METRIC_RECORDER);
     }
 
     private static final class TestingHistoricalLakeLookupManager
@@ -547,11 +356,10 @@ class HistoricalLakeLookupManagerTest {
         private final List<Configuration> createdClusterConfigs = new ArrayList<>();
         private final long lookupCacheFileBytes;
 
-        private TestingHistoricalLakeLookupManager(Configuration conf, ManualExecutor executor) {
+        private TestingHistoricalLakeLookupManager(Configuration conf) {
             super(
                     conf,
                     null,
-                    executor,
                     new File(conf.get(ConfigOptions.DATA_DIR)),
                     DATA_DIR_VOLUME_BYTES,
                     Ticker.systemTicker(),
@@ -561,14 +369,10 @@ class HistoricalLakeLookupManagerTest {
         }
 
         private TestingHistoricalLakeLookupManager(
-                Configuration conf,
-                ManualExecutor executor,
-                Ticker ticker,
-                Scheduler cacheScheduler) {
+                Configuration conf, Ticker ticker, Scheduler cacheScheduler) {
             super(
                     conf,
                     null,
-                    executor,
                     new File(conf.get(ConfigOptions.DATA_DIR)),
                     DATA_DIR_VOLUME_BYTES,
                     ticker,
@@ -579,7 +383,6 @@ class HistoricalLakeLookupManagerTest {
 
         private TestingHistoricalLakeLookupManager(
                 Configuration conf,
-                ManualExecutor executor,
                 Ticker ticker,
                 Scheduler cacheScheduler,
                 long dataDirVolumeBytes,
@@ -587,7 +390,6 @@ class HistoricalLakeLookupManagerTest {
             super(
                     conf,
                     null,
-                    executor,
                     new File(conf.get(ConfigOptions.DATA_DIR)),
                     dataDirVolumeBytes,
                     ticker,
@@ -671,55 +473,4 @@ class HistoricalLakeLookupManagerTest {
             return null;
         }
     }
-
-    private static final class ManualExecutor extends AbstractExecutorService {
-        private final BlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
-        private volatile boolean shutdown;
-
-        @Override
-        public void shutdown() {
-            shutdown = true;
-        }
-
-        @Override
-        public List<Runnable> shutdownNow() {
-            shutdown = true;
-            List<Runnable> remainingTasks = new ArrayList<>();
-            tasks.drainTo(remainingTasks);
-            return remainingTasks;
-        }
-
-        @Override
-        public boolean isShutdown() {
-            return shutdown;
-        }
-
-        @Override
-        public boolean isTerminated() {
-            return shutdown && tasks.isEmpty();
-        }
-
-        @Override
-        public boolean awaitTermination(long timeout, TimeUnit unit) {
-            return isTerminated();
-        }
-
-        @Override
-        public void execute(Runnable command) {
-            if (shutdown) {
-                throw new RejectedExecutionException();
-            }
-            tasks.add(command);
-        }
-
-        private void runNext() throws Exception {
-            Runnable task = tasks.poll(1, TimeUnit.SECONDS);
-            assertThat(task).isNotNull();
-            task.run();
-        }
-
-        private int numQueuedTasks() {
-            return tasks.size();
-        }
-    }
 }
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java
new file mode 100644
index 000000000..9c69edabe
--- /dev/null
+++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java
@@ -0,0 +1,997 @@
+/*
+ * 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.fluss.server.replica.historical;
+
+import org.apache.fluss.cluster.Endpoint;
+import org.apache.fluss.cluster.ServerType;
+import org.apache.fluss.config.AutoPartitionTimeUnit;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.HistoricalPartitionThrottledException;
+import org.apache.fluss.exception.InvalidPartitionException;
+import org.apache.fluss.lake.lakestorage.LakeTableLookuper;
+import org.apache.fluss.metadata.ChangelogImage;
+import org.apache.fluss.metadata.DataLakeFormat;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.ResolvedPartitionSpec;
+import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.SchemaGetter;
+import org.apache.fluss.metadata.SchemaInfo;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TableDescriptor;
+import org.apache.fluss.metadata.TableInfo;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.BinaryValue;
+import org.apache.fluss.record.ChangeType;
+import org.apache.fluss.record.KvRecordBatch;
+import org.apache.fluss.record.LogRecords;
+import org.apache.fluss.record.TestingSchemaGetter;
+import org.apache.fluss.row.BinaryString;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.row.encode.CompactedKeyEncoder;
+import org.apache.fluss.row.encode.ValueDecoder;
+import org.apache.fluss.row.encode.ValueEncoder;
+import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
+import org.apache.fluss.rpc.entity.LookupResultForBucket;
+import org.apache.fluss.rpc.entity.PutKvResultForBucket;
+import org.apache.fluss.rpc.protocol.ApiKeys;
+import org.apache.fluss.rpc.protocol.Errors;
+import org.apache.fluss.rpc.protocol.MergeMode;
+import org.apache.fluss.server.entity.FetchReqInfo;
+import org.apache.fluss.server.entity.LookupDataForBucket;
+import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
+import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
+import org.apache.fluss.server.kv.KvStateLookupResult;
+import org.apache.fluss.server.kv.KvTablet;
+import org.apache.fluss.server.log.FetchParams;
+import org.apache.fluss.server.log.LogAppendInfo;
+import org.apache.fluss.server.metadata.BucketMetadata;
+import org.apache.fluss.server.metadata.ClusterMetadata;
+import org.apache.fluss.server.metadata.PartitionMetadata;
+import org.apache.fluss.server.metadata.ServerInfo;
+import org.apache.fluss.server.metadata.TableMetadata;
+import org.apache.fluss.server.replica.Replica;
+import org.apache.fluss.server.replica.ReplicaTestBase;
+import org.apache.fluss.server.zk.data.LeaderAndIsr;
+import org.apache.fluss.server.zk.data.TableRegistration;
+import org.apache.fluss.server.zk.data.lake.LakeTableHelper;
+import org.apache.fluss.server.zk.data.lake.LakeTableSnapshot;
+import org.apache.fluss.testutils.common.ManuallyTriggeredScheduledExecutorService;
+import org.apache.fluss.types.DataField;
+import org.apache.fluss.types.DataTypes;
+import org.apache.fluss.types.RowType;
+import org.apache.fluss.utils.types.Tuple2;
+
+import com.github.benmanes.caffeine.cache.Scheduler;
+import com.github.benmanes.caffeine.cache.Ticker;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR;
+import static org.apache.fluss.server.coordinator.CoordinatorContext.INITIAL_COORDINATOR_EPOCH;
+import static org.apache.fluss.server.kv.KvTabletTestUtils.flushAndWait;
+import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_BUCKET_EPOCH;
+import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_LEADER_EPOCH;
+import static org.apache.fluss.testutils.DataTestUtils.assertLogRecordsEqualsWithRowKind;
+import static org.apache.fluss.testutils.DataTestUtils.compactedRow;
+import static org.apache.fluss.testutils.DataTestUtils.genKvRecordBatch;
+import static org.apache.fluss.testutils.DataTestUtils.row;
+import static org.apache.fluss.testutils.InternalRowAssert.assertThatRow;
+import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for historical primary-key writes coordinated by {@link HistoricalPartitionManager}. */
+class HistoricalPartitionManagerTest extends ReplicaTestBase {
+
+    private static final long TABLE_ID = 987654L;
+    private static final long PARTITION_ID = 123L;
+    private static final TablePath TABLE_PATH =
+            TablePath.of("historical_write_db", "historical_write_table");
+    private static final String ORIGINAL_PARTITION = "20240107";
+    private static final String ANOTHER_ORIGINAL_PARTITION = "20240108";
+    private static final String HISTORICAL_PARTITION = HISTORICAL_PARTITION_VALUE;
+    private static final TableBucket TABLE_BUCKET = new TableBucket(TABLE_ID, PARTITION_ID, 0);
+
+    @Test
+    void testResolvesMultipleLakeMissesWithoutPrewriteRollback() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        KvTablet kvTablet = replica.getKvTablet();
+        assertThat(kvTablet).isNotNull();
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        CompactedKeyEncoder keyEncoder = new CompactedKeyEncoder(keyType);
+        byte[] firstKey = keyEncoder.encodeKey(row(1, "us"));
+        byte[] secondKey = keyEncoder.encodeKey(row(2, "eu"));
+        KvRecordBatch insertBatch =
+                batch(
+                        keyType,
+                        tableInfo.getRowType(),
+                        Tuple2.of(
+                                new Object[] {1, "us"},
+                                new Object[] {1, "us", ORIGINAL_PARTITION, "v1"}),
+                        // The second record reuses the first record's staged state and must not
+                        // trigger another lake lookup for the same key.
+                        Tuple2.of(
+                                new Object[] {1, "us"},
+                                new Object[] {1, "us", ORIGINAL_PARTITION, "v1-updated"}),
+                        Tuple2.of(
+                                new Object[] {2, "eu"},
+                                new Object[] {2, "eu", ORIGINAL_PARTITION, "v2"}));
+        long truncateCount =
+                replicaManager.getServerMetricGroup().kvTruncateAsErrorCount().getCount();
+
+        try {
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(TABLE_BUCKET, insertBatch, ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+
+            assertThat(lakeLookupManager.lookupCount).hasValue(2);
+            assertThat(lakeLookupManager.lookupBatchCount).hasValue(1);
+            assertThat(replicaManager.getServerMetricGroup().kvTruncateAsErrorCount().getCount())
+                    .isEqualTo(truncateCount);
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    firstKey,
+                    tableInfo,
+                    row(1, "us", ORIGINAL_PARTITION, "v1-updated"));
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    secondKey,
+                    tableInfo,
+                    row(2, "eu", ORIGINAL_PARTITION, "v2"));
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testWalFullRowUpsertDoesNotLookupLake() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader(ChangelogImage.WAL);
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        KvTablet kvTablet = replica.getKvTablet();
+        assertThat(kvTablet).isNotNull();
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        byte[] primaryKey = new CompactedKeyEncoder(keyType).encodeKey(row(1, "us"));
+        KvRecordBatch insertBatch =
+                batch(
+                        keyType,
+                        tableInfo.getRowType(),
+                        Tuple2.of(
+                                new Object[] {1, "us"},
+                                new Object[] {1, "us", ORIGINAL_PARTITION, "v1"}));
+
+        try {
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(TABLE_BUCKET, insertBatch, ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+
+            assertThat(lakeLookupManager.lookupCount).hasValue(0);
+            assertThat(lakeLookupManager.lookupBatchCount).hasValue(0);
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", ORIGINAL_PARTITION, "v1"));
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testHistoricalInsertUpdateAndDelete() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        KvTablet kvTablet = replica.getKvTablet();
+        assertThat(kvTablet).isNotNull();
+        assertThat(kvManager.getKv(TABLE_BUCKET)).contains(kvTablet);
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        RowType rowType = tableInfo.getRowType();
+        byte[] primaryKey = new CompactedKeyEncoder(keyType).encodeKey(row(1, "us"));
+
+        try {
+            // The first write misses both local state and lake, so it creates a local overlay.
+            KvRecordBatch insertBatch =
+                    batch(
+                            keyType,
+                            rowType,
+                            Tuple2.of(
+                                    new Object[] {1, "us"},
+                                    new Object[] {1, "us", "20240107", "v1"}));
+            assertThat(
+                            historicalPartitionManager
+                                    .processPut(
+                                            replica,
+                                            new PutKvDataForBucket(
+                                                    TABLE_BUCKET, insertBatch, ORIGINAL_PARTITION),
+                                            null,
+                                            MergeMode.DEFAULT,
+                                            1)
+                                    .lastOffset())
+                    .isZero();
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240107", "v1"));
+            assertThat(lakeLookupManager.lookupCount).hasValue(1);
+
+            // The same primary key in another original partition must use a separate state entry.
+            KvRecordBatch anotherPartitionBatch =
+                    batch(
+                            keyType,
+                            rowType,
+                            Tuple2.of(
+                                    new Object[] {1, "us"},
+                                    new Object[] {1, "us", "20240108", "another"}));
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(
+                            TABLE_BUCKET, anotherPartitionBatch, ANOTHER_ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240107", "v1"));
+            assertHistoricalValue(
+                    kvTablet,
+                    ANOTHER_ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240108", "another"));
+            assertThat(lakeLookupManager.lookupCount).hasValue(2);
+
+            // Exercise the ReplicaManager entry point; the update should reuse the local overlay.
+            KvRecordBatch updateBatch =
+                    batch(
+                            keyType,
+                            rowType,
+                            Tuple2.of(
+                                    new Object[] {1, "us"},
+                                    new Object[] {1, "us", "20240107", "v2"}));
+            CompletableFuture<List<PutKvResultForBucket>> updateResponse =
+                    new CompletableFuture<>();
+            assertThat(replica.tableMetrics().totalHistoricalPutKvRequests().getCount()).isZero();
+            assertThat(replica.tableMetrics().failedHistoricalPutKvRequests().getCount()).isZero();
+            replicaManager.putHistoricalRecordsToKv(
+                    10_000,
+                    1,
+                    Collections.singletonList(
+                            new PutKvDataForBucket(TABLE_BUCKET, updateBatch, ORIGINAL_PARTITION)),
+                    null,
+                    MergeMode.DEFAULT,
+                    ApiKeys.PUT_KV.highestSupportedVersion,
+                    updateResponse::complete);
+            Map<TableBucket, PutKvResultForBucket> updateResults =
+                    updateResponse.get(10, TimeUnit.SECONDS).stream()
+                            .collect(
+                                    java.util.stream.Collectors.toMap(
+                                            PutKvResultForBucket::getTableBucket,
+                                            result -> result));
+            assertThat(updateResults.get(TABLE_BUCKET).failed()).isFalse();
+            assertThat(replica.tableMetrics().totalHistoricalPutKvRequests().getCount()).isOne();
+            assertThat(replica.tableMetrics().failedHistoricalPutKvRequests().getCount()).isZero();
+            assertHistoricalValue(
+                    kvTablet,
+                    ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240107", "v2"));
+            assertHistoricalValue(
+                    kvTablet,
+                    ANOTHER_ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240108", "another"));
+            assertThat(lakeLookupManager.lookupCount).hasValue(2);
+
+            // Historical lookup should observe the updated value from the local overlay.
+            CompletableFuture<List<LookupResultForBucket>> lookupResponse =
+                    new CompletableFuture<>();
+            replicaManager.historicalLookups(
+                    Collections.singletonList(
+                            new LookupDataForBucket(
+                                    TABLE_BUCKET,
+                                    Collections.singletonList(primaryKey),
+                                    ORIGINAL_PARTITION)),
+                    lookupResponse::complete);
+            LookupResultForBucket lookupResult = lookupResponse.get(10, TimeUnit.SECONDS).get(0);
+            assertThat(lookupResult.failed()).isFalse();
+            BinaryValue lookedUpValue =
+                    new ValueDecoder(
+                                    schemaGetter(tableInfo),
+                                    tableInfo.getTableConfig().getKvFormat())
+                            .decodeValue(lookupResult.lookupValues().get(0));
+            assertThat(lookedUpValue.row.getString(3)).isEqualTo(BinaryString.fromString("v2"));
+
+            // Keep a tombstone locally so a later lookup cannot resurrect the value from lake.
+            KvRecordBatch deleteBatch =
+                    batch(keyType, rowType, Tuple2.of(new Object[] {1, "us"}, null));
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(TABLE_BUCKET, deleteBatch, ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+            assertThat(kvTablet.lookupHistoricalLocal(ORIGINAL_PARTITION, primaryKey))
+                    .isEqualTo(KvStateLookupResult.deleted());
+            assertHistoricalValue(
+                    kvTablet,
+                    ANOTHER_ORIGINAL_PARTITION,
+                    primaryKey,
+                    tableInfo,
+                    row(1, "us", "20240108", "another"));
+            assertThat(lakeLookupManager.lookupCount).hasValue(2);
+
+            CompletableFuture<List<LookupResultForBucket>> deletedLookupResponse =
+                    new CompletableFuture<>();
+            replicaManager.historicalLookups(
+                    Collections.singletonList(
+                            new LookupDataForBucket(
+                                    TABLE_BUCKET,
+                                    Collections.singletonList(primaryKey),
+                                    ORIGINAL_PARTITION)),
+                    deletedLookupResponse::complete);
+            LookupResultForBucket deletedLookup =
+                    deletedLookupResponse.get(10, TimeUnit.SECONDS).get(0);
+            assertThat(deletedLookup.failed()).isFalse();
+            assertThat(deletedLookup.lookupValues()).containsExactly((byte[]) null);
+
+            // Historical replicas must reject the normal KV write path.
+            assertThatThrownBy(
+                            () ->
+                                    replica.putRecordsToLeader(
+                                            insertBatch, null, MergeMode.DEFAULT, 1))
+                    .isInstanceOf(InvalidPartitionException.class);
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testUpdateAndDeleteFromLakeFallback() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        KvTablet kvTablet = replica.getKvTablet();
+        assertThat(kvTablet).isNotNull();
+
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        RowType rowType = tableInfo.getRowType();
+        String updatePartition = "20240109";
+        String deletePartition = "20240110";
+        byte[] updateKey = new CompactedKeyEncoder(keyType).encodeKey(row(1, "us"));
+        byte[] deleteKey = new CompactedKeyEncoder(keyType).encodeKey(row(2, "eu"));
+        short schemaId = (short) tableInfo.getSchemaId();
+        // Seed lake-only values so the first local operations must use lake fallback.
+        lakeLookupManager.putLakeValue(
+                updatePartition,
+                ValueEncoder.encodeValue(
+                        schemaId,
+                        compactedRow(rowType, new Object[] {1, "us", updatePartition, "lake-v1"})));
+        lakeLookupManager.putLakeValue(
+                deletePartition,
+                ValueEncoder.encodeValue(
+                        schemaId,
+                        compactedRow(rowType, new Object[] {2, "eu", deletePartition, "lake-v1"})));
+
+        try {
+            // Updating a lake-only value emits the before and after images.
+            KvRecordBatch updateBatch =
+                    batch(
+                            keyType,
+                            rowType,
+                            Tuple2.of(
+                                    new Object[] {1, "us"},
+                                    new Object[] {1, "us", updatePartition, "lake-v2"}));
+            assertThat(
+                            historicalPartitionManager
+                                    .processPut(
+                                            replica,
+                                            new PutKvDataForBucket(
+                                                    TABLE_BUCKET, updateBatch, updatePartition),
+                                            null,
+                                            MergeMode.DEFAULT,
+                                            1)
+                                    .numMessages())
+                    .isEqualTo(2);
+
+            // Deleting a lake-only value emits a delete and leaves a local tombstone.
+            KvRecordBatch deleteBatch =
+                    batch(keyType, rowType, Tuple2.of(new Object[] {2, "eu"}, null));
+            assertThat(
+                            historicalPartitionManager
+                                    .processPut(
+                                            replica,
+                                            new PutKvDataForBucket(
+                                                    TABLE_BUCKET, deleteBatch, deletePartition),
+                                            null,
+                                            MergeMode.DEFAULT,
+                                            1)
+                                    .numMessages())
+                    .isOne();
+
+            // Verify both lake fallbacks are materialized into the shared historical tablet.
+            flushAndWait(kvTablet, Long.MAX_VALUE);
+            assertHistoricalValue(
+                    kvTablet,
+                    updatePartition,
+                    updateKey,
+                    tableInfo,
+                    row(1, "us", updatePartition, "lake-v2"));
+            assertThat(kvTablet.lookupHistoricalLocal(deletePartition, deleteKey))
+                    .isEqualTo(KvStateLookupResult.deleted());
+            assertThat(lakeLookupManager.lookupCount).hasValue(2);
+
+            assertLogRecordsEqualsWithRowKind(
+                    tableInfo.getSchemaId(),
+                    rowType,
+                    fetchLog(0L),
+                    Arrays.asList(
+                            Tuple2.of(
+                                    ChangeType.UPDATE_BEFORE,
+                                    new Object[] {1, "us", updatePartition, "lake-v1"}),
+                            Tuple2.of(
+                                    ChangeType.UPDATE_AFTER,
+                                    new Object[] {1, "us", updatePartition, "lake-v2"}),
+                            Tuple2.of(
+                                    ChangeType.DELETE,
+                                    new Object[] {2, "eu", deletePartition, "lake-v1"})),
+                    schemaGetter(tableInfo));
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testLeaderChangeDuringLakeLookupFencesHistoricalWrite() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+        CountDownLatch lakeLookupStarted = new CountDownLatch(1);
+        CountDownLatch finishLakeLookup = new CountDownLatch(1);
+        lakeLookupManager.setLookupHook(
+                () -> {
+                    lakeLookupStarted.countDown();
+                    await(finishLakeLookup);
+                });
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        KvRecordBatch insertBatch =
+                batch(
+                        keyType,
+                        tableInfo.getRowType(),
+                        Tuple2.of(
+                                new Object[] {1, "us"},
+                                new Object[] {1, "us", ORIGINAL_PARTITION, "v1"}));
+        long logEndOffsetBeforeWrite = replica.getLocalLogEndOffset();
+
+        try {
+            CompletableFuture<PutKvResultForBucket> writeFuture =
+                    historicalPartitionManager.putKv(
+                            replica,
+                            new PutKvDataForBucket(TABLE_BUCKET, insertBatch, ORIGINAL_PARTITION),
+                            null,
+                            MergeMode.DEFAULT,
+                            1);
+            assertThat(lakeLookupStarted.await(10, TimeUnit.SECONDS)).isTrue();
+
+            // Both replica locks must be available while the lake lookup is blocked. Move the
+            // replica away and back so the retry observes a different leader epoch.
+            assertThat(replica.makeFollower(followerState())).isTrue();
+            replica.makeLeader(leaderStateAfterFollower());
+            finishLakeLookup.countDown();
+
+            PutKvResultForBucket result = writeFuture.get(10, TimeUnit.SECONDS);
+            assertThat(result.failed()).isTrue();
+            assertThat(result.getError().error()).isEqualTo(Errors.FENCED_LEADER_EPOCH_EXCEPTION);
+            assertThat(replica.getLocalLogEndOffset()).isEqualTo(logEndOffsetBeforeWrite);
+        } finally {
+            finishLakeLookup.countDown();
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testRecoversHistoricalOverlayFromLakeCommitOffset() throws Exception {
+        TableInfo tableInfo = registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        TestingHistoricalLakeLookupManager lakeLookupManager =
+                new TestingHistoricalLakeLookupManager(lookupConfiguration());
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration()),
+                        lakeLookupManager);
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        CompactedKeyEncoder keyEncoder = new CompactedKeyEncoder(keyType);
+        byte[] tieredPrimaryKey = keyEncoder.encodeKey(row(1, "us"));
+        byte[] deletedPrimaryKey = keyEncoder.encodeKey(row(2, "eu"));
+
+        try {
+            LogAppendInfo firstAppend =
+                    historicalPartitionManager.processPut(
+                            replica,
+                            new PutKvDataForBucket(
+                                    TABLE_BUCKET,
+                                    batch(
+                                            keyType,
+                                            tableInfo.getRowType(),
+                                            Tuple2.of(
+                                                    new Object[] {1, "us"},
+                                                    new Object[] {
+                                                        1, "us", ORIGINAL_PARTITION, "v1"
+                                                    })),
+                                    ORIGINAL_PARTITION),
+                            null,
+                            MergeMode.DEFAULT,
+                            1);
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(
+                            TABLE_BUCKET,
+                            batch(
+                                    keyType,
+                                    tableInfo.getRowType(),
+                                    Tuple2.of(
+                                            new Object[] {1, "us"},
+                                            new Object[] {
+                                                1, "us", ANOTHER_ORIGINAL_PARTITION, "another"
+                                            })),
+                            ANOTHER_ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(
+                            TABLE_BUCKET,
+                            batch(
+                                    keyType,
+                                    tableInfo.getRowType(),
+                                    Tuple2.of(
+                                            new Object[] {2, "eu"},
+                                            new Object[] {
+                                                2, "eu", ORIGINAL_PARTITION, "delete-me"
+                                            })),
+                            ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+            historicalPartitionManager.processPut(
+                    replica,
+                    new PutKvDataForBucket(
+                            TABLE_BUCKET,
+                            batch(
+                                    keyType,
+                                    tableInfo.getRowType(),
+                                    Tuple2.of(new Object[] {2, "eu"}, null)),
+                            ORIGINAL_PARTITION),
+                    null,
+                    MergeMode.DEFAULT,
+                    1);
+            KvTablet kvTabletBeforeFollower = replica.getKvTablet();
+            assertThat(kvTabletBeforeFollower).isNotNull();
+            flushAndWait(kvTabletBeforeFollower, Long.MAX_VALUE);
+            assertThat(replica.getLogHighWatermark()).isEqualTo(replica.getLocalLogEndOffset());
+
+            // Persist the exclusive end offset of the first write as the lake recovery point. The
+            // replica has not received this offset locally, so becoming leader must load it before
+            // creating the historical overlay.
+            long lakeCommitOffset = firstAppend.lastOffset() + 1;
+            new LakeTableHelper(zkClient, DEFAULT_REMOTE_DATA_DIR)
+                    .registerLakeTableSnapshotV1(
+                            TABLE_ID,
+                            new LakeTableSnapshot(
+                                    1L, Collections.singletonMap(TABLE_BUCKET, lakeCommitOffset)));
+            assertThat(replica.getLakeLogEndOffset()).isEqualTo(-1L);
+
+            // Dropping and recreating the leader KV tablet forces the overlay to be rebuilt only
+            // from WAL after the lake commit offset. The recovered tombstone must remain
+            // authoritative over lake fallback.
+            assertThat(replica.makeFollower(followerState())).isTrue();
+            CompletableFuture<List<NotifyLeaderAndIsrResultForBucket>> leaderFuture =
+                    new CompletableFuture<>();
+            replicaManager.becomeLeaderOrFollower(
+                    INITIAL_COORDINATOR_EPOCH,
+                    Collections.singletonList(leaderStateAfterFollower()),
+                    leaderFuture::complete);
+            assertThat(leaderFuture.get(10, TimeUnit.SECONDS))
+                    .containsOnly(new NotifyLeaderAndIsrResultForBucket(TABLE_BUCKET));
+
+            KvTablet recoveredKvTablet = replica.getKvTablet();
+            assertThat(recoveredKvTablet).isNotNull();
+            assertThat(replica.getLakeLogEndOffset()).isEqualTo(lakeCommitOffset);
+            assertThat(replica.getKvSnapshotManager()).isNull();
+            assertThat(recoveredKvTablet.getFlushedLogOffset())
+                    .isEqualTo(replica.getLogHighWatermark());
+            assertThat(recoveredKvTablet.getRocksDBKv().limitScan(10)).hasSize(2);
+            // The first record is covered by the lake commit offset and is not replayed locally.
+            assertThat(
+                            recoveredKvTablet.lookupHistoricalLocal(
+                                    ORIGINAL_PARTITION, tieredPrimaryKey))
+                    .isEqualTo(KvStateLookupResult.notFound());
+            assertThat(
+                            recoveredKvTablet.lookupHistoricalLocal(
+                                    ORIGINAL_PARTITION, deletedPrimaryKey))
+                    .isEqualTo(KvStateLookupResult.deleted());
+            assertHistoricalValue(
+                    recoveredKvTablet,
+                    ANOTHER_ORIGINAL_PARTITION,
+                    tieredPrimaryKey,
+                    tableInfo,
+                    row(1, "us", ANOTHER_ORIGINAL_PARTITION, "another"));
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    @Test
+    void testHistoricalLookupThrottledWhenPermitsExhausted() throws Exception {
+        registerHistoricalTableAndBecomeLeader();
+        Replica replica = replicaManager.getReplicaOrException(TABLE_BUCKET);
+        ManuallyTriggeredScheduledExecutorService executor =
+                new ManuallyTriggeredScheduledExecutorService();
+        HistoricalPartitionManager historicalPartitionManager =
+                new HistoricalPartitionManager(
+                        new HistoricalPartitionTaskExecutor(lookupConfiguration(), executor),
+                        new TestingHistoricalLakeLookupManager(lookupConfiguration()));
+
+        RowType keyType =
+                DataTypes.ROW(
+                        new DataField("id", DataTypes.INT()),
+                        new DataField("region", DataTypes.STRING()));
+        byte[] primaryKey = new CompactedKeyEncoder(keyType).encodeKey(row(1, "us"));
+        LookupDataForBucket lookupData =
+                new LookupDataForBucket(
+                        TABLE_BUCKET, Collections.singletonList(primaryKey), ORIGINAL_PARTITION);
+
+        try {
+            // Keep the first lookup queued so it retains the only available request permit.
+            CompletableFuture<LookupResultForBucket> first =
+                    historicalPartitionManager.lookup(
+                            replica, lookupData, (lookupTimeNanos, lookupFileDownloaded) -> {});
+            assertThat(first).isNotDone();
+            assertThat(executor.numQueuedRunnables()).isOne();
+            assertThat(historicalPartitionManager.numInflightRequests()).isOne();
+
+            LookupResultForBucket throttled =
+                    historicalPartitionManager
+                            .lookup(
+                                    replica,
+                                    lookupData,
+                                    (lookupTimeNanos, lookupFileDownloaded) -> {})
+                            .get(10, TimeUnit.SECONDS);
+            assertThat(throttled.failed()).isTrue();
+            assertThat(throttled.getError().error())
+                    .isEqualTo(Errors.HISTORICAL_PARTITION_THROTTLED);
+            assertThat(throttled.getError().exception())
+                    .isInstanceOf(HistoricalPartitionThrottledException.class);
+            assertThat(executor.numQueuedRunnables()).isOne();
+            assertThat(historicalPartitionManager.numInflightRequests()).isOne();
+        } finally {
+            historicalPartitionManager.close();
+        }
+    }
+
+    private LogRecords fetchLog(long fetchOffset) throws Exception {
+        CompletableFuture<Map<TableBucket, FetchLogResultForBucket>> future =
+                new CompletableFuture<>();
+        replicaManager.fetchLogRecords(
+                new FetchParams(-1, Integer.MAX_VALUE),
+                Collections.singletonMap(
+                        TABLE_BUCKET, new FetchReqInfo(TABLE_ID, fetchOffset, Integer.MAX_VALUE)),
+                null,
+                future::complete);
+        FetchLogResultForBucket result = future.get(10, TimeUnit.SECONDS).get(TABLE_BUCKET);
+        assertThat(result.failed()).isFalse();
+        return result.records();
+    }
+
+    private TableInfo registerHistoricalTableAndBecomeLeader() throws Exception {
+        return registerHistoricalTableAndBecomeLeader(ChangelogImage.FULL);
+    }
+
+    private TableInfo registerHistoricalTableAndBecomeLeader(ChangelogImage changelogImage)
+            throws Exception {
+        replicaManager.getDiskUsageMonitor().update(0.10);
+        Schema schema =
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .column("region", DataTypes.STRING())
+                        .column("dt", DataTypes.STRING())
+                        .column("value", DataTypes.STRING())
+                        .primaryKey("id", "region", "dt")
+                        .build();
+        TableDescriptor descriptor =
+                TableDescriptor.builder()
+                        .schema(schema)
+                        .distributedBy(1, "id")
+                        .partitionedBy("dt")
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true)
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_KEY, "dt")
+                        .property(
+                                ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT,
+                                AutoPartitionTimeUnit.DAY)
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION, 2)
+                        .property(ConfigOptions.TABLE_AUTO_PARTITION_TIMEZONE, "UTC")
+                        .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true)
+                        .property(ConfigOptions.TABLE_DATALAKE_FORMAT, DataLakeFormat.PAIMON)
+                        .property(ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED, true)
+                        .property(ConfigOptions.TABLE_CHANGELOG_IMAGE, changelogImage)
+                        .property(
+                                ConfigOptions.TABLE_KV_FORMAT_VERSION,
+                                ConfigOptions.KV_FORMAT_VERSION_2)
+                        .build();
+        TableInfo tableInfo =
+                TableInfo.of(TABLE_PATH, TABLE_ID, 1, descriptor, DEFAULT_REMOTE_DATA_DIR, 1L, 1L);
+        zkClient.registerTable(
+                TABLE_PATH,
+                TableRegistration.newTable(TABLE_ID, DEFAULT_REMOTE_DATA_DIR, descriptor));
+        zkClient.registerFirstSchema(TABLE_PATH, schema);
+
+        BucketMetadata bucketMetadata =
+                new BucketMetadata(
+                        TABLE_BUCKET.getBucket(),
+                        TABLET_SERVER_ID,
+                        INITIAL_LEADER_EPOCH,
+                        Collections.singletonList(TABLET_SERVER_ID));
+        ServerInfo tabletServer =
+                new ServerInfo(
+                        TABLET_SERVER_ID,
+                        "rack1",
+                        Endpoint.fromListenersString("CLIENT://localhost:90"),
+                        ServerType.TABLET_SERVER);
+        serverMetadataCache.updateClusterMetadata(
+                new ClusterMetadata(
+                        null,
+                        Collections.singleton(tabletServer),
+                        Collections.singletonList(
+                                new TableMetadata(tableInfo, Collections.emptyList())),
+                        Collections.singletonList(
+                                new PartitionMetadata(
+                                        TABLE_ID,
+                                        HISTORICAL_PARTITION,
+                                        PARTITION_ID,
+                                        Collections.singletonList(bucketMetadata)))));
+
+        CompletableFuture<List<NotifyLeaderAndIsrResultForBucket>> leaderFuture =
+                new CompletableFuture<>();
+        replicaManager.becomeLeaderOrFollower(
+                INITIAL_COORDINATOR_EPOCH,
+                Collections.singletonList(
+                        new NotifyLeaderAndIsrData(
+                                PhysicalTablePath.of(TABLE_PATH, HISTORICAL_PARTITION),
+                                TABLE_BUCKET,
+                                Collections.singletonList(TABLET_SERVER_ID),
+                                new LeaderAndIsr(
+                                        TABLET_SERVER_ID,
+                                        INITIAL_LEADER_EPOCH,
+                                        Collections.singletonList(TABLET_SERVER_ID),
+                                        Collections.emptyList(),
+                                        INITIAL_COORDINATOR_EPOCH,
+                                        INITIAL_BUCKET_EPOCH))),
+                leaderFuture::complete);
+        assertThat(leaderFuture.get(10, TimeUnit.SECONDS))
+                .containsOnly(new NotifyLeaderAndIsrResultForBucket(TABLE_BUCKET));
+        return tableInfo;
+    }
+
+    private static NotifyLeaderAndIsrData followerState() {
+        int newLeaderId = TABLET_SERVER_ID + 1;
+        List<Integer> replicas = Arrays.asList(TABLET_SERVER_ID, newLeaderId);
+        return new NotifyLeaderAndIsrData(
+                PhysicalTablePath.of(TABLE_PATH, HISTORICAL_PARTITION),
+                TABLE_BUCKET,
+                replicas,
+                new LeaderAndIsr(
+                        newLeaderId,
+                        INITIAL_LEADER_EPOCH + 1,
+                        replicas,
+                        Collections.emptyList(),
+                        INITIAL_COORDINATOR_EPOCH,
+                        INITIAL_BUCKET_EPOCH + 1));
+    }
+
+    private static NotifyLeaderAndIsrData leaderStateAfterFollower() {
+        List<Integer> replicas = Collections.singletonList(TABLET_SERVER_ID);
+        return new NotifyLeaderAndIsrData(
+                PhysicalTablePath.of(TABLE_PATH, HISTORICAL_PARTITION),
+                TABLE_BUCKET,
+                replicas,
+                new LeaderAndIsr(
+                        TABLET_SERVER_ID,
+                        INITIAL_LEADER_EPOCH + 2,
+                        replicas,
+                        Collections.emptyList(),
+                        INITIAL_COORDINATOR_EPOCH,
+                        INITIAL_BUCKET_EPOCH + 2));
+    }
+
+    private static void await(CountDownLatch latch) {
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+    }
+
+    @SafeVarargs
+    private static KvRecordBatch batch(
+            RowType keyType, RowType rowType, Tuple2<Object[], Object[]>... keyAndValues)
+            throws Exception {
+        List<Tuple2<Object[], Object[]>> records = Arrays.asList(keyAndValues);
+        return genKvRecordBatch(keyType, rowType, records);
+    }
+
+    private static void assertHistoricalValue(
+            KvTablet kvTablet,
+            String originalPartition,
+            byte[] primaryKey,
+            TableInfo tableInfo,
+            InternalRow expectedRow)
+            throws Exception {
+        KvStateLookupResult result = kvTablet.lookupHistoricalLocal(originalPartition, primaryKey);
+        assertThat(result.isPresent()).isTrue();
+        BinaryValue value =
+                new ValueDecoder(schemaGetter(tableInfo), tableInfo.getTableConfig().getKvFormat())
+                        .decodeValue(result.value());
+        assertThatRow(value.row).withSchema(tableInfo.getRowType()).isEqualTo(expectedRow);
+    }
+
+    private static SchemaGetter schemaGetter(TableInfo tableInfo) {
+        return new TestingSchemaGetter(
+                new SchemaInfo(tableInfo.getSchema(), tableInfo.getSchemaId()));
+    }
+
+    private final class TestingHistoricalLakeLookupManager extends HistoricalLakeLookupManager {
+        private final AtomicInteger lookupCount = new AtomicInteger();
+        private final AtomicInteger lookupBatchCount = new AtomicInteger();
+        private final Map<String, byte[]> lakeValuesByPartition = new HashMap<>();
+        private volatile @Nullable Runnable lookupHook;
+
+        private TestingHistoricalLakeLookupManager(Configuration configuration) {
+            super(
+                    configuration,
+                    null,
+                    new java.io.File(tempDir, "historical-lookup"),
+                    1L,
+                    Ticker.systemTicker(),
+                    Scheduler.disabledScheduler(),
+                    () -> {});
+        }
+
+        private void putLakeValue(String partitionName, byte[] value) {
+            lakeValuesByPartition.put(partitionName, value);
+        }
+
+        private void setLookupHook(Runnable lookupHook) {
+            this.lookupHook = lookupHook;
+        }
+
+        @Override
+        List<byte[]> lookup(
+                LookupDataForBucket lookupData,
+                TableInfo tableInfo,
+                SchemaInfo schemaInfo,
+                ResolvedPartitionSpec originalPartitionSpec,
+                LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) {
+            lookupBatchCount.incrementAndGet();
+            lookupCount.addAndGet(lookupData.keys().size());
+            Runnable hook = lookupHook;
+            if (hook != null) {
+                hook.run();
+            }
+            List<byte[]> values = new ArrayList<>(lookupData.keys().size());
+            for (int i = 0; i < lookupData.keys().size(); i++) {
+                values.add(lakeValuesByPartition.get(originalPartitionSpec.getPartitionName()));
+            }
+            return values;
+        }
+    }
+
+    private Configuration lookupConfiguration() {
+        Configuration configuration = new Configuration();
+        configuration.set(ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS, 1);
+        return configuration;
+    }
+}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutorTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutorTest.java
new file mode 100644
index 000000000..98574e7d9
--- /dev/null
+++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionTaskExecutorTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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.fluss.server.replica.historical;
+
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link HistoricalPartitionTaskExecutor}. */
+class HistoricalPartitionTaskExecutorTest {
+
+    @Test
+    void testSharedRequestLimit() throws Exception {
+        ManualExecutor executor = new ManualExecutor();
+        HistoricalPartitionTaskExecutor taskExecutor =
+                new HistoricalPartitionTaskExecutor(configuration(1), executor);
+
+        CompletableFuture<String> first = taskExecutor.submit(() -> "accepted", () -> "throttled");
+        CompletableFuture<String> second = taskExecutor.submit(() -> "accepted", () -> "throttled");
+
+        assertThat(first).isNotDone();
+        assertThat(second).isCompletedWithValue("throttled");
+        assertThat(taskExecutor.numInflightRequests()).isOne();
+
+        executor.runNext();
+        assertThat(first).isCompletedWithValue("accepted");
+        assertThat(taskExecutor.numInflightRequests()).isZero();
+    }
+
+    @Test
+    void testReleasesPermitAfterFailure() throws Exception {
+        ManualExecutor executor = new ManualExecutor();
+        HistoricalPartitionTaskExecutor taskExecutor =
+                new HistoricalPartitionTaskExecutor(configuration(1), executor);
+
+        CompletableFuture<String> failed =
+                taskExecutor.submit(
+                        () -> {
+                            throw new IllegalStateException("expected");
+                        },
+                        () -> "throttled");
+        executor.runNext();
+
+        assertThatThrownBy(failed::join).hasCauseInstanceOf(IllegalStateException.class);
+        assertThat(taskExecutor.numInflightRequests()).isZero();
+        assertThat(taskExecutor.submit(() -> "accepted", () -> "throttled")).isNotDone();
+    }
+
+    @Test
+    void testSerializesSameKeyAndAllowsDifferentKeysToRunConcurrently() throws Exception {
+        ManualExecutor executor = new ManualExecutor();
+        HistoricalPartitionTaskExecutor taskExecutor =
+                new HistoricalPartitionTaskExecutor(configuration(3), executor);
+        List<String> executionOrder = new ArrayList<>();
+
+        CompletableFuture<String> first =
+                taskExecutor.submitOrdered(
+                        "partition-1",
+                        () -> {
+                            executionOrder.add("partition-1-first");
+                            return "first";
+                        },
+                        () -> "throttled");
+        CompletableFuture<String> second =
+                taskExecutor.submitOrdered(
+                        "partition-1",
+                        () -> {
+                            executionOrder.add("partition-1-second");
+                            return "second";
+                        },
+                        () -> "throttled");
+        CompletableFuture<String> otherPartition =
+                taskExecutor.submitOrdered(
+                        "partition-2",
+                        () -> {
+                            executionOrder.add("partition-2");
+                            return "other";
+                        },
+                        () -> "throttled");
+
+        assertThat(executor.numQueuedTasks()).isEqualTo(2);
+        executor.runNext();
+        assertThat(first).isCompletedWithValue("first");
+        assertThat(second).isNotDone();
+        assertThat(executor.numQueuedTasks()).isEqualTo(2);
+
+        executor.runNext();
+        executor.runNext();
+        assertThat(otherPartition).isCompletedWithValue("other");
+        assertThat(second).isCompletedWithValue("second");
+        assertThat(executionOrder)
+                .containsExactly("partition-1-first", "partition-2", "partition-1-second");
+    }
+
+    @Test
+    void testOrderedTaskContinuesAfterPreviousFailure() throws Exception {
+        ManualExecutor executor = new ManualExecutor();
+        HistoricalPartitionTaskExecutor taskExecutor =
+                new HistoricalPartitionTaskExecutor(configuration(2), executor);
+
+        CompletableFuture<String> failed =
+                taskExecutor.submitOrdered(
+                        "partition",
+                        () -> {
+                            throw new IllegalStateException("expected");
+                        },
+                        () -> "throttled");
+        CompletableFuture<String> next =
+                taskExecutor.submitOrdered("partition", () -> "accepted", () -> "throttled");
+
+        assertThat(executor.numQueuedTasks()).isOne();
+        executor.runNext();
+        assertThatThrownBy(failed::join).hasCauseInstanceOf(IllegalStateException.class);
+        assertThat(executor.numQueuedTasks()).isOne();
+
+        executor.runNext();
+        assertThat(next).isCompletedWithValue("accepted");
+        assertThat(taskExecutor.numInflightRequests()).isZero();
+    }
+
+    @Test
+    void testRejectNonPositiveRequestLimit() {
+        assertThatThrownBy(
+                        () ->
+                                new HistoricalPartitionTaskExecutor(
+                                        configuration(0), new ManualExecutor()))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(
+                        ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS.key());
+    }
+
+    @ParameterizedTest
+    @ValueSource(ints = {0, -1})
+    void testRejectNonPositiveThreadPoolSize(int maxThreadPoolSize) {
+        Configuration conf = configuration(1);
+        conf.set(ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE, maxThreadPoolSize);
+
+        assertThatThrownBy(() -> new HistoricalPartitionTaskExecutor(conf))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(
+                        ConfigOptions.SERVER_HISTORICAL_PARTITION_THREAD_POOL_MAX_SIZE.key());
+    }
+
+    private static Configuration configuration(int maxQueuedHistoricalRequests) {
+        Configuration conf = new Configuration();
+        conf.set(
+                ConfigOptions.NETTY_SERVER_MAX_QUEUED_HISTORICAL_REQUESTS,
+                maxQueuedHistoricalRequests);
+        return conf;
+    }
+
+    private static final class ManualExecutor extends AbstractExecutorService {
+        private final BlockingQueue<Runnable> tasks = new LinkedBlockingQueue<>();
+        private volatile boolean shutdown;
+
+        @Override
+        public void shutdown() {
+            shutdown = true;
+        }
+
+        @Override
+        public List<Runnable> shutdownNow() {
+            shutdown = true;
+            List<Runnable> remainingTasks = new ArrayList<>();
+            tasks.drainTo(remainingTasks);
+            return remainingTasks;
+        }
+
+        @Override
+        public boolean isShutdown() {
+            return shutdown;
+        }
+
+        @Override
+        public boolean isTerminated() {
+            return shutdown && tasks.isEmpty();
+        }
+
+        @Override
+        public boolean awaitTermination(long timeout, TimeUnit unit) {
+            return isTerminated();
+        }
+
+        @Override
+        public void execute(Runnable command) {
+            if (shutdown) {
+                throw new RejectedExecutionException();
+            }
+            tasks.add(command);
+        }
+
+        private void runNext() throws Exception {
+            Runnable task = tasks.poll(1, TimeUnit.SECONDS);
+            assertThat(task).isNotNull();
+            task.run();
+        }
+
+        private int numQueuedTasks() {
+            return tasks.size();
+        }
+    }
+}
diff --git a/fluss-server/src/test/java/org/apache/fluss/server/utils/ServerRpcMessageUtilsTest.java b/fluss-server/src/test/java/org/apache/fluss/server/utils/ServerRpcMessageUtilsTest.java
new file mode 100644
index 000000000..c9e6f2304
--- /dev/null
+++ b/fluss-server/src/test/java/org/apache/fluss/server/utils/ServerRpcMessageUtilsTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.fluss.server.utils;
+
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.KvRecordBatch;
+import org.apache.fluss.rpc.entity.PutKvResultForBucket;
+import org.apache.fluss.rpc.messages.PbPutKvReqForBucket;
+import org.apache.fluss.rpc.messages.PbPutKvRespForBucket;
+import org.apache.fluss.rpc.messages.PutKvRequest;
+import org.apache.fluss.rpc.messages.PutKvResponse;
+import org.apache.fluss.server.entity.PutKvDataForBucket;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.fluss.record.TestData.DATA_1_WITH_KEY_AND_VALUE;
+import static org.apache.fluss.server.testutils.RpcMessageTestUtils.newPutKvRequest;
+import static org.apache.fluss.testutils.DataTestUtils.genKvRecordBatch;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link ServerRpcMessageUtils}. */
+class ServerRpcMessageUtilsTest {
+
+    @Test
+    void testHistoricalPutKvRequestAndResponsePreserveOriginalPartitions() throws Exception {
+        long tableId = 1L;
+        long partitionId = 2L;
+        KvRecordBatch records = genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE);
+        PutKvRequest request = newPutKvRequest(tableId, 0, 1, records);
+        PbPutKvReqForBucket bucketRequest = request.getBucketsReqsList().get(0);
+        bucketRequest.setPartitionId(partitionId).setOriginalPartitionName("dt=2025-01-01");
+        request.addAllBucketsReqs(
+                Collections.singletonList(
+                        new PbPutKvReqForBucket()
+                                .copyFrom(bucketRequest)
+                                .setOriginalPartitionName("dt=2025-01-02")));
+
+        TableBucket tableBucket = new TableBucket(tableId, partitionId, 0);
+        List<PutKvDataForBucket> decoded = ServerRpcMessageUtils.toPutKvDataForBuckets(request);
+        assertThat(decoded).extracting(PutKvDataForBucket::tableBucket).containsOnly(tableBucket);
+        assertThat(decoded)
+                .extracting(PutKvDataForBucket::originalPartitionName)
+                .containsExactly("dt=2025-01-01", "dt=2025-01-02");
+        assertThat(decoded).extracting(PutKvDataForBucket::records).containsOnly(records);
+
+        PutKvResponse response =
+                ServerRpcMessageUtils.makePutKvResponse(
+                        Arrays.asList(
+                                PutKvResultForBucket.historicalSuccess(
+                                        tableBucket, 1L, "dt=2025-01-01"),
+                                PutKvResultForBucket.historicalSuccess(
+                                        tableBucket, 2L, "dt=2025-01-02")));
+        assertThat(response.getBucketsRespsList())
+                .extracting(PbPutKvRespForBucket::getOriginalPartitionName)
+                .containsExactly("dt=2025-01-01", "dt=2025-01-02");
+
+        request.addAllBucketsReqs(
+                Collections.singletonList(new PbPutKvReqForBucket().copyFrom(bucketRequest)));
+        assertThatThrownBy(() -> ServerRpcMessageUtils.toPutKvDataForBuckets(request))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("duplicate table bucket");
+    }
+}
diff --git a/website/docs/maintenance/observability/monitor-metrics.md b/website/docs/maintenance/observability/monitor-metrics.md
index 4bb313e61..dd7f0dbf6 100644
--- a/website/docs/maintenance/observability/monitor-metrics.md
+++ b/website/docs/maintenance/observability/monitor-metrics.md
@@ -699,6 +699,7 @@ Some metrics might not be exposed when using other JVM implementations (e.g. IBM
       <td rowspan="7">
           request_produceLog
           request_putKv
+          request_historicalPutKv
           request_lookup
           request_historicalLookup
           request_prefixLookup
@@ -789,7 +790,7 @@ Some metrics might not be exposed when using other JVM implementations (e.g. IBM
   </thead>
   <tbody>
     <tr>
-      <th rowspan="37"><strong>tabletserver</strong></th>
+      <th rowspan="39"><strong>tabletserver</strong></th>
       <td rowspan="20">table</td>
       <td>messagesInPerSecond</td>
       <td>The number of messages written per second to this table.</td>
@@ -891,7 +892,17 @@ Some metrics might not be exposed when using other JVM implementations (e.g. IBM
       <td>Meter</td>
     </tr>
     <tr>
-      <td rowspan="4">table_historical</td>
+      <td rowspan="6">table_historical</td>
+      <td>totalPutKvRequestsPerSecond</td>
+      <td>The number of historical put kv requests to this table per second.</td>
+      <td>Meter</td>
+    </tr>
+    <tr>
+      <td>failedPutKvRequestsPerSecond</td>
+      <td>The number of historical put kv requests that failed unexpectedly for this table per second.</td>
+      <td>Meter</td>
+    </tr>
+    <tr>
       <td>totalLookupRequestsPerSecond</td>
       <td>The number of historical lookup requests to this table per second.</td>
       <td>Meter</td>