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

wuchong 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 c6a1c2dbf [kv] Implement KvScan on Server Side (#3151)
c6a1c2dbf is described below

commit c6a1c2dbfe36aa8b503cff04470acf2951ef9e51
Author: Giannis Polyzos <[email protected]>
AuthorDate: Sat May 9 13:52:05 2026 +0300

    [kv] Implement KvScan on Server Side (#3151)
---
 .../org/apache/fluss/config/ConfigOptions.java     |  45 ++
 .../fluss/record/DefaultValueRecordBatch.java      |   5 +
 .../java/org/apache/fluss/server/kv/KvTablet.java  |  75 ++++
 .../fluss/server/kv/scan/OpenScanResult.java       |  49 +++
 .../fluss/server/kv/scan/ScannerContext.java       | 212 +++++++++
 .../fluss/server/kv/scan/ScannerManager.java       | 359 +++++++++++++++
 .../org/apache/fluss/server/replica/Replica.java   |  69 ++-
 .../fluss/server/replica/ReplicaManager.java       |  13 +-
 .../apache/fluss/server/tablet/TabletServer.java   |  14 +-
 .../apache/fluss/server/tablet/TabletService.java  | 211 ++++++++-
 .../org/apache/fluss/server/kv/KvTabletTest.java   | 137 ++++++
 .../fluss/server/kv/scan/ScannerManagerTest.java   | 488 +++++++++++++++++++++
 .../fluss/server/replica/ReplicaManagerTest.java   |  39 ++
 .../fluss/server/replica/ReplicaTestBase.java      |  13 +-
 .../replica/fetcher/ReplicaFetcherThreadTest.java  |   2 +
 .../fluss/server/tablet/TabletServiceITCase.java   | 349 +++++++++++++++
 website/docs/maintenance/configuration.md          |   5 +
 17 files changed, 2075 insertions(+), 10 deletions(-)

diff --git 
a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java 
b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
index df21129db..02e016852 100644
--- a/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
+++ b/fluss-common/src/main/java/org/apache/fluss/config/ConfigOptions.java
@@ -515,6 +515,51 @@ public class ConfigOptions {
                                     + WRITER_ID_EXPIRATION_TIME.key()
                                     + " passing. The default value is 10 
minutes.");
 
+    public static final ConfigOption<Duration> KV_SCANNER_TTL =
+            key("kv.scanner.ttl")
+                    .durationType()
+                    .defaultValue(Duration.ofMinutes(10))
+                    .withDescription(
+                            "The time-to-live for an idle KV scanner session 
on the server. A scanner that has not "
+                                    + "received a request within this duration 
will be automatically expired and its "
+                                    + "resources released. The default value 
is 10 minutes.");
+
+    public static final ConfigOption<Duration> KV_SCANNER_EXPIRATION_INTERVAL =
+            key("kv.scanner.expiration-interval")
+                    .durationType()
+                    .defaultValue(Duration.ofSeconds(30))
+                    .withDescription(
+                            "How often the TTL evictor runs to close idle 
scanner sessions. "
+                                    + "The default value is 30 seconds.");
+
+    public static final ConfigOption<Integer> KV_SCANNER_MAX_PER_BUCKET =
+            key("kv.scanner.max-per-bucket")
+                    .intType()
+                    .defaultValue(8)
+                    .withDescription(
+                            "Maximum number of concurrent scanner sessions per 
bucket. "
+                                    + "Exceeding this limit returns 
TOO_MANY_SCANNERS. "
+                                    + "The default value is 8.");
+
+    public static final ConfigOption<Integer> KV_SCANNER_MAX_PER_SERVER =
+            key("kv.scanner.max-per-server")
+                    .intType()
+                    .defaultValue(200)
+                    .withDescription(
+                            "Maximum number of concurrent scanner sessions per 
tablet server. "
+                                    + "Exceeding this limit returns 
TOO_MANY_SCANNERS. "
+                                    + "The default value is 200.");
+
+    public static final ConfigOption<MemorySize> KV_SCANNER_MAX_BATCH_SIZE =
+            key("kv.scanner.max-batch-size")
+                    .memoryType()
+                    .defaultValue(MemorySize.parse("10mb"))
+                    .withDescription(
+                            "Server-side cap on the per-batch payload size for 
KV full-scan responses. "
+                                    + "The effective batch size is 
min(client-requested batch_size_bytes, "
+                                    + "this value). Protects the tablet server 
from out-of-memory if a "
+                                    + "client passes an excessively large 
batch size. The default value is 10mb.");
+
     public static final ConfigOption<Integer> 
TABLET_SERVER_CONTROLLED_SHUTDOWN_MAX_RETRIES =
             key("tablet-server.controlled-shutdown.max-retries")
                     .intType()
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/DefaultValueRecordBatch.java
 
b/fluss-common/src/main/java/org/apache/fluss/record/DefaultValueRecordBatch.java
index 8d41ec4c8..3bb7539f6 100644
--- 
a/fluss-common/src/main/java/org/apache/fluss/record/DefaultValueRecordBatch.java
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/DefaultValueRecordBatch.java
@@ -278,6 +278,11 @@ public class DefaultValueRecordBatch implements 
ValueRecordBatch {
             return DefaultValueRecordBatch.pointToMemory(segment, 0);
         }
 
+        /** Returns the current serialized size of the batch, including the 
record-batch header. */
+        public int sizeInBytes() {
+            return sizeInBytes;
+        }
+
         private void writeBatchHeader() throws IOException {
             outputView.setPosition(0);
             // update header
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 85c192c92..f38019399 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
@@ -58,6 +58,8 @@ 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;
 import org.apache.fluss.server.kv.snapshot.KvFileHandleAndLocalPath;
 import org.apache.fluss.server.kv.snapshot.KvSnapshotDataUploader;
 import org.apache.fluss.server.kv.snapshot.RocksIncrementalSnapshot;
@@ -70,12 +72,17 @@ 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;
 
 import org.rocksdb.RateLimiter;
+import org.rocksdb.ReadOptions;
+import org.rocksdb.RocksIterator;
+import org.rocksdb.Snapshot;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -757,6 +764,74 @@ public final class KvTablet {
                 });
     }
 
+    /**
+     * Opens a new full-scan session under the {@code kvLock} read lock. 
Returns an empty-bucket
+     * result (context = {@code null}, all RocksDB resources released 
internally) when the bucket
+     * has no rows. The returned {@link ScannerContext} is unregistered; the 
caller owns
+     * registration and close.
+     *
+     * @param limit row-count cap across all batches ({@code ≤ 0} means 
unlimited)
+     * @throws IOException if RocksDB is shutting down
+     */
+    public OpenScanResult openScan(String scannerId, long limit, long 
initialAccessTimeMs)
+            throws IOException {
+        return inReadLock(
+                kvLock,
+                () -> {
+                    rocksDBKv.checkIfRocksDBClosed();
+                    ResourceGuard.Lease lease = 
rocksDBKv.getResourceGuard().acquireResource();
+                    Snapshot snapshot = null;
+                    ReadOptions readOptions = null;
+                    RocksIterator iterator = null;
+                    boolean success = false;
+                    try {
+                        snapshot = rocksDBKv.getDb().getSnapshot();
+                        // Capture under kvLock so the offset matches the data 
visible through
+                        // the snapshot.
+                        long capturedLogOffset = flushedLogOffset;
+                        readOptions = new ReadOptions().setSnapshot(snapshot);
+                        iterator =
+                                rocksDBKv
+                                        .getDb()
+                                        .newIterator(
+                                                
rocksDBKv.getDefaultColumnFamilyHandle(),
+                                                readOptions);
+                        iterator.seekToFirst();
+                        if (!iterator.isValid()) {
+                            return new OpenScanResult(null, capturedLogOffset);
+                        }
+                        ScannerContext context =
+                                new ScannerContext(
+                                        scannerId,
+                                        tableBucket,
+                                        rocksDBKv,
+                                        iterator,
+                                        readOptions,
+                                        snapshot,
+                                        lease,
+                                        limit,
+                                        capturedLogOffset,
+                                        initialAccessTimeMs);
+                        success = true;
+                        return new OpenScanResult(context, capturedLogOffset);
+                    } finally {
+                        if (!success) {
+                            IOUtils.closeQuietly(iterator);
+                            IOUtils.closeQuietly(readOptions);
+                            if (snapshot != null) {
+                                try {
+                                    
rocksDBKv.getDb().releaseSnapshot(snapshot);
+                                } catch (Throwable t) {
+                                    LOG.warn("Error releasing RocksDB 
snapshot.", t);
+                                }
+                                IOUtils.closeQuietly(snapshot);
+                            }
+                            IOUtils.closeQuietly(lease);
+                        }
+                    }
+                });
+    }
+
     public KvBatchWriter createKvBatchWriter() {
         return rocksDBKv.newWriteBatch(
                 writeBatchSize,
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/OpenScanResult.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/OpenScanResult.java
new file mode 100644
index 000000000..5331a7a1c
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/OpenScanResult.java
@@ -0,0 +1,49 @@
+/*
+ * 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.scan;
+
+import org.apache.fluss.annotation.Internal;
+
+import javax.annotation.Nullable;
+
+/**
+ * Result of opening a KV full-scan session: an optional {@link 
ScannerContext} plus the log offset
+ * captured at snapshot time. The offset must reach the client even on the 
empty-bucket fast path so
+ * a snapshot-to-log handoff cannot miss or duplicate records.
+ */
+@Internal
+public final class OpenScanResult {
+
+    @Nullable private final ScannerContext context;
+    private final long logOffset;
+
+    public OpenScanResult(@Nullable ScannerContext context, long logOffset) {
+        this.context = context;
+        this.logOffset = logOffset;
+    }
+
+    /** {@code null} if the bucket was empty at snapshot time. */
+    @Nullable
+    public ScannerContext getContext() {
+        return context;
+    }
+
+    public long getLogOffset() {
+        return logOffset;
+    }
+}
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerContext.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerContext.java
new file mode 100644
index 000000000..6347a3d43
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerContext.java
@@ -0,0 +1,212 @@
+/*
+ * 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.scan;
+
+import org.apache.fluss.exception.KvStorageException;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.server.kv.rocksdb.RocksDBKv;
+import org.apache.fluss.server.utils.ResourceGuard;
+import org.apache.fluss.utils.IOUtils;
+
+import org.rocksdb.ReadOptions;
+import org.rocksdb.RocksDBException;
+import org.rocksdb.RocksIterator;
+import org.rocksdb.Snapshot;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+import java.io.Closeable;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Server-side state for a single KV full-scan session: a pinned RocksDB 
{@link Snapshot}, a {@link
+ * RocksIterator} cursor, and a {@link ResourceGuard.Lease} that keeps the 
underlying RocksDB alive
+ * for the lifetime of the scan.
+ *
+ * <p>Cursor methods ({@link #advance()}, {@link #isValid()}, {@link 
#currentValue()}) are
+ * single-threaded; callers must hold the {@link #tryAcquireForUse()} flag 
while mutating cursor or
+ * sequence-id state. {@link #close()} is thread-safe and fences on that flag.
+ */
+@NotThreadSafe
+public class ScannerContext implements Closeable {
+    private final String scannerId;
+    private final byte[] scannerIdBytes;
+    private final TableBucket tableBucket;
+    private final RocksDBKv rocksDBKv;
+    private final RocksIterator iterator;
+    private final ReadOptions readOptions;
+    private final Snapshot snapshot;
+    private final ResourceGuard.Lease resourceLease;
+
+    /** Log offset captured when the snapshot was opened. */
+    private final long logOffset;
+
+    private long remainingLimit;
+
+    // -1 so the first client call_seq_id of 0 satisfies expectedSeqId = 
callSeqId + 1.
+    // volatile: a continuation may run on a different RPC worker than the 
previous one.
+    private volatile int callSeqId = -1;
+
+    /** Last-access wall-clock (ms); volatile for the TTL evictor. */
+    private volatile long lastAccessTime;
+
+    private final AtomicBoolean closed = new AtomicBoolean(false);
+
+    private final AtomicBoolean inUse = new AtomicBoolean(false);
+
+    public ScannerContext(
+            String scannerId,
+            TableBucket tableBucket,
+            RocksDBKv rocksDBKv,
+            RocksIterator iterator,
+            ReadOptions readOptions,
+            Snapshot snapshot,
+            ResourceGuard.Lease resourceLease,
+            long limit,
+            long logOffset,
+            long initialAccessTimeMs) {
+        this.scannerId = scannerId;
+        this.scannerIdBytes = scannerId.getBytes(StandardCharsets.UTF_8);
+        this.tableBucket = tableBucket;
+        this.rocksDBKv = rocksDBKv;
+        this.iterator = iterator;
+        this.readOptions = readOptions;
+        this.snapshot = snapshot;
+        this.resourceLease = resourceLease;
+        this.remainingLimit = limit <= 0 ? -1L : limit;
+        this.logOffset = logOffset;
+        this.lastAccessTime = initialAccessTimeMs;
+    }
+
+    /** Log offset captured when the snapshot was opened. */
+    public long getLogOffset() {
+        return logOffset;
+    }
+
+    public byte[] getScannerId() {
+        return scannerIdBytes;
+    }
+
+    /** Scanner ID as a UTF-8 string; used as the key in {@code 
ScannerManager#scanners}. */
+    String getIdString() {
+        return scannerId;
+    }
+
+    public TableBucket getTableBucket() {
+        return tableBucket;
+    }
+
+    public boolean isValid() {
+        return iterator.isValid() && remainingLimit != 0;
+    }
+
+    public byte[] currentValue() {
+        return iterator.value();
+    }
+
+    /** Advances the cursor; must be called only when {@link #isValid()} is 
{@code true}. */
+    public void advance() {
+        iterator.next();
+        if (remainingLimit > 0) {
+            remainingLimit--;
+        }
+    }
+
+    /**
+     * Surfaces RocksDB-internal iterator errors. {@link RocksIterator#next()} 
does not throw on
+     * such errors; instead the iterator silently transitions to invalid. 
Callers MUST invoke this
+     * once iteration has stopped, otherwise an internal failure would be 
indistinguishable from a
+     * clean end-of-range and the client would think the scan completed when 
rows were dropped.
+     *
+     * @throws KvStorageException if the iterator reports an internal error
+     */
+    public void checkIteratorStatus() {
+        try {
+            iterator.status();
+        } catch (RocksDBException e) {
+            throw new KvStorageException(
+                    "RocksDB iterator error during scan for bucket " + 
tableBucket, e);
+        }
+    }
+
+    /** Returns the call-sequence ID of the last successfully served request, 
or {@code -1}. */
+    public int getCallSeqId() {
+        return callSeqId;
+    }
+
+    /**
+     * Updates the call-sequence ID. Must be called <em>after</em> the 
response payload is fully
+     * prepared so a client retry with the same id can be detected.
+     */
+    public void setCallSeqId(int callSeqId) {
+        this.callSeqId = callSeqId;
+    }
+
+    /** Resets the idle-TTL timer to the given wall-clock time. */
+    public void updateLastAccessTime(long nowMs) {
+        this.lastAccessTime = nowMs;
+    }
+
+    /** Returns {@code true} if idle for longer than {@code ttlMs}. */
+    public boolean isExpired(long ttlMs, long nowMs) {
+        return nowMs - lastAccessTime > ttlMs;
+    }
+
+    /**
+     * Tries to claim exclusive use of the cursor. Returns {@code false} if 
another thread already
+     * holds it, or if {@link #close()} has been initiated. Every successful 
acquire MUST be paired
+     * with {@link #releaseAfterUse()} in a {@code finally}, otherwise {@code 
close()} blocks.
+     */
+    public boolean tryAcquireForUse() {
+        if (closed.get()) {
+            return false;
+        }
+        if (!inUse.compareAndSet(false, true)) {
+            return false;
+        }
+        // Re-check after the CAS in case close() flipped `closed` in between; 
let close() proceed.
+        if (closed.get()) {
+            inUse.set(false);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Releases the flag obtained via {@link #tryAcquireForUse()}. MUST be 
called BEFORE any path
+     * that may trigger {@link #close()} on the same thread, otherwise {@code 
close()} self-
+     * deadlocks on its inUse fence.
+     */
+    public void releaseAfterUse() {
+        inUse.set(false);
+    }
+
+    @Override
+    public void close() {
+        if (closed.compareAndSet(false, true)) {
+            // force close the inUse fence so any racing tryAcquireForUse() 
calls
+            inUse.set(false);
+            IOUtils.closeQuietly(iterator);
+            IOUtils.closeQuietly(readOptions);
+            IOUtils.closeQuietly(() -> 
rocksDBKv.getDb().releaseSnapshot(snapshot));
+            IOUtils.closeQuietly(snapshot);
+            IOUtils.closeQuietly(resourceLease);
+        }
+    }
+}
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerManager.java
new file mode 100644
index 000000000..c6e834c0d
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/kv/scan/ScannerManager.java
@@ -0,0 +1,359 @@
+/*
+ * 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.scan;
+
+import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.TooManyScannersException;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.server.replica.Replica;
+import org.apache.fluss.utils.AutoCloseableAsync;
+import org.apache.fluss.utils.clock.Clock;
+import org.apache.fluss.utils.clock.SystemClock;
+import org.apache.fluss.utils.concurrent.FutureUtils;
+import org.apache.fluss.utils.concurrent.Scheduler;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.ThreadSafe;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Manages server-side KV full-scan sessions ({@link ScannerContext}). 
Sessions are keyed by a
+ * server-assigned UUID and persist across multiple batched-fetch RPCs.
+ *
+ * <p>Concurrency limits are enforced both per-bucket and per-server, with a 
pre-check followed by
+ * an atomic increment + re-check (rolled back on contention) so the 
configured caps cannot be
+ * permanently breached. Sessions idle longer than {@code kv.scanner.ttl} are 
evicted by a
+ * background task; their IDs are retained for {@code 2 × ttl} so that {@link
+ * #isRecentlyExpired(byte[])} can distinguish expired sessions from unknown 
ones.
+ *
+ * <p>{@link #closeScannersForBucket(TableBucket)} must be called when a 
bucket loses leadership to
+ * release RocksDB resources promptly.
+ */
+@ThreadSafe
+public class ScannerManager implements AutoCloseableAsync {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(ScannerManager.class);
+
+    /** Sentinel for read paths so a missing bucket entry does not allocate a 
counter. */
+    private static final AtomicInteger ZERO = new AtomicInteger(0);
+
+    private final Map<String, ScannerContext> scanners = new 
ConcurrentHashMap<>();
+    private final Map<String, Long> recentlyExpiredIds = new 
ConcurrentHashMap<>();
+    private final Map<TableBucket, AtomicInteger> perBucketCount = new 
ConcurrentHashMap<>();
+    private final AtomicInteger totalScanners = new AtomicInteger(0);
+
+    private final AtomicBoolean closed = new AtomicBoolean(false);
+
+    private final Clock clock;
+    private final long scannerTtlMs;
+    private final long recentlyExpiredRetentionMs;
+    private final int maxPerBucket;
+    private final int maxPerServer;
+    private final int maxBatchSizeBytes;
+
+    @Nullable private ScheduledFuture<?> evictorTask;
+
+    public ScannerManager(Configuration conf, Scheduler scheduler) {
+        this(conf, scheduler, SystemClock.getInstance());
+    }
+
+    @VisibleForTesting
+    ScannerManager(Configuration conf, Scheduler scheduler, Clock clock) {
+        this.clock = clock;
+        this.scannerTtlMs = conf.get(ConfigOptions.KV_SCANNER_TTL).toMillis();
+        this.recentlyExpiredRetentionMs = 2 * scannerTtlMs;
+        this.maxPerBucket = conf.get(ConfigOptions.KV_SCANNER_MAX_PER_BUCKET);
+        this.maxPerServer = conf.get(ConfigOptions.KV_SCANNER_MAX_PER_SERVER);
+        long configuredMaxBatch = 
conf.get(ConfigOptions.KV_SCANNER_MAX_BATCH_SIZE).getBytes();
+        this.maxBatchSizeBytes = (int) Math.min(Integer.MAX_VALUE, 
configuredMaxBatch);
+
+        long expirationIntervalMs =
+                
conf.get(ConfigOptions.KV_SCANNER_EXPIRATION_INTERVAL).toMillis();
+        this.evictorTask =
+                scheduler.schedule(
+                        "scanner-expiration",
+                        this::evictExpiredScanners,
+                        expirationIntervalMs,
+                        expirationIntervalMs);
+
+        LOG.info(
+                "Started ScannerManager: ttl={}ms, expirationInterval={}ms, "
+                        + "maxPerBucket={}, maxPerServer={}",
+                scannerTtlMs,
+                expirationIntervalMs,
+                maxPerBucket,
+                maxPerServer);
+    }
+
+    /**
+     * Opens a new scan session against the given leader replica. The snapshot 
is taken under the
+     * replica's leader-ISR read lock; on {@link TooManyScannersException} the 
just-opened context
+     * is closed by {@link Replica#openScan} before the exception propagates. 
Returns an empty-
+     * bucket result (context = {@code null}) without consuming a slot when 
the bucket has no rows.
+     *
+     * @param limit optional row-count cap ({@code null} or ≤ 0 means 
unlimited)
+     * @throws TooManyScannersException if the per-bucket or per-server limit 
is exceeded
+     * @throws IOException if RocksDB is shutting down
+     */
+    public OpenScanResult createScanner(Replica replica, @Nullable Long limit) 
throws IOException {
+        checkLimits(replica.getTableBucket());
+        String scannerId = generateScannerId();
+        return replica.openScan(this, scannerId, limit != null ? limit : -1L, 
clock.milliseconds());
+    }
+
+    /**
+     * Registers an already-opened {@link ScannerContext}, enforcing the 
per-bucket and per-server
+     * limits. The caller must close the context if {@link 
TooManyScannersException} is thrown.
+     */
+    public void register(ScannerContext context) {
+        registerContext(context);
+    }
+
+    /**
+     * Looks up a scanner session. <em>Does not</em> refresh the last-access 
timestamp — call {@link
+     * #markAccessed(ScannerContext)} only after the request has been fully 
validated, so malformed
+     * requests cannot extend the idle TTL of an orphan session.
+     */
+    @Nullable
+    public ScannerContext getScanner(byte[] scannerId) {
+        return scanners.get(new String(scannerId, StandardCharsets.UTF_8));
+    }
+
+    /** Refreshes the last-access timestamp; call only when about to do real 
work. */
+    public void markAccessed(ScannerContext context) {
+        context.updateLastAccessTime(clock.milliseconds());
+    }
+
+    /**
+     * Returns {@code true} if the ID belongs to a session evicted within the 
last {@code 2 ×
+     * ttlMs}; lets callers distinguish "expired" from "unknown".
+     */
+    public boolean isRecentlyExpired(byte[] scannerId) {
+        return recentlyExpiredIds.containsKey(new String(scannerId, 
StandardCharsets.UTF_8));
+    }
+
+    /**
+     * Removes and closes the given scanner. Uses a conditional remove so the 
TTL evictor and an
+     * explicit close cannot both run the close path.
+     */
+    public void removeScanner(ScannerContext context) {
+        if (scanners.remove(context.getIdString(), context)) {
+            decrementCounts(context.getTableBucket());
+            closeScannerContext(context);
+        }
+    }
+
+    /** Looks up and removes a scanner by raw ID bytes; no-op if not found. */
+    public void removeScanner(byte[] scannerId) {
+        String key = new String(scannerId, StandardCharsets.UTF_8);
+        ScannerContext context = scanners.get(key);
+        if (context != null) {
+            removeScanner(context);
+        }
+    }
+
+    /** Returns the max batch size in bytes allowed for a kv scan response. */
+    public int getMaxBatchSizeBytes() {
+        return maxBatchSizeBytes;
+    }
+
+    @VisibleForTesting
+    public int activeScannerCount() {
+        return totalScanners.get();
+    }
+
+    @VisibleForTesting
+    public int activeScannerCountForBucket(TableBucket tableBucket) {
+        return perBucketCount.getOrDefault(tableBucket, ZERO).get();
+    }
+
+    /**
+     * Closes all active scanner sessions for the bucket on a leadership 
change. Records the IDs in
+     * {@link #recentlyExpiredIds} so continuation RPCs after the close 
surface SCANNER_EXPIRED
+     * (recoverable) instead of UNKNOWN_SCANNER_ID.
+     */
+    public void closeScannersForBucket(TableBucket tableBucket) {
+        List<ScannerContext> toRemove = new ArrayList<>();
+        for (Map.Entry<String, ScannerContext> entry : scanners.entrySet()) {
+            if (tableBucket.equals(entry.getValue().getTableBucket())) {
+                toRemove.add(entry.getValue());
+            }
+        }
+        long now = clock.milliseconds();
+        for (ScannerContext context : toRemove) {
+            LOG.info(
+                    "Closing scanner {} for bucket {} due to leadership 
change.",
+                    context.getIdString(),
+                    tableBucket);
+            // Record before removal so a racing continuation never sees the 
ID as both unknown
+            // and not-recently-expired.
+            recentlyExpiredIds.put(context.getIdString(), now);
+            removeScanner(context);
+        }
+        perBucketCount.remove(tableBucket);
+    }
+
+    /** Fast best-effort pre-check; the atomic re-check in {@link 
#registerContext} is the gate. */
+    private void checkLimits(TableBucket tableBucket) {
+        if (totalScanners.get() >= maxPerServer) {
+            throw new TooManyScannersException(
+                    String.format(
+                            "Cannot create scanner for bucket %s: server-wide 
limit of %d reached.",
+                            tableBucket, maxPerServer));
+        }
+        if (perBucketCount.getOrDefault(tableBucket, ZERO).get() >= 
maxPerBucket) {
+            throw new TooManyScannersException(
+                    String.format(
+                            "Cannot create scanner for bucket %s: per-bucket 
limit of %d reached.",
+                            tableBucket, maxPerBucket));
+        }
+    }
+
+    private void registerContext(ScannerContext context) {
+        TableBucket tableBucket = context.getTableBucket();
+
+        int newTotal = totalScanners.incrementAndGet();
+        if (newTotal > maxPerServer) {
+            totalScanners.decrementAndGet();
+            throw new TooManyScannersException(
+                    String.format(
+                            "Cannot create scanner for bucket %s: server-wide 
limit of %d reached.",
+                            tableBucket, maxPerServer));
+        }
+
+        AtomicInteger bucketCount =
+                perBucketCount.computeIfAbsent(tableBucket, k -> new 
AtomicInteger(0));
+        int newBucketCount = bucketCount.incrementAndGet();
+        if (newBucketCount > maxPerBucket) {
+            bucketCount.decrementAndGet();
+            totalScanners.decrementAndGet();
+            throw new TooManyScannersException(
+                    String.format(
+                            "Cannot create scanner for bucket %s: per-bucket 
limit of %d reached.",
+                            tableBucket, maxPerBucket));
+        }
+
+        scanners.put(context.getIdString(), context);
+
+        LOG.debug(
+                "Registered scanner {} for bucket {} (total={}, perBucket={})",
+                context.getIdString(),
+                tableBucket,
+                newTotal,
+                newBucketCount);
+    }
+
+    private void evictExpiredScanners() {
+        if (closed.get()) {
+            return;
+        }
+        long now = clock.milliseconds();
+
+        recentlyExpiredIds
+                .entrySet()
+                .removeIf(e -> now - e.getValue() > 
recentlyExpiredRetentionMs);
+
+        for (Map.Entry<String, ScannerContext> entry : scanners.entrySet()) {
+            ScannerContext context = entry.getValue();
+            if (context.isExpired(scannerTtlMs, now)) {
+                if (scanners.remove(entry.getKey(), context)) {
+                    LOG.info(
+                            "Evicted idle scanner {} for bucket {} (idle > 
{}ms).",
+                            entry.getKey(),
+                            context.getTableBucket(),
+                            scannerTtlMs);
+                    recentlyExpiredIds.put(entry.getKey(), now);
+                    decrementCounts(context.getTableBucket());
+                    closeScannerContext(context);
+                }
+            }
+        }
+    }
+
+    private void decrementCounts(TableBucket bucket) {
+        totalScanners.decrementAndGet();
+        perBucketCount.computeIfPresent(
+                bucket,
+                (k, count) -> {
+                    int remaining = count.decrementAndGet();
+                    return remaining <= 0 ? null : count;
+                });
+    }
+
+    private void closeScannerContext(ScannerContext context) {
+        try {
+            context.close();
+        } catch (Exception e) {
+            LOG.warn(
+                    "Error closing scanner {} for bucket {}.",
+                    context.getIdString(),
+                    context.getTableBucket(),
+                    e);
+        }
+    }
+
+    private static String generateScannerId() {
+        return UUID.randomUUID().toString().replace("-", "");
+    }
+
+    @Override
+    public CompletableFuture<Void> closeAsync() {
+        try {
+            close();
+            return CompletableFuture.completedFuture(null);
+        } catch (Exception e) {
+            return FutureUtils.completedExceptionally(e);
+        }
+    }
+
+    @Override
+    public void close() {
+        if (!closed.compareAndSet(false, true)) {
+            return;
+        }
+
+        if (evictorTask != null) {
+            evictorTask.cancel(false);
+            evictorTask = null;
+        }
+
+        for (Map.Entry<String, ScannerContext> entry : scanners.entrySet()) {
+            if (scanners.remove(entry.getKey(), entry.getValue())) {
+                decrementCounts(entry.getValue().getTableBucket());
+                closeScannerContext(entry.getValue());
+            }
+        }
+        recentlyExpiredIds.clear();
+    }
+}
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 2259ec4c3..6a84316c3 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
@@ -31,6 +31,7 @@ import org.apache.fluss.exception.LogStorageException;
 import org.apache.fluss.exception.NonPrimaryKeyTableException;
 import org.apache.fluss.exception.NotEnoughReplicasException;
 import org.apache.fluss.exception.NotLeaderOrFollowerException;
+import org.apache.fluss.exception.TooManyScannersException;
 import org.apache.fluss.fs.FsPath;
 import org.apache.fluss.metadata.ChangelogImage;
 import org.apache.fluss.metadata.LogFormat;
@@ -61,6 +62,9 @@ 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.rocksdb.RocksDBKvBuilder;
+import org.apache.fluss.server.kv.scan.OpenScanResult;
+import org.apache.fluss.server.kv.scan.ScannerContext;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.kv.snapshot.CompletedKvSnapshotCommitter;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import org.apache.fluss.server.kv.snapshot.KvFileHandleAndLocalPath;
@@ -211,6 +215,13 @@ public final class Replica {
     private volatile @Nullable CloseableRegistry closeableRegistryForKv;
     private @Nullable PeriodicSnapshotManager kvSnapshotManager;
 
+    /**
+     * Server-wide {@link ScannerManager}. Active sessions for this bucket are 
closed in {@link
+     * #dropKv()} under the {@code leaderIsrUpdateLock} write lock; {@link 
#openScan} registers
+     * under the read lock, so registration cannot race a leadership flip.
+     */
+    private final ScannerManager scannerManager;
+
     // ------- metrics
     private Counter isrShrinks;
     private Counter isrExpands;
@@ -236,7 +247,8 @@ public final class Replica {
             BucketMetricGroup bucketMetricGroup,
             TableInfo tableInfo,
             Clock clock,
-            RemoteLogManager remoteLogManager)
+            RemoteLogManager remoteLogManager,
+            ScannerManager scannerManager)
             throws Exception {
         this.physicalPath = physicalPath;
         this.tableBucket = tableBucket;
@@ -269,6 +281,7 @@ public final class Replica {
         
this.logTablet.updateIsDataLakeEnabled(tableConfig.isDataLakeEnabled());
         this.clock = clock;
         this.remoteLogManager = remoteLogManager;
+        this.scannerManager = checkNotNull(scannerManager, "scannerManager");
         registerMetrics();
     }
 
@@ -702,16 +715,16 @@ public final class Replica {
     }
 
     private void dropKv() {
-        // close any closeable registry for kv
+        // Release scanner leases first; otherwise resourceGuard.close() 
inside kvTablet.close()
+        // blocks waiting for them. Runs under leaderIsrUpdateLock(W), so no 
concurrent register.
+        scannerManager.closeScannersForBucket(tableBucket);
+
         if (closeableRegistry.unregisterCloseable(closeableRegistryForKv)) {
             IOUtils.closeQuietly(closeableRegistryForKv);
         }
         if (kvTablet != null) {
-            // Unregister RocksDB statistics before dropping KvTablet
-            // This ensures statistics are cleaned up when KvTablet is 
destroyed
             bucketMetricGroup.unregisterRocksDBStatistics();
 
-            // drop the kv tablet
             checkNotNull(kvManager);
             kvManager.dropKv(tableBucket);
             kvTablet = null;
@@ -1377,6 +1390,52 @@ public final class Replica {
                 });
     }
 
+    /**
+     * Opens a new full-scan session against this replica's KV store. The 
leader check, snapshot
+     * open, and {@link ScannerManager#register} all happen under {@code 
leaderIsrUpdateLock(R)}, so
+     * a leadership flip cannot leave a scanner registered for a non-leader 
bucket.
+     *
+     * @param limit row-count cap ({@code ≤ 0} means unlimited)
+     * @throws NonPrimaryKeyTableException if this replica is not a 
primary-key table
+     * @throws NotLeaderOrFollowerException if this replica is not the leader
+     * @throws TooManyScannersException if scanner limits are exceeded
+     * @throws IOException if RocksDB is shutting down
+     */
+    public OpenScanResult openScan(
+            ScannerManager scannerManager, String scannerId, long limit, long 
initialAccessTimeMs)
+            throws IOException {
+        if (!isKvTable()) {
+            throw new NonPrimaryKeyTableException(
+                    "the primary key table not exists for " + tableBucket);
+        }
+
+        return inReadLock(
+                leaderIsrUpdateLock,
+                () -> {
+                    if (!isLeader()) {
+                        throw new NotLeaderOrFollowerException(
+                                String.format(
+                                        "Leader not local for bucket %s on 
tabletServer %d",
+                                        tableBucket, localTabletServerId));
+                    }
+                    checkNotNull(
+                            kvTablet, "KvTablet for the replica to open scan 
shouldn't be null.");
+                    OpenScanResult result =
+                            kvTablet.openScan(scannerId, limit, 
initialAccessTimeMs);
+                    ScannerContext context = result.getContext();
+                    if (context == null) {
+                        return result;
+                    }
+                    try {
+                        scannerManager.register(context);
+                    } catch (TooManyScannersException e) {
+                        IOUtils.closeQuietly(context);
+                        throw e;
+                    }
+                    return result;
+                });
+    }
+
     public LogRecords limitLogScan(int limit) {
         return inReadLock(
                 leaderIsrUpdateLock,
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 3b847266e..f2a01cbe9 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
@@ -79,6 +79,7 @@ import 
org.apache.fluss.server.entity.StopReplicaResultForBucket;
 import org.apache.fluss.server.entity.UserContext;
 import org.apache.fluss.server.kv.KvManager;
 import org.apache.fluss.server.kv.KvSnapshotResource;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.kv.snapshot.CompletedKvSnapshotCommitter;
 import org.apache.fluss.server.kv.snapshot.DefaultSnapshotContext;
 import org.apache.fluss.server.log.FetchDataInfo;
@@ -209,6 +210,8 @@ public class ReplicaManager implements ServerReconfigurable 
{
 
     private final Clock clock;
 
+    private final ScannerManager scannerManager;
+
     public ReplicaManager(
             Configuration conf,
             Scheduler scheduler,
@@ -223,6 +226,7 @@ public class ReplicaManager implements ServerReconfigurable 
{
             FatalErrorHandler fatalErrorHandler,
             TabletServerMetricGroup serverMetricGroup,
             UserMetrics userMetrics,
+            ScannerManager scannerManager,
             Clock clock,
             ExecutorService ioExecutor)
             throws IOException {
@@ -241,6 +245,7 @@ public class ReplicaManager implements ServerReconfigurable 
{
                 serverMetricGroup,
                 userMetrics,
                 new RemoteLogManager(conf, zkClient, coordinatorGateway, 
clock, ioExecutor),
+                scannerManager,
                 clock,
                 ioExecutor);
     }
@@ -261,6 +266,7 @@ public class ReplicaManager implements ServerReconfigurable 
{
             TabletServerMetricGroup serverMetricGroup,
             UserMetrics userMetrics,
             RemoteLogManager remoteLogManager,
+            ScannerManager scannerManager,
             Clock clock,
             ExecutorService ioExecutor)
             throws IOException {
@@ -310,6 +316,7 @@ public class ReplicaManager implements ServerReconfigurable 
{
         this.clock = clock;
         this.ioExecutor = ioExecutor;
         this.minInSyncReplicas = 
conf.get(ConfigOptions.LOG_REPLICA_MIN_IN_SYNC_REPLICAS_NUMBER);
+        this.scannerManager = checkNotNull(scannerManager, "scannerManager");
         registerMetrics();
     }
 
@@ -1155,6 +1162,7 @@ public class ReplicaManager implements 
ServerReconfigurable {
                 Replica replica = getReplicaOrException(data.getTableBucket());
                 if (replica.makeFollower(data)) {
                     replicasBecomeFollower.add(replica);
+                    scannerManager.closeScannersForBucket(tb);
                 }
                 // stop the remote log tiering tasks for followers
                 remoteLogManager.stopLogTiering(replica);
@@ -1833,6 +1841,8 @@ public class ReplicaManager implements 
ServerReconfigurable {
         // First stop fetchers for this table bucket.
         
replicaFetcherManager.removeFetcherForBuckets(Collections.singleton(tb));
 
+        scannerManager.closeScannersForBucket(tb);
+
         HostedReplica replica = getReplica(tb);
         if (replica instanceof OnlineReplica) {
             Replica replicaToDelete = ((OnlineReplica) replica).getReplica();
@@ -1945,7 +1955,8 @@ public class ReplicaManager implements 
ServerReconfigurable {
                                 bucketMetricGroup,
                                 tableInfo,
                                 clock,
-                                remoteLogManager);
+                                remoteLogManager,
+                                scannerManager);
                 allReplicas.put(tb, new OnlineReplica(replica));
                 replicaOpt = Optional.of(replica);
             } else if (hostedReplica instanceof OnlineReplica) {
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
index 988f7565a..d0f57db64 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
@@ -40,6 +40,7 @@ import org.apache.fluss.server.authorizer.AuthorizerLoader;
 import org.apache.fluss.server.coordinator.LakeCatalogDynamicLoader;
 import org.apache.fluss.server.coordinator.MetadataManager;
 import org.apache.fluss.server.kv.KvManager;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.kv.snapshot.DefaultCompletedKvSnapshotCommitter;
 import org.apache.fluss.server.log.LogManager;
 import org.apache.fluss.server.log.remote.RemoteLogManager;
@@ -145,6 +146,9 @@ public class TabletServer extends ServerBase {
     @GuardedBy("lock")
     private ReplicaManager replicaManager;
 
+    @GuardedBy("lock")
+    private ScannerManager scannerManager;
+
     @GuardedBy("lock")
     private @Nullable RemoteLogManager remoteLogManager = null;
 
@@ -254,6 +258,8 @@ public class TabletServer extends ServerBase {
                             conf.get(ConfigOptions.SERVER_IO_POOL_SIZE),
                             new ExecutorThreadFactory("tablet-server-io"));
 
+            this.scannerManager = new ScannerManager(conf, scheduler);
+
             this.replicaManager =
                     new ReplicaManager(
                             conf,
@@ -270,6 +276,7 @@ public class TabletServer extends ServerBase {
                             this,
                             tabletServerMetricGroup,
                             userMetrics,
+                            scannerManager,
                             clock,
                             ioExecutor);
             replicaManager.startup();
@@ -291,7 +298,8 @@ public class TabletServer extends ServerBase {
                             metadataManager,
                             authorizer,
                             dynamicConfigManager,
-                            ioExecutor);
+                            ioExecutor,
+                            scannerManager);
 
             RequestsMetrics requestsMetrics =
                     
RequestsMetrics.createTabletServerRequestMetrics(tabletServerMetricGroup);
@@ -433,6 +441,10 @@ public class TabletServer extends ServerBase {
                     scheduler.shutdown();
                 }
 
+                if (scannerManager != null) {
+                    scannerManager.close();
+                }
+
                 if (kvManager != null) {
                     kvManager.shutdown();
                 }
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 08536adfd..88731daab 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
@@ -19,10 +19,15 @@ package org.apache.fluss.server.tablet;
 
 import org.apache.fluss.cluster.ServerType;
 import org.apache.fluss.exception.AuthorizationException;
+import org.apache.fluss.exception.InvalidScanRequestException;
+import org.apache.fluss.exception.NotLeaderOrFollowerException;
+import org.apache.fluss.exception.ScannerExpiredException;
+import org.apache.fluss.exception.UnknownScannerIdException;
 import org.apache.fluss.exception.UnknownTableOrBucketException;
 import org.apache.fluss.fs.FileSystem;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.DefaultValueRecordBatch;
 import org.apache.fluss.record.KvRecordBatch;
 import org.apache.fluss.record.MemoryLogRecords;
 import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
@@ -52,6 +57,7 @@ import 
org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest;
 import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse;
 import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsRequest;
 import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsResponse;
+import org.apache.fluss.rpc.messages.PbScanReqForBucket;
 import org.apache.fluss.rpc.messages.PrefixLookupRequest;
 import org.apache.fluss.rpc.messages.PrefixLookupResponse;
 import org.apache.fluss.rpc.messages.ProduceLogRequest;
@@ -76,12 +82,16 @@ import org.apache.fluss.server.coordinator.MetadataManager;
 import org.apache.fluss.server.entity.FetchReqInfo;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.UserContext;
+import org.apache.fluss.server.kv.scan.OpenScanResult;
+import org.apache.fluss.server.kv.scan.ScannerContext;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.log.FetchParams;
 import org.apache.fluss.server.log.FetchParamsBuilder;
 import org.apache.fluss.server.log.FilterInfo;
 import org.apache.fluss.server.log.ListOffsetsParam;
 import org.apache.fluss.server.metadata.TabletServerMetadataCache;
 import org.apache.fluss.server.metadata.TabletServerMetadataProvider;
+import org.apache.fluss.server.replica.Replica;
 import org.apache.fluss.server.replica.ReplicaManager;
 import org.apache.fluss.server.utils.ServerRpcMessageUtils;
 import org.apache.fluss.server.zk.ZooKeeperClient;
@@ -137,6 +147,7 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
     private final ReplicaManager replicaManager;
     private final TabletServerMetadataCache metadataCache;
     private final TabletServerMetadataProvider metadataFunctionProvider;
+    private final ScannerManager scannerManager;
 
     public TabletService(
             int serverId,
@@ -147,7 +158,8 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
             MetadataManager metadataManager,
             @Nullable Authorizer authorizer,
             DynamicConfigManager dynamicConfigManager,
-            ExecutorService ioExecutor) {
+            ExecutorService ioExecutor,
+            ScannerManager scannerManager) {
         super(
                 remoteFileSystem,
                 ServerType.TABLET_SERVER,
@@ -161,6 +173,7 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
         this.metadataCache = metadataCache;
         this.metadataFunctionProvider =
                 new TabletServerMetadataProvider(zkClient, metadataManager, 
metadataCache);
+        this.scannerManager = scannerManager;
     }
 
     @Override
@@ -435,7 +448,201 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
 
     @Override
     public CompletableFuture<ScanKvResponse> scanKv(ScanKvRequest request) {
-        return null;
+        ScanKvResponse response = new ScanKvResponse();
+        ScannerContext openedContext = null;
+        ScannerContext acquiredContext = null;
+        try {
+            if (request.hasBucketScanReq() && request.hasScannerId()) {
+                throw new InvalidScanRequestException(
+                        "ScanKvRequest must not set both bucket_scan_req and 
scanner_id.");
+            }
+            if (request.hasBucketScanReq()
+                    && request.hasCloseScanner()
+                    && request.isCloseScanner()) {
+                throw new InvalidScanRequestException(
+                        "ScanKvRequest must not set close_scanner together 
with bucket_scan_req.");
+            }
+            if (!request.hasBucketScanReq() && !request.hasScannerId()) {
+                throw new InvalidScanRequestException(
+                        "ScanKvRequest must have either bucket_scan_req (new 
scan) "
+                                + "or scanner_id (continuation).");
+            }
+
+            boolean isCloseRequest = request.hasCloseScanner() && 
request.isCloseScanner();
+
+            // Validate batch_size_bytes up-front so malformed requests never 
open a snapshot.
+            int effectiveBatchSize = 0;
+            if (!isCloseRequest) {
+                if (!request.hasBatchSizeBytes()) {
+                    throw new InvalidScanRequestException(
+                            "batch_size_bytes is required for data-fetching 
scan requests.");
+                }
+                int requestedBatchSize = request.getBatchSizeBytes();
+                if (requestedBatchSize <= 0) {
+                    throw new InvalidScanRequestException(
+                            "batch_size_bytes must be greater than 0.");
+                }
+                effectiveBatchSize =
+                        Math.min(requestedBatchSize, 
scannerManager.getMaxBatchSizeBytes());
+            }
+
+            ScannerContext context;
+
+            boolean isNewScan = false;
+            long initialLogOffset = 0L;
+
+            if (request.hasBucketScanReq()) {
+                PbScanReqForBucket bucketReq = request.getBucketScanReq();
+                long tableId = bucketReq.getTableId();
+                authorizeTable(READ, tableId);
+
+                TableBucket tableBucket =
+                        new TableBucket(
+                                tableId,
+                                bucketReq.hasPartitionId() ? 
bucketReq.getPartitionId() : null,
+                                bucketReq.getBucketId());
+                Long limit = bucketReq.hasLimit() ? bucketReq.getLimit() : 
null;
+
+                OpenScanResult openResult =
+                        scannerManager.createScanner(
+                                
replicaManager.getReplicaOrException(tableBucket), limit);
+                isNewScan = true;
+                initialLogOffset = openResult.getLogOffset();
+
+                context = openResult.getContext();
+                if (context == null) {
+                    // Empty bucket: no session registered; still return the 
captured offset.
+                    response.setHasMoreResults(false);
+                    response.setLogOffset(initialLogOffset);
+                    return CompletableFuture.completedFuture(response);
+                }
+                openedContext = context;
+            } else {
+                byte[] scannerId = request.getScannerId();
+                context = scannerManager.getScanner(scannerId);
+                if (context == null) {
+                    if (isCloseRequest) {
+                        response.setScannerId(scannerId);
+                        response.setHasMoreResults(false);
+                        return CompletableFuture.completedFuture(response);
+                    }
+                    if (scannerManager.isRecentlyExpired(scannerId)) {
+                        throw new ScannerExpiredException(
+                                "Scanner session has expired due to 
inactivity. "
+                                        + "Please start a new scan.");
+                    } else {
+                        throw new UnknownScannerIdException(
+                                "Unknown scanner ID. The session may have 
expired or "
+                                        + "never existed.");
+                    }
+                }
+                openedContext = context;
+            }
+
+            // Cursor-exclusion CAS: serialises concurrent same-scannerId RPCs 
and rejects if
+            // close() has begun.
+            if (!context.tryAcquireForUse()) {
+                throw new InvalidScanRequestException(
+                        String.format(
+                                "Concurrent scan request on scanner ID for 
bucket %s, or session "
+                                        + "is closing; only one in-flight 
scanKv RPC per scanner "
+                                        + "is allowed.",
+                                context.getTableBucket()));
+            }
+            acquiredContext = context;
+
+            if (!request.hasBucketScanReq() && request.hasCallSeqId()) {
+                long expectedSeqId = (long) context.getCallSeqId() + 1L;
+                int requestSeqId = request.getCallSeqId();
+                if ((long) requestSeqId != expectedSeqId) {
+                    throw new InvalidScanRequestException(
+                            String.format(
+                                    "Out-of-order scan request: expected 
callSeqId=%d but got %d.",
+                                    expectedSeqId, requestSeqId));
+                }
+            }
+
+            // Honour close even on a non-leader: the local session is still 
ours to release.
+            if (isCloseRequest) {
+                response.setScannerId(context.getScannerId());
+                response.setHasMoreResults(false);
+                return CompletableFuture.completedFuture(response);
+            }
+
+            // Catch a leadership flip ahead of the eventual 
closeScannersForBucket callback so
+            // the client can redirect rather than consume a stale snapshot.
+            if (!request.hasBucketScanReq()) {
+                Replica replica = 
replicaManager.getReplicaOrException(context.getTableBucket());
+                if (!replica.isLeader()) {
+                    throw new NotLeaderOrFollowerException(
+                            String.format(
+                                    "Leader is no longer local for bucket %s; 
client should "
+                                            + "restart the scan against the 
new leader.",
+                                    context.getTableBucket()));
+                }
+            }
+
+            // Refresh TTL only now that the request is fully validated.
+            scannerManager.markAccessed(context);
+
+            // Gate on builder.sizeInBytes() (not raw bytes) so the threshold 
reflects the
+            // serialised batch. Always append at least one record so a tiny 
effectiveBatchSize
+            // cannot produce an empty has_more=true response.
+            DefaultValueRecordBatch.Builder builder = 
DefaultValueRecordBatch.builder();
+            boolean appendedAny = false;
+            while (context.isValid()
+                    && (!appendedAny || builder.sizeInBytes() < 
effectiveBatchSize)) {
+                builder.append(context.currentValue());
+                context.advance();
+                appendedAny = true;
+            }
+
+            boolean hasMore = context.isValid();
+            if (!hasMore) {
+                // RocksIterator.next() does not throw on internal errors; an 
unchecked status
+                // would silently truncate the scan and report has_more=false 
to the client.
+                context.checkIteratorStatus();
+            }
+            DefaultValueRecordBatch batch = builder.build();
+
+            response.setScannerId(context.getScannerId());
+            response.setHasMoreResults(hasMore);
+            if (batch.sizeInBytes() > 0) {
+                response.setRecords(batch.getSegment(), batch.getPosition(), 
batch.sizeInBytes());
+            }
+            if (isNewScan) {
+                response.setLogOffset(initialLogOffset);
+            }
+
+            // Update callSeqId AFTER the response is prepared so a duplicate 
retry can be
+            // detected via the in-order check.
+            if (request.hasCallSeqId()) {
+                context.setCallSeqId(request.getCallSeqId());
+            }
+
+            // Keep the session alive only if there's more to read; otherwise 
leave openedContext
+            // set so finally drains it.
+            if (hasMore) {
+                openedContext = null;
+            }
+
+        } catch (Exception e) {
+            if (e instanceof InterruptedException || e.getCause() instanceof 
InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            ApiError apiError = ApiError.fromThrowable(e);
+            response.setErrorCode(apiError.error().code());
+            response.setErrorMessage(apiError.message() != null ? 
apiError.message() : "");
+        } finally {
+            if (acquiredContext != null) {
+                acquiredContext.releaseAfterUse();
+            }
+            if (openedContext != null) {
+                scannerManager.removeScanner(openedContext);
+            }
+        }
+
+        return CompletableFuture.completedFuture(response);
     }
 
     @Override
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java 
b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
index fd08eeb4a..1f2d5a89f 100644
--- a/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
+++ b/fluss-server/src/test/java/org/apache/fluss/server/kv/KvTabletTest.java
@@ -57,6 +57,8 @@ import 
org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.KvEntry;
 import org.apache.fluss.server.kv.prewrite.KvPreWriteBuffer.Value;
 import org.apache.fluss.server.kv.rocksdb.RocksDBStatistics;
 import org.apache.fluss.server.kv.rowmerger.RowMerger;
+import org.apache.fluss.server.kv.scan.OpenScanResult;
+import org.apache.fluss.server.kv.scan.ScannerContext;
 import org.apache.fluss.server.log.FetchIsolation;
 import org.apache.fluss.server.log.LogAppendInfo;
 import org.apache.fluss.server.log.LogTablet;
@@ -1857,4 +1859,139 @@ class KvTabletTest {
 
         kvTablet.close();
     }
+
+    @Test
+    void testOpenScan_emptyBucket_returnsNullContext() throws Exception {
+        initLogTabletAndKvTablet(DATA1_SCHEMA_PK, new HashMap<>());
+        OpenScanResult result = kvTablet.openScan("scanner-empty", -1L, 0L);
+        assertThat(result).isNotNull();
+        assertThat(result.getContext()).isNull();
+        assertThat(result.getLogOffset()).isGreaterThanOrEqualTo(0L);
+    }
+
+    @Test
+    void testOpenScan_returnsAllRows() throws Exception {
+        initLogTabletAndKvTablet(DATA1_SCHEMA_PK, new HashMap<>());
+
+        int numRows = 5;
+        List<KvRecord> rows = new ArrayList<>();
+        for (int i = 0; i < numRows; i++) {
+            rows.add(
+                    kvRecordFactory.ofRecord(
+                            String.valueOf(i).getBytes(), new Object[] {i, "v" 
+ i}));
+        }
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(rows), null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        OpenScanResult result = kvTablet.openScan("scanner-all", -1L, 0L);
+        ScannerContext context = result.getContext();
+        assertThat(context).isNotNull();
+        assertThat(result.getLogOffset()).isEqualTo(context.getLogOffset());
+
+        int count = 0;
+        while (context.isValid()) {
+            assertThat(context.currentValue()).isNotNull();
+            context.advance();
+            count++;
+        }
+        context.close();
+
+        assertThat(count).isEqualTo(numRows);
+    }
+
+    /** Snapshot isolation: rows written after openScan must not appear in the 
scan. */
+    @Test
+    void testOpenScan_snapshotIsolation() throws Exception {
+        initLogTabletAndKvTablet(DATA1_SCHEMA_PK, new HashMap<>());
+
+        List<KvRecord> initialRows =
+                Arrays.asList(
+                        kvRecordFactory.ofRecord("0".getBytes(), new Object[] 
{0, "v0"}),
+                        kvRecordFactory.ofRecord("1".getBytes(), new Object[] 
{1, "v1"}),
+                        kvRecordFactory.ofRecord("2".getBytes(), new Object[] 
{2, "v2"}));
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(initialRows), 
null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        ScannerContext context = kvTablet.openScan("scanner-snap", -1L, 
0L).getContext();
+        assertThat(context).isNotNull();
+
+        List<KvRecord> lateRows =
+                Arrays.asList(
+                        kvRecordFactory.ofRecord("3".getBytes(), new Object[] 
{3, "v3"}),
+                        kvRecordFactory.ofRecord("4".getBytes(), new Object[] 
{4, "v4"}));
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(lateRows), null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        int count = 0;
+        while (context.isValid()) {
+            context.advance();
+            count++;
+        }
+        context.close();
+
+        assertThat(count).isEqualTo(3);
+    }
+
+    @Test
+    void testOpenScan_withLimit() throws Exception {
+        initLogTabletAndKvTablet(DATA1_SCHEMA_PK, new HashMap<>());
+
+        int numRows = 5;
+        List<KvRecord> rows = new ArrayList<>();
+        for (int i = 0; i < numRows; i++) {
+            rows.add(
+                    kvRecordFactory.ofRecord(
+                            String.valueOf(i).getBytes(), new Object[] {i, "v" 
+ i}));
+        }
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(rows), null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        long limit = 3L;
+        ScannerContext context = kvTablet.openScan("scanner-limit", limit, 
0L).getContext();
+        assertThat(context).isNotNull();
+
+        int count = 0;
+        while (context.isValid()) {
+            context.advance();
+            count++;
+        }
+        context.close();
+
+        assertThat(count).isEqualTo((int) limit);
+    }
+
+    @Test
+    void testOpenScan_multipleSessionsIndependent() throws Exception {
+        initLogTabletAndKvTablet(DATA1_SCHEMA_PK, new HashMap<>());
+
+        List<KvRecord> rows =
+                Arrays.asList(
+                        kvRecordFactory.ofRecord("0".getBytes(), new Object[] 
{0, "v0"}),
+                        kvRecordFactory.ofRecord("1".getBytes(), new Object[] 
{1, "v1"}),
+                        kvRecordFactory.ofRecord("2".getBytes(), new Object[] 
{2, "v2"}));
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(rows), null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        ScannerContext ctx1 = kvTablet.openScan("scanner-a", -1L, 
0L).getContext();
+        ScannerContext ctx2 = kvTablet.openScan("scanner-b", -1L, 
0L).getContext();
+        assertThat(ctx1).isNotNull();
+        assertThat(ctx2).isNotNull();
+
+        int count1 = 0;
+        while (ctx1.isValid()) {
+            ctx1.advance();
+            count1++;
+        }
+        ctx1.close();
+
+        int count2 = 0;
+        while (ctx2.isValid()) {
+            ctx2.advance();
+            count2++;
+        }
+        ctx2.close();
+
+        assertThat(count1).isEqualTo(3);
+        assertThat(count2).isEqualTo(3);
+    }
 }
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/kv/scan/ScannerManagerTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/kv/scan/ScannerManagerTest.java
new file mode 100644
index 000000000..c04a907b3
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/kv/scan/ScannerManagerTest.java
@@ -0,0 +1,488 @@
+/*
+ * 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.scan;
+
+import org.apache.fluss.config.ConfigOptions;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.config.TableConfig;
+import org.apache.fluss.exception.TooManyScannersException;
+import org.apache.fluss.memory.TestingMemorySegmentPool;
+import org.apache.fluss.metadata.KvFormat;
+import org.apache.fluss.metadata.LogFormat;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.SchemaInfo;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.KvRecord;
+import org.apache.fluss.record.KvRecordTestUtils;
+import org.apache.fluss.record.TestData;
+import org.apache.fluss.record.TestingSchemaGetter;
+import org.apache.fluss.server.kv.KvManager;
+import org.apache.fluss.server.kv.KvTablet;
+import org.apache.fluss.server.kv.autoinc.AutoIncrementManager;
+import org.apache.fluss.server.kv.autoinc.TestingSequenceGeneratorFactory;
+import org.apache.fluss.server.kv.rowmerger.RowMerger;
+import org.apache.fluss.server.log.LogTablet;
+import org.apache.fluss.server.log.LogTestUtils;
+import org.apache.fluss.server.metrics.group.TestingMetricGroups;
+import org.apache.fluss.server.zk.NOPErrorHandler;
+import org.apache.fluss.shaded.arrow.org.apache.arrow.memory.RootAllocator;
+import org.apache.fluss.utils.clock.ManualClock;
+import org.apache.fluss.utils.concurrent.FlussScheduler;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static 
org.apache.fluss.compression.ArrowCompressionInfo.DEFAULT_COMPRESSION;
+import static org.apache.fluss.record.TestData.DATA1_SCHEMA_PK;
+import static org.apache.fluss.testutils.common.CommonTestUtils.retry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link ScannerManager}. */
+class ScannerManagerTest {
+
+    private static final short SCHEMA_ID = 1;
+
+    @TempDir File tempLogDir;
+    @TempDir File tmpKvDir;
+
+    private final Configuration conf = new Configuration();
+    private final KvRecordTestUtils.KvRecordBatchFactory kvRecordBatchFactory =
+            KvRecordTestUtils.KvRecordBatchFactory.of(SCHEMA_ID);
+    private final KvRecordTestUtils.KvRecordFactory kvRecordFactory =
+            KvRecordTestUtils.KvRecordFactory.of(TestData.DATA1_ROW_TYPE);
+
+    private ManualClock clock;
+    private FlussScheduler scheduler;
+    private LogTablet logTablet;
+    private KvTablet kvTablet;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        clock = new ManualClock(0);
+        scheduler = new FlussScheduler(1);
+        scheduler.startup();
+
+        PhysicalTablePath physicalTablePath = 
PhysicalTablePath.of(TablePath.of("testDb", "t1"));
+        TestingSchemaGetter schemaGetter =
+                new TestingSchemaGetter(new SchemaInfo(DATA1_SCHEMA_PK, 
SCHEMA_ID));
+
+        File logTabletDir =
+                LogTestUtils.makeRandomLogTabletDir(
+                        tempLogDir,
+                        physicalTablePath.getDatabaseName(),
+                        0L,
+                        physicalTablePath.getTableName());
+        logTablet =
+                LogTablet.create(
+                        physicalTablePath,
+                        logTabletDir,
+                        conf,
+                        TestingMetricGroups.TABLET_SERVER_METRICS,
+                        0,
+                        new FlussScheduler(1),
+                        LogFormat.ARROW,
+                        1,
+                        true,
+                        org.apache.fluss.utils.clock.SystemClock.getInstance(),
+                        true);
+
+        TableBucket tableBucket = logTablet.getTableBucket();
+        TableConfig tableConf = new TableConfig(new Configuration());
+        RowMerger rowMerger = RowMerger.create(tableConf, KvFormat.COMPACTED, 
schemaGetter);
+        AutoIncrementManager autoIncrementManager =
+                new AutoIncrementManager(
+                        schemaGetter,
+                        physicalTablePath.getTablePath(),
+                        tableConf,
+                        new TestingSequenceGeneratorFactory());
+
+        kvTablet =
+                KvTablet.create(
+                        physicalTablePath,
+                        tableBucket,
+                        logTablet,
+                        tmpKvDir,
+                        conf,
+                        TestingMetricGroups.TABLET_SERVER_METRICS,
+                        new RootAllocator(Long.MAX_VALUE),
+                        new TestingMemorySegmentPool(10 * 1024),
+                        KvFormat.COMPACTED,
+                        rowMerger,
+                        DEFAULT_COMPRESSION,
+                        schemaGetter,
+                        tableConf.getChangelogImage(),
+                        KvManager.getDefaultRateLimiter(),
+                        autoIncrementManager);
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        if (kvTablet != null) {
+            kvTablet.close();
+        }
+        if (logTablet != null) {
+            logTablet.close();
+        }
+        if (scheduler != null) {
+            scheduler.shutdown();
+        }
+    }
+
+    private ScannerManager createManager() {
+        Configuration c = new Configuration();
+        c.set(ConfigOptions.KV_SCANNER_TTL, Duration.ofHours(1));
+        c.set(ConfigOptions.KV_SCANNER_EXPIRATION_INTERVAL, 
Duration.ofHours(1));
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_BUCKET, 8);
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_SERVER, 200);
+        return new ScannerManager(c, scheduler, clock);
+    }
+
+    private ScannerManager createManager(int maxPerBucket, int maxPerServer) {
+        Configuration c = new Configuration();
+        c.set(ConfigOptions.KV_SCANNER_TTL, Duration.ofHours(1));
+        c.set(ConfigOptions.KV_SCANNER_EXPIRATION_INTERVAL, 
Duration.ofHours(1));
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_BUCKET, maxPerBucket);
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_SERVER, maxPerServer);
+        return new ScannerManager(c, scheduler, clock);
+    }
+
+    private ScannerManager createManagerWithShortTtl(long ttlMs, long 
expirationIntervalMs) {
+        Configuration c = new Configuration();
+        c.set(ConfigOptions.KV_SCANNER_TTL, Duration.ofMillis(ttlMs));
+        c.set(
+                ConfigOptions.KV_SCANNER_EXPIRATION_INTERVAL,
+                Duration.ofMillis(expirationIntervalMs));
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_BUCKET, 8);
+        c.set(ConfigOptions.KV_SCANNER_MAX_PER_SERVER, 200);
+        return new ScannerManager(c, scheduler, clock);
+    }
+
+    /** Opens a scanner directly via KvTablet and registers it; mirrors 
Replica#openScan. */
+    private ScannerContext openAndRegister(ScannerManager manager) throws 
Exception {
+        ScannerContext ctx =
+                kvTablet.openScan(java.util.UUID.randomUUID().toString(), -1L, 
clock.milliseconds())
+                        .getContext();
+        if (ctx == null) {
+            return null;
+        }
+        try {
+            manager.register(ctx);
+        } catch (RuntimeException e) {
+            ctx.close();
+            throw e;
+        }
+        return ctx;
+    }
+
+    private void putAndFlush(int count) throws Exception {
+        List<KvRecord> rows = new ArrayList<>();
+        for (int i = 0; i < count; i++) {
+            rows.add(
+                    kvRecordFactory.ofRecord(
+                            String.valueOf(i).getBytes(), new Object[] {i, "v" 
+ i}));
+        }
+        kvTablet.putAsLeader(kvRecordBatchFactory.ofRecords(rows), null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+    }
+
+    @Test
+    void testCreateScanner_emptyBucket_returnsNull() throws Exception {
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNull();
+            assertThat(manager.activeScannerCount()).isEqualTo(0);
+        }
+    }
+
+    @Test
+    void testCreateAndRemoveScanner() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            TableBucket tableBucket = kvTablet.getTableBucket();
+
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+            assertThat(manager.activeScannerCount()).isEqualTo(1);
+            
assertThat(manager.activeScannerCountForBucket(tableBucket)).isEqualTo(1);
+
+            manager.removeScanner(context);
+            assertThat(manager.activeScannerCount()).isEqualTo(0);
+            
assertThat(manager.activeScannerCountForBucket(tableBucket)).isEqualTo(0);
+        }
+    }
+
+    @Test
+    void testGetScanner_doesNotRefreshLastAccessTime() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+            byte[] scannerId = context.getScannerId();
+
+            clock.advanceTime(5000, TimeUnit.MILLISECONDS);
+            ScannerContext fetched = manager.getScanner(scannerId);
+            assertThat(fetched).isSameAs(context);
+
+            assertThat(context.isExpired(1000L, 
clock.milliseconds())).isTrue();
+
+            manager.removeScanner(context);
+        }
+    }
+
+    @Test
+    void testMarkAccessed_refreshesLastAccessTime() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+
+            clock.advanceTime(5000, TimeUnit.MILLISECONDS);
+            manager.markAccessed(context);
+
+            assertThat(context.isExpired(3_600_000L, 
clock.milliseconds())).isFalse();
+
+            manager.removeScanner(context);
+        }
+    }
+
+    @Test
+    void testTryAcquireForUse_serialisesConcurrentClaims() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+
+            assertThat(context.tryAcquireForUse()).isTrue();
+            assertThat(context.tryAcquireForUse()).isFalse();
+
+            context.releaseAfterUse();
+
+            assertThat(context.tryAcquireForUse()).isTrue();
+            context.releaseAfterUse();
+
+            manager.removeScanner(context);
+        }
+    }
+
+    @Test
+    void testTryAcquireForUse_exactlyOneWinnerUnderContention() throws 
Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+
+            int threadCount = 16;
+            java.util.concurrent.CountDownLatch start = new 
java.util.concurrent.CountDownLatch(1);
+            java.util.concurrent.atomic.AtomicInteger winners =
+                    new java.util.concurrent.atomic.AtomicInteger(0);
+            java.util.concurrent.ExecutorService pool =
+                    
java.util.concurrent.Executors.newFixedThreadPool(threadCount);
+            try {
+                for (int i = 0; i < threadCount; i++) {
+                    pool.submit(
+                            () -> {
+                                try {
+                                    start.await();
+                                } catch (InterruptedException ie) {
+                                    Thread.currentThread().interrupt();
+                                    return;
+                                }
+                                if (context.tryAcquireForUse()) {
+                                    winners.incrementAndGet();
+                                }
+                            });
+                }
+                start.countDown();
+                pool.shutdown();
+                assertThat(pool.awaitTermination(10, 
TimeUnit.SECONDS)).isTrue();
+            } finally {
+                pool.shutdownNow();
+            }
+
+            assertThat(winners.get()).isEqualTo(1);
+            context.releaseAfterUse();
+            manager.removeScanner(context);
+        }
+    }
+
+    @Test
+    void testCheckIteratorStatus_healthyIteratorIsNoOp() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+
+            while (context.isValid()) {
+                context.advance();
+            }
+
+            context.checkIteratorStatus();
+
+            manager.removeScanner(context);
+        }
+    }
+
+    @Test
+    void testTtlEviction() throws Exception {
+        putAndFlush(3);
+        ScannerManager manager = createManagerWithShortTtl(200, 200);
+        try {
+            ScannerContext context = openAndRegister(manager);
+            assertThat(context).isNotNull();
+            byte[] scannerId = context.getScannerId();
+
+            clock.advanceTime(500, TimeUnit.MILLISECONDS);
+
+            retry(
+                    Duration.ofSeconds(10),
+                    () -> 
assertThat(manager.activeScannerCount()).isEqualTo(0));
+            assertThat(manager.getScanner(scannerId)).isNull();
+            assertThat(manager.isRecentlyExpired(scannerId)).isTrue();
+        } finally {
+            manager.close();
+        }
+    }
+
+    @Test
+    void testPerBucketLimit() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager(2, 200)) {
+            TableBucket tableBucket = kvTablet.getTableBucket();
+
+            ScannerContext ctx1 = openAndRegister(manager);
+            ScannerContext ctx2 = openAndRegister(manager);
+            
assertThat(manager.activeScannerCountForBucket(tableBucket)).isEqualTo(2);
+
+            assertThatThrownBy(() -> openAndRegister(manager))
+                    .isInstanceOf(TooManyScannersException.class);
+
+            
assertThat(manager.activeScannerCountForBucket(tableBucket)).isEqualTo(2);
+
+            manager.removeScanner(ctx1);
+            manager.removeScanner(ctx2);
+        }
+    }
+
+    @Test
+    void testPerServerLimit() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager(8, 2)) {
+            ScannerContext ctx1 = openAndRegister(manager);
+            ScannerContext ctx2 = openAndRegister(manager);
+            assertThat(manager.activeScannerCount()).isEqualTo(2);
+
+            assertThatThrownBy(() -> openAndRegister(manager))
+                    .isInstanceOf(TooManyScannersException.class);
+
+            assertThat(manager.activeScannerCount()).isEqualTo(2);
+
+            manager.removeScanner(ctx1);
+            manager.removeScanner(ctx2);
+        }
+    }
+
+    @Test
+    void testCloseScannersForBucket() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            TableBucket tableBucket = kvTablet.getTableBucket();
+
+            openAndRegister(manager);
+            openAndRegister(manager);
+            assertThat(manager.activeScannerCount()).isEqualTo(2);
+
+            manager.closeScannersForBucket(tableBucket);
+
+            assertThat(manager.activeScannerCount()).isEqualTo(0);
+            
assertThat(manager.activeScannerCountForBucket(tableBucket)).isEqualTo(0);
+        }
+    }
+
+    @Test
+    void testCloseScannersForBucket_marksRecentlyExpired() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            TableBucket tableBucket = kvTablet.getTableBucket();
+
+            ScannerContext ctx = openAndRegister(manager);
+            assertThat(ctx).isNotNull();
+            byte[] scannerId = ctx.getScannerId();
+
+            manager.closeScannersForBucket(tableBucket);
+
+            assertThat(manager.getScanner(scannerId)).isNull();
+            assertThat(manager.isRecentlyExpired(scannerId)).isTrue();
+        }
+    }
+
+    @Test
+    void testShutdown_closesAllScanners() throws Exception {
+        putAndFlush(3);
+        ScannerManager manager = createManager();
+        openAndRegister(manager);
+        openAndRegister(manager);
+        assertThat(manager.activeScannerCount()).isEqualTo(2);
+
+        manager.close();
+
+        assertThat(manager.activeScannerCount()).isEqualTo(0);
+    }
+
+    @Test
+    void testTryAcquireForUse_rejectsAfterClose() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext ctx = openAndRegister(manager);
+            assertThat(ctx).isNotNull();
+
+            manager.removeScanner(ctx);
+
+            assertThat(ctx.tryAcquireForUse()).isFalse();
+        }
+    }
+
+    /** close() force-clears inUse and completes immediately even if 
inUse=true. */
+    @Test
+    void testClose_notWaitForInUse() throws Exception {
+        putAndFlush(3);
+        try (ScannerManager manager = createManager()) {
+            ScannerContext ctx = openAndRegister(manager);
+            assertThat(ctx).isNotNull();
+
+            assertThat(ctx.tryAcquireForUse()).isTrue();
+
+            // close() should complete immediately despite inUse=true
+            ctx.close();
+
+            // after close, tryAcquireForUse must be rejected
+            assertThat(ctx.tryAcquireForUse()).isFalse();
+
+            manager.removeScanner(ctx);
+        }
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
index 5120bf6a6..fa502f928 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java
@@ -37,6 +37,7 @@ import org.apache.fluss.record.ChangeType;
 import org.apache.fluss.record.DefaultValueRecordBatch;
 import org.apache.fluss.record.KvRecord;
 import org.apache.fluss.record.KvRecordBatch;
+import org.apache.fluss.record.KvRecordTestUtils;
 import org.apache.fluss.record.LogRecord;
 import org.apache.fluss.record.LogRecordBatch;
 import org.apache.fluss.record.LogRecordReadContext;
@@ -62,6 +63,7 @@ import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
 import org.apache.fluss.server.entity.StopReplicaData;
 import org.apache.fluss.server.entity.StopReplicaResultForBucket;
+import org.apache.fluss.server.kv.KvTablet;
 import org.apache.fluss.server.kv.rocksdb.RocksDBKv;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import org.apache.fluss.server.log.FetchParams;
@@ -72,6 +74,7 @@ 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.testutils.KvTestUtils;
+import org.apache.fluss.server.zk.NOPErrorHandler;
 import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.server.zk.data.TableRegistration;
 import org.apache.fluss.testutils.DataTestUtils;
@@ -2374,4 +2377,40 @@ class ReplicaManagerTest extends ReplicaTestBase {
                     }
                 });
     }
