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

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


The following commit(s) were added to refs/heads/main by this push:
     new e32ba4709 [client] Support log scanner scan to arrow record batch 
(#2995)
e32ba4709 is described below

commit e32ba47099fb1ea03e3565ce6db903495d58a350
Author: yuxia Luo <[email protected]>
AuthorDate: Thu Jun 4 10:23:21 2026 +0800

    [client] Support log scanner scan to arrow record batch (#2995)
---
 fluss-client/pom.xml                               |  18 ++
 ...llector.java => AbstractLogFetchCollector.java} | 192 ++++++++--------
 .../table/scanner/log/ArrowLogFetchCollector.java  |  84 +++++++
 .../client/table/scanner/log/ArrowScanRecords.java | 123 +++++++++++
 .../client/table/scanner/log/CompletedFetch.java   | 130 +++++++++--
 .../table/scanner/log/LogFetchCollector.java       | 245 +--------------------
 .../fluss/client/table/scanner/log/LogFetcher.java |   7 +
 .../client/table/scanner/log/LogScannerImpl.java   | 157 ++++++++-----
 .../client/table/scanner/log/LogScannerITCase.java | 101 +++++++++
 fluss-common/pom.xml                               |  18 ++
 .../UnshadedArrowCompressionFactory.java           |  60 +++++
 .../UnshadedLz4ArrowCompressionCodec.java          |  93 ++++++++
 .../UnshadedZstdArrowCompressionCodec.java         | 112 ++++++++++
 .../org/apache/fluss/record/ArrowBatchData.java    | 103 +++++++++
 .../fluss/record/ArrowRecordBatchContext.java      |  43 ++++
 .../apache/fluss/record/DefaultLogRecordBatch.java |  40 +++-
 .../apache/fluss/record/FileLogInputStream.java    |   5 +
 .../org/apache/fluss/record/LogRecordBatch.java    |  10 +
 .../apache/fluss/record/LogRecordReadContext.java  | 120 +++++++++-
 .../fluss/record/MemoryLogRecordsArrowBuilder.java |  12 +
 .../fluss/record/UnshadedFlussVectorLoader.java    | 132 +++++++++++
 .../java/org/apache/fluss/row/ProjectedRow.java    |  10 +-
 .../apache/fluss/utils/UnshadedArrowReadUtils.java | 122 ++++++++++
 .../fluss/record/FileLogInputStreamTest.java       |  50 +++++
 .../org/apache/fluss/testutils/DataTestUtils.java  |  61 ++++-
 25 files changed, 1633 insertions(+), 415 deletions(-)

diff --git a/fluss-client/pom.xml b/fluss-client/pom.xml
index 8e4ec956e..df80059c2 100644
--- a/fluss-client/pom.xml
+++ b/fluss-client/pom.xml
@@ -49,6 +49,24 @@
             <version>${project.version}</version>
         </dependency>
 
+        <!-- Unshaded Arrow dependencies for the Arrow scan batch API 
(ArrowBatchData).
+             Marked as "provided" to avoid Arrow class conflicts with 
consumers that
+             bring their own Arrow version. Currently only the internal 
tiering service
+             uses this API and is responsible for including Arrow on its 
classpath. -->
+        <dependency>
+            <groupId>org.apache.arrow</groupId>
+            <artifactId>arrow-vector</artifactId>
+            <version>${arrow.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.arrow</groupId>
+            <artifactId>arrow-memory-netty</artifactId>
+            <version>${arrow.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
         <!-- test dependency -->
         <dependency>
             <groupId>org.apache.fluss</groupId>
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
similarity index 76%
copy from 
fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
copy to 
fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
index 05c43eca0..f4daf890e 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/AbstractLogFetchCollector.java
@@ -19,21 +19,16 @@ package org.apache.fluss.client.table.scanner.log;
 
 import org.apache.fluss.annotation.Internal;
 import org.apache.fluss.client.metadata.MetadataUpdater;
-import org.apache.fluss.client.table.scanner.ScanRecord;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.exception.AuthorizationException;
 import org.apache.fluss.exception.FetchException;
-import org.apache.fluss.exception.LogOffsetOutOfRangeException;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TablePath;
-import org.apache.fluss.record.LogRecord;
-import org.apache.fluss.record.LogRecordBatch;
 import org.apache.fluss.rpc.protocol.ApiError;
 import org.apache.fluss.rpc.protocol.Errors;
 
 import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
@@ -44,30 +39,23 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
-/* This file is based on source code of Apache Kafka Project 
(https://kafka.apache.org/), licensed by the Apache
- * Software Foundation (ASF) under the Apache License, Version 2.0. See the 
NOTICE file distributed with this work for
- * additional information regarding copyright ownership. */
-
-/**
- * {@link LogFetchCollector} operates at the {@link LogRecordBatch} level, as 
that is what is stored
- * in the {@link LogFetchBuffer}. Each {@link LogRecord} in the {@link 
LogRecordBatch} is converted
- * to a {@link ScanRecord} and added to the returned {@link LogFetcher}.
- */
+/** Shared implementation for polling completed fetches into scanner results. 
*/
 @ThreadSafe
 @Internal
-public class LogFetchCollector {
-    private static final Logger LOG = 
LoggerFactory.getLogger(LogFetchCollector.class);
-
-    private final TablePath tablePath;
-    private final LogScannerStatus logScannerStatus;
+abstract class AbstractLogFetchCollector<T, R> {
+    protected final Logger log;
+    protected final TablePath tablePath;
+    protected final LogScannerStatus logScannerStatus;
     private final int maxPollRecords;
     private final MetadataUpdater metadataUpdater;
 
-    public LogFetchCollector(
+    protected AbstractLogFetchCollector(
+            Logger log,
             TablePath tablePath,
             LogScannerStatus logScannerStatus,
             Configuration conf,
             MetadataUpdater metadataUpdater) {
+        this.log = log;
         this.tablePath = tablePath;
         this.logScannerStatus = logScannerStatus;
         this.maxPollRecords = 
conf.getInt(ConfigOptions.CLIENT_SCANNER_LOG_MAX_POLL_RECORDS);
@@ -80,11 +68,11 @@ public class LogFetchCollector {
      * <p>NOTE: returning empty records guarantees the consumed position are 
NOT updated.
      *
      * @return The fetched records per partition
-     * @throws LogOffsetOutOfRangeException If there is OffsetOutOfRange error 
in fetchResponse and
-     *     the defaultResetPolicy is NONE
+     * @throws FetchException If there is OffsetOutOfRange error in 
fetchResponse and the
+     *     defaultResetPolicy is NONE
      */
-    public ScanRecords collectFetch(final LogFetchBuffer logFetchBuffer) {
-        Map<TableBucket, List<ScanRecord>> fetched = new HashMap<>();
+    public R collectFetch(final LogFetchBuffer logFetchBuffer) {
+        Map<TableBucket, List<T>> fetched = new HashMap<>();
         int recordsRemaining = maxPollRecords;
 
         try {
@@ -106,8 +94,8 @@ public class LogFetchCollector {
                         } catch (Exception e) {
                             // Remove a completedFetch upon a parse with 
exception if
                             // (1) it contains no records, and
-                            // (2) there are no fetched records with actual 
content preceding this
-                            // exception.
+                            // (2) there are no fetched records with actual 
content
+                            // preceding this exception.
                             if (fetched.isEmpty() && 
completedFetch.sizeInBytes == 0) {
                                 logFetchBuffer.poll();
                             }
@@ -119,25 +107,25 @@ public class LogFetchCollector {
 
                     logFetchBuffer.poll();
                 } else {
-                    List<ScanRecord> records = fetchRecords(nextInLineFetch, 
recordsRemaining);
+                    List<T> records = fetchRecords(nextInLineFetch, 
recordsRemaining);
                     if (!records.isEmpty()) {
                         TableBucket tableBucket = nextInLineFetch.tableBucket;
-                        List<ScanRecord> currentRecords = 
fetched.get(tableBucket);
+                        List<T> currentRecords = fetched.get(tableBucket);
                         if (currentRecords == null) {
                             fetched.put(tableBucket, records);
                         } else {
-                            // this case shouldn't usually happen because we 
only send one fetch at
-                            // a time per bucket, but it might conceivably 
happen in some rare
+                            // this case shouldn't usually happen because we 
only send one fetch
+                            // at a time per bucket, but it might conceivably 
happen in some rare
                             // cases (such as bucket leader changes). we have 
to copy to a new list
                             // because the old one may be immutable
-                            List<ScanRecord> newScanRecords =
+                            List<T> mergedRecords =
                                     new ArrayList<>(records.size() + 
currentRecords.size());
-                            newScanRecords.addAll(currentRecords);
-                            newScanRecords.addAll(records);
-                            fetched.put(tableBucket, newScanRecords);
+                            mergedRecords.addAll(currentRecords);
+                            mergedRecords.addAll(records);
+                            fetched.put(tableBucket, mergedRecords);
                         }
 
-                        recordsRemaining -= records.size();
+                        recordsRemaining -= recordCount(records);
                     }
                 }
             }
@@ -145,58 +133,19 @@ public class LogFetchCollector {
             if (fetched.isEmpty()) {
                 throw e;
             }
+        } catch (Exception e) {
+            // Release any off-heap resources (e.g. Arrow buffers) held by
+            // already-fetched records before propagating the unexpected error.
+            closeFetchedRecords(fetched);
+            throw e;
         }
 
-        return new ScanRecords(fetched);
-    }
-
-    private List<ScanRecord> fetchRecords(CompletedFetch nextInLineFetch, int 
maxRecords) {
-        TableBucket tb = nextInLineFetch.tableBucket;
-        Long offset = logScannerStatus.getBucketOffset(tb);
-        if (offset == null) {
-            LOG.debug(
-                    "Ignoring fetched records for {} at offset {} since the 
current offset is null which means the "
-                            + "bucket has been unsubscribe.",
-                    tb,
-                    nextInLineFetch.fetchOffset());
-        } else {
-            if (nextInLineFetch.nextFetchOffset() == offset) {
-                List<ScanRecord> records = 
nextInLineFetch.fetchRecords(maxRecords);
-                LOG.trace(
-                        "Returning {} fetched records at offset {} for 
assigned bucket {}.",
-                        records.size(),
-                        offset,
-                        tb);
-
-                if (nextInLineFetch.nextFetchOffset() > offset) {
-                    LOG.trace(
-                            "Updating fetch offset from {} to {} for bucket {} 
and returning {} records from poll()",
-                            offset,
-                            nextInLineFetch.nextFetchOffset(),
-                            tb,
-                            records.size());
-                    logScannerStatus.updateOffset(tb, 
nextInLineFetch.nextFetchOffset());
-                }
-                return records;
-            } else {
-                // these records aren't next in line based on the last 
consumed offset, ignore them
-                // they must be from an obsolete request
-                LOG.warn(
-                        "Ignoring fetched records for {} at offset {} since 
the current offset is {}",
-                        nextInLineFetch.tableBucket,
-                        nextInLineFetch.nextFetchOffset(),
-                        offset);
-            }
-        }
-
-        LOG.trace("Draining fetched records for bucket {}", 
nextInLineFetch.tableBucket);
-        nextInLineFetch.drain();
-
-        return Collections.emptyList();
+        return toResult(fetched);
     }
 
     /** Initialize a {@link CompletedFetch} object. */
-    private @Nullable CompletedFetch initialize(CompletedFetch completedFetch) 
{
+    @Nullable
+    private CompletedFetch initialize(CompletedFetch completedFetch) {
         TableBucket tb = completedFetch.tableBucket;
         ApiError error = completedFetch.error;
 
@@ -219,20 +168,19 @@ public class LogFetchCollector {
 
     private @Nullable CompletedFetch handleInitializeSuccess(CompletedFetch 
completedFetch) {
         TableBucket tb = completedFetch.tableBucket;
-        long fetchOffset = completedFetch.fetchOffset();
+        long fetchOffset = completedFetch.nextFetchOffset();
 
         // we are interested in this fetch only if the beginning offset 
matches the
         // current consumed position.
         Long offset = logScannerStatus.getBucketOffset(tb);
         if (offset == null) {
-            LOG.debug(
-                    "Discarding stale fetch response for bucket {} since the 
expected offset is null which means the bucket has been "
-                            + "unsubscribed.",
+            log.debug(
+                    "Discarding stale fetch response for bucket {} since the 
expected offset is null which means the bucket has been unsubscribed.",
                     tb);
             return null;
         }
         if (offset != fetchOffset) {
-            LOG.warn(
+            log.warn(
                     "Discarding stale fetch response for bucket {} since its 
offset {} does not match the expected offset {}.",
                     tb,
                     fetchOffset,
@@ -242,7 +190,7 @@ public class LogFetchCollector {
 
         long highWatermark = completedFetch.highWatermark;
         if (highWatermark >= 0) {
-            LOG.trace("Updating high watermark for bucket {} to {}.", tb, 
highWatermark);
+            log.trace("Updating high watermark for bucket {} to {}.", tb, 
highWatermark);
             logScannerStatus.updateHighWatermark(tb, highWatermark);
         }
 
@@ -253,20 +201,20 @@ public class LogFetchCollector {
     private void handleInitializeErrors(
             CompletedFetch completedFetch, Errors error, String errorMessage) {
         TableBucket tb = completedFetch.tableBucket;
-        long fetchOffset = completedFetch.fetchOffset();
+        long fetchOffset = completedFetch.nextFetchOffset();
         if (error == Errors.NOT_LEADER_OR_FOLLOWER
                 || error == Errors.LOG_STORAGE_EXCEPTION
                 || error == Errors.KV_STORAGE_EXCEPTION
                 || error == Errors.STORAGE_EXCEPTION
                 || error == Errors.FENCED_LEADER_EPOCH_EXCEPTION) {
-            LOG.debug(
+            log.debug(
                     "Error in fetch for bucket {}: {}:{}",
                     tb,
                     error.exceptionName(),
                     error.exception(errorMessage));
             metadataUpdater.checkAndUpdateMetadata(tablePath, tb);
         } else if (error == Errors.UNKNOWN_TABLE_OR_BUCKET_EXCEPTION) {
-            LOG.warn("Received unknown table or bucket error in fetch for 
bucket {}", tb);
+            log.warn("Received unknown table or bucket error in fetch for 
bucket {}", tb);
             metadataUpdater.checkAndUpdateMetadata(tablePath, tb);
         } else if (error == Errors.LOG_OFFSET_OUT_OF_RANGE_EXCEPTION) {
             throw new FetchException(
@@ -276,7 +224,7 @@ public class LogFetchCollector {
         } else if (error == Errors.AUTHORIZATION_EXCEPTION) {
             throw new AuthorizationException(errorMessage);
         } else if (error == Errors.UNKNOWN_SERVER_ERROR) {
-            LOG.warn(
+            log.warn(
                     "Unknown server error while fetching offset {} for bucket 
{}: {}",
                     fetchOffset,
                     tb,
@@ -293,4 +241,64 @@ public class LogFetchCollector {
                             error, fetchOffset, tb, 
error.exception(errorMessage)));
         }
     }
+
+    protected List<T> fetchRecords(CompletedFetch nextInLineFetch, int 
maxRecords) {
+        TableBucket tb = nextInLineFetch.tableBucket;
+        Long offset = logScannerStatus.getBucketOffset(tb);
+        if (offset == null) {
+            log.debug(
+                    "Ignoring fetched records for {} at offset {} since the 
current offset is null which means the bucket has been unsubscribed.",
+                    tb,
+                    nextInLineFetch.fetchOffset());
+        } else {
+            if (nextInLineFetch.nextFetchOffset() == offset) {
+                List<T> records = doFetchRecords(nextInLineFetch, maxRecords);
+                log.trace(
+                        "Returning {} fetched records at offset {} for 
assigned bucket {}.",
+                        records.size(),
+                        offset,
+                        tb);
+
+                if (nextInLineFetch.nextFetchOffset() > offset) {
+                    log.trace(
+                            "Updating fetch offset from {} to {} for bucket {} 
and returning {} records from poll()",
+                            offset,
+                            nextInLineFetch.nextFetchOffset(),
+                            tb,
+                            records.size());
+                    logScannerStatus.updateOffset(tb, 
nextInLineFetch.nextFetchOffset());
+                }
+                return records;
+            } else {
+                // these records aren't next in line based on the last 
consumed offset, ignore them
+                // they must be from an obsolete request
+                log.warn(
+                        "Ignoring fetched records for {} at offset {} since 
the current offset is {}",
+                        nextInLineFetch.tableBucket,
+                        nextInLineFetch.nextFetchOffset(),
+                        offset);
+            }
+        }
+
+        log.trace("Draining fetched records for bucket {}", 
nextInLineFetch.tableBucket);
+        nextInLineFetch.drain();
+        return Collections.emptyList();
+    }
+
+    /**
+     * Fetch records from the given {@link CompletedFetch}. Subclasses 
implement this to call the
+     * appropriate method on {@link CompletedFetch} for their record type.
+     */
+    protected abstract List<T> doFetchRecords(CompletedFetch nextInLineFetch, 
int maxRecords);
+
+    protected abstract int recordCount(List<T> fetchedRecords);
+
+    protected abstract R toResult(Map<TableBucket, List<T>> fetchedRecords);
+
+    /**
+     * Release resources held by fetched records on failure. The default 
implementation is a no-op,
+     * suitable for record types that are plain Java objects. Subclasses whose 
record types hold
+     * off-heap resources (e.g. Arrow buffers) should override this to close 
them.
+     */
+    protected void closeFetchedRecords(Map<TableBucket, List<T>> fetched) {}
 }
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
new file mode 100644
index 000000000..39b6aac02
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowLogFetchCollector.java
@@ -0,0 +1,84 @@
+/*
+ * 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.client.table.scanner.log;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.client.metadata.MetadataUpdater;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ArrowBatchData;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.concurrent.ThreadSafe;
+
+import java.util.List;
+import java.util.Map;
+
+/** Collects Arrow batches from completed fetches. */
+@ThreadSafe
+@Internal
+public class ArrowLogFetchCollector
+        extends AbstractLogFetchCollector<ArrowBatchData, ArrowScanRecords> {
+    private static final Logger LOG = 
LoggerFactory.getLogger(ArrowLogFetchCollector.class);
+
+    public ArrowLogFetchCollector(
+            TablePath tablePath,
+            LogScannerStatus logScannerStatus,
+            Configuration conf,
+            MetadataUpdater metadataUpdater) {
+        super(LOG, tablePath, logScannerStatus, conf, metadataUpdater);
+    }
+
+    @Override
+    protected List<ArrowBatchData> doFetchRecords(CompletedFetch 
nextInLineFetch, int maxRecords) {
+        return nextInLineFetch.fetchArrowBatches(maxRecords);
+    }
+
+    @Override
+    protected int recordCount(List<ArrowBatchData> fetchedRecords) {
+        int count = 0;
+        for (ArrowBatchData fetchedRecord : fetchedRecords) {
+            count += fetchedRecord.getRecordCount();
+        }
+        return count;
+    }
+
+    @Override
+    protected ArrowScanRecords toResult(Map<TableBucket, List<ArrowBatchData>> 
fetchedRecords) {
+        return new ArrowScanRecords(fetchedRecords);
+    }
+
+    @Override
+    protected void closeFetchedRecords(Map<TableBucket, List<ArrowBatchData>> 
fetched) {
+        for (List<ArrowBatchData> batches : fetched.values()) {
+            for (ArrowBatchData batch : batches) {
+                try {
+                    batch.close();
+                } catch (Exception e) {
+                    LOG.warn(
+                            "Failed to close Arrow batch during cleanup for 
table {}",
+                            tablePath,
+                            e);
+                }
+            }
+        }
+    }
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowScanRecords.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowScanRecords.java
new file mode 100644
index 000000000..d5cb63a8e
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/ArrowScanRecords.java
@@ -0,0 +1,123 @@
+/*
+ * 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.client.table.scanner.log;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.ArrowBatchData;
+import org.apache.fluss.utils.AbstractIterator;
+import org.apache.fluss.utils.IOUtils;
+
+import javax.annotation.Nonnull;
+
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A container that holds the scanned Arrow batches per bucket for a 
particular table.
+ *
+ * <p>Each {@link ArrowBatchData} holds off-heap Arrow memory. Callers should 
use try-with-resources
+ * on this container to ensure all batches are released if processing fails 
mid-iteration.
+ */
+@Internal
+public class ArrowScanRecords implements Iterable<ArrowBatchData>, 
AutoCloseable {
+    public static final ArrowScanRecords EMPTY = new 
ArrowScanRecords(Collections.emptyMap());
+
+    private final Map<TableBucket, List<ArrowBatchData>> records;
+
+    public ArrowScanRecords(Map<TableBucket, List<ArrowBatchData>> records) {
+        this.records = records;
+    }
+
+    /** Get just the Arrow batches for the given bucket. */
+    public List<ArrowBatchData> records(TableBucket scanBucket) {
+        List<ArrowBatchData> recs = records.get(scanBucket);
+        if (recs == null) {
+            return Collections.emptyList();
+        }
+        return Collections.unmodifiableList(recs);
+    }
+
+    /** Returns the buckets that contain Arrow batches. */
+    public Set<TableBucket> buckets() {
+        return Collections.unmodifiableSet(records.keySet());
+    }
+
+    /** Returns the total number of rows in all batches. */
+    public int count() {
+        int count = 0;
+        for (List<ArrowBatchData> recs : records.values()) {
+            for (ArrowBatchData rec : recs) {
+                count += rec.getRecordCount();
+            }
+        }
+        return count;
+    }
+
+    public boolean isEmpty() {
+        return records.isEmpty();
+    }
+
+    /** Closes all Arrow batches held by this container, releasing off-heap 
memory. */
+    @Override
+    public void close() {
+        for (List<ArrowBatchData> recs : records.values()) {
+            for (ArrowBatchData rec : recs) {
+                IOUtils.closeQuietly(rec);
+            }
+        }
+    }
+
+    @Override
+    @Nonnull
+    public Iterator<ArrowBatchData> iterator() {
+        return new ConcatenatedIterable(records.values()).iterator();
+    }
+
+    private static class ConcatenatedIterable implements 
Iterable<ArrowBatchData> {
+
+        private final Iterable<? extends Iterable<ArrowBatchData>> iterables;
+
+        private ConcatenatedIterable(Iterable<? extends 
Iterable<ArrowBatchData>> iterables) {
+            this.iterables = iterables;
+        }
+
+        @Override
+        @Nonnull
+        public Iterator<ArrowBatchData> iterator() {
+            return new AbstractIterator<ArrowBatchData>() {
+                final Iterator<? extends Iterable<ArrowBatchData>> iters = 
iterables.iterator();
+                Iterator<ArrowBatchData> current;
+
+                public ArrowBatchData makeNext() {
+                    while (current == null || !current.hasNext()) {
+                        if (iters.hasNext()) {
+                            current = iters.next().iterator();
+                        } else {
+                            return allDone();
+                        }
+                    }
+                    return current.next();
+                }
+            };
+        }
+    }
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
index e7baeb963..f22a65213 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/CompletedFetch.java
@@ -22,6 +22,7 @@ import org.apache.fluss.client.table.scanner.ScanRecord;
 import org.apache.fluss.exception.CorruptRecordException;
 import org.apache.fluss.exception.FetchException;
 import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.record.ArrowBatchData;
 import org.apache.fluss.record.ChangeType;
 import org.apache.fluss.record.CompactedLogRecord;
 import org.apache.fluss.record.IndexedLogRecord;
@@ -50,7 +51,7 @@ import static 
org.apache.fluss.utils.Preconditions.checkArgument;
  * to maintain state between calls to {@link #fetchRecords(int)}.
  */
 @Internal
-abstract class CompletedFetch {
+public abstract class CompletedFetch {
     static final Logger LOG = LoggerFactory.getLogger(CompletedFetch.class);
     static final long NO_FILTERED_END_OFFSET = -1L;
 
@@ -262,33 +263,89 @@ abstract class CompletedFetch {
         cachedRecordException = null;
     }
 
+    /**
+     * The {@link LogRecordBatch batches} are loaded as {@link ArrowBatchData 
Arrow batches} and
+     * returned.
+     *
+     * @param maxRecords A soft upper bound on the number of records to 
return. Because batches are
+     *     returned whole (never split), the actual number of records may 
exceed this value. At
+     *     least one batch is always returned if available, even if it alone 
exceeds the limit.
+     * @return {@link ArrowBatchData Arrow batches}
+     */
+    List<ArrowBatchData> fetchArrowBatches(int maxRecords) {
+        if (cachedRecordException != null) {
+            throw new FetchException(
+                    "Received exception when fetching the next Arrow batch 
from "
+                            + tableBucket
+                            + ". If needed, please back past the batch to 
continue scanning.",
+                    cachedRecordException);
+        }
+
+        if (isConsumed) {
+            return Collections.emptyList();
+        }
+
+        List<ArrowBatchData> arrowBatches = new ArrayList<>();
+        int recordsFetched = 0;
+        try {
+            while (recordsFetched < maxRecords || arrowBatches.isEmpty()) {
+                LogRecordBatch batch = nextFetchedBatch();
+                if (batch == null) {
+                    break;
+                }
+
+                ArrowBatchData arrowBatchData = 
batch.loadArrowBatch(readContext);
+                if (arrowBatchData.getRecordCount() == 0) {
+                    arrowBatchData.close();
+                    continue;
+                }
+
+                // Skip records that are before nextFetchOffset, analogous to 
the
+                // record-level filtering in nextFetchedRecord() for the 
row-based path.
+                long batchBaseOffset = arrowBatchData.getBaseLogOffset();
+                if (batchBaseOffset < nextFetchOffset) {
+                    int skipRows = (int) (nextFetchOffset - batchBaseOffset);
+                    if (skipRows >= arrowBatchData.getRecordCount()) {
+                        arrowBatchData.close();
+                        continue;
+                    }
+                    arrowBatchData = 
arrowBatchData.sliceAndTransferOwnership(skipRows);
+                }
+
+                arrowBatches.add(arrowBatchData);
+                recordsRead += arrowBatchData.getRecordCount();
+                recordsFetched += arrowBatchData.getRecordCount();
+                nextFetchOffset = batch.nextLogOffset();
+            }
+        } catch (Exception e) {
+            // Deliver partial results when possible, mirroring fetchRecords() 
semantics.
+            // The batch iterator is single-pass (batches.next()), so 
already-consumed
+            // batches cannot be re-read. Rolling back nextFetchOffset would 
leave it
+            // pointing at batches the iterator has already passed, causing a 
different
+            // kind of inconsistency. Delivering partial results keeps 
offsets, recordsRead,
+            // and the iterator position all in sync.
+            cachedRecordException = e;
+            if (arrowBatches.isEmpty()) {
+                throw new FetchException(
+                        "Received exception when fetching the next Arrow batch 
from "
+                                + tableBucket
+                                + ". If needed, please back past the batch to 
continue scanning.",
+                        e);
+            }
+        }
+
+        return arrowBatches;
+    }
+
     private LogRecord nextFetchedRecord() throws Exception {
         while (true) {
             if (records == null || !records.hasNext()) {
-                maybeCloseRecordStream();
-
-                if (!batches.hasNext()) {
-                    // In batch, we preserve the last offset in a batch. By 
using the next offset
-                    // computed from the last offset in the batch, we ensure 
that the offset of the
-                    // next fetch will point to the next batch, which avoids 
unnecessary re-fetching
-                    // of the same batch (in the worst case, the scanner could 
get stuck fetching
-                    // the same batch repeatedly).
-                    // When filteredEndOffset is set, use the max of the 
batch-derived offset and
-                    // filteredEndOffset to skip already-scanned-and-filtered 
trailing batches.
-                    if (currentBatch != null) {
-                        nextFetchOffset = 
Math.max(currentBatch.nextLogOffset(), filteredEndOffset);
-                    } else if (filteredEndOffset != NO_FILTERED_END_OFFSET) {
-                        nextFetchOffset = filteredEndOffset;
-                    }
-                    drain();
+                LogRecordBatch batch = nextFetchedBatch();
+                if (batch == null) {
                     return null;
                 }
 
-                currentBatch = batches.next();
-                // TODO get last epoch.
-                maybeEnsureValid(currentBatch);
-
-                records = currentBatch.records(readContext);
+                records = batch.records(readContext);
             } else {
                 LogRecord record = records.next();
                 // skip any records out of range.
@@ -299,6 +356,35 @@ abstract class CompletedFetch {
         }
     }
 
+    private LogRecordBatch nextFetchedBatch() {
+        maybeCloseRecordStream();
+        if (!batches.hasNext()) {
+            finishFetchedBatches();
+            return null;
+        }
+
+        currentBatch = batches.next();
+        // TODO get last epoch.
+        maybeEnsureValid(currentBatch);
+        return currentBatch;
+    }
+
+    private void finishFetchedBatches() {
+        // In batch, we preserve the last offset in a batch. By using the next 
offset
+        // computed from the last offset in the batch, we ensure that the 
offset of the
+        // next fetch will point to the next batch, which avoids unnecessary 
re-fetching
+        // of the same batch (in the worst case, the scanner could get stuck 
fetching
+        // the same batch repeatedly).
+        // When filteredEndOffset is set, use the max of the batch-derived 
offset and
+        // filteredEndOffset to skip already-scanned-and-filtered trailing 
batches.
+        if (currentBatch != null) {
+            nextFetchOffset = Math.max(currentBatch.nextLogOffset(), 
filteredEndOffset);
+        } else if (filteredEndOffset != NO_FILTERED_END_OFFSET) {
+            nextFetchOffset = filteredEndOffset;
+        }
+        drain();
+    }
+
     private void maybeEnsureValid(LogRecordBatch batch) {
         if (isCheckCrcs) {
             if (readContext.isProjectionPushDowned()) {
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
index 05c43eca0..bcf47c07f 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetchCollector.java
@@ -20,27 +20,17 @@ package org.apache.fluss.client.table.scanner.log;
 import org.apache.fluss.annotation.Internal;
 import org.apache.fluss.client.metadata.MetadataUpdater;
 import org.apache.fluss.client.table.scanner.ScanRecord;
-import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.config.Configuration;
-import org.apache.fluss.exception.AuthorizationException;
-import org.apache.fluss.exception.FetchException;
-import org.apache.fluss.exception.LogOffsetOutOfRangeException;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TablePath;
 import org.apache.fluss.record.LogRecord;
 import org.apache.fluss.record.LogRecordBatch;
-import org.apache.fluss.rpc.protocol.ApiError;
-import org.apache.fluss.rpc.protocol.Errors;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -55,242 +45,29 @@ import java.util.Map;
  */
 @ThreadSafe
 @Internal
-public class LogFetchCollector {
+public class LogFetchCollector extends AbstractLogFetchCollector<ScanRecord, 
ScanRecords> {
     private static final Logger LOG = 
LoggerFactory.getLogger(LogFetchCollector.class);
 
-    private final TablePath tablePath;
-    private final LogScannerStatus logScannerStatus;
-    private final int maxPollRecords;
-    private final MetadataUpdater metadataUpdater;
-
     public LogFetchCollector(
             TablePath tablePath,
             LogScannerStatus logScannerStatus,
             Configuration conf,
             MetadataUpdater metadataUpdater) {
-        this.tablePath = tablePath;
-        this.logScannerStatus = logScannerStatus;
-        this.maxPollRecords = 
conf.getInt(ConfigOptions.CLIENT_SCANNER_LOG_MAX_POLL_RECORDS);
-        this.metadataUpdater = metadataUpdater;
+        super(LOG, tablePath, logScannerStatus, conf, metadataUpdater);
     }
 
-    /**
-     * Return the fetched log records, empty the record buffer and update the 
consumed position.
-     *
-     * <p>NOTE: returning empty records guarantees the consumed position are 
NOT updated.
-     *
-     * @return The fetched records per partition
-     * @throws LogOffsetOutOfRangeException If there is OffsetOutOfRange error 
in fetchResponse and
-     *     the defaultResetPolicy is NONE
-     */
-    public ScanRecords collectFetch(final LogFetchBuffer logFetchBuffer) {
-        Map<TableBucket, List<ScanRecord>> fetched = new HashMap<>();
-        int recordsRemaining = maxPollRecords;
-
-        try {
-            while (recordsRemaining > 0) {
-                CompletedFetch nextInLineFetch = 
logFetchBuffer.nextInLineFetch();
-                if (nextInLineFetch == null || nextInLineFetch.isConsumed()) {
-                    CompletedFetch completedFetch = logFetchBuffer.peek();
-                    if (completedFetch == null) {
-                        break;
-                    }
-
-                    if (!completedFetch.isInitialized()) {
-                        try {
-                            CompletedFetch initialized = 
initialize(completedFetch);
-                            logFetchBuffer.setNextInLineFetch(initialized);
-                            if (initialized == null) {
-                                completedFetch.drain();
-                            }
-                        } catch (Exception e) {
-                            // Remove a completedFetch upon a parse with 
exception if
-                            // (1) it contains no records, and
-                            // (2) there are no fetched records with actual 
content preceding this
-                            // exception.
-                            if (fetched.isEmpty() && 
completedFetch.sizeInBytes == 0) {
-                                logFetchBuffer.poll();
-                            }
-                            throw e;
-                        }
-                    } else {
-                        logFetchBuffer.setNextInLineFetch(completedFetch);
-                    }
-
-                    logFetchBuffer.poll();
-                } else {
-                    List<ScanRecord> records = fetchRecords(nextInLineFetch, 
recordsRemaining);
-                    if (!records.isEmpty()) {
-                        TableBucket tableBucket = nextInLineFetch.tableBucket;
-                        List<ScanRecord> currentRecords = 
fetched.get(tableBucket);
-                        if (currentRecords == null) {
-                            fetched.put(tableBucket, records);
-                        } else {
-                            // this case shouldn't usually happen because we 
only send one fetch at
-                            // a time per bucket, but it might conceivably 
happen in some rare
-                            // cases (such as bucket leader changes). we have 
to copy to a new list
-                            // because the old one may be immutable
-                            List<ScanRecord> newScanRecords =
-                                    new ArrayList<>(records.size() + 
currentRecords.size());
-                            newScanRecords.addAll(currentRecords);
-                            newScanRecords.addAll(records);
-                            fetched.put(tableBucket, newScanRecords);
-                        }
-
-                        recordsRemaining -= records.size();
-                    }
-                }
-            }
-        } catch (FetchException e) {
-            if (fetched.isEmpty()) {
-                throw e;
-            }
-        }
-
-        return new ScanRecords(fetched);
+    @Override
+    protected List<ScanRecord> doFetchRecords(CompletedFetch nextInLineFetch, 
int maxRecords) {
+        return nextInLineFetch.fetchRecords(maxRecords);
     }
 
-    private List<ScanRecord> fetchRecords(CompletedFetch nextInLineFetch, int 
maxRecords) {
-        TableBucket tb = nextInLineFetch.tableBucket;
-        Long offset = logScannerStatus.getBucketOffset(tb);
-        if (offset == null) {
-            LOG.debug(
-                    "Ignoring fetched records for {} at offset {} since the 
current offset is null which means the "
-                            + "bucket has been unsubscribe.",
-                    tb,
-                    nextInLineFetch.fetchOffset());
-        } else {
-            if (nextInLineFetch.nextFetchOffset() == offset) {
-                List<ScanRecord> records = 
nextInLineFetch.fetchRecords(maxRecords);
-                LOG.trace(
-                        "Returning {} fetched records at offset {} for 
assigned bucket {}.",
-                        records.size(),
-                        offset,
-                        tb);
-
-                if (nextInLineFetch.nextFetchOffset() > offset) {
-                    LOG.trace(
-                            "Updating fetch offset from {} to {} for bucket {} 
and returning {} records from poll()",
-                            offset,
-                            nextInLineFetch.nextFetchOffset(),
-                            tb,
-                            records.size());
-                    logScannerStatus.updateOffset(tb, 
nextInLineFetch.nextFetchOffset());
-                }
-                return records;
-            } else {
-                // these records aren't next in line based on the last 
consumed offset, ignore them
-                // they must be from an obsolete request
-                LOG.warn(
-                        "Ignoring fetched records for {} at offset {} since 
the current offset is {}",
-                        nextInLineFetch.tableBucket,
-                        nextInLineFetch.nextFetchOffset(),
-                        offset);
-            }
-        }
-
-        LOG.trace("Draining fetched records for bucket {}", 
nextInLineFetch.tableBucket);
-        nextInLineFetch.drain();
-
-        return Collections.emptyList();
-    }
-
-    /** Initialize a {@link CompletedFetch} object. */
-    private @Nullable CompletedFetch initialize(CompletedFetch completedFetch) 
{
-        TableBucket tb = completedFetch.tableBucket;
-        ApiError error = completedFetch.error;
-
-        try {
-            if (error.isSuccess()) {
-                return handleInitializeSuccess(completedFetch);
-            } else {
-                handleInitializeErrors(completedFetch, error.error(), 
error.messageWithFallback());
-                return null;
-            }
-        } finally {
-            if (error.isFailure()) {
-                // we move the bucket to the end if there was an error. This 
way,
-                // it's more likely that buckets for the same table can remain 
together
-                // (allowing for more efficient serialization).
-                logScannerStatus.moveBucketToEnd(tb);
-            }
-        }
-    }
-
-    private @Nullable CompletedFetch handleInitializeSuccess(CompletedFetch 
completedFetch) {
-        TableBucket tb = completedFetch.tableBucket;
-        long fetchOffset = completedFetch.fetchOffset();
-
-        // we are interested in this fetch only if the beginning offset 
matches the
-        // current consumed position.
-        Long offset = logScannerStatus.getBucketOffset(tb);
-        if (offset == null) {
-            LOG.debug(
-                    "Discarding stale fetch response for bucket {} since the 
expected offset is null which means the bucket has been "
-                            + "unsubscribed.",
-                    tb);
-            return null;
-        }
-        if (offset != fetchOffset) {
-            LOG.warn(
-                    "Discarding stale fetch response for bucket {} since its 
offset {} does not match the expected offset {}.",
-                    tb,
-                    fetchOffset,
-                    offset);
-            return null;
-        }
-
-        long highWatermark = completedFetch.highWatermark;
-        if (highWatermark >= 0) {
-            LOG.trace("Updating high watermark for bucket {} to {}.", tb, 
highWatermark);
-            logScannerStatus.updateHighWatermark(tb, highWatermark);
-        }
-
-        completedFetch.setInitialized();
-        return completedFetch;
+    @Override
+    protected int recordCount(List<ScanRecord> fetchedRecords) {
+        return fetchedRecords.size();
     }
 
-    private void handleInitializeErrors(
-            CompletedFetch completedFetch, Errors error, String errorMessage) {
-        TableBucket tb = completedFetch.tableBucket;
-        long fetchOffset = completedFetch.fetchOffset();
-        if (error == Errors.NOT_LEADER_OR_FOLLOWER
-                || error == Errors.LOG_STORAGE_EXCEPTION
-                || error == Errors.KV_STORAGE_EXCEPTION
-                || error == Errors.STORAGE_EXCEPTION
-                || error == Errors.FENCED_LEADER_EPOCH_EXCEPTION) {
-            LOG.debug(
-                    "Error in fetch for bucket {}: {}:{}",
-                    tb,
-                    error.exceptionName(),
-                    error.exception(errorMessage));
-            metadataUpdater.checkAndUpdateMetadata(tablePath, tb);
-        } else if (error == Errors.UNKNOWN_TABLE_OR_BUCKET_EXCEPTION) {
-            LOG.warn("Received unknown table or bucket error in fetch for 
bucket {}", tb);
-            metadataUpdater.checkAndUpdateMetadata(tablePath, tb);
-        } else if (error == Errors.LOG_OFFSET_OUT_OF_RANGE_EXCEPTION) {
-            throw new FetchException(
-                    String.format(
-                            "The fetching offset %s is out of range: %s",
-                            fetchOffset, error.exception(errorMessage)));
-        } else if (error == Errors.AUTHORIZATION_EXCEPTION) {
-            throw new AuthorizationException(errorMessage);
-        } else if (error == Errors.UNKNOWN_SERVER_ERROR) {
-            LOG.warn(
-                    "Unknown server error while fetching offset {} for bucket 
{}: {}",
-                    fetchOffset,
-                    tb,
-                    error.exception(errorMessage));
-        } else if (error == Errors.CORRUPT_MESSAGE) {
-            throw new FetchException(
-                    String.format(
-                            "Encountered corrupt message when fetching offset 
%s for bucket %s: %s",
-                            fetchOffset, tb, error.exception(errorMessage)));
-        } else {
-            throw new FetchException(
-                    String.format(
-                            "Unexpected error code %s while fetching at offset 
%s from bucket %s: %s",
-                            error, fetchOffset, tb, 
error.exception(errorMessage)));
-        }
+    @Override
+    protected ScanRecords toResult(Map<TableBucket, List<ScanRecord>> 
fetchedRecords) {
+        return new ScanRecords(fetchedRecords);
     }
 }
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
index 2625716cd..2a1fc4361 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java
@@ -107,6 +107,7 @@ public class LogFetcher implements Closeable {
     private final LogScannerStatus logScannerStatus;
     private final LogFetchBuffer logFetchBuffer;
     private final LogFetchCollector logFetchCollector;
+    private final ArrowLogFetchCollector arrowLogFetchCollector;
     private final RemoteLogDownloader remoteLogDownloader;
 
     @GuardedBy("this")
@@ -162,6 +163,8 @@ public class LogFetcher implements Closeable {
         this.metadataUpdater = metadataUpdater;
         this.logFetchCollector =
                 new LogFetchCollector(tablePath, logScannerStatus, conf, 
metadataUpdater);
+        this.arrowLogFetchCollector =
+                new ArrowLogFetchCollector(tablePath, logScannerStatus, conf, 
metadataUpdater);
         this.scannerMetricGroup = scannerMetricGroup;
         this.remoteLogDownloader =
                 new RemoteLogDownloader(tablePath, conf, remoteFileDownloader, 
scannerMetricGroup);
@@ -181,6 +184,10 @@ public class LogFetcher implements Closeable {
         return logFetchCollector.collectFetch(logFetchBuffer);
     }
 
+    public ArrowScanRecords collectArrowFetch() {
+        return arrowLogFetchCollector.collectFetch(logFetchBuffer);
+    }
+
     /**
      * Set up a fetch request for any node that we have assigned buckets for 
which doesn't already
      * have an in-flight fetch or pending fetch data.
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
index 9a2dbf0b4..e7178f066 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogScannerImpl.java
@@ -17,12 +17,15 @@
 
 package org.apache.fluss.client.table.scanner.log;
 
+import org.apache.fluss.annotation.Internal;
 import org.apache.fluss.annotation.PublicEvolving;
 import org.apache.fluss.client.metadata.MetadataUpdater;
 import org.apache.fluss.client.metrics.ScannerMetricGroup;
 import org.apache.fluss.client.table.scanner.RemoteFileDownloader;
+import org.apache.fluss.client.table.scanner.Scan;
 import org.apache.fluss.config.Configuration;
 import org.apache.fluss.exception.WakeupException;
+import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.SchemaGetter;
 import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableInfo;
@@ -43,6 +46,7 @@ import java.util.Collections;
 import java.util.ConcurrentModificationException;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
 
 /**
  * The default impl of {@link LogScanner}.
@@ -56,24 +60,27 @@ import java.util.concurrent.atomic.AtomicLong;
 @PublicEvolving
 public class LogScannerImpl implements LogScanner {
     private static final Logger LOG = 
LoggerFactory.getLogger(LogScannerImpl.class);
-
     private static final long NO_CURRENT_THREAD = -1L;
+
     private final TablePath tablePath;
     private final LogScannerStatus logScannerStatus;
     private final MetadataUpdater metadataUpdater;
     private final LogFetcher logFetcher;
     private final long tableId;
     private final boolean isPartitionedTable;
-
-    private volatile boolean closed = false;
+    private final boolean isArrowLogFormat;
+    private final boolean isLogTable;
+    private final boolean hasProjection;
+    // metrics
+    private final ScannerMetricGroup scannerMetricGroup;
 
     // currentThread holds the threadId of the current thread accessing 
FlussLogScanner
     // and is used to prevent multithreaded access
     private final AtomicLong currentThread = new AtomicLong(NO_CURRENT_THREAD);
     // refCount is used to allow reentrant access by the thread who has 
acquired currentThread.
     private final AtomicInteger refCount = new AtomicInteger(0);
-    // metrics
-    private final ScannerMetricGroup scannerMetricGroup;
+
+    private volatile boolean closed = false;
 
     public LogScannerImpl(
             Configuration conf,
@@ -87,6 +94,9 @@ public class LogScannerImpl implements LogScanner {
         this.tablePath = tableInfo.getTablePath();
         this.tableId = tableInfo.getTableId();
         this.isPartitionedTable = tableInfo.isPartitioned();
+        this.isArrowLogFormat = tableInfo.getTableConfig().getLogFormat() == 
LogFormat.ARROW;
+        this.isLogTable = !tableInfo.hasPrimaryKey();
+        this.hasProjection = projectedFields != null;
         // add this table to metadata updater.
         
metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
         this.logScannerStatus = new LogScannerStatus();
@@ -131,43 +141,34 @@ public class LogScannerImpl implements LogScanner {
 
     @Override
     public ScanRecords poll(Duration timeout) {
-        acquireAndEnsureOpen();
-        try {
-            if (!logScannerStatus.prepareToPoll()) {
-                throw new IllegalStateException("LogScanner is not subscribed 
any buckets.");
-            }
-
-            scannerMetricGroup.recordPollStart(System.currentTimeMillis());
-            long timeoutNanos = timeout.toNanos();
-            long startNanos = System.nanoTime();
-            do {
-                ScanRecords scanRecords = pollForFetches();
-                if (scanRecords.isEmpty()) {
-                    try {
-                        if (!logFetcher.awaitNotEmpty(startNanos + 
timeoutNanos)) {
-                            // logFetcher waits for the timeout and no data in 
buffer,
-                            // so we return empty
-                            return scanRecords;
-                        }
-                    } catch (WakeupException e) {
-                        // wakeup() is called, we need to return empty
-                        return scanRecords;
-                    }
-                } else {
-                    // before returning the fetched records, we can send off 
the next round of
-                    // fetches and avoid block waiting for their responses to 
enable pipelining
-                    // while the user is handling the fetched records.
-                    logFetcher.sendFetches();
-
-                    return scanRecords;
-                }
-            } while (System.nanoTime() - startNanos < timeoutNanos);
+        return doPoll(timeout, this::pollForFetches, ScanRecords::isEmpty, () 
-> ScanRecords.EMPTY);
+    }
 
-            return ScanRecords.EMPTY;
-        } finally {
-            release();
-            scannerMetricGroup.recordPollEnd(System.currentTimeMillis());
+    /**
+     * Polls Arrow record batches for internal callers.
+     *
+     * <p>This method is intentionally kept off the public {@link Scan} API 
surface for now.
+     */
+    @Internal
+    public ArrowScanRecords pollRecordBatch(Duration timeout) {
+        if (!isArrowLogFormat) {
+            throw new UnsupportedOperationException(
+                    "Arrow record batch polling is only supported for tables 
whose log format is ARROW.");
+        }
+        if (!isLogTable) {
+            throw new UnsupportedOperationException(
+                    "Arrow record batch polling is only supported for log 
tables. CDC scanning is not supported.");
         }
+        if (hasProjection) {
+            throw new UnsupportedOperationException(
+                    "Arrow record batch polling does not support projection. 
Please create the scanner without projection.");
+        }
+
+        return doPoll(
+                timeout,
+                this::pollForRecordBatches,
+                ArrowScanRecords::isEmpty,
+                () -> ArrowScanRecords.EMPTY);
     }
 
     @Override
@@ -181,8 +182,8 @@ public class LogScannerImpl implements LogScanner {
         acquireAndEnsureOpen();
         try {
             TableBucket tableBucket = new TableBucket(tableId, bucket);
-            
this.metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
-            
this.logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, 
offset));
+            
metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath));
+            
logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, 
offset));
         } finally {
             release();
         }
@@ -202,9 +203,9 @@ public class LogScannerImpl implements LogScanner {
             // we make assumption that the partition id must belong to the 
current table
             // if we can't find the partition id from the table path, we'll 
consider the table
             // is not exist
-            this.metadataUpdater.checkAndUpdatePartitionMetadata(
+            metadataUpdater.checkAndUpdatePartitionMetadata(
                     tablePath, Collections.singleton(partitionId));
-            
this.logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, 
offset));
+            
logScannerStatus.assignScanBuckets(Collections.singletonMap(tableBucket, 
offset));
         } finally {
             release();
         }
@@ -219,7 +220,7 @@ public class LogScannerImpl implements LogScanner {
         acquireAndEnsureOpen();
         try {
             TableBucket tableBucket = new TableBucket(tableId, partitionId, 
bucket);
-            
this.logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
+            
logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
         } finally {
             release();
         }
@@ -236,7 +237,7 @@ public class LogScannerImpl implements LogScanner {
         acquireAndEnsureOpen();
         try {
             TableBucket tableBucket = new TableBucket(tableId, bucket);
-            
this.logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
+            
logScannerStatus.unassignScanBuckets(Collections.singletonList(tableBucket));
         } finally {
             release();
         }
@@ -255,12 +256,66 @@ public class LogScannerImpl implements LogScanner {
 
         // send any new fetches (won't resend pending fetches).
         logFetcher.sendFetches();
-
         return logFetcher.collectFetch();
     }
 
+    private ArrowScanRecords pollForRecordBatches() {
+        ArrowScanRecords scanRecords = logFetcher.collectArrowFetch();
+        if (!scanRecords.isEmpty()) {
+            return scanRecords;
+        }
+
+        // send any new fetches (won't resend pending fetches).
+        logFetcher.sendFetches();
+        return logFetcher.collectArrowFetch();
+    }
+
+    /** Shared polling loop for row and Arrow scan results. */
+    private <T> T doPoll(
+            Duration timeout,
+            Supplier<T> pollForFetches,
+            java.util.function.Predicate<T> isEmpty,
+            Supplier<T> emptyResult) {
+        acquireAndEnsureOpen();
+        try {
+            if (!logScannerStatus.prepareToPoll()) {
+                throw new IllegalStateException("LogScanner is not subscribed 
any buckets.");
+            }
+
+            scannerMetricGroup.recordPollStart(System.currentTimeMillis());
+            long timeoutNanos = timeout.toNanos();
+            long startNanos = System.nanoTime();
+            do {
+                T scanRecords = pollForFetches.get();
+                if (isEmpty.test(scanRecords)) {
+                    try {
+                        if (!logFetcher.awaitNotEmpty(startNanos + 
timeoutNanos)) {
+                            // logFetcher waits for the timeout and no data in 
buffer,
+                            // so we return empty
+                            return scanRecords;
+                        }
+                    } catch (WakeupException e) {
+                        // wakeup() is called, we need to return empty
+                        return scanRecords;
+                    }
+                } else {
+                    // before returning the fetched records, we can send off 
the next round of
+                    // fetches and avoid block waiting for their responses to 
enable pipelining
+                    // while the user is handling the fetched records.
+                    logFetcher.sendFetches();
+                    return scanRecords;
+                }
+            } while (System.nanoTime() - startNanos < timeoutNanos);
+
+            return emptyResult.get();
+        } finally {
+            release();
+            scannerMetricGroup.recordPollEnd(System.currentTimeMillis());
+        }
+    }
+
     /**
-     * Acquire the light lock and ensure that the consumer hasn't been closed.
+     * Acquire the light lock and ensure that the scanner hasn't been closed.
      *
      * @throws IllegalStateException If the scanner has been closed
      */
@@ -280,8 +335,8 @@ public class LogScannerImpl implements LogScanner {
      * @throws ConcurrentModificationException if another thread already has 
the lock
      */
     private void acquire() {
-        final Thread thread = Thread.currentThread();
-        final long threadId = thread.getId();
+        Thread thread = Thread.currentThread();
+        long threadId = thread.getId();
         if (threadId != currentThread.get()
                 && !currentThread.compareAndSet(NO_CURRENT_THREAD, threadId)) {
             throw new ConcurrentModificationException(
@@ -298,7 +353,7 @@ public class LogScannerImpl implements LogScanner {
         refCount.incrementAndGet();
     }
 
-    /** Release the light lock protecting the consumer from multithreaded 
access. */
+    /** Release the light lock protecting the scanner from multithreaded 
access. */
     private void release() {
         if (refCount.decrementAndGet() == 0) {
             currentThread.set(NO_CURRENT_THREAD);
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
index cfe215fb0..e3f60e2df 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/table/scanner/log/LogScannerITCase.java
@@ -23,21 +23,27 @@ import org.apache.fluss.client.table.scanner.ScanRecord;
 import org.apache.fluss.client.table.writer.AppendWriter;
 import org.apache.fluss.client.table.writer.UpsertWriter;
 import org.apache.fluss.exception.FetchException;
+import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableChange;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.record.ArrowBatchData;
 import org.apache.fluss.record.ChangeType;
 import org.apache.fluss.row.GenericRow;
 import org.apache.fluss.row.InternalRow;
 import org.apache.fluss.types.DataTypes;
 import org.apache.fluss.types.RowType;
 
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
@@ -467,4 +473,99 @@ public class LogScannerITCase extends 
ClientToServerITCaseBase {
             }
         }
     }
+
+    @Test
+    void testPollArrowBatchesWithSchemaEvolution() throws Exception {
+        TablePath tablePath = TablePath.of("test_db_1", 
"test_arrow_batches_with_schema_evolution");
+        TableDescriptor tableDescriptor =
+                TableDescriptor.builder()
+                        .schema(DATA1_SCHEMA)
+                        .distributedBy(1)
+                        .logFormat(LogFormat.ARROW)
+                        .build();
+        createTable(tablePath, tableDescriptor, false);
+
+        // write 3 rows with the original schema (a: INT, b: STRING)
+        try (Table table = conn.getTable(tablePath)) {
+            AppendWriter appendWriter = table.newAppend().createWriter();
+            for (int i = 0; i < 3; i++) {
+                appendWriter.append(row(i, "value-" + i));
+            }
+            appendWriter.flush();
+        }
+
+        // add column c: STRING
+        admin.alterTable(
+                        tablePath,
+                        Collections.singletonList(
+                                TableChange.addColumn(
+                                        "c",
+                                        DataTypes.STRING(),
+                                        null,
+                                        TableChange.ColumnPosition.last())),
+                        false)
+                .get();
+
+        // write 3 more rows with the evolved schema (a: INT, b: STRING, c: 
STRING)
+        try (Table table = conn.getTable(tablePath)) {
+            AppendWriter appendWriter = table.newAppend().createWriter();
+            for (int i = 3; i < 6; i++) {
+                appendWriter.append(row(i, "value-" + i, "extra-" + i));
+            }
+            appendWriter.flush();
+
+            int totalRecords = 6;
+            // subscribe from beginning and verify all 6 records
+            try (LogScannerImpl scanner = (LogScannerImpl) 
table.newScan().createLogScanner()) {
+                scanner.subscribeFromBeginning(0);
+                pollAndVerifyArrowBatches(scanner, totalRecords, 0);
+            }
+
+            // subscribe from the middle of the first batch (offset 1)
+            // to ensure records before the subscribe offset are not returned
+            int subscribeOffset = 1;
+            try (LogScannerImpl scanner2 = (LogScannerImpl) 
table.newScan().createLogScanner()) {
+                scanner2.subscribe(0, subscribeOffset);
+                pollAndVerifyArrowBatches(
+                        scanner2, totalRecords - subscribeOffset, 
subscribeOffset);
+            }
+        }
+    }
+
+    private void pollAndVerifyArrowBatches(
+            LogScannerImpl scanner, int expectedRecords, int 
minExpectedOffset) {
+        int count = 0;
+        long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
+        while (count < expectedRecords) {
+            assertThat(System.nanoTime())
+                    .as("Timed out waiting for %s records, got %s", 
expectedRecords, count)
+                    .isLessThan(deadline);
+            try (ArrowScanRecords records = 
scanner.pollRecordBatch(Duration.ofSeconds(1))) {
+                for (ArrowBatchData batch : records) {
+                    try (ArrowBatchData b = batch) {
+                        IntVector intVector = (IntVector) 
b.getVectorSchemaRoot().getVector(0);
+                        VarCharVector stringVector =
+                                (VarCharVector) 
b.getVectorSchemaRoot().getVector(1);
+                        VarCharVector extraVector =
+                                (VarCharVector) 
b.getVectorSchemaRoot().getVector(2);
+                        for (int rowId = 0; rowId < b.getRecordCount(); 
rowId++) {
+                            int expectedValue = (int) (b.getBaseLogOffset() + 
rowId);
+                            
assertThat(expectedValue).isGreaterThanOrEqualTo(minExpectedOffset);
+                            
assertThat(intVector.get(rowId)).isEqualTo(expectedValue);
+                            
assertThat(stringVector.getObject(rowId).toString())
+                                    .isEqualTo("value-" + expectedValue);
+                            if (expectedValue < 3) {
+                                assertThat(extraVector.isNull(rowId)).isTrue();
+                            } else {
+                                
assertThat(extraVector.getObject(rowId).toString())
+                                        .isEqualTo("extra-" + expectedValue);
+                            }
+                            count++;
+                        }
+                    }
+                }
+            }
+        }
+        assertThat(count).isEqualTo(expectedRecords);
+    }
 }
diff --git a/fluss-common/pom.xml b/fluss-common/pom.xml
index 206f203a6..dded3a331 100644
--- a/fluss-common/pom.xml
+++ b/fluss-common/pom.xml
@@ -62,6 +62,24 @@
             <artifactId>fluss-shaded-arrow</artifactId>
         </dependency>
 
+        <!-- Unshaded Arrow dependencies used by UnshadedArrowReadUtils to 
load Arrow batches
+             with original Arrow classes (for ArrowBatchData / log scanner 
arrow batch scan).
+             Marked as provided because the actual Arrow classes are supplied 
at runtime by
+             downstream modules (e.g. fluss-client); fluss-common only needs 
them for compilation. -->
+        <dependency>
+            <groupId>org.apache.arrow</groupId>
+            <artifactId>arrow-vector</artifactId>
+            <version>${arrow.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.arrow</groupId>
+            <artifactId>arrow-memory-netty</artifactId>
+            <version>${arrow.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
         <!-- TODO: these two dependencies need to be shaded. -->
         <dependency>
             <groupId>at.yawk.lz4</groupId>
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedArrowCompressionFactory.java
 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedArrowCompressionFactory.java
new file mode 100644
index 000000000..0e7da37bb
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedArrowCompressionFactory.java
@@ -0,0 +1,60 @@
+/*
+ * 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.compression;
+
+import org.apache.fluss.annotation.Internal;
+
+import org.apache.arrow.vector.compression.CompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+import org.apache.arrow.vector.compression.NoCompressionCodec;
+
+/** Unshaded Arrow compression factory for scanner/read path. */
+@Internal
+public class UnshadedArrowCompressionFactory implements 
CompressionCodec.Factory {
+
+    public static final UnshadedArrowCompressionFactory INSTANCE =
+            new UnshadedArrowCompressionFactory();
+
+    @Override
+    public CompressionCodec createCodec(CompressionUtil.CodecType codecType) {
+        switch (codecType) {
+            case LZ4_FRAME:
+                return new UnshadedLz4ArrowCompressionCodec();
+            case ZSTD:
+                return new UnshadedZstdArrowCompressionCodec();
+            case NO_COMPRESSION:
+                return NoCompressionCodec.INSTANCE;
+            default:
+                throw new IllegalArgumentException("Compression type not 
supported: " + codecType);
+        }
+    }
+
+    @Override
+    public CompressionCodec createCodec(CompressionUtil.CodecType codecType, 
int compressionLevel) {
+        switch (codecType) {
+            case LZ4_FRAME:
+                return new UnshadedLz4ArrowCompressionCodec();
+            case ZSTD:
+                return new UnshadedZstdArrowCompressionCodec(compressionLevel);
+            case NO_COMPRESSION:
+                return NoCompressionCodec.INSTANCE;
+            default:
+                throw new IllegalArgumentException("Compression type not 
supported: " + codecType);
+        }
+    }
+}
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedLz4ArrowCompressionCodec.java
 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedLz4ArrowCompressionCodec.java
new file mode 100644
index 000000000..bc41aa802
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedLz4ArrowCompressionCodec.java
@@ -0,0 +1,93 @@
+/*
+ * 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.compression;
+
+import org.apache.fluss.utils.IOUtils;
+
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.compression.AbstractCompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+
+/** Unshaded Arrow compression codec for the LZ4 algorithm. */
+public class UnshadedLz4ArrowCompressionCodec extends AbstractCompressionCodec 
{
+    @Override
+    protected ArrowBuf doCompress(BufferAllocator allocator, ArrowBuf 
uncompressedBuffer) {
+        checkArgument(
+                uncompressedBuffer.writerIndex() <= Integer.MAX_VALUE,
+                "The uncompressed buffer size exceeds the integer limit");
+
+        byte[] inBytes = new byte[(int) uncompressedBuffer.writerIndex()];
+        uncompressedBuffer.getBytes(0, inBytes);
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (InputStream in = new ByteArrayInputStream(inBytes);
+                OutputStream out = new FlussLZ4BlockOutputStream(baos)) {
+            IOUtils.copyBytes(in, out);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        byte[] outBytes = baos.toByteArray();
+        ArrowBuf compressedBuffer =
+                allocator.buffer(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH + 
outBytes.length);
+        compressedBuffer.setBytes(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH, 
outBytes);
+        
compressedBuffer.writerIndex(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH + 
outBytes.length);
+        return compressedBuffer;
+    }
+
+    @Override
+    protected ArrowBuf doDecompress(BufferAllocator allocator, ArrowBuf 
compressedBuffer) {
+        checkArgument(
+                compressedBuffer.writerIndex() <= Integer.MAX_VALUE,
+                "The compressed buffer size exceeds the integer limit");
+
+        long decompressedLength = readUncompressedLength(compressedBuffer);
+        ByteBuffer inByteBuffer =
+                compressedBuffer.nioBuffer(
+                        CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH,
+                        (int)
+                                (compressedBuffer.writerIndex()
+                                        - 
CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH));
+        ByteArrayOutputStream out = new ByteArrayOutputStream((int) 
decompressedLength);
+        try (InputStream in = new FlussLZ4BlockInputStream(inByteBuffer)) {
+            IOUtils.copyBytes(in, out);
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+
+        byte[] outBytes = out.toByteArray();
+        ArrowBuf decompressedBuffer = allocator.buffer(outBytes.length);
+        decompressedBuffer.setBytes(0, outBytes);
+        decompressedBuffer.writerIndex(decompressedLength);
+        return decompressedBuffer;
+    }
+
+    @Override
+    public CompressionUtil.CodecType getCodecType() {
+        return CompressionUtil.CodecType.LZ4_FRAME;
+    }
+}
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedZstdArrowCompressionCodec.java
 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedZstdArrowCompressionCodec.java
new file mode 100644
index 000000000..803889521
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/compression/UnshadedZstdArrowCompressionCodec.java
@@ -0,0 +1,112 @@
+/*
+ * 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.compression;
+
+import com.github.luben.zstd.Zstd;
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.compression.AbstractCompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+
+import java.nio.ByteBuffer;
+
+/** Unshaded Arrow compression codec for the Zstd algorithm. */
+public class UnshadedZstdArrowCompressionCodec extends 
AbstractCompressionCodec {
+    private static final int DEFAULT_COMPRESSION_LEVEL = 3;
+    private final int compressionLevel;
+
+    public UnshadedZstdArrowCompressionCodec() {
+        this(DEFAULT_COMPRESSION_LEVEL);
+    }
+
+    public UnshadedZstdArrowCompressionCodec(int compressionLevel) {
+        this.compressionLevel = compressionLevel;
+    }
+
+    @Override
+    protected ArrowBuf doCompress(BufferAllocator allocator, ArrowBuf 
uncompressedBuffer) {
+        long maxSize = Zstd.compressBound(uncompressedBuffer.writerIndex());
+        ByteBuffer uncompressedDirectBuffer = uncompressedBuffer.nioBuffer();
+
+        long compressedSize = CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH + 
maxSize;
+        ArrowBuf compressedBuffer = allocator.buffer(compressedSize);
+        ByteBuffer compressedDirectBuffer =
+                compressedBuffer.nioBuffer(
+                        CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH, (int) 
maxSize);
+
+        long bytesWritten =
+                Zstd.compressDirectByteBuffer(
+                        compressedDirectBuffer,
+                        0,
+                        (int) maxSize,
+                        uncompressedDirectBuffer,
+                        0,
+                        (int) uncompressedBuffer.writerIndex(),
+                        compressionLevel);
+
+        if (Zstd.isError(bytesWritten)) {
+            compressedBuffer.close();
+            throw new RuntimeException("Error compressing: " + 
Zstd.getErrorName(bytesWritten));
+        }
+
+        
compressedBuffer.writerIndex(CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH + 
bytesWritten);
+        return compressedBuffer;
+    }
+
+    @Override
+    protected ArrowBuf doDecompress(BufferAllocator allocator, ArrowBuf 
compressedBuffer) {
+        long decompressedLength = readUncompressedLength(compressedBuffer);
+
+        ByteBuffer compressedDirectBuffer = compressedBuffer.nioBuffer();
+        ArrowBuf uncompressedBuffer = allocator.buffer(decompressedLength);
+        ByteBuffer uncompressedDirectBuffer =
+                uncompressedBuffer.nioBuffer(0, (int) decompressedLength);
+
+        long decompressedSize =
+                Zstd.decompressDirectByteBuffer(
+                        uncompressedDirectBuffer,
+                        0,
+                        (int) decompressedLength,
+                        compressedDirectBuffer,
+                        (int) CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH,
+                        (int)
+                                (compressedBuffer.writerIndex()
+                                        - 
CompressionUtil.SIZE_OF_UNCOMPRESSED_LENGTH));
+        if (Zstd.isError(decompressedSize)) {
+            uncompressedBuffer.close();
+            throw new RuntimeException(
+                    "Error decompressing: " + 
Zstd.getErrorName(decompressedSize));
+        }
+
+        if (decompressedLength != decompressedSize) {
+            uncompressedBuffer.close();
+            throw new RuntimeException(
+                    "Expected != actual decompressed length: "
+                            + decompressedLength
+                            + " != "
+                            + decompressedSize);
+        }
+        uncompressedBuffer.writerIndex(decompressedLength);
+        return uncompressedBuffer;
+    }
+
+    @Override
+    public CompressionUtil.CodecType getCodecType() {
+        return CompressionUtil.CodecType.ZSTD;
+    }
+}
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/ArrowBatchData.java 
b/fluss-common/src/main/java/org/apache/fluss/record/ArrowBatchData.java
new file mode 100644
index 000000000..515f73d5a
--- /dev/null
+++ b/fluss-common/src/main/java/org/apache/fluss/record/ArrowBatchData.java
@@ -0,0 +1,103 @@
+/*
+ * 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.record;
+
+import org.apache.fluss.annotation.Internal;
+
+import org.apache.arrow.vector.VectorSchemaRoot;
+
+import static org.apache.fluss.utils.Preconditions.checkArgument;
+import static org.apache.fluss.utils.Preconditions.checkNotNull;
+
+/**
+ * Holds a scanned Arrow batch together with the log metadata of the batch.
+ *
+ * <p>This class only supports append-only log tables. CDC tables are not 
supported.
+ *
+ * <p>The caller must close this object after use in order to release the 
underlying Arrow memory.
+ */
+@Internal
+public class ArrowBatchData implements AutoCloseable {
+
+    private final VectorSchemaRoot vectorSchemaRoot;
+    private final long baseLogOffset;
+    private final long timestamp;
+    private final int schemaId;
+
+    public ArrowBatchData(
+            VectorSchemaRoot vectorSchemaRoot, long baseLogOffset, long 
timestamp, int schemaId) {
+        this.vectorSchemaRoot = checkNotNull(vectorSchemaRoot, 
"vectorSchemaRoot must not be null");
+        this.baseLogOffset = baseLogOffset;
+        this.timestamp = timestamp;
+        this.schemaId = schemaId;
+    }
+
+    /** Returns the Arrow vectors of this batch. */
+    public VectorSchemaRoot getVectorSchemaRoot() {
+        return vectorSchemaRoot;
+    }
+
+    /** Returns the schema id of this batch. */
+    public int getSchemaId() {
+        return schemaId;
+    }
+
+    /** Returns the base log offset of this batch. */
+    public long getBaseLogOffset() {
+        return baseLogOffset;
+    }
+
+    /** Returns the commit timestamp of this batch. */
+    public long getTimestamp() {
+        return timestamp;
+    }
+
+    /** Returns the number of rows in this batch. */
+    public int getRecordCount() {
+        return vectorSchemaRoot.getRowCount();
+    }
+
+    /**
+     * Creates a new {@link ArrowBatchData} containing a contiguous slice of 
this batch's rows and
+     * releases the original vector data.
+     *
+     * <p>After this method returns, the original {@link ArrowBatchData} 
instance MUST NOT be used
+     * or closed. The caller is responsible for closing the returned instance.
+     *
+     * @param skipRows the number of leading rows to skip
+     * @return a new {@link ArrowBatchData} containing the remaining rows 
after skipping
+     */
+    public ArrowBatchData sliceAndTransferOwnership(int skipRows) {
+        checkArgument(skipRows >= 0, "skipRows must be >= 0, but is %s", 
skipRows);
+        checkArgument(
+                skipRows < getRecordCount(),
+                "skipRows(%s) must be < recordCount(%s)",
+                skipRows,
+                getRecordCount());
+        int remainingRows = getRecordCount() - skipRows;
+        VectorSchemaRoot slicedRoot = vectorSchemaRoot.slice(skipRows, 
remainingRows);
+        // release original vector buffers; sliced vectors hold independent 
copies
+        vectorSchemaRoot.close();
+        return new ArrowBatchData(slicedRoot, baseLogOffset + skipRows, 
timestamp, schemaId);
+    }
+
+    @Override
+    public void close() {
+        vectorSchemaRoot.close();
+    }
+}
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/ArrowRecordBatchContext.java
 
b/fluss-common/src/main/java/org/apache/fluss/record/ArrowRecordBatchContext.java
new file mode 100644
index 000000000..64cf19ba4
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/ArrowRecordBatchContext.java
@@ -0,0 +1,43 @@
+/*
+ * 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.record;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.memory.MemorySegment;
+
+/** Internal context for loading Arrow record batches with unshaded Arrow 
resources. */
+@Internal
+interface ArrowRecordBatchContext extends LogRecordBatch.ReadContext {
+
+    /** Creates a batch-scoped access wrapper for unshaded Arrow resources. */
+    UnshadedArrowBatchAccess createUnshadedArrowBatchAccess(int schemaId);
+
+    /** Internal wrapper that hides unshaded Arrow types from shared 
signatures. */
+    @Internal
+    interface UnshadedArrowBatchAccess extends AutoCloseable {
+
+        /** Loads one Arrow batch from the given memory segment into the 
internal read root. */
+        void loadArrowBatch(MemorySegment segment, int arrowOffset, int 
arrowLength);
+
+        /**
+         * Applies schema-evolution projection (if needed) internally and 
builds the final {@link
+         * ArrowBatchData}, transferring ownership to the caller.
+         */
+        ArrowBatchData createArrowBatchData(long baseLogOffset, long 
timestamp, int schemaId);
+    }
+}
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java 
b/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
index 288c4b906..e5e961fb1 100644
--- 
a/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/DefaultLogRecordBatch.java
@@ -31,6 +31,7 @@ import org.apache.fluss.types.DataType;
 import org.apache.fluss.types.RowType;
 import org.apache.fluss.utils.ArrowUtils;
 import org.apache.fluss.utils.CloseableIterator;
+import org.apache.fluss.utils.IOUtils;
 import org.apache.fluss.utils.MurmurHashUtils;
 import org.apache.fluss.utils.crc.Crc32C;
 
@@ -62,6 +63,7 @@ import static 
org.apache.fluss.record.LogRecordBatchFormat.schemaIdOffset;
 import static 
org.apache.fluss.record.LogRecordBatchFormat.statisticsDataOffset;
 import static 
org.apache.fluss.record.LogRecordBatchFormat.statisticsLengthOffset;
 import static org.apache.fluss.record.LogRecordBatchFormat.writeClientIdOffset;
+import static org.apache.fluss.utils.Preconditions.checkArgument;
 
 /* This file is based on source code of Apache Kafka Project 
(https://kafka.apache.org/), licensed by the Apache
  * Software Foundation (ASF) under the Apache License, Version 2.0. See the 
NOTICE file distributed with this work for
@@ -262,6 +264,38 @@ public class DefaultLogRecordBatch implements 
LogRecordBatch {
         }
     }
 
+    @Override
+    public ArrowBatchData loadArrowBatch(ReadContext context) {
+        if (context.getLogFormat() != LogFormat.ARROW) {
+            throw new UnsupportedOperationException(
+                    "loadArrowBatch is only supported for ARROW log format.");
+        }
+
+        int schemaId = schemaId();
+        checkArgument(
+                context instanceof ArrowRecordBatchContext,
+                "Arrow batch loading requires context to implement 
ArrowRecordBatchContext, but is %s.",
+                context.getClass().getName());
+        ArrowRecordBatchContext arrowRecordBatchContext = 
(ArrowRecordBatchContext) context;
+        checkArgument(isAppendOnly(), "Arrow batch loading only supports 
append-only batches.");
+        ArrowRecordBatchContext.UnshadedArrowBatchAccess batchAccess =
+                
arrowRecordBatchContext.createUnshadedArrowBatchAccess(schemaId);
+
+        try {
+            int recordsDataOffset = recordsDataOffset();
+            int arrowOffset = position + recordsDataOffset;
+            int arrowLength = sizeInBytes() - recordsDataOffset;
+            batchAccess.loadArrowBatch(segment, arrowOffset, arrowLength);
+            ArrowBatchData arrowBatchData =
+                    batchAccess.createArrowBatchData(baseLogOffset(), 
commitTimestamp(), schemaId);
+            batchAccess = null;
+            return arrowBatchData;
+        } catch (Throwable t) {
+            IOUtils.closeQuietly(batchAccess);
+            throw t;
+        }
+    }
+
     @Override
     public boolean equals(Object o) {
         if (this == o) {
@@ -352,7 +386,7 @@ public class DefaultLogRecordBatch implements 
LogRecordBatch {
             VectorSchemaRoot root,
             BufferAllocator allocator,
             long timestamp) {
-        boolean isAppendOnly = (attributes() & APPEND_ONLY_FLAG_MASK) > 0;
+        boolean isAppendOnly = isAppendOnly();
         int recordsDataOffset = recordsDataOffset();
         if (isAppendOnly) {
             // append only batch, no change type vector,
@@ -388,6 +422,10 @@ public class DefaultLogRecordBatch implements 
LogRecordBatch {
         }
     }
 
+    private boolean isAppendOnly() {
+        return (attributes() & APPEND_ONLY_FLAG_MASK) > 0;
+    }
+
     /** The basic implementation for Arrow log record iterator. */
     private abstract class ArrowLogRecordIterator extends LogRecordIterator {
         private final VectorSchemaRoot root;
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/FileLogInputStream.java 
b/fluss-common/src/main/java/org/apache/fluss/record/FileLogInputStream.java
index fca1a2c49..9e15a508e 100644
--- a/fluss-common/src/main/java/org/apache/fluss/record/FileLogInputStream.java
+++ b/fluss-common/src/main/java/org/apache/fluss/record/FileLogInputStream.java
@@ -179,6 +179,11 @@ public class FileLogInputStream
             return loadFullBatch().records(context);
         }
 
+        @Override
+        public ArrowBatchData loadArrowBatch(ReadContext context) {
+            return loadFullBatch().loadArrowBatch(context);
+        }
+
         @Override
         public boolean isValid() {
             return loadFullBatch().isValid();
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordBatch.java 
b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordBatch.java
index f856471b6..5d38bb2f6 100644
--- a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordBatch.java
+++ b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordBatch.java
@@ -173,6 +173,16 @@ public interface LogRecordBatch {
      */
     CloseableIterator<LogRecord> records(ReadContext context);
 
+    /**
+     * Loads the underlying Arrow batch directly.
+     *
+     * <p>This method is only supported for Arrow log batches.
+     */
+    default ArrowBatchData loadArrowBatch(ReadContext context) {
+        throw new UnsupportedOperationException(
+                "loadArrowBatch is only supported for ARROW log format.");
+    }
+
     /** The read context of a {@link LogRecordBatch} to read records. */
     interface ReadContext {
 
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java 
b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
index b65a3ac8e..904fbd505 100644
--- 
a/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/LogRecordReadContext.java
@@ -18,6 +18,7 @@
 package org.apache.fluss.record;
 
 import org.apache.fluss.annotation.VisibleForTesting;
+import org.apache.fluss.memory.MemorySegment;
 import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.metadata.Schema;
 import org.apache.fluss.metadata.SchemaGetter;
@@ -33,7 +34,9 @@ import 
org.apache.fluss.shaded.arrow.org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.fluss.types.DataType;
 import org.apache.fluss.types.RowType;
 import org.apache.fluss.utils.ArrowUtils;
+import org.apache.fluss.utils.IOUtils;
 import org.apache.fluss.utils.Projection;
+import org.apache.fluss.utils.UnshadedArrowReadUtils;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
@@ -42,11 +45,10 @@ import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.IntStream;
 
-import static org.apache.fluss.utils.Preconditions.checkNotNull;
-
 /** A simple implementation for {@link LogRecordBatch.ReadContext}. */
 @ThreadSafe
-public class LogRecordReadContext implements LogRecordBatch.ReadContext, 
AutoCloseable {
+public class LogRecordReadContext
+        implements LogRecordBatch.ReadContext, ArrowRecordBatchContext, 
AutoCloseable {
 
     // the log format of the table
     private final LogFormat logFormat;
@@ -54,15 +56,18 @@ public class LogRecordReadContext implements 
LogRecordBatch.ReadContext, AutoClo
     private final RowType dataRowType;
     // the static schemaId of the table, should support dynamic schema 
evolution in the future
     private final int targetSchemaId;
-    // the Arrow memory buffer allocator for the table, should be null if not 
ARROW log format
+    // the shaded Arrow memory buffer allocator, should be null if not ARROW 
log format
     @Nullable private final BufferAllocator bufferAllocator;
-    // the final selected fields of the read data
+    // the unshaded Arrow memory buffer allocator, declared as AutoCloseable 
to avoid
+    // importing unshaded Arrow classes in this module (they are 
provided-scope)
+    @Nullable private volatile AutoCloseable unshadedBufferAllocator;
     private final FieldGetter[] selectedFieldGetters;
     // whether the projection is push downed to the server side and the 
returned data is pruned.
     private final boolean projectionPushDowned;
     private final SchemaGetter schemaGetter;
     private final ConcurrentHashMap<Integer, VectorSchemaRoot> 
vectorSchemaRootMap =
             new ConcurrentHashMap<>();
+    private final Object unshadedArrowResourceLock = new Object();
 
     public static LogRecordReadContext createReadContext(
             TableInfo tableInfo,
@@ -318,6 +323,22 @@ public class LogRecordReadContext implements 
LogRecordBatch.ReadContext, AutoClo
         return schemaGetter;
     }
 
+    /**
+     * Returns the column index mapping for schema evolution, or {@code null} 
if the batch schema
+     * matches the current schema and no remapping is needed.
+     *
+     * <p>Each entry maps an output column to its position in the batch's 
schema. A value of {@code
+     * -1} means the column does not exist in the batch's schema and should be 
filled with nulls.
+     */
+    @Nullable
+    private int[] getSchemaEvolutionMapping(int schemaId) {
+        ProjectedRow projectedRow = getOutputProjectedRow(schemaId);
+        if (projectedRow == null) {
+            return null;
+        }
+        return projectedRow.getIndexMapping();
+    }
+
     @Override
     public VectorSchemaRoot getVectorSchemaRoot(int schemaId) {
         if (logFormat != LogFormat.ARROW) {
@@ -338,10 +359,18 @@ public class LogRecordReadContext implements 
LogRecordBatch.ReadContext, AutoClo
         if (logFormat != LogFormat.ARROW) {
             throw new IllegalArgumentException("Only Arrow log format provides 
buffer allocator.");
         }
-        checkNotNull(bufferAllocator, "The buffer allocator is not 
available.");
         return bufferAllocator;
     }
 
+    @Override
+    public UnshadedArrowBatchAccess createUnshadedArrowBatchAccess(int 
schemaId) {
+        if (logFormat != LogFormat.ARROW) {
+            throw new IllegalArgumentException(
+                    "Only Arrow log format provides unshaded Arrow 
resources.");
+        }
+        return new UnshadedArrowBatchAccessImpl(schemaId);
+    }
+
     @Nullable
     @Override
     public ProjectedRow getOutputProjectedRow(int schemaId) {
@@ -359,9 +388,88 @@ public class LogRecordReadContext implements 
LogRecordBatch.ReadContext, AutoClo
 
     public void close() {
         vectorSchemaRootMap.values().forEach(VectorSchemaRoot::close);
+        vectorSchemaRootMap.clear();
         if (bufferAllocator != null) {
             bufferAllocator.close();
         }
+
+        synchronized (unshadedArrowResourceLock) {
+            if (unshadedBufferAllocator != null) {
+                try {
+                    unshadedBufferAllocator.close();
+                } catch (Exception e) {
+                    throw new RuntimeException(
+                            "Failed to close Arrow buffer allocator. "
+                                    + "Arrow batches returned by 
pollRecordBatch() must be closed "
+                                    + "before closing the scanner.",
+                            e);
+                }
+                unshadedBufferAllocator = null;
+            }
+        }
+    }
+
+    private AutoCloseable getOrCreateUnshadedBufferAllocator() {
+        AutoCloseable allocator = unshadedBufferAllocator;
+        if (allocator != null) {
+            return allocator;
+        }
+
+        synchronized (unshadedArrowResourceLock) {
+            if (unshadedBufferAllocator == null) {
+                unshadedBufferAllocator = new 
org.apache.arrow.memory.RootAllocator(Long.MAX_VALUE);
+            }
+            return unshadedBufferAllocator;
+        }
+    }
+
+    private final class UnshadedArrowBatchAccessImpl implements 
UnshadedArrowBatchAccess {
+        private final org.apache.arrow.memory.BufferAllocator allocator;
+        private org.apache.arrow.vector.VectorSchemaRoot readRoot;
+        private org.apache.arrow.vector.VectorSchemaRoot outputRoot;
+
+        private UnshadedArrowBatchAccessImpl(int schemaId) {
+            this.allocator =
+                    (org.apache.arrow.memory.BufferAllocator) 
getOrCreateUnshadedBufferAllocator();
+            this.readRoot =
+                    org.apache.arrow.vector.VectorSchemaRoot.create(
+                            
org.apache.fluss.utils.UnshadedArrowReadUtils.toArrowSchema(
+                                    getRowType(schemaId)),
+                            allocator);
+            this.outputRoot = readRoot;
+        }
+
+        @Override
+        public void loadArrowBatch(MemorySegment segment, int arrowOffset, int 
arrowLength) {
+            UnshadedArrowReadUtils.loadArrowBatch(
+                    segment, arrowOffset, arrowLength, readRoot, allocator);
+        }
+
+        @Override
+        public ArrowBatchData createArrowBatchData(
+                long baseLogOffset, long timestamp, int schemaId) {
+            int[] schemaMapping = getSchemaEvolutionMapping(schemaId);
+            if (schemaMapping != null) {
+                outputRoot =
+                        UnshadedArrowReadUtils.projectVectorSchemaRoot(
+                                readRoot, dataRowType, schemaMapping, 
allocator);
+                readRoot.close();
+                readRoot = null;
+            }
+            ArrowBatchData arrowBatchData =
+                    new ArrowBatchData(outputRoot, baseLogOffset, timestamp, 
schemaId);
+            outputRoot = null;
+            readRoot = null;
+            return arrowBatchData;
+        }
+
+        @Override
+        public void close() {
+            IOUtils.closeQuietly(outputRoot);
+            if (outputRoot != readRoot) {
+                IOUtils.closeQuietly(readRoot);
+            }
+        }
     }
 
     private boolean isSameRowType(int schemaId) {
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilder.java
 
b/fluss-common/src/main/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilder.java
index 3276771d0..8ca7bc440 100644
--- 
a/fluss-common/src/main/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilder.java
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/MemoryLogRecordsArrowBuilder.java
@@ -142,6 +142,18 @@ public class MemoryLogRecordsArrowBuilder implements 
AutoCloseable {
                 baseLogOffset, schemaId, magic, arrowWriter, outputView, 
false, null);
     }
 
+    @VisibleForTesting
+    public static MemoryLogRecordsArrowBuilder builder(
+            long baseLogOffset,
+            byte magic,
+            int schemaId,
+            ArrowWriter arrowWriter,
+            AbstractPagedOutputView outputView,
+            boolean appendOnly) {
+        return new MemoryLogRecordsArrowBuilder(
+                baseLogOffset, schemaId, magic, arrowWriter, outputView, 
appendOnly, null);
+    }
+
     /** Builder with limited write size and the memory segment used to 
serialize records. */
     public static MemoryLogRecordsArrowBuilder builder(
             int schemaId,
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/record/UnshadedFlussVectorLoader.java
 
b/fluss-common/src/main/java/org/apache/fluss/record/UnshadedFlussVectorLoader.java
new file mode 100644
index 000000000..7798f8ef6
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/record/UnshadedFlussVectorLoader.java
@@ -0,0 +1,132 @@
+/*
+ * 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.record;
+
+import org.apache.arrow.memory.ArrowBuf;
+import org.apache.arrow.util.Collections2;
+import org.apache.arrow.util.Preconditions;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.TypeLayout;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.compression.CompressionCodec;
+import org.apache.arrow.vector.compression.CompressionUtil;
+import org.apache.arrow.vector.compression.CompressionUtil.CodecType;
+import org.apache.arrow.vector.compression.NoCompressionCodec;
+import org.apache.arrow.vector.ipc.message.ArrowFieldNode;
+import org.apache.arrow.vector.ipc.message.ArrowRecordBatch;
+import org.apache.arrow.vector.types.pojo.Field;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+/** Unshaded variant of {@link FlussVectorLoader} for scanner/read path. */
+public class UnshadedFlussVectorLoader {
+    private final VectorSchemaRoot root;
+    private final CompressionCodec.Factory factory;
+    private boolean decompressionNeeded;
+
+    public UnshadedFlussVectorLoader(VectorSchemaRoot root, 
CompressionCodec.Factory factory) {
+        this.root = root;
+        this.factory = factory;
+    }
+
+    public void load(ArrowRecordBatch recordBatch) {
+        Iterator<ArrowBuf> buffers = recordBatch.getBuffers().iterator();
+        Iterator<ArrowFieldNode> nodes = recordBatch.getNodes().iterator();
+        CompressionUtil.CodecType codecType =
+                
CodecType.fromCompressionType(recordBatch.getBodyCompression().getCodec());
+        this.decompressionNeeded = codecType != CodecType.NO_COMPRESSION;
+        CompressionCodec codec =
+                this.decompressionNeeded
+                        ? this.factory.createCodec(codecType)
+                        : NoCompressionCodec.INSTANCE;
+
+        for (FieldVector fieldVector : this.root.getFieldVectors()) {
+            this.loadBuffers(fieldVector, fieldVector.getField(), buffers, 
nodes, codec);
+        }
+
+        this.root.setRowCount(recordBatch.getLength());
+        if (nodes.hasNext() || buffers.hasNext()) {
+            throw new IllegalArgumentException(
+                    "not all nodes and buffers were consumed. nodes: "
+                            + Collections2.toString(nodes)
+                            + " buffers: "
+                            + Collections2.toString(buffers));
+        }
+    }
+
+    private void loadBuffers(
+            FieldVector vector,
+            Field field,
+            Iterator<ArrowBuf> buffers,
+            Iterator<ArrowFieldNode> nodes,
+            CompressionCodec codec) {
+        Preconditions.checkArgument(
+                nodes.hasNext(), "no more field nodes for field %s and vector 
%s", field, vector);
+        ArrowFieldNode fieldNode = nodes.next();
+        int bufferLayoutCount = TypeLayout.getTypeBufferCount(field.getType());
+        List<ArrowBuf> ownBuffers = new ArrayList<>(bufferLayoutCount);
+
+        try {
+            for (int j = 0; j < bufferLayoutCount; ++j) {
+                ArrowBuf nextBuf = buffers.next();
+                ArrowBuf bufferToAdd =
+                        nextBuf.writerIndex() > 0L
+                                ? codec.decompress(vector.getAllocator(), 
nextBuf)
+                                : nextBuf;
+                ownBuffers.add(bufferToAdd);
+                if (this.decompressionNeeded) {
+                    nextBuf.getReferenceManager().retain();
+                }
+            }
+            vector.loadFieldBuffers(fieldNode, ownBuffers);
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException(
+                    "Could not load buffers for field "
+                            + field
+                            + ". error message: "
+                            + e.getMessage(),
+                    e);
+        } finally {
+            if (this.decompressionNeeded) {
+                for (ArrowBuf buf : ownBuffers) {
+                    buf.close();
+                }
+            }
+        }
+
+        List<Field> children = field.getChildren();
+        if (!children.isEmpty()) {
+            List<FieldVector> childrenFromFields = 
vector.getChildrenFromFields();
+            Preconditions.checkArgument(
+                    children.size() == childrenFromFields.size(),
+                    "should have as many children as in the schema: found %s 
expected %s",
+                    childrenFromFields.size(),
+                    children.size());
+
+            for (int i = 0; i < childrenFromFields.size(); ++i) {
+                Field child = children.get(i);
+                FieldVector fieldVector = childrenFromFields.get(i);
+                this.loadBuffers(fieldVector, child, buffers, nodes, codec);
+            }
+        }
+    }
+}
diff --git a/fluss-common/src/main/java/org/apache/fluss/row/ProjectedRow.java 
b/fluss-common/src/main/java/org/apache/fluss/row/ProjectedRow.java
index 25656204d..0af0f40bd 100644
--- a/fluss-common/src/main/java/org/apache/fluss/row/ProjectedRow.java
+++ b/fluss-common/src/main/java/org/apache/fluss/row/ProjectedRow.java
@@ -19,11 +19,10 @@ package org.apache.fluss.row;
 
 import org.apache.fluss.annotation.PublicEvolving;
 import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.utils.SchemaUtil;
 
 import java.util.Arrays;
 
-import static org.apache.fluss.utils.SchemaUtil.getIndexMapping;
-
 /**
  * An implementation of {@link InternalRow} which provides a projected view of 
the underlying {@link
  * InternalRow}.
@@ -190,10 +189,15 @@ public class ProjectedRow implements InternalRow {
     }
 
     public static ProjectedRow from(Schema originSchema, Schema 
expectedSchema) {
-        int[] indexMapping = getIndexMapping(originSchema, expectedSchema);
+        int[] indexMapping = SchemaUtil.getIndexMapping(originSchema, 
expectedSchema);
         return new ProjectedRow(indexMapping);
     }
 
+    /** Returns the index mapping used by this projected row. */
+    public int[] getIndexMapping() {
+        return Arrays.copyOf(indexMapping, indexMapping.length);
+    }
+
     /**
      * Returns the underlying row before column projection.
      *
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/utils/UnshadedArrowReadUtils.java 
b/fluss-common/src/main/java/org/apache/fluss/utils/UnshadedArrowReadUtils.java
new file mode 100644
index 000000000..dff13f040
--- /dev/null
+++ 
b/fluss-common/src/main/java/org/apache/fluss/utils/UnshadedArrowReadUtils.java
@@ -0,0 +1,122 @@
+/*
+ * 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.utils;
+
+import org.apache.fluss.annotation.Internal;
+import org.apache.fluss.compression.UnshadedArrowCompressionFactory;
+import org.apache.fluss.memory.MemorySegment;
+import org.apache.fluss.record.UnshadedFlussVectorLoader;
+import org.apache.fluss.types.RowType;
+
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.ipc.ReadChannel;
+import org.apache.arrow.vector.ipc.message.ArrowRecordBatch;
+import org.apache.arrow.vector.ipc.message.MessageSerializer;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.Schema;
+import org.apache.arrow.vector.util.TransferPair;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Utilities for loading and projecting Arrow scan batches with unshaded 
Arrow classes. */
+@Internal
+public final class UnshadedArrowReadUtils {
+
+    private UnshadedArrowReadUtils() {}
+
+    /**
+     * Converts a Fluss RowType to an unshaded Arrow Schema.
+     *
+     * <p>Converts via FlatBuffer binary serialization: shaded Schema → bytes 
→ unshaded Schema.
+     * Both shaded and unshaded Arrow use the same FlatBuffer wire format, so 
the binary
+     * representation is identical regardless of Java package relocation.
+     */
+    public static Schema toArrowSchema(RowType rowType) {
+        
org.apache.fluss.shaded.arrow.org.apache.arrow.vector.types.pojo.Schema 
shadedSchema =
+                ArrowUtils.toArrowSchema(rowType);
+        return 
Schema.deserializeMessage(ByteBuffer.wrap(shadedSchema.serializeAsMessage()));
+    }
+
+    public static void loadArrowBatch(
+            MemorySegment segment,
+            int arrowOffset,
+            int arrowLength,
+            VectorSchemaRoot schemaRoot,
+            BufferAllocator allocator) {
+        ByteBuffer arrowBatchBuffer = segment.wrap(arrowOffset, arrowLength);
+        try (ReadChannel channel =
+                        new ReadChannel(new 
ByteBufferReadableChannel(arrowBatchBuffer));
+                ArrowRecordBatch batch =
+                        MessageSerializer.deserializeRecordBatch(channel, 
allocator)) {
+            new UnshadedFlussVectorLoader(schemaRoot, 
UnshadedArrowCompressionFactory.INSTANCE)
+                    .load(batch);
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to deserialize 
ArrowRecordBatch.", e);
+        }
+    }
+
+    /**
+     * Projects a VectorSchemaRoot from old schema to target schema using a 
column projection
+     * mapping.
+     *
+     * @param sourceRoot the source VectorSchemaRoot with old schema data
+     * @param targetRowType the target RowType to project to
+     * @param columnProjection mapping from target column index to source 
column index; -1 means
+     *     column doesn't exist in source (filled with nulls)
+     * @param allocator the allocator for creating new vectors
+     * @return a new VectorSchemaRoot with the target schema
+     */
+    public static VectorSchemaRoot projectVectorSchemaRoot(
+            VectorSchemaRoot sourceRoot,
+            RowType targetRowType,
+            int[] columnProjection,
+            BufferAllocator allocator) {
+        int rowCount = sourceRoot.getRowCount();
+        Schema targetSchema = toArrowSchema(targetRowType);
+        List<Field> targetFields = targetSchema.getFields();
+        List<FieldVector> targetVectors = new 
ArrayList<>(columnProjection.length);
+        try {
+            for (int i = 0; i < columnProjection.length; i++) {
+                if (columnProjection[i] < 0) {
+                    // Column doesn't exist in source, fill with nulls
+                    FieldVector targetVector = 
targetFields.get(i).createVector(allocator);
+                    targetVector.allocateNew();
+                    for (int rowId = 0; rowId < rowCount; rowId++) {
+                        targetVector.setNull(rowId);
+                    }
+                    targetVector.setValueCount(rowCount);
+                    targetVectors.add(targetVector);
+                } else {
+                    FieldVector sourceVector = 
sourceRoot.getVector(columnProjection[i]);
+                    TransferPair transfer = 
sourceVector.getTransferPair(allocator);
+                    transfer.splitAndTransfer(0, rowCount);
+                    targetVectors.add((FieldVector) transfer.getTo());
+                }
+            }
+        } catch (Exception e) {
+            targetVectors.forEach(FieldVector::close);
+            throw e;
+        }
+        return new VectorSchemaRoot(targetFields, targetVectors, rowCount);
+    }
+}
diff --git 
a/fluss-common/src/test/java/org/apache/fluss/record/FileLogInputStreamTest.java
 
b/fluss-common/src/test/java/org/apache/fluss/record/FileLogInputStreamTest.java
index 6afb25549..5a994ba8a 100644
--- 
a/fluss-common/src/test/java/org/apache/fluss/record/FileLogInputStreamTest.java
+++ 
b/fluss-common/src/test/java/org/apache/fluss/record/FileLogInputStreamTest.java
@@ -20,6 +20,8 @@ package org.apache.fluss.record;
 import org.apache.fluss.metadata.LogFormat;
 import org.apache.fluss.utils.CloseableIterator;
 
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -27,11 +29,13 @@ import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
 import java.util.Collections;
+import java.util.List;
 import java.util.Optional;
 
 import static org.apache.fluss.record.LogRecordBatchFormat.LOG_MAGIC_VALUE_V0;
 import static org.apache.fluss.record.LogRecordBatchFormat.LOG_MAGIC_VALUE_V1;
 import static org.apache.fluss.record.LogRecordBatchFormat.LOG_MAGIC_VALUE_V2;
+import static org.apache.fluss.record.TestData.DATA1;
 import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE;
 import static org.apache.fluss.record.TestData.DATA1_SCHEMA;
 import static org.apache.fluss.record.TestData.DEFAULT_SCHEMA_ID;
@@ -257,4 +261,50 @@ public class FileLogInputStreamTest extends LogTestBase {
             
assertThat(memoryStats.getMaxValues().getString(1).toString()).isEqualTo("j");
         }
     }
+
+    @ParameterizedTest
+    @ValueSource(bytes = {LOG_MAGIC_VALUE_V0, LOG_MAGIC_VALUE_V1})
+    void testLoadArrowBatch(byte recordBatchMagic) throws Exception {
+        List<Object[]> data = DATA1;
+        try (FileLogRecords fileLogRecords =
+                FileLogRecords.open(new File(tempDir, 
"test_arrow_batch.tmp"))) {
+            fileLogRecords.append(
+                    createRecordsWithoutBaseLogOffset(
+                            DATA1_ROW_TYPE,
+                            DEFAULT_SCHEMA_ID,
+                            0L,
+                            -1L,
+                            recordBatchMagic,
+                            data,
+                            LogFormat.ARROW,
+                            true));
+            fileLogRecords.flush();
+
+            FileLogInputStream logInputStream =
+                    new FileLogInputStream(fileLogRecords, 0, 
fileLogRecords.sizeInBytes());
+            FileLogInputStream.FileChannelLogRecordBatch batch = 
logInputStream.nextBatch();
+            assertThat(batch).isNotNull();
+
+            TestingSchemaGetter schemaGetter =
+                    new TestingSchemaGetter(DEFAULT_SCHEMA_ID, DATA1_SCHEMA);
+            try (LogRecordReadContext readContext =
+                    LogRecordReadContext.createArrowReadContext(
+                            DATA1_ROW_TYPE, DEFAULT_SCHEMA_ID, schemaGetter)) {
+                try (ArrowBatchData arrowBatchData = 
batch.loadArrowBatch(readContext)) {
+                    
assertThat(arrowBatchData.getRecordCount()).isEqualTo(data.size());
+                    
assertThat(arrowBatchData.getBaseLogOffset()).isEqualTo(0L);
+                    
assertThat(arrowBatchData.getSchemaId()).isEqualTo(DEFAULT_SCHEMA_ID);
+
+                    org.apache.arrow.vector.VectorSchemaRoot root =
+                            arrowBatchData.getVectorSchemaRoot();
+                    IntVector intVector = (IntVector) root.getVector(0);
+                    VarCharVector stringVector = (VarCharVector) 
root.getVector(1);
+                    for (int i = 0; i < data.size(); i++) {
+                        assertThat(intVector.get(i)).isEqualTo(data.get(i)[0]);
+                        
assertThat(stringVector.getObject(i).toString()).isEqualTo(data.get(i)[1]);
+                    }
+                }
+            }
+        }
+    }
 }
diff --git 
a/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java 
b/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
index 5ee50776a..aaa449d46 100644
--- a/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
+++ b/fluss-common/src/test/java/org/apache/fluss/testutils/DataTestUtils.java
@@ -494,6 +494,20 @@ public class DataTestUtils {
             List<Object[]> objects,
             LogFormat logFormat)
             throws Exception {
+        return createRecordsWithoutBaseLogOffset(
+                rowType, schemaId, offsetBase, maxTimestamp, magic, objects, 
logFormat, false);
+    }
+
+    public static MemoryLogRecords createRecordsWithoutBaseLogOffset(
+            RowType rowType,
+            int schemaId,
+            long offsetBase,
+            long maxTimestamp,
+            byte magic,
+            List<Object[]> objects,
+            LogFormat logFormat,
+            boolean appendOnly)
+            throws Exception {
         List<ChangeType> changeTypes =
                 objects.stream().map(row -> 
ChangeType.APPEND_ONLY).collect(Collectors.toList());
         return createBasicMemoryLogRecords(
@@ -507,7 +521,8 @@ public class DataTestUtils {
                 changeTypes,
                 objects,
                 logFormat,
-                DEFAULT_COMPRESSION);
+                DEFAULT_COMPRESSION,
+                appendOnly);
     }
 
     public static MemoryLogRecords createBasicMemoryLogRecords(
@@ -534,7 +549,37 @@ public class DataTestUtils {
                 changeTypes,
                 objects,
                 logFormat,
-                arrowCompressionInfo);
+                arrowCompressionInfo,
+                false);
+    }
+
+    public static MemoryLogRecords createBasicMemoryLogRecords(
+            RowType rowType,
+            int schemaId,
+            long offsetBase,
+            long maxTimestamp,
+            byte magic,
+            long writerId,
+            int batchSequence,
+            List<ChangeType> changeTypes,
+            List<Object[]> objects,
+            LogFormat logFormat,
+            ArrowCompressionInfo arrowCompressionInfo,
+            boolean appendOnly)
+            throws Exception {
+        return createMemoryLogRecords(
+                rowType,
+                schemaId,
+                offsetBase,
+                maxTimestamp,
+                magic,
+                writerId,
+                batchSequence,
+                changeTypes,
+                objects,
+                logFormat,
+                arrowCompressionInfo,
+                appendOnly);
     }
 
     public static MemoryLogRecords createMemoryLogRecords(
@@ -548,7 +593,8 @@ public class DataTestUtils {
             List<ChangeType> changeTypes,
             List<Object[]> objects,
             LogFormat logFormat,
-            ArrowCompressionInfo arrowCompressionInfo)
+            ArrowCompressionInfo arrowCompressionInfo,
+            boolean appendOnly)
             throws Exception {
         if (logFormat == LogFormat.ARROW) {
             List<InternalRow> rows =
@@ -563,7 +609,8 @@ public class DataTestUtils {
                     batchSequence,
                     changeTypes,
                     rows,
-                    arrowCompressionInfo);
+                    arrowCompressionInfo,
+                    appendOnly);
         } else {
             return createIndexedMemoryLogRecords(
                     offsetBase,
@@ -614,7 +661,8 @@ public class DataTestUtils {
             int batchSequence,
             List<ChangeType> changeTypes,
             List<InternalRow> rows,
-            ArrowCompressionInfo arrowCompressionInfo)
+            ArrowCompressionInfo arrowCompressionInfo,
+            boolean appendOnly)
             throws Exception {
         try (BufferAllocator allocator = new RootAllocator(Integer.MAX_VALUE);
                 ArrowWriterPool provider = new ArrowWriterPool(allocator)) {
@@ -627,7 +675,8 @@ public class DataTestUtils {
                             magic,
                             schemaId,
                             writer,
-                            new ManagedPagedOutputView(new 
TestingMemorySegmentPool(10 * 1024)));
+                            new ManagedPagedOutputView(new 
TestingMemorySegmentPool(10 * 1024)),
+                            appendOnly);
             for (int i = 0; i < changeTypes.size(); i++) {
                 builder.append(changeTypes.get(i), rows.get(i));
             }

Reply via email to