+
+    @Test
+    void testStopReplicas_closesScanners() throws Exception {
+        TableBucket tb = new TableBucket(DATA1_TABLE_ID_PK, 0);
+        makeKvTableAsLeader(DATA1_TABLE_ID_PK, DATA1_TABLE_PATH_PK, 
tb.getBucket());
+
+        KvTablet kvTablet =
+                kvManager
+                        .getKv(tb)
+                        .orElseThrow(() -> new IllegalStateException("KvTablet 
not found"));
+        KvRecordTestUtils.KvRecordBatchFactory batchFactory =
+                KvRecordTestUtils.KvRecordBatchFactory.of(DEFAULT_SCHEMA_ID);
+        KvRecordTestUtils.KvRecordFactory recordFactory =
+                KvRecordTestUtils.KvRecordFactory.of(DATA1_ROW_TYPE);
+        kvTablet.putAsLeader(
+                batchFactory.ofRecords(
+                        Collections.singletonList(
+                                recordFactory.ofRecord("k1".getBytes(), new 
Object[] {1, "v1"}))),
+                null);
+        kvTablet.flush(Long.MAX_VALUE, NOPErrorHandler.INSTANCE);
+
+        Replica replica = replicaManager.getReplicaOrException(tb);
+        scannerManager.createScanner(replica, null);
+        assertThat(scannerManager.activeScannerCount()).isEqualTo(1);
+
+        CompletableFuture<List<StopReplicaResultForBucket>> future = new 
CompletableFuture<>();
+        replicaManager.stopReplicas(
+                INITIAL_COORDINATOR_EPOCH,
+                Collections.singletonList(
+                        new StopReplicaData(
+                                tb, false, false, INITIAL_COORDINATOR_EPOCH, 
INITIAL_LEADER_EPOCH)),
+                future::complete);
+        future.get();
+
+        assertThat(scannerManager.activeScannerCount()).isEqualTo(0);
+    }
 }
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java
index f6b2e01c8..a9041acbd 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java
@@ -37,6 +37,7 @@ import org.apache.fluss.server.coordinator.MetadataManager;
 import org.apache.fluss.server.coordinator.TestCoordinatorGateway;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.kv.KvManager;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.kv.snapshot.CompletedKvSnapshotCommitter;
 import org.apache.fluss.server.kv.snapshot.CompletedSnapshot;
 import org.apache.fluss.server.kv.snapshot.KvSnapshotDataDownloader;
@@ -140,6 +141,7 @@ public class ReplicaTestBase {
     protected LogManager logManager;
     protected KvManager kvManager;
     protected ReplicaManager replicaManager;
+    protected ScannerManager scannerManager;
     protected RpcClient rpcClient;
     protected Configuration conf;
     protected TabletServerMetadataCache serverMetadataCache;
@@ -308,6 +310,9 @@ public class ReplicaTestBase {
 
     protected ReplicaManager buildReplicaManager(CoordinatorGateway 
coordinatorGateway)
             throws Exception {
+        if (scannerManager == null) {
+            scannerManager = new ScannerManager(conf, scheduler);
+        }
         return new ReplicaManager(
                 conf,
                 scheduler,
@@ -323,6 +328,7 @@ public class ReplicaTestBase {
                 TestingMetricGroups.TABLET_SERVER_METRICS,
                 TestingMetricGroups.USER_METRICS,
                 remoteLogManager,
+                scannerManager,
                 manualClock,
                 ioExecutor);
     }
@@ -351,6 +357,10 @@ public class ReplicaTestBase {
             replicaManager.shutdown();
         }
 
+        if (scannerManager != null) {
+            scannerManager.close();
+        }
+
         if (rpcClient != null) {
             rpcClient.close();
         }
@@ -502,7 +512,8 @@ public class ReplicaTestBase {
                 metricGroup,
                 DATA1_TABLE_INFO,
                 manualClock,
-                remoteLogManager);
+                remoteLogManager,
+                scannerManager);
     }
 
     private void initRemoteLogEnv() throws Exception {
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java
index ff089cb4a..b68cbe6b7 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java
@@ -33,6 +33,7 @@ import 
org.apache.fluss.server.coordinator.TestCoordinatorGateway;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
 import org.apache.fluss.server.kv.KvManager;
+import org.apache.fluss.server.kv.scan.ScannerManager;
 import org.apache.fluss.server.kv.snapshot.TestingCompletedKvSnapshotCommitter;
 import org.apache.fluss.server.log.LogManager;
 import org.apache.fluss.server.metadata.TabletServerMetadataCache;
@@ -566,6 +567,7 @@ public class ReplicaFetcherThreadTest {
                     NOPErrorHandler.INSTANCE,
                     serverMetricGroup,
                     USER_METRICS,
+                    new ScannerManager(conf, scheduler),
                     clock,
                     ioExecutor);
         }
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
index 17b436e1a..8adb942da 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java
@@ -51,6 +51,9 @@ import 
org.apache.fluss.rpc.messages.PbPrefixLookupRespForBucket;
 import org.apache.fluss.rpc.messages.PbPutKvRespForBucket;
 import org.apache.fluss.rpc.messages.ProduceLogResponse;
 import org.apache.fluss.rpc.messages.PutKvResponse;
+import org.apache.fluss.rpc.messages.ScanKvRequest;
+import org.apache.fluss.rpc.messages.ScanKvResponse;
+import org.apache.fluss.rpc.messages.StopReplicaRequest;
 import org.apache.fluss.rpc.protocol.Errors;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrData;
 import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket;
@@ -113,6 +116,7 @@ import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.newProduceLo
 import static 
org.apache.fluss.server.testutils.RpcMessageTestUtils.newPutKvRequest;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.getNotifyLeaderAndIsrResponseData;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeNotifyBucketLeaderAndIsr;
+import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeStopBucketReplica;
 import static 
org.apache.fluss.server.utils.ServerRpcMessageUtils.makeUpdateMetadataRequest;
 import static org.apache.fluss.testutils.DataTestUtils.compactedRow;
 import static org.apache.fluss.testutils.DataTestUtils.genKvRecordBatch;
@@ -1075,4 +1079,349 @@ public class TabletServiceITCase {
         assertThat(existingRow.getLong(1)).isEqualTo(1L);
         assertThat(newRow.getLong(1)).isEqualTo(3L);
     }
+
+    @Test
+    void testScanKv_newScan_happyPath() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse response =
+                leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1024 * 
1024)).get();
+
+        assertThat(response.hasErrorCode()).isFalse();
+        assertThat(response.getScannerId()).isNotEmpty();
+        assertThat(response.isHasMoreResults()).isFalse();
+        assertThat(response.hasLogOffset()).isTrue();
+        assertThat(response.getLogOffset()).isGreaterThanOrEqualTo(0L);
+        assertThat(response.hasRecords()).isTrue();
+        DefaultValueRecordBatch batch = 
DefaultValueRecordBatch.pointToBytes(response.getRecords());
+        assertThat(batch.getRecordCount()).isEqualTo(2);
+    }
+
+    @Test
+    void testScanKv_multiBatchContinuation() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        // batch_size_bytes=1 forces one record per batch via the appendedAny 
progress guard.
+        ScanKvResponse first = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        assertThat(first.hasErrorCode()).isFalse();
+        byte[] scannerId = first.getScannerId();
+        long firstLogOffset = first.getLogOffset();
+        int totalRecords =
+                
DefaultValueRecordBatch.pointToBytes(first.getRecords()).getRecordCount();
+
+        ScanKvResponse current = first;
+        int seq = 0;
+        while (current.isHasMoreResults()) {
+            current =
+                    leaderGateWay
+                            .scanKv(newScanKvContinueRequest(scannerId, seq++, 
/*batch=*/ 1))
+                            .get();
+            assertThat(current.hasErrorCode()).isFalse();
+            assertThat(current.getScannerId()).isEqualTo(scannerId);
+            // log_offset is carried only on the open response.
+            assertThat(current.hasLogOffset()).isFalse();
+            if (current.hasRecords()) {
+                totalRecords +=
+                        
DefaultValueRecordBatch.pointToBytes(current.getRecords()).getRecordCount();
+            }
+        }
+        assertThat(totalRecords).isEqualTo(2);
+        // After draining, the session is auto-closed.
+        ScanKvResponse afterDrain =
+                leaderGateWay.scanKv(newScanKvContinueRequest(scannerId, seq, 
1024)).get();
+        
assertThat(afterDrain.getErrorCode()).isEqualTo(Errors.UNKNOWN_SCANNER_ID.code());
+        assertThat(firstLogOffset).isGreaterThanOrEqualTo(0L);
+    }
+
+    @Test
+    void testScanKv_explicitClose() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        // Tiny batch keeps the session open after the first response.
+        ScanKvResponse open = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        assertThat(open.hasErrorCode()).isFalse();
+        assertThat(open.isHasMoreResults()).isTrue();
+        byte[] scannerId = open.getScannerId();
+
+        ScanKvResponse close = 
leaderGateWay.scanKv(newScanKvCloseRequest(scannerId)).get();
+        assertThat(close.hasErrorCode()).isFalse();
+        assertThat(close.getScannerId()).isEqualTo(scannerId);
+        assertThat(close.isHasMoreResults()).isFalse();
+        assertThat(close.hasRecords()).isFalse();
+    }
+
+    @Test
+    void testScanKv_closeOnAlreadyGoneScannerIsNoOp() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse open = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        byte[] scannerId = open.getScannerId();
+
+        leaderGateWay.scanKv(newScanKvCloseRequest(scannerId)).get();
+
+        // Second close on the already-gone session must be a benign no-op.
+        ScanKvResponse second = 
leaderGateWay.scanKv(newScanKvCloseRequest(scannerId)).get();
+        assertThat(second.hasErrorCode()).isFalse();
+        assertThat(second.getScannerId()).isEqualTo(scannerId);
+        assertThat(second.isHasMoreResults()).isFalse();
+    }
+
+    @Test
+    void testScanKv_unknownScannerId() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        byte[] fakeId = "not-a-real-scanner-id".getBytes();
+        ScanKvResponse response =
+                leaderGateWay.scanKv(newScanKvContinueRequest(fakeId, 0, 
1024)).get();
+        
assertThat(response.getErrorCode()).isEqualTo(Errors.UNKNOWN_SCANNER_ID.code());
+        assertThat(response.getErrorMessage()).contains("Unknown scanner ID");
+    }
+
+    @Test
+    void testScanKv_expiredOnRecentlyEvicted() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse open = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        assertThat(open.hasErrorCode()).isFalse();
+        assertThat(open.isHasMoreResults()).isTrue();
+        byte[] scannerId = open.getScannerId();
+
+        // stopReplica(delete=false) routes through closeScannersForBucket, 
which records
+        // the id in recentlyExpiredIds.
+        LeaderAndIsr la = FLUSS_CLUSTER_EXTENSION.waitLeaderAndIsrReady(tb);
+        stopReplicaWithLatestEpoch(leaderGateWay, tb, la);
+
+        ScanKvResponse cont =
+                leaderGateWay.scanKv(newScanKvContinueRequest(scannerId, 0, 
1024)).get();
+        
assertThat(cont.getErrorCode()).isEqualTo(Errors.SCANNER_EXPIRED.code());
+        assertThat(cont.getErrorMessage()).contains("expired");
+    }
+
+    @Test
+    void testScanKv_leadershipFlipMidScan() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse open = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        assertThat(open.hasErrorCode()).isFalse();
+        assertThat(open.isHasMoreResults()).isTrue();
+        byte[] scannerId = open.getScannerId();
+
+        LeaderAndIsr la = FLUSS_CLUSTER_EXTENSION.waitLeaderAndIsrReady(tb);
+        stopReplicaWithLatestEpoch(leaderGateWay, tb, la);
+
+        ScanKvResponse cont =
+                leaderGateWay.scanKv(newScanKvContinueRequest(scannerId, 0, 
1024)).get();
+        assertThat(cont.getErrorCode())
+                .isIn(Errors.SCANNER_EXPIRED.code(), 
Errors.NOT_LEADER_OR_FOLLOWER.code());
+    }
+
+    @Test
+    void testScanKv_callSeqIdMismatch() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse open = 
leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 1)).get();
+        byte[] scannerId = open.getScannerId();
+
+        ScanKvResponse bad =
+                leaderGateWay.scanKv(newScanKvContinueRequest(scannerId, 5, 
1024)).get();
+        
assertThat(bad.getErrorCode()).isEqualTo(Errors.INVALID_SCAN_REQUEST.code());
+        assertThat(bad.getErrorMessage()).contains("Out-of-order");
+    }
+
+    @Test
+    void testScanKv_oversizeBatchSizeBytesIsClamped() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        assertPutKvResponse(
+                leaderGateWay
+                        .putKv(
+                                newPutKvRequest(
+                                        tableId, 0, 1, 
genKvRecordBatch(DATA_1_WITH_KEY_AND_VALUE)))
+                        .get());
+        FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb);
+
+        ScanKvResponse response =
+                leaderGateWay.scanKv(newScanKvOpenRequest(tableId, 0, 
Integer.MAX_VALUE)).get();
+        assertThat(response.hasErrorCode()).isFalse();
+        assertThat(response.hasRecords()).isTrue();
+        
assertThat(DefaultValueRecordBatch.pointToBytes(response.getRecords()).getRecordCount())
+                .isEqualTo(2);
+    }
+
+    @Test
+    void testScanKv_bucketScanReqAndScannerIdRejected() throws Exception {
+        long tableId =
+                createTable(
+                        FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, 
DATA1_TABLE_DESCRIPTOR_PK);
+        TableBucket tb = new TableBucket(tableId, 0);
+        FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb);
+        int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb);
+        TabletServerGateway leaderGateWay =
+                FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader);
+
+        ScanKvRequest bad = new ScanKvRequest();
+        bad.setBucketScanReq().setTableId(tableId).setBucketId(0);
+        bad.setScannerId("ambiguous".getBytes());
+        bad.setBatchSizeBytes(1024);
+        ScanKvResponse response = leaderGateWay.scanKv(bad).get();
+        
assertThat(response.getErrorCode()).isEqualTo(Errors.INVALID_SCAN_REQUEST.code());
+        assertThat(response.getErrorMessage())
+                .contains("must not set both bucket_scan_req and scanner_id");
+    }
+
+    private static ScanKvRequest newScanKvOpenRequest(long tableId, int 
bucketId, int batchSize) {
+        ScanKvRequest req = new ScanKvRequest();
+        req.setBucketScanReq().setTableId(tableId).setBucketId(bucketId);
+        req.setBatchSizeBytes(batchSize);
+        return req;
+    }
+
+    private static ScanKvRequest newScanKvContinueRequest(
+            byte[] scannerId, int callSeqId, int batchSize) {
+        ScanKvRequest req = new ScanKvRequest();
+        req.setScannerId(scannerId);
+        req.setCallSeqId(callSeqId);
+        req.setBatchSizeBytes(batchSize);
+        return req;
+    }
+
+    private static ScanKvRequest newScanKvCloseRequest(byte[] scannerId) {
+        ScanKvRequest req = new ScanKvRequest();
+        req.setScannerId(scannerId);
+        req.setCloseScanner(true);
+        return req;
+    }
+
+    /** Sends stopReplica using the current coordinator epoch (vs. the 
helper's hardcoded 0). */
+    private static void stopReplicaWithLatestEpoch(
+            TabletServerGateway leaderGateWay, TableBucket tb, LeaderAndIsr 
la) throws Exception {
+        leaderGateWay
+                .stopReplica(
+                        new StopReplicaRequest()
+                                .setCoordinatorEpoch(la.coordinatorEpoch())
+                                .addAllStopReplicasReqs(
+                                        Collections.singleton(
+                                                makeStopBucketReplica(
+                                                        tb, false, false, 
la.leaderEpoch()))))
+                .get();
+    }
 }
diff --git a/website/docs/maintenance/configuration.md 
b/website/docs/maintenance/configuration.md
index 48fc393b8..2f77fb567 100644
--- a/website/docs/maintenance/configuration.md
+++ b/website/docs/maintenance/configuration.md
@@ -171,6 +171,11 @@ during the Fluss cluster working.
 | kv.rocksdb.bloom-filter.block-based-mode          | Boolean    | false       
                  | If true, RocksDB will use block-based filter instead of 
full filter, this only take effect when bloom filter is used. The default value 
is `false`.                                                                     
                                                                                
                                                                                
                  [...]
 | kv.rocksdb.shared-rate-limiter-bytes-per-sec      | MemorySize | 
Long.MAX_VALUE                | The bytes per second rate limit for RocksDB 
flush and compaction operations shared across all RocksDB instances on the 
TabletServer. The rate limiter is always enabled. The default value is 
Long.MAX_VALUE (effectively unlimited). Set to a lower value (e.g., 100MB) to 
limit the rate. This configuration can be updated dynamically without server 
restart. See [Updating Configs](operations/updat [...]
 | kv.recover.log-record-batch.max-size              | MemorySize | 16mb        
                  | The max fetch size for fetching log to apply to kv during 
recovering kv.                                                                  
                                                                                
                                                                                
                                                                                
                [...]
+| kv.scanner.ttl                                    | Duration   | 10min       
                  | The time-to-live for an idle KV scanner session on the 
server. A scanner that has not received a request within this duration will be 
automatically expired and its resources released. The default value is 10 
minutes.                                                                        
                                                                                
                          [...]
+| kv.scanner.expiration-interval                    | Duration   | 30s         
                  | The interval at which the server checks for and removes 
expired KV scanner sessions. The default value is 30 seconds.                   
                                                                                
                                                                                
                                                                                
                  [...]
+| kv.scanner.max-per-bucket                         | Integer    | 8           
                  | The maximum number of concurrent KV scanner sessions 
allowed per bucket. New scan requests that exceed this limit will be rejected 
with an error. The default value is 8.                                          
                                                                                
                                                                                
                       [...]
+| kv.scanner.max-per-server                         | Integer    | 200         
                  | The maximum total number of concurrent KV scanner sessions 
allowed across all buckets on a single tablet server. New scan requests that 
exceed this limit will be rejected with an error. The default value is 200.     
                                                                                
                                                                                
                  [...]
+| kv.scanner.max-batch-size                         | MemorySize | 10mb        
                  | Server-side cap on the per-batch payload size for KV 
full-scan responses. The effective batch size is min(client-requested 
batch_size_bytes, this value). Protects the tablet server from out-of-memory if 
a client passes an excessively large batch size. The default value is 10mb.     
                                                                                
                               [...]
 
 ## Metrics
 

Reply via email to