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

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 5d9f9645 perf(read): support warmup between data files (#286)
5d9f9645 is described below

commit 5d9f9645a659564633a9111e9492021ceaacf046
Author: Yonghao Fang <[email protected]>
AuthorDate: Thu Sep 10 13:59:43 2026 +0800

    perf(read): support warmup between data files (#286)
---
 CMakeLists.txt                                     |   5 +-
 benchmark/CMakeLists.txt                           |  10 +-
 benchmark/parquet_format_benchmark_test.cpp        |  21 +-
 docs/source/api/read.rst                           |   2 +
 include/paimon/read_context.h                      |  21 +-
 include/paimon/reader/file_batch_reader.h          |  20 ++
 include/paimon/utils/prefetch_cache_config.h       |  22 ++
 src/paimon/CMakeLists.txt                          |   1 +
 ...map_shared_shredding_read_plan_factory_test.cpp |  14 +
 .../common/data/shredding/shredding_file_reader.h  |   4 +
 .../bitmap/apply_bitmap_index_batch_reader.h       |   4 +
 .../apply_bitmap_index_batch_reader_test.cpp       |  17 +-
 .../common/reader/data_file_reader_factory.cpp     |  19 +-
 .../common/reader/data_file_reader_factory.h       |   3 +
 .../reader/data_file_reader_factory_test.cpp       |  80 ++++++
 .../common/reader/delegating_prefetch_reader.h     |   9 +
 .../late_materializing_file_batch_reader_test.cpp  |   6 +-
 .../reader/prefetch_file_batch_reader_impl.cpp     |  98 +++++--
 .../reader/prefetch_file_batch_reader_impl.h       |  22 +-
 .../prefetch_file_batch_reader_impl_test.cpp       | 293 ++++++++++++++++++---
 src/paimon/common/utils/arrow/status_utils.h       |  12 +-
 .../apply_deletion_vector_batch_reader.h           |   4 +
 .../apply_deletion_vector_batch_reader_test.cpp    |  17 +-
 .../core/io/complete_row_tracking_fields_reader.h  |   4 +
 .../complete_row_tracking_fields_reader_test.cpp   |  14 +
 .../core/io/concat_key_value_record_reader.h       |  30 +++
 .../io/concat_key_value_record_reader_test.cpp     | 142 ++++++++++
 src/paimon/core/io/field_mapping_reader.h          |   6 +
 src/paimon/core/io/field_mapping_reader_test.cpp   |  23 ++
 .../core/io/key_value_data_file_record_reader.cpp  |   9 +
 .../core/io/key_value_data_file_record_reader.h    |   2 +
 .../io/key_value_data_file_record_reader_test.cpp  |  15 ++
 .../core/io/key_value_in_memory_record_reader.h    |   3 +
 src/paimon/core/io/key_value_record_reader.h       |  11 +
 .../core/io/merged_key_value_record_reader.h       |   4 +
 src/paimon/core/io/vector_file_batch_reader.h      |   4 +
 .../core/io/vector_file_batch_reader_test.cpp      |  12 +
 src/paimon/core/mergetree/compact/loser_tree.cpp   |  15 ++
 src/paimon/core/mergetree/compact/loser_tree.h     |   4 +
 .../mergetree/compact/sort_merge_reader_test.cpp   |  70 +++++
 .../core/mergetree/merge_tree_writer_test.cpp      |   4 +
 src/paimon/core/mergetree/spill_reader.h           |   2 +
 src/paimon/core/operation/abstract_split_read.cpp  |   1 +
 src/paimon/core/operation/internal_read_context.h  |   4 +
 .../core/operation/merge_file_split_read.cpp       |   4 +
 src/paimon/core/operation/read_context.cpp         |  14 +-
 src/paimon/core/operation/read_context_test.cpp    |  21 ++
 src/paimon/core/table/format/format_table_read.cpp |   1 +
 .../core/table/system/audit_log_system_table.cpp   |  40 +--
 .../core/table/system/audit_log_system_table.h     |   8 +
 .../table/system/read_optimized_system_table.cpp   |  11 +-
 .../table/system/read_optimized_system_table.h     |   8 +
 src/paimon/core/table/system/system_table_test.cpp |  44 ++++
 src/paimon/testing/mock/mock_file_batch_reader.h   |  11 +
 54 files changed, 1124 insertions(+), 121 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 2fe2df8e..f7f6adb4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -569,7 +569,10 @@ install(EXPORT PaimonTargets
         NAMESPACE Paimon::
         DESTINATION ${PAIMON_CMAKE_INSTALL_DIR})
 
-if(PAIMON_BUILD_BENCHMARKS)
+# benchmark/ also holds gtest targets that guard the assumptions the 
benchmarks are built on, and
+# those have to be built whenever tests are. The subdirectory gates each 
target on the option it
+# belongs to, so a tests-only build adds no benchmark executable.
+if(PAIMON_BUILD_TESTS OR PAIMON_BUILD_BENCHMARKS)
     add_subdirectory(benchmark)
 endif()
 
diff --git a/benchmark/CMakeLists.txt b/benchmark/CMakeLists.txt
index 0d4dc28c..60ac3fb3 100644
--- a/benchmark/CMakeLists.txt
+++ b/benchmark/CMakeLists.txt
@@ -89,9 +89,9 @@ if(PAIMON_BUILD_TESTS)
                     paimon_shared
                     ${GTEST_LINK_TOOLCHAIN})
 
-    # Guards the format-layer assumptions parquet_format_benchmark.cpp is 
built on. The benchmark
-    # itself is only compiled under PAIMON_BUILD_BENCHMARKS, which CI does not 
set, so this test is
-    # what keeps those assumptions covered.
+    # Guards the format-layer assumptions parquet_format_benchmark.cpp is 
built on. Links the same
+    # libraries as the benchmark itself: CoreOptions resolves manifest.format, 
whose default is avro,
+    # so a binary that builds CoreOptions needs every enabled format factory 
registered.
     add_paimon_test(parquet_format_benchmark_test
                     SOURCES
                     parquet_format_benchmark_test.cpp
@@ -100,9 +100,7 @@ if(PAIMON_BUILD_TESTS)
                     STATIC_LINK_LIBS
                     arrow
                     parquet
-                    paimon_shared
-                    ${PAIMON_LOCAL_FILE_SYSTEM_SHARED_LINK_LIBS}
-                    ${PAIMON_PARQUET_FILE_FORMAT_STATIC_LINK_LIBS}
+                    ${PAIMON_BENCHMARK_STATIC_LINK_LIBS}
                     test_utils_static
                     ${GTEST_LINK_TOOLCHAIN})
 endif()
diff --git a/benchmark/parquet_format_benchmark_test.cpp 
b/benchmark/parquet_format_benchmark_test.cpp
index 0bf8f4a7..813c9b70 100644
--- a/benchmark/parquet_format_benchmark_test.cpp
+++ b/benchmark/parquet_format_benchmark_test.cpp
@@ -19,8 +19,8 @@
 
 // Smoke test for the assumptions parquet_format_benchmark.cpp is built on.
 //
-// The benchmark is only compiled under PAIMON_BUILD_BENCHMARKS, which CI does 
not set, so nothing
-// there runs in CI. Every assumption it makes about the format layer - that a 
codec name is
+// The benchmark itself is only compiled under PAIMON_BUILD_BENCHMARKS, which 
CI does not set, so no
+// benchmark runs there. Every assumption it makes about the format layer - 
that a codec name is
 // accepted, that a dictionary-encoded input array can be written, that a 
nested or high-precision
 // column survives a round trip, that a predicate and a selection bitmap 
return the rows the
 // benchmark asserts on, that the reader metrics it reports exist - is checked 
here instead, at a
@@ -42,6 +42,7 @@
 #include "arrow/c/helpers.h"
 #include "gtest/gtest.h"
 #include "paimon/common/utils/arrow/arrow_input_stream_adapter.h"
+#include "paimon/common/utils/arrow/arrow_utils.h"
 #include "paimon/common/utils/arrow/mem_utils.h"
 #include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/path_util.h"
@@ -62,6 +63,7 @@
 #include "paimon/status.h"
 #include "paimon/testing/utils/testharness.h"
 #include "paimon/utils/roaring_bitmap32.h"
+#include "parquet/types.h"
 
 namespace paimon::parquet {
 namespace {
@@ -281,7 +283,7 @@ TEST_F(ParquetFormatBenchmarkTest, RegisteredCodecsWrite) {
     ASSERT_TRUE(builder.Finish(&ids).ok());
     ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::Array> batch, Wrap(schema, 
{ids}));
 
-    for (const std::string& codec :
+    for (const std::string codec :
          {"none", "snappy", "gzip", "brotli", "zstd", "lz4_raw", 
"lz4_hadoop"}) {
         const std::string path = PathOf("codec_" + codec + ".parquet");
         ASSERT_OK(Write(path, schema, batch, codec)) << "codec " << codec;
@@ -291,9 +293,14 @@ TEST_F(ParquetFormatBenchmarkTest, RegisteredCodecsWrite) {
         EXPECT_TRUE(result.data->Equals(*batch)) << "codec " << codec;
     }
 
-    // The name the benchmark deliberately does not register still has to be 
rejected; if arrow
-    // ever starts accepting it, the comment explaining its absence is stale.
-    EXPECT_FALSE(Write(PathOf("codec_lz4.parquet"), schema, batch, 
"lz4").ok());
+    // The name the benchmark deliberately does not register still has to be 
one Parquet rejects; if
+    // arrow ever starts accepting it, the comment explaining its absence is 
stale. Checked at the
+    // name-resolution layer, not by writing a file: an actual LZ4_FRAME write 
reaches parquet's
+    // Thrift conversion, whose default branch is a DCHECK, so it aborts a 
Debug build instead of
+    // returning an error.
+    ASSERT_OK_AND_ASSIGN(arrow::Compression::type lz4, 
ArrowUtils::GetCompressionType("lz4"));
+    EXPECT_EQ(arrow::Compression::LZ4_FRAME, lz4);
+    EXPECT_FALSE(::parquet::IsCodecSupported(lz4));
 }
 
 // A dictionary-encoded input array must reach the writer intact. VARCHAR 
takes arrow's direct
@@ -511,7 +518,7 @@ TEST_F(ParquetFormatBenchmarkTest, NestedRoundTrip) {
 
     // Each nested column also has to be readable on its own, which is what 
the projected nested
     // cases do.
-    for (const std::string& column : {"tags", "attrs"}) {
+    for (const std::string column : {"tags", "attrs"}) {
         std::shared_ptr<arrow::Schema> projected = 
arrow::schema({schema->GetFieldByName(column)});
         ASSERT_OK_AND_ASSIGN(ReadResult projected_result, Read(path, 
projected));
         EXPECT_EQ(kRows, projected_result.rows) << "column " << column;
diff --git a/docs/source/api/read.rst b/docs/source/api/read.rst
index 3c437d7d..ccf21c6a 100644
--- a/docs/source/api/read.rst
+++ b/docs/source/api/read.rst
@@ -32,6 +32,8 @@ Interface
    :members:
    :undoc-members:
 
+.. doxygenenum:: paimon::WarmupLevel
+
 .. doxygenclass:: paimon::ReadContext
    :members:
    :undoc-members:
diff --git a/include/paimon/read_context.h b/include/paimon/read_context.h
index 8d34aab5..25cf7ec5 100644
--- a/include/paimon/read_context.h
+++ b/include/paimon/read_context.h
@@ -62,7 +62,8 @@ class PAIMON_EXPORT ReadContext {
                 const std::shared_ptr<RealtimeContext>& realtime_context,
                 const std::map<std::string, std::string>& options, bool 
read_ahead_cache_enabled,
                 const CacheConfig& cache_config, const std::shared_ptr<Cache>& 
cache,
-                const std::shared_ptr<FormatTable>& format_table);
+                const std::shared_ptr<FormatTable>& format_table, WarmupLevel 
warmup_level);
+
     ~ReadContext();
 
     const std::string& GetPath() const {
@@ -150,6 +151,10 @@ class PAIMON_EXPORT ReadContext {
         return format_table_;
     }
 
+    WarmupLevel GetWarmupLevel() const {
+        return warmup_level_;
+    }
+
     /// Whether a read schema (C ArrowSchema) for nested column pruning was 
provided.
     bool HasReadSchema() const {
         return read_schema_ != nullptr && read_schema_->release != nullptr;
@@ -190,6 +195,7 @@ class PAIMON_EXPORT ReadContext {
     CacheConfig cache_config_;
     std::shared_ptr<Cache> cache_;
     std::shared_ptr<FormatTable> format_table_;
+    WarmupLevel warmup_level_;
     // Owns schema resources and releases ArrowSchema::release in destructor.
     std::unique_ptr<ArrowSchema> read_schema_;
 };
@@ -351,6 +357,19 @@ class PAIMON_EXPORT ReadContextBuilder {
     /// @return Reference to this builder for method chaining.
     ReadContextBuilder& WithCacheConfig(const CacheConfig& config);
 
+    /// Set how far the reader prepares the next file before it is read.
+    ///
+    /// Warmup overlaps remote-storage latency with the read of the current 
file. Higher levels hide
+    /// more latency but use more memory, and may warm files that a query 
never reads (for example
+    /// when a LIMIT stops the scan early).
+    /// @param level The warmup level to use (default: WarmupLevel::DECODED).
+    /// @return Reference to this builder for method chaining.
+    /// @note WarmupLevel::RAW warms the read-ahead cache, so it has no effect 
and behaves like
+    /// WarmupLevel::NONE when the cache is off (see 
SetReadAheadCacheEnabled()).
+    /// WarmupLevel::DECODED does not depend on the cache.
+    /// @see WarmupLevel
+    ReadContextBuilder& SetWarmupLevel(WarmupLevel level);
+
     /// Set the total number of batches to prefetch across all files.
     ///
     /// This controls the memory usage and parallelism of the prefetching 
mechanism.
diff --git a/include/paimon/reader/file_batch_reader.h 
b/include/paimon/reader/file_batch_reader.h
index 87771be3..32f2508c 100644
--- a/include/paimon/reader/file_batch_reader.h
+++ b/include/paimon/reader/file_batch_reader.h
@@ -63,6 +63,26 @@ class PAIMON_EXPORT FileBatchReader : public BatchReader {
 
     /// Get whether or not support read precisely while bitmap pushed down.
     virtual bool SupportPreciseBitmapSelection() const = 0;
+
+    /// Starts whatever background work this reader would otherwise start on 
its first read, so a
+    /// caller that knows this reader is next can pay that startup while still 
consuming the
+    /// previous one.
+    ///
+    /// This is an optional hint and never changes what the reader returns: 
ordering, filtering and
+    /// metrics are the same with or without it. A reader with nothing to 
start, or one that is not
+    /// yet ready to start it, does nothing. Calling it before the reader is 
configured (for example
+    /// before `SetReadSchema()`), or after `Close()`, is such a no-op.
+    ///
+    /// It reports no error on purpose: the caller may warm up a reader it 
never ends up reading,
+    /// and a hint about a file nobody reads must not fail the read in 
progress. An implementation
+    /// that cannot start its work leaves it to be started by the first read, 
which reports the
+    /// failure itself.
+    ///
+    /// What an implementation starts is up to the implementation - a reader 
that prefetches on a
+    /// background thread starts that thread, one that reads synchronously may 
do nothing at all.
+    /// @warning Whatever it starts, the call itself is not thread-safe: make 
it from the same
+    /// thread that reads this reader, and not concurrently with any other 
call on it.
+    virtual void Warmup() {}
 };
 
 }  // namespace paimon
diff --git a/include/paimon/utils/prefetch_cache_config.h 
b/include/paimon/utils/prefetch_cache_config.h
index bd1197a6..9f20fbf2 100644
--- a/include/paimon/utils/prefetch_cache_config.h
+++ b/include/paimon/utils/prefetch_cache_config.h
@@ -114,4 +114,26 @@ class PAIMON_EXPORT CacheConfig {
     uint64_t block_cache_limit_ = 1024 * 1024;
 };
 
+/// Controls how far a reader prepares the next file before that file is 
actually read.
+///
+/// Warmup overlaps remote-storage latency with the read of the current file. 
Each level takes the
+/// next file one step further along the read pipeline: `RAW` covers the 
remote fetch, `DECODED`
+/// adds the decode on top of it. Higher levels hide more latency, but commit 
more memory and
+/// background I/O to files that a query may end up never reading (for example 
when a LIMIT stops
+/// the scan early). Callers can trade latency against memory by picking a 
level.
+enum class PAIMON_EXPORT WarmupLevel {
+    /// Do not warm up. The next file's I/O starts only when it is actually 
read. This is the
+    /// behavior from before warmup existed and uses no extra memory or 
background threads.
+    NONE,
+    /// Fetch only the next file's raw, still-compressed bytes into memory, 
and leave the decoder
+    /// alone. Overlaps the remote fetch while keeping memory lower than 
`DECODED`, because no
+    /// decoded batches are materialized ahead of the read. It fetches through 
the read-ahead
+    /// cache, so it falls back to `NONE` when that cache is disabled.
+    RAW,
+    /// Fetch the raw bytes and start the background decode loop as well, so 
decoded batches are
+    /// ready before the file is read. Hides the most latency but uses the 
most memory. This is the
+    /// default.
+    DECODED,
+};
+
 }  // namespace paimon
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index 0402cc23..cb083e99 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -636,6 +636,7 @@ if(PAIMON_BUILD_TESTS)
                     common/predicate/predicate_utils_test.cpp
                     common/predicate/predicate_validator_test.cpp
                     common/reader/concat_batch_reader_test.cpp
+                    common/reader/data_file_reader_factory_test.cpp
                     common/reader/late_materializing_file_batch_reader_test.cpp
                     common/reader/predicate_batch_reader_test.cpp
                     common/reader/prefetch_file_batch_reader_impl_test.cpp
diff --git 
a/src/paimon/common/data/shredding/map_shared_shredding_read_plan_factory_test.cpp
 
b/src/paimon/common/data/shredding/map_shared_shredding_read_plan_factory_test.cpp
index 1763d6a2..fe15921c 100644
--- 
a/src/paimon/common/data/shredding/map_shared_shredding_read_plan_factory_test.cpp
+++ 
b/src/paimon/common/data/shredding/map_shared_shredding_read_plan_factory_test.cpp
@@ -867,4 +867,18 @@ TEST_F(MapSharedShreddingReadPlanFactoryTest, 
TestReadsRealFormatFile) {
     AssertChunkedArrayEquals(expected, actual);
 }
 
+TEST(ShreddingFileReaderTest, WarmupForwardsToInnerReader) {
+    auto file_type = arrow::struct_({arrow::field("id", arrow::int32())});
+    auto mock_reader =
+        std::make_unique<MockFileBatchReader>(/*data=*/nullptr, file_type, 
/*read_batch_size=*/1);
+    auto* inner_reader = mock_reader.get();
+    auto reader = std::make_unique<ShreddingFileReader>(
+        std::move(mock_reader), std::map<std::string, 
std::shared_ptr<ShreddingColumnReadPlan>>(),
+        GetArrowPool(GetDefaultPool()));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader->Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/common/data/shredding/shredding_file_reader.h 
b/src/paimon/common/data/shredding/shredding_file_reader.h
index d3584c7d..503fbf18 100644
--- a/src/paimon/common/data/shredding/shredding_file_reader.h
+++ b/src/paimon/common/data/shredding/shredding_file_reader.h
@@ -59,6 +59,10 @@ class ShreddingFileReader : public FileBatchReader {
 
     bool SupportPreciseBitmapSelection() const override;
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     std::shared_ptr<arrow::MemoryPool> arrow_pool_;
     std::unique_ptr<FileBatchReader> reader_;
diff --git 
a/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader.h 
b/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader.h
index 4894ad06..efb159ea 100644
--- a/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader.h
+++ b/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader.h
@@ -94,6 +94,10 @@ class ApplyBitmapIndexBatchReader : public FileBatchReader {
         return reader_->SupportPreciseBitmapSelection();
     }
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     Result<RoaringBitmap32> Filter(int32_t batch_size) const {
         RoaringBitmap32 result;
diff --git 
a/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader_test.cpp 
b/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader_test.cpp
index ef01bbea..85f257ca 100644
--- 
a/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader_test.cpp
+++ 
b/src/paimon/common/file_index/bitmap/apply_bitmap_index_batch_reader_test.cpp
@@ -100,7 +100,8 @@ class ApplyBitmapIndexBatchReaderTest : public 
::testing::Test,
                         /*enable_adaptive_prefetch_strategy=*/false, executor_,
                         /*initialize_read_ranges=*/true,
                         /*read_ahead_cache_enabled=*/true, CacheConfig(),
-                        /*enable_io_metrics=*/false, pool_, 
GetArrowPool(pool_)));
+                        /*enable_io_metrics=*/false, WarmupLevel::DECODED, 
pool_,
+                        GetArrowPool(pool_)));
             } else {
                 file_batch_reader =
                     std::make_unique<MockFileBatchReader>(data, target_type_, 
batch_size);
@@ -193,6 +194,20 @@ TEST_P(ApplyBitmapIndexBatchReaderTest, TestBulkData) {
     std::string result_str = fmt::format("[{}]", fmt::join(bitmap_data, ","));
     CheckResult(data_str, bitmap_data, result_str, 
/*specified_batch_size=*/1024);
 }
+
+TEST(ApplyBitmapIndexBatchReaderWarmupTest, WarmupForwardsToInnerReader) {
+    auto target_type = arrow::struct_({arrow::field("f1", arrow::int32())});
+    auto mock_reader =
+        std::make_unique<MockFileBatchReader>(/*data=*/nullptr, target_type, 
/*batch_size=*/1);
+    auto* inner_reader = mock_reader.get();
+    ApplyBitmapIndexBatchReader reader(std::move(mock_reader),
+                                       RoaringBitmap32::From(/*values=*/{0}));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader.Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 INSTANTIATE_TEST_SUITE_P(EnablePrefetch, ApplyBitmapIndexBatchReaderTest,
                          ::testing::Values(false, true));
 
diff --git a/src/paimon/common/reader/data_file_reader_factory.cpp 
b/src/paimon/common/reader/data_file_reader_factory.cpp
index 4507127f..eeb46958 100644
--- a/src/paimon/common/reader/data_file_reader_factory.cpp
+++ b/src/paimon/common/reader/data_file_reader_factory.cpp
@@ -77,15 +77,16 @@ Result<std::unique_ptr<FileBatchReader>> 
DataFileReaderFactory::Open(
     const std::shared_ptr<FileSystem>& file_system, const 
std::shared_ptr<Executor>& executor,
     const std::shared_ptr<MemoryPool>& pool, const 
std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
     if (read_options.prefetch_enabled && 
FormatSupportsPrefetch(format_identifier)) {
-        PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<PrefetchFileBatchReaderImpl> 
prefetch_reader,
-                               PrefetchFileBatchReaderImpl::Create(
-                                   file_path, file_size, reader_builder, 
file_system,
-                                   read_options.prefetch_max_parallel_num,
-                                   read_options.read_batch_size, 
read_options.prefetch_batch_count,
-                                   read_options.adaptive_prefetch_strategy, 
executor,
-                                   /*initialize_read_ranges=*/false,
-                                   read_options.read_ahead_cache_enabled, 
read_options.cache_config,
-                                   read_options.prefetch_io_metrics_enabled, 
pool, arrow_pool));
+        PAIMON_ASSIGN_OR_RAISE(
+            std::unique_ptr<PrefetchFileBatchReaderImpl> prefetch_reader,
+            PrefetchFileBatchReaderImpl::Create(
+                file_path, file_size, reader_builder, file_system,
+                read_options.prefetch_max_parallel_num, 
read_options.read_batch_size,
+                read_options.prefetch_batch_count, 
read_options.adaptive_prefetch_strategy,
+                executor,
+                /*initialize_read_ranges=*/false, 
read_options.read_ahead_cache_enabled,
+                read_options.cache_config, 
read_options.prefetch_io_metrics_enabled,
+                read_options.warmup_level, pool, arrow_pool));
         return 
std::make_unique<DelegatingPrefetchReader>(std::move(prefetch_reader));
     }
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<InputStream> input_stream,
diff --git a/src/paimon/common/reader/data_file_reader_factory.h 
b/src/paimon/common/reader/data_file_reader_factory.h
index 264ea34b..3be8f660 100644
--- a/src/paimon/common/reader/data_file_reader_factory.h
+++ b/src/paimon/common/reader/data_file_reader_factory.h
@@ -54,6 +54,9 @@ struct DataFileReadOptions {
     CacheConfig cache_config;
     /// Whether the prefetching reader records what its I/O costs.
     bool prefetch_io_metrics_enabled = false;
+    /// How far the prefetching reader prepares a file before it is read. It 
only matters when
+    /// prefetching, because that reader is the one that can start work ahead 
of the read.
+    WarmupLevel warmup_level = WarmupLevel::DECODED;
 };
 
 /// Opens one data file as a `FileBatchReader`.
diff --git a/src/paimon/common/reader/data_file_reader_factory_test.cpp 
b/src/paimon/common/reader/data_file_reader_factory_test.cpp
new file mode 100644
index 00000000..895baad4
--- /dev/null
+++ b/src/paimon/common/reader/data_file_reader_factory_test.cpp
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+#include "paimon/common/reader/data_file_reader_factory.h"
+
+#include <memory>
+#include <utility>
+
+#include "arrow/api.h"
+#include "arrow/array/array_nested.h"
+#include "arrow/ipc/json_simple.h"
+#include "gtest/gtest.h"
+#include "paimon/common/reader/delegating_prefetch_reader.h"
+#include "paimon/common/utils/arrow/mem_utils.h"
+#include "paimon/executor.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/reader/file_batch_reader.h"
+#include "paimon/testing/mock/mock_file_system.h"
+#include "paimon/testing/mock/mock_format_reader_builder.h"
+#include "paimon/testing/utils/testharness.h"
+#include "paimon/utils/prefetch_cache_config.h"
+
+namespace paimon::test {
+
+/// `Open()` is the one place a production read builds the prefetching reader, 
and a level that is
+/// not forwarded here fails nothing: the reader warms up however its own 
default says, and the only
+/// symptom is latency and memory a query never asked for.
+TEST(DataFileReaderFactoryTest, OpenForwardsWarmupLevelToPrefetchReader) {
+    std::shared_ptr<MemoryPool> pool = GetDefaultPool();
+    auto mock_fs = std::make_shared<MockFileSystem>();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Executor> executor,
+                         CreateDefaultExecutor(/*thread_count=*/1));
+
+    std::shared_ptr<arrow::DataType> data_type =
+        arrow::struct_({arrow::field("f1", arrow::int32())});
+    std::shared_ptr<arrow::Array> data =
+        arrow::StructArray::Make(
+            {arrow::ipc::internal::json::ArrayFromJSON(arrow::int32(), "[1, 2, 
3]").ValueOrDie()},
+            data_type->fields())
+            .ValueOrDie();
+
+    for (WarmupLevel level : {WarmupLevel::NONE, WarmupLevel::RAW, 
WarmupLevel::DECODED}) {
+        MockFormatReaderBuilder reader_builder(data, data_type, 
/*read_batch_size=*/10);
+        DataFileReadOptions read_options;
+        read_options.read_batch_size = 10;
+        read_options.prefetch_enabled = true;
+        read_options.prefetch_max_parallel_num = 1;
+        read_options.prefetch_batch_count = 2;
+        // No file backs the mock file system, so keep the read-ahead cache 
out of the read.
+        read_options.read_ahead_cache_enabled = false;
+        read_options.warmup_level = level;
+
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader,
+                             DataFileReaderFactory::Open(
+                                 "parquet", /*file_path=*/"DUMMY", 
/*file_size=*/0, &reader_builder,
+                                 read_options, mock_fs, executor, pool, 
GetArrowPool(pool)));
+
+        // `Open()` hands back the delegating wrapper. The level has to have 
arrived on the
+        // prefetching reader underneath it, because that reader's Warmup() is 
what it governs.
+        auto* delegating_reader = 
dynamic_cast<DelegatingPrefetchReader*>(reader.get());
+        ASSERT_NE(delegating_reader, nullptr);
+        EXPECT_EQ(level, delegating_reader->prefetch_reader_->warmup_level_);
+    }
+}
+
+}  // namespace paimon::test
diff --git a/src/paimon/common/reader/delegating_prefetch_reader.h 
b/src/paimon/common/reader/delegating_prefetch_reader.h
index 78396e20..b91c0418 100644
--- a/src/paimon/common/reader/delegating_prefetch_reader.h
+++ b/src/paimon/common/reader/delegating_prefetch_reader.h
@@ -73,6 +73,15 @@ class DelegatingPrefetchReader : public FileBatchReader {
         return GetReader()->SupportPreciseBitmapSelection();
     }
 
+    void Warmup() override {
+        // GetReader() bypasses the prefetch layer entirely when prefetching 
is off, and a
+        // bypassed reader has no background thread to start.
+        if (!prefetch_reader_->NeedPrefetch()) {
+            return;
+        }
+        prefetch_reader_->Warmup();
+    }
+
  private:
     inline FileBatchReader* GetReader() const {
         assert(prefetch_reader_);
diff --git 
a/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp 
b/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
index c86635f5..4346eeed 100644
--- a/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
+++ b/src/paimon/common/reader/late_materializing_file_batch_reader_test.cpp
@@ -549,7 +549,7 @@ TEST_F(LateMaterializingFileBatchReaderTest, 
WorksAsInnerOfPrefetchReader) {
             /*prefetch_max_parallel_num=*/1, /*batch_size=*/3, 
/*prefetch_batch_count=*/2,
             /*enable_adaptive_prefetch_strategy=*/false, executor,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/false, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     auto predicate =
         PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(4l));
     ::ArrowSchema c_schema;
@@ -585,7 +585,7 @@ TEST_F(LateMaterializingFileBatchReaderTest, 
PrefetchInnerReentrantSetReadSchema
             /*prefetch_max_parallel_num=*/1, /*batch_size=*/3, 
/*prefetch_batch_count=*/2,
             /*enable_adaptive_prefetch_strategy=*/false, executor,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/false, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     auto full_schema = arrow::schema(full_fields_);
     auto predicate1 =
@@ -648,7 +648,7 @@ TEST_F(LateMaterializingFileBatchReaderTest, 
PrefetchInnerParallelReadersWithSee
             /*prefetch_max_parallel_num=*/3, /*batch_size=*/3, 
/*prefetch_batch_count=*/6,
             /*enable_adaptive_prefetch_strategy=*/false, executor,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/false, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     auto predicate =
         PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(5l));
     ::ArrowSchema c_schema;
diff --git a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp 
b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
index 51589ba3..da7ba458 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
@@ -205,7 +205,8 @@ Result<std::unique_ptr<PrefetchFileBatchReaderImpl>> 
PrefetchFileBatchReaderImpl
     uint32_t prefetch_batch_count, bool enable_adaptive_prefetch_strategy,
     const std::shared_ptr<Executor>& executor, bool initialize_read_ranges,
     bool read_ahead_cache_enabled, const CacheConfig& cache_config, bool 
enable_io_metrics,
-    const std::shared_ptr<MemoryPool>& pool, const 
std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
+    WarmupLevel warmup_level, const std::shared_ptr<MemoryPool>& pool,
+    const std::shared_ptr<arrow::MemoryPool>& arrow_pool) {
     if (prefetch_max_parallel_num == 0) {
         return Status::Invalid("prefetch max parallel num should be greater 
than 0.");
     }
@@ -283,7 +284,7 @@ Result<std::unique_ptr<PrefetchFileBatchReaderImpl>> 
PrefetchFileBatchReaderImpl
 
     auto reader = std::unique_ptr<PrefetchFileBatchReaderImpl>(new 
PrefetchFileBatchReaderImpl(
         readers, batch_size, prefetch_queue_capacity, 
enable_adaptive_prefetch_strategy, executor,
-        cache, io_metrics, arrow_pool));
+        cache, io_metrics, warmup_level, arrow_pool));
     if (initialize_read_ranges) {
         // normally initialize read ranges should be false, as set read schema 
will refresh read
         // ranges, and set read schema will always be called before read.
@@ -296,7 +297,7 @@ PrefetchFileBatchReaderImpl::PrefetchFileBatchReaderImpl(
     const std::vector<std::shared_ptr<PrefetchFileBatchReader>>& readers, 
int32_t batch_size,
     uint32_t prefetch_queue_capacity, bool enable_adaptive_prefetch_strategy,
     const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<ReadAheadCache>& cache,
-    const std::shared_ptr<PrefetchIoMetricsState>& io_metrics,
+    const std::shared_ptr<PrefetchIoMetricsState>& io_metrics, WarmupLevel 
warmup_level,
     const std::shared_ptr<arrow::MemoryPool>& arrow_pool)
     : readers_(std::move(readers)),
       batch_size_(batch_size),
@@ -305,6 +306,7 @@ PrefetchFileBatchReaderImpl::PrefetchFileBatchReaderImpl(
       arrow_pool_(arrow_pool),
       prefetch_queue_capacity_(prefetch_queue_capacity),
       enable_adaptive_prefetch_strategy_(enable_adaptive_prefetch_strategy),
+      warmup_level_(warmup_level),
       prefetch_metrics_(std::make_shared<PrefetchMetricsState>()),
       io_metrics_(io_metrics) {
     for (size_t i = 0; i < readers_.size(); i++) {
@@ -470,6 +472,9 @@ Status PrefetchFileBatchReaderImpl::CleanUp() {
     read_ranges_in_group_.clear();
     current_batch_global_row_ids_.clear();
     read_ranges_freshed_ = false;
+    // SetReadSchema()/RefreshReadRanges() call cache_->Reset() right after 
CleanUp(), clearing the
+    // cache's initialized state, so the next read-range generation must be 
allowed to Init again.
+    cache_warmed_.store(false);
     clean_prefetch_queue();
     prefetch_metrics_->queue_depth.store(0, kMetricsMemoryOrder);
     for (size_t i = 0; i < readers_pos_.size(); i++) {
@@ -484,26 +489,10 @@ Status PrefetchFileBatchReaderImpl::CleanUp() {
 void PrefetchFileBatchReaderImpl::Workloop() {
     std::vector<std::future<void>> futures;
     futures.resize(readers_.size());
-    if (cache_) {
-        auto read_ranges = readers_[0]->PreBufferRange();
-        if (read_ranges.ok()) {
-            std::vector<ByteRange> ranges;
-            for (const auto& read_range : read_ranges.value()) {
-                ranges.emplace_back(read_range.first, read_range.second);
-            }
-            auto s = cache_->Init(std::move(ranges));
-            if (!s.ok()) {
-                SetReadStatus(s);
-            } else {
-                // Init() only registers the ranges, so without this the first
-                // cache fetch races the readers' first reads instead of 
running
-                // ahead of them.
-                cache_->Warmup();
-            }
-        } else {
-            SetReadStatus(read_ranges.status());
-        }
-    }
+    // Warm the read-ahead cache before decoding. At DECODED this is the first 
warmup; at RAW
+    // Warmup() already ran it on the caller's thread and the guard makes this 
a no-op, so the
+    // cache is never Init'd twice.
+    WarmCacheOnce();
 
     while (true) {
         if (!GetReadStatus().ok()) {
@@ -729,14 +718,69 @@ Status PrefetchFileBatchReaderImpl::DoReadBatch(size_t 
reader_idx) {
     return HandleReadResult(reader_idx, read_range, 
std::move(read_batch_with_bitmap));
 }
 
-Result<BatchReader::ReadBatchWithBitmap> 
PrefetchFileBatchReaderImpl::NextBatchWithBitmap() {
-    if (!read_ranges_freshed_) {
-        return Status::Invalid("prefetch reader read ranges are not 
initialized");
-    }
+void PrefetchFileBatchReaderImpl::EnsureBackgroundThread() {
     if (!background_thread_) {
         background_thread_ =
             
std::make_unique<std::thread>(&PrefetchFileBatchReaderImpl::Workloop, this);
     }
+}
+
+void PrefetchFileBatchReaderImpl::WarmCacheOnce() {
+    if (!cache_) {
+        return;
+    }
+    // Init() is not idempotent (a second call returns Invalid), so at most 
one warmup may run per
+    // read-range generation. A RAW Warmup() and the Workloop() call are 
ordered by the reader's own
+    // thread, which warms first and only then starts the background thread, 
so the loser of this
+    // exchange never continues on a cache another thread is still 
initializing.
+    if (cache_warmed_.exchange(true)) {
+        return;
+    }
+    auto read_ranges = readers_[0]->PreBufferRange();
+    if (!read_ranges.ok()) {
+        SetReadStatus(read_ranges.status());
+        return;
+    }
+    std::vector<ByteRange> ranges;
+    for (const auto& read_range : read_ranges.value()) {
+        ranges.emplace_back(read_range.first, read_range.second);
+    }
+    Status s = cache_->Init(std::move(ranges));
+    if (!s.ok()) {
+        SetReadStatus(s);
+        return;
+    }
+    // Init() only registers the ranges, so without this the first cache fetch 
races the readers'
+    // first reads instead of running ahead of them.
+    cache_->Warmup();
+}
+
+void PrefetchFileBatchReaderImpl::Warmup() {
+    if (warmup_level_ == WarmupLevel::NONE) {
+        return;
+    }
+    // Ranges that are not set mean the reader is either not configured yet or 
already cleaned up,
+    // and CleanUp() leaves no background thread behind, so there is nothing 
to warm up in either
+    // state. NextBatchWithBitmap still rejects the former, so a genuinely 
unprepared read is
+    // reported there rather than swallowed here.
+    if (!read_ranges_freshed_) {
+        return;
+    }
+    if (warmup_level_ == WarmupLevel::RAW) {
+        // Prefetch the raw bytes only, without starting the decoder. The 
first real read starts the
+        // background thread, whose Workloop() finds the cache already warmed 
and skips re-Init.
+        WarmCacheOnce();
+        return;
+    }
+    // DECODED: start the background decode loop now so decoded batches are 
ready before the read.
+    EnsureBackgroundThread();
+}
+
+Result<BatchReader::ReadBatchWithBitmap> 
PrefetchFileBatchReaderImpl::NextBatchWithBitmap() {
+    if (!read_ranges_freshed_) {
+        return Status::Invalid("prefetch reader read ranges are not 
initialized");
+    }
+    EnsureBackgroundThread();
 
     const auto wait_start = std::chrono::steady_clock::now();
     while (true) {
diff --git a/src/paimon/common/reader/prefetch_file_batch_reader_impl.h 
b/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
index 37bf2f89..b0b0c6f7 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
@@ -68,7 +68,7 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
         uint32_t prefetch_max_parallel_num, int32_t batch_size, uint32_t 
prefetch_batch_count,
         bool enable_adaptive_prefetch_strategy, const 
std::shared_ptr<Executor>& executor,
         bool initialize_read_ranges, bool read_ahead_cache_enabled, const 
CacheConfig& cache_config,
-        bool enable_io_metrics, const std::shared_ptr<MemoryPool>& pool,
+        bool enable_io_metrics, WarmupLevel warmup_level, const 
std::shared_ptr<MemoryPool>& pool,
         const std::shared_ptr<arrow::MemoryPool>& arrow_pool);
 
     ~PrefetchFileBatchReaderImpl() override;
@@ -103,6 +103,8 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
 
     Status RefreshReadRanges();
 
+    void Warmup() override;
+
     inline PrefetchFileBatchReader* GetFirstReader() const {
         return readers_[0].get();
     }
@@ -122,11 +124,23 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
         const std::vector<std::shared_ptr<PrefetchFileBatchReader>>& readers, 
int32_t batch_size,
         uint32_t prefetch_queue_capacity, bool 
enable_adaptive_prefetch_strategy,
         const std::shared_ptr<Executor>& executor, const 
std::shared_ptr<ReadAheadCache>& cache,
-        const std::shared_ptr<PrefetchIoMetricsState>& io_metrics,
+        const std::shared_ptr<PrefetchIoMetricsState>& io_metrics, WarmupLevel 
warmup_level,
         const std::shared_ptr<arrow::MemoryPool>& arrow_pool);
 
     Status CleanUp();
     void Workloop();
+    /// Starts the background prefetch thread if it is not running yet. The 
first read does this
+    /// itself; Warmup() is the same call made earlier, so the two must not 
diverge.
+    void EnsureBackgroundThread();
+    /// Initializes and warms the read-ahead cache at most once per read-range 
generation, using the
+    /// first reader's PreBufferRange(). Shared by Workloop() (DECODED) and 
Warmup() (RAW), so the
+    /// two never Init the cache twice. The flag only elects the caller that 
initializes, it does
+    /// not make a second caller wait: the two calls are ordered rather than 
concurrent, because a
+    /// RAW warmup runs on the reader's own thread before that same thread 
starts the background
+    /// thread that runs Workloop(). A no-op when there is no cache. Errors 
are recorded via
+    /// SetReadStatus() rather than returned: a warmup hint for a file that 
may never be read must
+    /// not fail an in-flight read.
+    void WarmCacheOnce();
     void SetReadStatus(const Status& status);
     Status GetReadStatus() const;
     Result<bool> IsEofRange(const std::pair<uint64_t, uint64_t>& read_range) 
const;
@@ -179,8 +193,12 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
     std::vector<uint64_t> current_batch_global_row_ids_;
     bool need_prefetch_ = false;
     bool read_ranges_freshed_ = false;
+    // Guards the one-shot read-ahead cache Init/Warmup so it runs at most 
once per read-range
+    // generation. Reset by CleanUp() alongside read_ranges_freshed_.
+    std::atomic<bool> cache_warmed_{false};
     const uint32_t prefetch_queue_capacity_;
     const bool enable_adaptive_prefetch_strategy_;
+    const WarmupLevel warmup_level_;
     int32_t parallel_num_;
     std::shared_ptr<PrefetchMetricsState> prefetch_metrics_;
     std::shared_ptr<PrefetchIoMetricsState> io_metrics_;
diff --git a/src/paimon/common/reader/prefetch_file_batch_reader_impl_test.cpp 
b/src/paimon/common/reader/prefetch_file_batch_reader_impl_test.cpp
index 5e0c40d4..27375241 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl_test.cpp
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl_test.cpp
@@ -25,6 +25,7 @@
 #include "arrow/compute/api.h"
 #include "arrow/ipc/api.h"
 #include "gtest/gtest.h"
+#include "paimon/common/reader/delegating_prefetch_reader.h"
 #include "paimon/common/utils/arrow/mem_utils.h"
 #include "paimon/common/utils/checked_cast.h"
 #include "paimon/common/utils/path_util.h"
@@ -253,7 +254,8 @@ class PrefetchFileBatchReaderImplTest : public 
::testing::Test,
         const std::string& file_format_str, const arrow::Schema* read_schema,
         const std::shared_ptr<Predicate>& predicate,
         const std::optional<RoaringBitmap32>& selection_bitmap, int32_t 
batch_size,
-        int32_t prefetch_max_parallel_num, bool read_ahead_cache_enabled) 
const {
+        int32_t prefetch_max_parallel_num, bool read_ahead_cache_enabled,
+        WarmupLevel warmup_level) const {
         EXPECT_OK_AND_ASSIGN(std::unique_ptr<FileFormat> file_format,
                              FileFormatFactory::Get(file_format_str, {}));
         EXPECT_OK_AND_ASSIGN(auto reader_builder, 
file_format->CreateReaderBuilder(batch_size));
@@ -269,7 +271,7 @@ class PrefetchFileBatchReaderImplTest : public 
::testing::Test,
                 prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
                 /*enable_adaptive_prefetch_strategy=*/false, executor,
                 /*initialize_read_ranges=*/false, read_ahead_cache_enabled, 
CacheConfig(),
-                /*enable_io_metrics=*/true, pool_, GetArrowPool(pool_)));
+                /*enable_io_metrics=*/true, warmup_level, pool_, 
GetArrowPool(pool_)));
         std::unique_ptr<ArrowSchema> c_schema = 
std::make_unique<ArrowSchema>();
         auto arrow_status = arrow::ExportSchema(*read_schema, c_schema.get());
         EXPECT_TRUE(arrow_status.ok());
@@ -335,6 +337,17 @@ CollectResultAndRowIds(FileBatchReader* reader) {
     return std::make_pair(result_array, row_ids);
 }
 
+/// Whether Warmup() started the background decode loop.
+///
+/// The levels differ only in what Warmup() itself starts, and metrics cannot 
show that: the first
+/// NextBatch starts the same loop lazily, so once a read is under way every 
level reports produced
+/// batches, while right after Warmup() a DECODED warmup may not have finished 
its first batch yet.
+/// Whether the thread exists is settled the moment Warmup() returns, so it 
separates the levels
+/// without racing the background work.
+bool DecodeThreadStarted(const PrefetchFileBatchReaderImpl* reader) {
+    return reader->background_thread_ != nullptr;
+}
+
 std::vector<TestParam> PrepareTestParam() {
     std::vector<TestParam> values = {TestParam{"parquet", 
/*read_ahead_cache_enabled=*/true},
                                      TestParam{"parquet", 
/*read_ahead_cache_enabled=*/false}};
@@ -360,7 +373,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestSimple) {
                 prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
                 /*enable_adaptive_prefetch_strategy=*/false, executor_,
                 /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-                /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+                /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
         if (prefetch_max_parallel_num == 1) {
             ASSERT_NOK(
                 
reader->GetReaderMetrics()->GetCounter(PrefetchIoMetrics::READ_LATENCY_COUNT));
@@ -388,7 +401,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestReadWithLimits) 
{
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/true, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/true, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     // simulate read limits, only read 8 batches
     for (int32_t i = 0; i < 8; i++) {
         ASSERT_OK_AND_ASSIGN(BatchReader::ReadBatchWithBitmap 
batch_with_bitmap,
@@ -442,7 +455,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestReadWithoutInitializeReadRanges) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     // simulate read limits, only read 8 batches
     ASSERT_NOK_WITH_MSG(reader->NextBatchWithBitmap(),
                         "prefetch reader read ranges are not initialized");
@@ -460,7 +473,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestFailedIoMetrics) {
             /*prefetch_max_parallel_num=*/1, /*batch_size=*/10,
             /*prefetch_batch_count=*/2, 
/*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/false, CacheConfig(),
-            /*enable_io_metrics=*/true, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/true, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     ASSERT_NOK_WITH_MSG(reader->NextBatchWithBitmap(), "injected synchronous 
read failure");
     std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
@@ -549,7 +562,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, RefreshReadRanges) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     ASSERT_OK(prefetch_reader->RefreshReadRanges());
     std::vector<std::pair<uint64_t, uint64_t>> read_ranges_0 = {{0, 30}, {90, 
101}};
@@ -580,7 +593,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
RefreshReadRangesDisablePrefetchByAdapti
             /*prefetch_batch_count=*/2,
             /*enable_adaptive_prefetch_strategy=*/true, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     ASSERT_FALSE(reader->NeedPrefetch());
     std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
@@ -593,6 +606,69 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
RefreshReadRangesDisablePrefetchByAdapti
     ASSERT_EQ(adaptive_disabled_count, 1);
 }
 
+// DelegatingPrefetchReader is the layer that decides whether a read goes 
through the prefetch
+// machinery or straight to the first inner reader, so its Warmup() has to 
follow the same decision.
+// With prefetching on it must forward, otherwise a DECODED warmup silently 
degrades to a cold
+// read.
+TEST_F(PrefetchFileBatchReaderImplTest, 
DelegatingPrefetchReaderWarmupForwardsWhenPrefetchEnabled) {
+    auto data_array = PrepareArray(100);
+    int32_t batch_size = 10;
+    int32_t prefetch_max_parallel_num = 1;
+    MockFormatReaderBuilder reader_builder(data_array, data_type_, batch_size);
+    ASSERT_OK_AND_ASSIGN(
+        auto reader,
+        PrefetchFileBatchReaderImpl::Create(
+            /*data_file_path=*/"", /*data_file_size=*/0, &reader_builder, 
mock_fs_,
+            prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
+            /*enable_adaptive_prefetch_strategy=*/false, executor_,
+            /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
+    auto* prefetch_reader = reader.get();
+    ASSERT_TRUE(prefetch_reader->NeedPrefetch());
+
+    DelegatingPrefetchReader delegating_reader(std::move(reader));
+    delegating_reader.Warmup();
+    ASSERT_TRUE(DecodeThreadStarted(prefetch_reader));
+
+    ASSERT_OK_AND_ASSIGN(auto array, 
ReadResultCollector::CollectResult(&delegating_reader));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array));
+}
+
+// With prefetching off the delegating layer hands the consumer readers_[0] 
directly, so forwarding
+// the warmup would start a Workloop that decodes readers_[0] on a background 
thread while the
+// consumer reads that same reader on its own thread. Warmup() must stay a 
no-op in that state and
+// the bypassed read must still return every row.
+TEST_F(PrefetchFileBatchReaderImplTest, 
DelegatingPrefetchReaderWarmupSkippedWhenPrefetchBypassed) {
+    auto data_array = PrepareArray(200);
+    int32_t batch_size = 10;
+    int32_t prefetch_max_parallel_num = 1;
+    ControlledMockFormatReaderBuilder reader_builder(data_array, data_type_, 
batch_size,
+                                                     /*read_ranges=*/{{0, 
100}},
+                                                     /*need_prefetch=*/true,
+                                                     
/*set_read_ranges_statuses=*/{});
+    ASSERT_OK_AND_ASSIGN(
+        auto reader,
+        PrefetchFileBatchReaderImpl::Create(
+            /*data_file_path=*/"", /*data_file_size=*/0, &reader_builder, 
mock_fs_,
+            prefetch_max_parallel_num, batch_size,
+            /*prefetch_batch_count=*/2,
+            /*enable_adaptive_prefetch_strategy=*/true, executor_,
+            /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
+    auto* prefetch_reader = reader.get();
+    ASSERT_FALSE(prefetch_reader->NeedPrefetch());
+
+    DelegatingPrefetchReader delegating_reader(std::move(reader));
+    delegating_reader.Warmup();
+    ASSERT_FALSE(DecodeThreadStarted(prefetch_reader));
+
+    ASSERT_OK_AND_ASSIGN(auto array, 
ReadResultCollector::CollectResult(&delegating_reader));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array));
+    ASSERT_FALSE(DecodeThreadStarted(prefetch_reader));
+}
+
 TEST_F(PrefetchFileBatchReaderImplTest, SetReadRanges) {
     auto data_array = PrepareArray(400);
     int32_t batch_size = 30;
@@ -605,7 +681,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, SetReadRanges) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     ASSERT_FALSE(prefetch_reader->need_prefetch_);
     prefetch_reader->need_prefetch_ = true;
@@ -649,7 +725,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
SetReadRangesReturnErrorWhenPushDownFail
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     prefetch_reader->need_prefetch_ = true;
@@ -670,13 +746,13 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
WorkloopSetReadStatusWhenCacheInitFailed
     invalid_cache_config.SetPreBufferLimit(128 * 1024);
 
     ASSERT_OK_AND_ASSIGN(
-        auto reader,
-        PrefetchFileBatchReaderImpl::Create(
-            /*data_file_path=*/"", /*data_file_size=*/0, &reader_builder, 
mock_fs_,
-            prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
-            /*enable_adaptive_prefetch_strategy=*/false, executor_,
-            /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true,
-            invalid_cache_config, /*enable_io_metrics=*/false, pool_, 
GetArrowPool(pool_)));
+        auto reader, PrefetchFileBatchReaderImpl::Create(
+                         /*data_file_path=*/"", /*data_file_size=*/0, 
&reader_builder, mock_fs_,
+                         prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
+                         /*enable_adaptive_prefetch_strategy=*/false, 
executor_,
+                         /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true,
+                         invalid_cache_config, /*enable_io_metrics=*/false, 
WarmupLevel::DECODED,
+                         pool_, GetArrowPool(pool_)));
 
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     prefetch_reader->Workloop();
@@ -697,7 +773,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
DoReadBatchReturnOkWhenShutdown) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     prefetch_reader->is_shutdown_ = true;
@@ -716,7 +792,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
DoReadBatchReturnOkWhenNoCurrentReadRang
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/false, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     prefetch_reader->read_ranges_in_group_ = {{}};
@@ -735,7 +811,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestReadWithLargeBatchSize) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
     ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
     auto row_ids = array_and_row_ids.second;
@@ -756,7 +832,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestPartialReaderSuccessRead) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     for (int32_t i = 0; i < prefetch_max_parallel_num; i++) {
         dynamic_cast<MockFileBatchReader*>(prefetch_reader->readers_[i].get())
@@ -802,7 +878,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestAllReaderFailedWithIOError) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 
     auto prefetch_reader = 
dynamic_cast<PrefetchFileBatchReaderImpl*>(reader.get());
     for (int32_t i = 0; i < prefetch_max_parallel_num; i++) {
@@ -841,7 +917,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestPrefetchWithEmptyData) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
     ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
     auto row_ids = array_and_row_ids.second;
@@ -861,7 +937,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestCallNextBatchAfterReadingEof) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
     ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
     auto row_ids = array_and_row_ids.second;
@@ -905,7 +981,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestCreateReaderWithoutNextBatch) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
 }
 
 TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
@@ -920,7 +996,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
             /*prefetch_max_parallel_num=*/0, batch_size, 2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     }
     {
         ASSERT_NOK(PrefetchFileBatchReaderImpl::Create(
@@ -928,7 +1004,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
             prefetch_max_parallel_num, /*batch_size=*/-1, 
prefetch_max_parallel_num * 2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     }
     {
         ASSERT_NOK_WITH_MSG(
@@ -937,7 +1013,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
                 prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
                 /*enable_adaptive_prefetch_strategy=*/false, executor_,
                 /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-                /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)),
+                /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)),
             "data file size should not be negative");
     }
     {
@@ -946,8 +1022,8 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false,
             /*executor=*/nullptr, /*initialize_read_ranges=*/true,
-            /*read_ahead_cache_enabled=*/true, CacheConfig(), 
/*enable_io_metrics=*/false, pool_,
-            GetArrowPool(pool_)));
+            /*read_ahead_cache_enabled=*/true, CacheConfig(), 
/*enable_io_metrics=*/false,
+            WarmupLevel::DECODED, pool_, GetArrowPool(pool_)));
     }
     {
         ASSERT_NOK(PrefetchFileBatchReaderImpl::Create(
@@ -955,7 +1031,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
             prefetch_max_parallel_num, batch_size, prefetch_max_parallel_num * 
2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     }
     {
         ASSERT_NOK(PrefetchFileBatchReaderImpl::Create(
@@ -963,7 +1039,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
             /*fs=*/nullptr, prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
             /*enable_adaptive_prefetch_strategy=*/false, executor_,
             /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-            /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
     }
     {
         ASSERT_OK_AND_ASSIGN(
@@ -973,7 +1049,7 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
                 prefetch_max_parallel_num, batch_size, 
prefetch_max_parallel_num * 2,
                 /*enable_adaptive_prefetch_strategy=*/false, executor_,
                 /*initialize_read_ranges=*/true, 
/*read_ahead_cache_enabled=*/true, CacheConfig(),
-                /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
+                /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
         ASSERT_NOK_WITH_MSG(reader->SeekToRow(/*row_number=*/101),
                             "not support seek to row for prefetch reader");
     }
@@ -999,7 +1075,7 @@ TEST_P(PrefetchFileBatchReaderImplTest, 
TestPrefetchWithPredicatePushdownWithCom
     auto reader = PreparePrefetchReader(file_format, schema.get(), predicate,
                                         /*selection_bitmap=*/std::nullopt,
                                         /*batch_size=*/batch_size, 
/*prefetch_max_parallel_num=*/3,
-                                        read_ahead_cache_enabled);
+                                        read_ahead_cache_enabled, 
WarmupLevel::DECODED);
     ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
 
     arrow::ArrayVector expected_array_vector;
@@ -1045,7 +1121,7 @@ TEST_P(PrefetchFileBatchReaderImplTest,
     auto reader = PreparePrefetchReader(file_format, schema.get(), predicate,
                                         /*selection_bitmap=*/std::nullopt,
                                         /*batch_size=*/batch_size, 
/*prefetch_max_parallel_num=*/3,
-                                        read_ahead_cache_enabled);
+                                        read_ahead_cache_enabled, 
WarmupLevel::DECODED);
     ASSERT_OK(reader->RefreshReadRanges());
     ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
     ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
@@ -1077,7 +1153,8 @@ TEST_F(PrefetchFileBatchReaderImplTest, 
TestPrefetchWithBitmap) {
                                           
/*enable_adaptive_prefetch_strategy=*/false, executor_,
                                           /*initialize_read_ranges=*/true,
                                           /*read_ahead_cache_enabled=*/true, 
CacheConfig(),
-                                          /*enable_io_metrics=*/false, pool_, 
GetArrowPool(pool_)));
+                                          /*enable_io_metrics=*/false, 
WarmupLevel::DECODED, pool_,
+                                          GetArrowPool(pool_)));
     ASSERT_OK_AND_ASSIGN(auto result_chunk_array,
                          
ReadResultCollector::CollectResult(std::move(reader)));
 
@@ -1108,7 +1185,7 @@ TEST_P(PrefetchFileBatchReaderImplTest, TestRowMapping) {
     auto reader = PreparePrefetchReader(file_format, schema.get(), predicate,
                                         /*selection_bitmap=*/std::nullopt,
                                         /*batch_size=*/10, 
/*prefetch_max_parallel_num=*/3,
-                                        read_ahead_cache_enabled);
+                                        read_ahead_cache_enabled, 
WarmupLevel::DECODED);
     ASSERT_NOK(reader->GetPreviousBatchFileRowId(0));
     ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::ChunkedArray> batch,
                          
paimon::test::ReadResultCollector::CollectResultOneBatch(reader.get()));
@@ -1158,4 +1235,146 @@ TEST_P(PrefetchFileBatchReaderImplTest, TestRowMapping) 
{
     }
 }
 
+// WarmupLevel::NONE makes Warmup() a complete no-op: it neither starts the 
background decode thread
+// nor warms the read-ahead cache. Reading still returns every row, starting 
cold on the first
+// NextBatch, which lazily starts the background loop.
+TEST_P(PrefetchFileBatchReaderImplTest, TestWarmupLevelNone) {
+    auto [file_format, read_ahead_cache_enabled] = GetParam();
+    auto data_array = PrepareArray(90);
+    PrepareTestData(file_format, data_array, /*stripe_row_count=*/30, 
/*row_index_stride=*/10);
+    auto schema = arrow::schema(fields_);
+    auto reader = PreparePrefetchReader(file_format, schema.get(), 
/*predicate=*/nullptr,
+                                        /*selection_bitmap=*/std::nullopt, 
/*batch_size=*/10,
+                                        /*prefetch_max_parallel_num=*/3, 
read_ahead_cache_enabled,
+                                        WarmupLevel::NONE);
+
+    reader->Warmup();
+
+    // Warmup() did nothing: it started no decode thread, decoded no batch and 
the cache issued no
+    // prefetch IO.
+    ASSERT_FALSE(DecodeThreadStarted(reader.get()));
+    std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
+    ASSERT_OK_AND_ASSIGN(uint64_t produced_batches,
+                         
metrics->GetCounter(PrefetchMetrics::PRODUCED_BATCHES));
+    ASSERT_EQ(produced_batches, 0);
+    if (read_ahead_cache_enabled) {
+        ASSERT_OK_AND_ASSIGN(uint64_t io_count,
+                             
metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+        ASSERT_EQ(io_count, 0);
+    }
+
+    ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array_and_row_ids.first));
+}
+
+// WarmupLevel::RAW fetches the file's raw bytes into memory but does NOT 
start the decoder, so no
+// batch is produced until the first real read. It overlaps the remote fetch 
while keeping memory
+// lower than DECODED.
+TEST_P(PrefetchFileBatchReaderImplTest, TestWarmupLevelRaw) {
+    auto [file_format, read_ahead_cache_enabled] = GetParam();
+    auto data_array = PrepareArray(90);
+    PrepareTestData(file_format, data_array, /*stripe_row_count=*/30, 
/*row_index_stride=*/10);
+    auto schema = arrow::schema(fields_);
+    auto reader = PreparePrefetchReader(file_format, schema.get(), 
/*predicate=*/nullptr,
+                                        /*selection_bitmap=*/std::nullopt, 
/*batch_size=*/10,
+                                        /*prefetch_max_parallel_num=*/3, 
read_ahead_cache_enabled,
+                                        WarmupLevel::RAW);
+
+    reader->Warmup();
+    // A second Warmup() must be a no-op: the one-shot guard prevents a 
duplicate cache Init(),
+    // which would otherwise fail with "Cache has already been initialized".
+    reader->Warmup();
+
+    std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
+    // The decoder never started, so no decode thread exists and no batch has 
been produced yet.
+    ASSERT_FALSE(DecodeThreadStarted(reader.get()));
+    if (read_ahead_cache_enabled) {
+        // The cache was warmed synchronously on the caller's thread, so 
prefetch IO was issued.
+        ASSERT_OK_AND_ASSIGN(uint64_t io_count,
+                             
metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+        ASSERT_GT(io_count, 0);
+    }
+    ASSERT_OK_AND_ASSIGN(uint64_t produced_batches,
+                         
metrics->GetCounter(PrefetchMetrics::PRODUCED_BATCHES));
+    ASSERT_EQ(produced_batches, 0);
+
+    // Reading returns every row: the background loop started by the first 
NextBatch finds the
+    // cache already warmed and skips re-initializing it.
+    ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array_and_row_ids.first));
+}
+
+// RefreshReadRanges() starts a new read-range generation, and it resets the 
read-ahead cache right
+// after CleanUp(), so the generation begins cold. The one-shot warmup guard 
has to be rearmed at
+// the same time: otherwise every generation after the first skips its warmup 
and reads cold while
+// the guard still claims the cache is warm.
+TEST_P(PrefetchFileBatchReaderImplTest, 
TestWarmupCacheRearmsForNewReadRangeGeneration) {
+    auto [file_format, read_ahead_cache_enabled] = GetParam();
+    if (!read_ahead_cache_enabled) {
+        GTEST_SKIP() << "there is no read-ahead cache to rearm";
+    }
+    auto data_array = PrepareArray(90);
+    PrepareTestData(file_format, data_array, /*stripe_row_count=*/30, 
/*row_index_stride=*/10);
+    auto schema = arrow::schema(fields_);
+    auto reader = PreparePrefetchReader(file_format, schema.get(), 
/*predicate=*/nullptr,
+                                        /*selection_bitmap=*/std::nullopt, 
/*batch_size=*/10,
+                                        /*prefetch_max_parallel_num=*/3, 
read_ahead_cache_enabled,
+                                        WarmupLevel::RAW);
+
+    // GetReaderMetrics() returns a snapshot, so it is re-read at every step.
+    ASSERT_OK(reader->RefreshReadRanges());
+    reader->Warmup();
+    ASSERT_OK_AND_ASSIGN(uint64_t first_io_count,
+                         
reader->GetReaderMetrics()->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+    ASSERT_GT(first_io_count, 0);
+
+    ASSERT_OK(reader->RefreshReadRanges());
+    ASSERT_OK_AND_ASSIGN(uint64_t reset_io_count,
+                         
reader->GetReaderMetrics()->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+    ASSERT_EQ(reset_io_count, 0);
+
+    // The new generation must be warmed again, which a stale guard would 
silently skip.
+    reader->Warmup();
+    ASSERT_OK_AND_ASSIGN(uint64_t second_io_count,
+                         
reader->GetReaderMetrics()->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+    ASSERT_GT(second_io_count, 0);
+
+    ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array_and_row_ids.first));
+}
+
+// WarmupLevel::DECODED (the default) starts the background decode loop during 
Warmup(). Reading
+// returns every row and reports produced batches, matching the behavior from 
before warmup levels
+// existed.
+TEST_P(PrefetchFileBatchReaderImplTest, TestWarmupLevelDecoded) {
+    auto [file_format, read_ahead_cache_enabled] = GetParam();
+    auto data_array = PrepareArray(90);
+    PrepareTestData(file_format, data_array, /*stripe_row_count=*/30, 
/*row_index_stride=*/10);
+    auto schema = arrow::schema(fields_);
+    auto reader = PreparePrefetchReader(file_format, schema.get(), 
/*predicate=*/nullptr,
+                                        /*selection_bitmap=*/std::nullopt, 
/*batch_size=*/10,
+                                        /*prefetch_max_parallel_num=*/3, 
read_ahead_cache_enabled,
+                                        WarmupLevel::DECODED);
+
+    reader->Warmup();
+
+    // The discriminating assertion: DECODED is the only level that starts the 
decode loop inside
+    // Warmup() itself, and it must have done so before the first read. 
Checking produced batches
+    // instead cannot tell DECODED from NONE, because the first NextBatch 
starts the same loop
+    // lazily and every level ends up with produced_batches > 0 once the read 
is over.
+    ASSERT_TRUE(DecodeThreadStarted(reader.get()));
+
+    ASSERT_OK_AND_ASSIGN(auto array_and_row_ids, 
CollectResultAndRowIds(reader.get()));
+    auto expected_array = std::make_shared<arrow::ChunkedArray>(data_array);
+    ASSERT_TRUE(expected_array->Equals(array_and_row_ids.first));
+
+    std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
+    ASSERT_OK_AND_ASSIGN(uint64_t produced_batches,
+                         
metrics->GetCounter(PrefetchMetrics::PRODUCED_BATCHES));
+    ASSERT_GT(produced_batches, 0);
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/common/utils/arrow/status_utils.h 
b/src/paimon/common/utils/arrow/status_utils.h
index a0b505f6..13f9a1cd 100644
--- a/src/paimon/common/utils/arrow/status_utils.h
+++ b/src/paimon/common/utils/arrow/status_utils.h
@@ -87,18 +87,20 @@ inline Status ToPaimonStatus(const arrow::Status& status) {
     }
 }
 
+// The conversion is spelled with its full name because these macros are also 
used outside of
+// namespace paimon, where an unqualified name would not be found.
 #define PAIMON_RETURN_NOT_OK_FROM_ARROW(ARROW_STATUS) \
     do {                                              \
         arrow::Status __s = (ARROW_STATUS);           \
         if (PAIMON_UNLIKELY(!(__s).ok())) {           \
-            return ToPaimonStatus(__s);               \
+            return ::paimon::ToPaimonStatus(__s);     \
         }                                             \
     } while (false)
 
-#define PAIMON_ASSIGN_OR_RAISE_IMPL_FROM_ARROW(result_name, lhs, rexpr)        
    \
-    auto&& result_name = (rexpr);                                              
    \
-    PAIMON_RETURN_IF_(!(result_name).ok(), 
ToPaimonStatus((result_name).status()), \
-                      PAIMON_STRINGIFY(rexpr));                                
    \
+#define PAIMON_ASSIGN_OR_RAISE_IMPL_FROM_ARROW(result_name, lhs, rexpr)        
              \
+    auto&& result_name = (rexpr);                                              
              \
+    PAIMON_RETURN_IF_(!(result_name).ok(), 
::paimon::ToPaimonStatus((result_name).status()), \
+                      PAIMON_STRINGIFY(rexpr));                                
              \
     lhs = std::move(result_name).ValueUnsafe()
 
 #define PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(lhs, rexpr) \
diff --git 
a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h 
b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
index 1f43468c..c428a079 100644
--- a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
+++ b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader.h
@@ -96,6 +96,10 @@ class ApplyDeletionVectorBatchReader : public 
FileBatchReader {
         return reader_->SupportPreciseBitmapSelection();
     }
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     Result<RoaringBitmap32> Filter(int32_t batch_size) const {
         RoaringBitmap32 is_valid;
diff --git 
a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader_test.cpp 
b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader_test.cpp
index 5f4ef074..2e7b29b9 100644
--- 
a/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader_test.cpp
+++ 
b/src/paimon/core/deletionvectors/apply_deletion_vector_batch_reader_test.cpp
@@ -90,7 +90,7 @@ class ApplyDeletionVectorBatchReaderTest : public 
::testing::Test,
                         /*enable_adaptive_prefetch_strategy=*/false, executor_,
                         /*initialize_read_ranges=*/true,
                         /*read_ahead_cache_enabled=*/true, CacheConfig(),
-                        /*enable_io_metrics=*/false, pool, arrow_pool));
+                        /*enable_io_metrics=*/false, WarmupLevel::DECODED, 
pool, arrow_pool));
             } else {
                 file_batch_reader =
                     std::make_unique<MockFileBatchReader>(data, target_type_, 
batch_size);
@@ -166,6 +166,21 @@ TEST_P(ApplyDeletionVectorBatchReaderTest, TestSimple2) {
         CheckResult(data_str, dv_data, "[10, 11, 12, 13, 14, 15, 16]");
     }
 }
+
+TEST(ApplyDeletionVectorBatchReaderWarmupTest, WarmupForwardsToInnerReader) {
+    auto target_type = arrow::struct_({arrow::field("f1", arrow::int32())});
+    auto mock_reader =
+        std::make_unique<MockFileBatchReader>(/*data=*/nullptr, target_type, 
/*batch_size=*/1);
+    auto* inner_reader = mock_reader.get();
+    std::shared_ptr<MemoryPool> pool = GetDefaultPool();
+    auto deletion_vector = 
DeletionVector::FromPrimitiveArray(/*is_deleted=*/{}, pool.get());
+    ApplyDeletionVectorBatchReader reader(std::move(mock_reader), 
std::move(deletion_vector));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader.Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 INSTANTIATE_TEST_SUITE_P(EnablePrefetch, ApplyDeletionVectorBatchReaderTest,
                          ::testing::Values(false, true));
 }  // namespace paimon::test
diff --git a/src/paimon/core/io/complete_row_tracking_fields_reader.h 
b/src/paimon/core/io/complete_row_tracking_fields_reader.h
index 21ec2211..894b5a92 100644
--- a/src/paimon/core/io/complete_row_tracking_fields_reader.h
+++ b/src/paimon/core/io/complete_row_tracking_fields_reader.h
@@ -77,6 +77,10 @@ class CompleteRowTrackingFieldsBatchReader : public 
FileBatchReader {
         return reader_->SupportPreciseBitmapSelection();
     }
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     Status ConvertRowTrackingField(int64_t array_length, int64_t init_value,
                                    const 
std::function<Result<int64_t>(int32_t)>& convert_func,
diff --git a/src/paimon/core/io/complete_row_tracking_fields_reader_test.cpp 
b/src/paimon/core/io/complete_row_tracking_fields_reader_test.cpp
index 806080b1..04f05c30 100644
--- a/src/paimon/core/io/complete_row_tracking_fields_reader_test.cpp
+++ b/src/paimon/core/io/complete_row_tracking_fields_reader_test.cpp
@@ -486,4 +486,18 @@ TEST_F(CompleteRowTrackingFieldsBatchReaderTest, 
TestOnlyReadRowTrackingFields)
     CheckResult(src_array, /*first_row_id=*/100, /*snapshot_id=*/4, 
read_schema, target_array);
 }
 
+TEST_F(CompleteRowTrackingFieldsBatchReaderTest, 
TestWarmupForwardsToInnerReader) {
+    auto file_batch_reader = std::make_unique<MockFileBatchReader>(
+        /*data=*/nullptr, arrow::struct_({arrow::field("f0", arrow::int32())}),
+        /*read_batch_size=*/1);
+    auto* inner_reader = file_batch_reader.get();
+    auto reader = std::make_unique<CompleteRowTrackingFieldsBatchReader>(
+        std::move(file_batch_reader), /*first_row_id=*/10, /*snapshot_id=*/1,
+        /*file_field_names=*/std::nullopt, GetArrowPool(pool_));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader->Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/io/concat_key_value_record_reader.h 
b/src/paimon/core/io/concat_key_value_record_reader.h
index 99318a45..b3b6ddc2 100644
--- a/src/paimon/core/io/concat_key_value_record_reader.h
+++ b/src/paimon/core/io/concat_key_value_record_reader.h
@@ -18,6 +18,7 @@
 
 #pragma once
 
+#include <algorithm>
 #include <cstddef>
 #include <memory>
 #include <utility>
@@ -41,6 +42,11 @@ class ConcatKeyValueRecordReader : public 
KeyValueRecordReader {
 
     Result<std::unique_ptr<KeyValueRecordReader::Iterator>> NextBatch() 
override {
         while (current_ < readers_.size()) {
+            // Lookahead: the next files' first reads are paid while this file 
is still being
+            // consumed, instead of serially after its EOF. The files here are 
read strictly one
+            // after another, and a filtered read of a keyed table yields 
about one batch per file,
+            // so without this every file costs a full round trip nobody 
overlaps with.
+            WarmupRange(current_, 1 + kWarmupLookahead);
             auto& current_reader = readers_[current_];
             
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<KeyValueRecordReader::Iterator> iterator,
                                    current_reader->NextBatch());
@@ -65,7 +71,31 @@ class ConcatKeyValueRecordReader : public 
KeyValueRecordReader {
         return MetricsImpl::CollectReadMetrics(readers_);
     }
 
+    /// Forwards to the child a read would touch next, so a Concat nested in 
another one is warmed
+    /// through instead of swallowing the call. Only that one child, without 
the lookahead: the
+    /// caller warming this reader is not consuming it yet, and the lookahead 
is started by the
+    /// first read anyway, so a section's runs do not each hold two warm files 
before the merge has
+    /// consumed anything.
+    void Warmup() override {
+        WarmupRange(current_, /*count=*/1);
+    }
+
  private:
+    /// How many files ahead of the one being consumed get their first read 
started. Each warm file
+    /// holds a prefetch queue of its own, so this trades memory for overlap, 
and one file ahead is
+    /// what a strictly sequential consumer can actually use.
+    static constexpr size_t kWarmupLookahead = 1;
+
+    /// Warms up \p count readers starting at \p idx, stopping at the end of 
the list. Idempotent,
+    /// so calling it on every batch costs one virtual call plus one pointer 
test per already-warm
+    /// reader.
+    void WarmupRange(size_t idx, size_t count) {
+        const size_t end = std::min(idx + count, readers_.size());
+        for (size_t i = idx; i < end; i++) {
+            readers_[i]->Warmup();
+        }
+    }
+
     // KeyValue rows may outlive the active child and still reference buffers 
allocated by it.
     std::vector<std::unique_ptr<KeyValueRecordReader>> readers_;
     size_t current_{0};
diff --git a/src/paimon/core/io/concat_key_value_record_reader_test.cpp 
b/src/paimon/core/io/concat_key_value_record_reader_test.cpp
index b9da3cfd..345df470 100644
--- a/src/paimon/core/io/concat_key_value_record_reader_test.cpp
+++ b/src/paimon/core/io/concat_key_value_record_reader_test.cpp
@@ -206,4 +206,146 @@ TEST_F(ConcatKeyValueRecordReaderTest, TestEmptyReader) {
     CheckResult({src_array2, src_array1}, expected, key_schema, value_schema);
 }
 
+namespace {
+/// Counts Warmup() calls and forwards everything else, so a test can assert 
how far ahead the
+/// lookahead reaches.
+class CountingWarmupReader : public KeyValueRecordReader {
+ public:
+    explicit CountingWarmupReader(std::unique_ptr<KeyValueRecordReader>&& 
inner)
+        : inner_(std::move(inner)) {}
+
+    Result<std::unique_ptr<KeyValueRecordReader::Iterator>> NextBatch() 
override {
+        return inner_->NextBatch();
+    }
+
+    std::shared_ptr<Metrics> GetReaderMetrics() const override {
+        return inner_->GetReaderMetrics();
+    }
+
+    void Warmup() override {
+        warmups_++;
+        inner_->Warmup();
+    }
+
+    void Close() override {
+        inner_->Close();
+    }
+
+    int warmups() const {
+        return warmups_;
+    }
+
+ private:
+    std::unique_ptr<KeyValueRecordReader> inner_;
+    int warmups_ = 0;
+};
+}  // namespace
+
+TEST_F(ConcatKeyValueRecordReaderTest, TestWarmupLooksOneReaderAhead) {
+    arrow::FieldVector fields = {arrow::field("_SEQUENCE_NUMBER", 
arrow::int64()),
+                                 arrow::field("_VALUE_KIND", arrow::int8()),
+                                 arrow::field("k0", arrow::int32()),
+                                 arrow::field("k1", arrow::int32()),
+                                 arrow::field("v0", arrow::int32()),
+                                 arrow::field("v1", arrow::int32()),
+                                 arrow::field("v2", arrow::int32())};
+    std::shared_ptr<arrow::Schema> key_schema =
+        arrow::schema(arrow::FieldVector({fields[2], fields[3]}));
+    std::shared_ptr<arrow::Schema> value_schema =
+        arrow::schema(arrow::FieldVector({fields[2], fields[3], fields[4], 
fields[5], fields[6]}));
+    std::shared_ptr<arrow::DataType> src_type = arrow::struct_(fields);
+    auto make_array = [&src_type](const std::string& json) {
+        return std::dynamic_pointer_cast<arrow::StructArray>(
+            arrow::ipc::internal::json::ArrayFromJSON(src_type, 
json).ValueOrDie());
+    };
+    const std::vector<std::shared_ptr<arrow::StructArray>> src_array_vec = {
+        make_array(R"([[0, 0, 1, 1, 10, 20, 30], [0, 0, 1, 2, 11, 21, 31]])"),
+        make_array(R"([[0, 0, 2, 1, 12, 22, 32], [0, 0, 2, 2, 13, 23, 33]])"),
+        make_array(R"([[0, 0, 3, 1, 14, 24, 34], [0, 0, 3, 2, 15, 25, 35]])")};
+
+    std::shared_ptr<MemoryPool> pool = GetDefaultPool();
+    std::vector<CountingWarmupReader*> counters;
+    // The counters point into what the returned Concat owns, so they are only 
valid while it is.
+    auto build = [&](std::vector<CountingWarmupReader*>* out) {
+        out->clear();
+        std::vector<std::unique_ptr<KeyValueRecordReader>> record_readers;
+        for (const auto& src_array : src_array_vec) {
+            auto file_batch_reader = std::make_unique<MockFileBatchReader>(
+                src_array, src_array->type(), /*batch_size=*/100);
+            auto record_reader = 
std::make_unique<MockKeyValueDataFileRecordReader>(
+                std::move(file_batch_reader), key_schema, value_schema, 
/*level=*/0, pool);
+            auto counting = 
std::make_unique<CountingWarmupReader>(std::move(record_reader));
+            out->push_back(counting.get());
+            record_readers.push_back(std::move(counting));
+        }
+        return 
std::make_unique<ConcatKeyValueRecordReader>(std::move(record_readers));
+    };
+
+    {
+        std::unique_ptr<ConcatKeyValueRecordReader> concat = build(&counters);
+        // Nothing is started before a read asks for it.
+        EXPECT_EQ(counters[0]->warmups(), 0);
+        EXPECT_EQ(counters[1]->warmups(), 0);
+        EXPECT_EQ(counters[2]->warmups(), 0);
+
+        // The first batch warms the file it reads and the one after it, and 
no further: a deeper
+        // lookahead would hold more files open at once than the prefetch 
executor has threads for.
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<KeyValueRecordReader::Iterator> 
iterator,
+                             concat->NextBatch());
+        ASSERT_NE(iterator, nullptr);
+        EXPECT_EQ(counters[0]->warmups(), 1);
+        EXPECT_EQ(counters[1]->warmups(), 1);
+        EXPECT_EQ(counters[2]->warmups(), 0);
+
+        // Rows may reference buffers the child allocated, so drop them before 
closing it.
+        iterator.reset();
+        // A caller that only warms this Concat is not consuming it yet, so 
the call reaches the
+        // child a read would touch next and stops there: it must not add the 
lookahead on top of
+        // what the read above already started.
+        concat->Warmup();
+        EXPECT_EQ(counters[0]->warmups(), 2);
+        EXPECT_EQ(counters[1]->warmups(), 1);
+        EXPECT_EQ(counters[2]->warmups(), 0);
+        // Idempotent: warming an already-warm reader is not an error.
+        concat->Warmup();
+        concat->Close();
+    }
+
+    {
+        // Warmup() before the first read reaches only the first file, so a 
merge that warms k runs
+        // starts k files and not 2k.
+        std::unique_ptr<ConcatKeyValueRecordReader> concat = build(&counters);
+        concat->Warmup();
+        EXPECT_EQ(counters[0]->warmups(), 1);
+        EXPECT_EQ(counters[1]->warmups(), 0);
+        EXPECT_EQ(counters[2]->warmups(), 0);
+        concat->Close();
+    }
+
+    {
+        std::unique_ptr<ConcatKeyValueRecordReader> concat = build(&counters);
+        ASSERT_OK_AND_ASSIGN(
+            auto results,
+            (ReadResultCollector::CollectKeyValueResult<
+                ConcatKeyValueRecordReader, 
KeyValueRecordReader::Iterator>(concat.get())));
+        std::vector<KeyValue> expected = KeyValueChecker::GenerateKeyValues(
+            /*seq_vec=*/{0, 0, 0, 0, 0, 0},
+            /*key_vec=*/{{1, 1}, {1, 2}, {2, 1}, {2, 2}, {3, 1}, {3, 2}},
+            /*value_vec=*/
+            {{1, 1, 10, 20, 30},
+             {1, 2, 11, 21, 31},
+             {2, 1, 12, 22, 32},
+             {2, 2, 13, 23, 33},
+             {3, 1, 14, 24, 34},
+             {3, 2, 15, 25, 35}},
+            pool);
+        // Warming up changes when a file is opened, never what it reads.
+        KeyValueChecker::CheckResult(expected, results, 
key_schema->num_fields(),
+                                     value_schema->num_fields());
+        for (const CountingWarmupReader* counter : counters) {
+            EXPECT_GE(counter->warmups(), 1);
+        }
+    }
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/io/field_mapping_reader.h 
b/src/paimon/core/io/field_mapping_reader.h
index c0057cf2..fa976b2f 100644
--- a/src/paimon/core/io/field_mapping_reader.h
+++ b/src/paimon/core/io/field_mapping_reader.h
@@ -91,6 +91,12 @@ class FieldMappingReader : public FileBatchReader {
         return reader_->SupportPreciseBitmapSelection();
     }
 
+    /// This is the outermost wrapper of every data file's reader stack, so 
swallowing the call here
+    /// would leave the whole stack cold no matter who asked for the warmup.
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     FieldMappingReader(int32_t field_count, std::unique_ptr<FileBatchReader>&& 
reader,
                        const BinaryRow& partition, 
std::unique_ptr<FieldMapping>&& mapping,
diff --git a/src/paimon/core/io/field_mapping_reader_test.cpp 
b/src/paimon/core/io/field_mapping_reader_test.cpp
index 3e73294f..3756475f 100644
--- a/src/paimon/core/io/field_mapping_reader_test.cpp
+++ b/src/paimon/core/io/field_mapping_reader_test.cpp
@@ -916,4 +916,27 @@ TEST_F(FieldMappingReaderTest, 
TestCreateFailFastOnInvalidMapSelectedKeysMetadat
                             /*skip_map_selected_keys_filter_field_ids=*/{}, 
GetArrowPool(pool_)),
                         "Duplicate selected key 'a'");
 }
+
+TEST_F(FieldMappingReaderTest, TestWarmupForwardsToInnerReader) {
+    std::vector<DataField> data_fields = {DataField(0, arrow::field("a", 
arrow::int32()))};
+    auto data_schema = DataField::ConvertDataFieldsToArrowSchema(data_fields);
+    ASSERT_OK_AND_ASSIGN(auto mapping_builder,
+                         FieldMappingBuilder::Create(data_schema, 
/*partition_keys=*/{},
+                                                     /*predicate=*/nullptr));
+    ASSERT_OK_AND_ASSIGN(auto mapping, 
mapping_builder->CreateFieldMapping(data_fields));
+    auto mock = std::make_unique<MockFileBatchReader>(
+        /*data=*/nullptr, arrow::struct_(data_schema->fields()), 
/*read_batch_size=*/1);
+    auto* inner_reader = mock.get();
+
+    ASSERT_OK_AND_ASSIGN(auto reader,
+                         FieldMappingReader::Create(data_schema->num_fields(), 
std::move(mock),
+                                                    BinaryRow::EmptyRow(), 
std::move(mapping),
+                                                    
/*skip_map_selected_keys_filter_field_ids=*/{},
+                                                    GetArrowPool(pool_)));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader->Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/io/key_value_data_file_record_reader.cpp 
b/src/paimon/core/io/key_value_data_file_record_reader.cpp
index 49a216e9..af6e3f32 100644
--- a/src/paimon/core/io/key_value_data_file_record_reader.cpp
+++ b/src/paimon/core/io/key_value_data_file_record_reader.cpp
@@ -162,6 +162,15 @@ Result<std::unique_ptr<KeyValueRecordReader::Iterator>> 
KeyValueDataFileRecordRe
     return std::make_unique<KeyValueDataFileRecordReader::Iterator>(this);
 }
 
+void KeyValueDataFileRecordReader::Warmup() {
+    // reader_ is typed as BatchReader, but Warmup() lives on FileBatchReader. 
This is the single
+    // boundary where the two hierarchies meet; every FileBatchReader 
decorator below here forwards
+    // Warmup() through its own FileBatchReader-typed inner reader without any 
cast.
+    if (auto* file_reader = dynamic_cast<FileBatchReader*>(reader_.get())) {
+        file_reader->Warmup();
+    }
+}
+
 void KeyValueDataFileRecordReader::Reset() {
     file_reader_ = nullptr;
     selection_bitmap_ = RoaringBitmap32();
diff --git a/src/paimon/core/io/key_value_data_file_record_reader.h 
b/src/paimon/core/io/key_value_data_file_record_reader.h
index d968dd14..1216c034 100644
--- a/src/paimon/core/io/key_value_data_file_record_reader.h
+++ b/src/paimon/core/io/key_value_data_file_record_reader.h
@@ -73,6 +73,8 @@ class KeyValueDataFileRecordReader : public 
KeyValueRecordReader {
         return reader_->GetReaderMetrics();
     }
 
+    void Warmup() override;
+
     void Close() override {
         Reset();
         reader_->Close();
diff --git a/src/paimon/core/io/key_value_data_file_record_reader_test.cpp 
b/src/paimon/core/io/key_value_data_file_record_reader_test.cpp
index d2077759..84689f23 100644
--- a/src/paimon/core/io/key_value_data_file_record_reader_test.cpp
+++ b/src/paimon/core/io/key_value_data_file_record_reader_test.cpp
@@ -456,4 +456,19 @@ TEST_F(KeyValueDataFileRecordReaderTest, 
TestKeyFieldAfterValueField) {
     }
 }
 
+TEST_F(KeyValueDataFileRecordReaderTest, TestWarmupForwardsToInnerReader) {
+    auto file_batch_reader = std::make_unique<MockFileBatchReader>(
+        /*data=*/nullptr, arrow::struct_({arrow::field("_SEQUENCE_NUMBER", 
arrow::int64())}),
+        /*read_batch_size=*/1);
+    auto* inner_reader = file_batch_reader.get();
+    auto key_schema = arrow::schema({arrow::field("k0", arrow::int32())});
+    auto value_schema = arrow::schema({arrow::field("v0", arrow::int32())});
+    KeyValueDataFileRecordReader reader(std::move(file_batch_reader), 
key_schema, value_schema,
+                                        /*level=*/0, pool_);
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader.Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/io/key_value_in_memory_record_reader.h 
b/src/paimon/core/io/key_value_in_memory_record_reader.h
index 8ffd0c7b..9e5d35e2 100644
--- a/src/paimon/core/io/key_value_in_memory_record_reader.h
+++ b/src/paimon/core/io/key_value_in_memory_record_reader.h
@@ -73,6 +73,9 @@ class KeyValueInMemoryRecordReader : public 
KeyValueRecordReader {
         return std::make_shared<MetricsImpl>();
     }
 
+    /// Nothing to warm up: the records are already in memory and no file is 
opened by a read.
+    void Warmup() override {}
+
     void Close() override;
 
  private:
diff --git a/src/paimon/core/io/key_value_record_reader.h 
b/src/paimon/core/io/key_value_record_reader.h
index efd2f3b4..4e452e0d 100644
--- a/src/paimon/core/io/key_value_record_reader.h
+++ b/src/paimon/core/io/key_value_record_reader.h
@@ -39,6 +39,17 @@ class KeyValueRecordReader {
 
     virtual std::shared_ptr<Metrics> GetReaderMetrics() const = 0;
 
+    /// Starts whatever background work this reader would otherwise start on 
its first read, so a
+    /// caller that knows this reader is next can pay that startup while still 
consuming the
+    /// previous one. Reports no error, like `FileBatchReader::Warmup()`: a 
hint about a file
+    /// nobody reads must not fail the read in progress.
+    ///
+    /// Pure virtual although a reader with nothing to start has nothing to do 
here: a wrapping
+    /// reader that forgot to forward the call would silently leave the file 
readers below it cold,
+    /// which no test of the read result can catch, so every implementation is 
made to say whether
+    /// it forwards or is a no-op.
+    virtual void Warmup() = 0;
+
     virtual void Close() = 0;
 };
 }  // namespace paimon
diff --git a/src/paimon/core/io/merged_key_value_record_reader.h 
b/src/paimon/core/io/merged_key_value_record_reader.h
index a1b7aa5e..45dff2f9 100644
--- a/src/paimon/core/io/merged_key_value_record_reader.h
+++ b/src/paimon/core/io/merged_key_value_record_reader.h
@@ -63,6 +63,10 @@ class MergedKeyValueRecordReader : public 
KeyValueRecordReader {
 
     std::shared_ptr<Metrics> GetReaderMetrics() const override;
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
     void Close() override;
 
  private:
diff --git a/src/paimon/core/io/vector_file_batch_reader.h 
b/src/paimon/core/io/vector_file_batch_reader.h
index 29b064d8..3e7dc418 100644
--- a/src/paimon/core/io/vector_file_batch_reader.h
+++ b/src/paimon/core/io/vector_file_batch_reader.h
@@ -74,6 +74,10 @@ class VectorFileBatchReader : public FileBatchReader {
         return reader_->SupportPreciseBitmapSelection();
     }
 
+    void Warmup() override {
+        reader_->Warmup();
+    }
+
  private:
     Result<ReadBatch> ConvertBatch(ReadBatch&& batch) const;
 
diff --git a/src/paimon/core/io/vector_file_batch_reader_test.cpp 
b/src/paimon/core/io/vector_file_batch_reader_test.cpp
index 560ea5b2..007b35e1 100644
--- a/src/paimon/core/io/vector_file_batch_reader_test.cpp
+++ b/src/paimon/core/io/vector_file_batch_reader_test.cpp
@@ -83,6 +83,18 @@ TEST(VectorFileBatchReaderTest, ConvertSchemaAndNextBatch) {
     ASSERT_TRUE(BatchReader::IsEofBatch(batch));
 }
 
+TEST(VectorFileBatchReaderTest, WarmupForwardsToInnerReader) {
+    auto physical_type = AsStructType(arrow::struct_({arrow::field("id", 
arrow::int32())}));
+    auto mock_reader =
+        std::make_unique<MockFileBatchReader>(/*data=*/nullptr, physical_type, 
/*batch_size=*/1);
+    MockFileBatchReader* inner_reader = mock_reader.get();
+    VectorFileBatchReader reader(std::move(mock_reader), 
GetArrowPool(GetDefaultPool()));
+
+    ASSERT_EQ(0, inner_reader->GetWarmupCount());
+    reader.Warmup();
+    ASSERT_EQ(1, inner_reader->GetWarmupCount());
+}
+
 TEST(VectorFileBatchReaderTest, KeepFixedSizeListFileSchema) {
     auto logical_type = AsStructType(arrow::struct_({
         arrow::field("id", arrow::int32()),
diff --git a/src/paimon/core/mergetree/compact/loser_tree.cpp 
b/src/paimon/core/mergetree/compact/loser_tree.cpp
index e9321190..ad48903a 100644
--- a/src/paimon/core/mergetree/compact/loser_tree.cpp
+++ b/src/paimon/core/mergetree/compact/loser_tree.cpp
@@ -36,8 +36,23 @@ 
LoserTree::LoserTree(std::vector<std::unique_ptr<KeyValueRecordReader>>&& reader
     }
 }
 
+void LoserTree::WarmupLeaves() {
+    // Warmed in the same order the loop below consumes them, so the leaf it 
blocks on first is the
+    // one whose read was started first. Each leaf only starts the file it is 
about to read: a
+    // deeper lookahead here would put two warm files per run in flight before 
the merge has
+    // consumed anything, and the run itself starts its next file once it is 
being read.
+    for (int32_t i = size_ - 1; i >= 0; i--) {
+        leaves_[i].reader->Warmup();
+    }
+}
+
 Status LoserTree::InitializeIfNeeded() {
     if (!initialized_) {
+        // Every leaf's first read below blocks on its own file, and the 
leaves of a section are
+        // independent sorted runs, so without warming them the section pays 
those read latencies
+        // one after another. Warming starts them together, and the loop then 
collects reads that
+        // are already in flight.
+        WarmupLeaves();
         std::fill(tree_.begin(), tree_.end(), -1);
         for (int32_t i = size_ - 1; i >= 0; i--) {
             PAIMON_RETURN_NOT_OK(leaves_[i].AdvanceIfAvailable());
diff --git a/src/paimon/core/mergetree/compact/loser_tree.h 
b/src/paimon/core/mergetree/compact/loser_tree.h
index 50b3bab5..65425848 100644
--- a/src/paimon/core/mergetree/compact/loser_tree.h
+++ b/src/paimon/core/mergetree/compact/loser_tree.h
@@ -154,6 +154,10 @@ class LoserTree {
     };
 
  private:
+    /// Starts the first read of the leaves InitializeIfNeeded is about to 
block on, so the sorted
+    /// runs of a section overlap their read latencies instead of serializing 
them.
+    void WarmupLeaves();
+
     int32_t size_;
     bool initialized_;
     // KeyValue rows may be consumed asynchronously and still reference 
buffers allocated by the
diff --git a/src/paimon/core/mergetree/compact/sort_merge_reader_test.cpp 
b/src/paimon/core/mergetree/compact/sort_merge_reader_test.cpp
index dabee55c..41db77ba 100644
--- a/src/paimon/core/mergetree/compact/sort_merge_reader_test.cpp
+++ b/src/paimon/core/mergetree/compact/sort_merge_reader_test.cpp
@@ -30,6 +30,7 @@
 #include "arrow/array/array_nested.h"
 #include "arrow/ipc/json_simple.h"
 #include "gtest/gtest.h"
+#include "paimon/common/metrics/metrics_impl.h"
 #include "paimon/common/types/data_field.h"
 #include "paimon/common/utils/fields_comparator.h"
 #include "paimon/core/core_options.h"
@@ -39,6 +40,7 @@
 #include "paimon/core/key_value.h"
 #include "paimon/core/mergetree/compact/aggregate/aggregate_merge_function.h"
 #include "paimon/core/mergetree/compact/deduplicate_merge_function.h"
+#include "paimon/core/mergetree/compact/loser_tree.h"
 #include "paimon/core/mergetree/compact/reducer_merge_function_wrapper.h"
 #include "paimon/core/mergetree/compact/sort_merge_reader_with_loser_tree.h"
 #include "paimon/core/mergetree/compact/sort_merge_reader_with_min_heap.h"
@@ -900,4 +902,72 @@ TEST_F(SortMergeReaderTest, TestRawSortNoMergeWithMinHeap) 
{
                                                      value_schema, expected, 
/*need_merge=*/false);
 }
 
+namespace {
+/// Records the order in which a loser tree touches its leaves, so a test can 
tell a tree that
+/// warms every leaf up front from one that warms each leaf only as it reaches 
it. NextBatch()
+/// returns no iterator, which puts every leaf at EOF immediately and keeps 
InitializeIfNeeded()
+/// the only thing under test.
+class LeafTouchRecordingReader : public KeyValueRecordReader {
+ public:
+    LeafTouchRecordingReader(int32_t leaf_index, std::vector<std::string>* 
events)
+        : leaf_index_(leaf_index), events_(events) {}
+
+    Result<std::unique_ptr<KeyValueRecordReader::Iterator>> NextBatch() 
override {
+        events_->push_back("read" + std::to_string(leaf_index_));
+        return std::unique_ptr<KeyValueRecordReader::Iterator>();
+    }
+
+    std::shared_ptr<Metrics> GetReaderMetrics() const override {
+        return std::make_shared<MetricsImpl>();
+    }
+
+    void Warmup() override {
+        events_->push_back("warm" + std::to_string(leaf_index_));
+    }
+
+    void Close() override {}
+
+ private:
+    int32_t leaf_index_;
+    std::vector<std::string>* events_;
+};
+}  // namespace
+
+// Every leaf of a section is an independent sorted run whose first read 
blocks on its own file, so
+// those latencies only overlap when all leaves are warmed before the first 
one is advanced. The
+// merge then collects reads that are already in flight instead of paying them 
one after another.
+TEST_F(SortMergeReaderTest, TestLoserTreeWarmsAllLeavesBeforeAdvancingAny) {
+    std::vector<std::string> events;
+    std::vector<std::unique_ptr<KeyValueRecordReader>> readers;
+    constexpr int32_t kLeafCount = 3;
+    for (int32_t i = 0; i < kLeafCount; i++) {
+        readers.push_back(std::make_unique<LeafTouchRecordingReader>(i, 
&events));
+    }
+    // An exhausted leaf compares as the smallest, which is how the merge 
reader's own comparators
+    // treat a run that has nothing left.
+    auto exhausted_last_comparator = [](const std::optional<KeyValue>& lhs,
+                                        const std::optional<KeyValue>& rhs) -> 
int32_t {
+        if (lhs == std::nullopt) {
+            return -1;
+        }
+        if (rhs == std::nullopt) {
+            return 1;
+        }
+        return 0;
+    };
+    LoserTree loser_tree(std::move(readers), exhausted_last_comparator, 
exhausted_last_comparator);
+
+    ASSERT_OK(loser_tree.InitializeIfNeeded());
+
+    // Warmup visits the leaves in the order the advancing loop consumes them, 
so the leaf the tree
+    // blocks on first is the one whose read was started first.
+    const std::vector<std::string> expected_events = {"warm2", "warm1", 
"warm0",
+                                                      "read2", "read1", 
"read0"};
+    ASSERT_EQ(expected_events, events);
+
+    // Initialization is one-shot: a second call must neither warm nor read 
again.
+    ASSERT_OK(loser_tree.InitializeIfNeeded());
+    ASSERT_EQ(expected_events, events);
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/mergetree/merge_tree_writer_test.cpp 
b/src/paimon/core/mergetree/merge_tree_writer_test.cpp
index f4cbaeaf..8a152e2f 100644
--- a/src/paimon/core/mergetree/merge_tree_writer_test.cpp
+++ b/src/paimon/core/mergetree/merge_tree_writer_test.cpp
@@ -85,6 +85,10 @@ class TrackingKeyValueRecordReader : public 
KeyValueRecordReader {
         return inner_reader_->GetReaderMetrics();
     }
 
+    void Warmup() override {
+        inner_reader_->Warmup();
+    }
+
     void Close() override {
         if (closed_flag_ != nullptr) {
             *closed_flag_ = true;
diff --git a/src/paimon/core/mergetree/spill_reader.h 
b/src/paimon/core/mergetree/spill_reader.h
index 6dba6fd7..af05d271 100644
--- a/src/paimon/core/mergetree/spill_reader.h
+++ b/src/paimon/core/mergetree/spill_reader.h
@@ -61,6 +61,8 @@ class SpillReader : public KeyValueRecordReader {
 
     Result<std::unique_ptr<KeyValueRecordReader::Iterator>> NextBatch() 
override;
     std::shared_ptr<Metrics> GetReaderMetrics() const override;
+    /// Nothing to warm up: the spill file is read directly, without a 
prefetching reader below.
+    void Warmup() override {}
     void Close() override;
 
  private:
diff --git a/src/paimon/core/operation/abstract_split_read.cpp 
b/src/paimon/core/operation/abstract_split_read.cpp
index 0a33c7e7..f1540843 100644
--- a/src/paimon/core/operation/abstract_split_read.cpp
+++ b/src/paimon/core/operation/abstract_split_read.cpp
@@ -138,6 +138,7 @@ DataFileReadOptions 
AbstractSplitRead::BuildDataFileReadOptions() const {
     read_options.read_ahead_cache_enabled = context_->ReadAheadCacheEnabled();
     read_options.cache_config = context_->GetCacheConfig();
     read_options.prefetch_io_metrics_enabled = 
options_.PrefetchIoMetricsEnabled();
+    read_options.warmup_level = context_->GetWarmupLevel();
     return read_options;
 }
 
diff --git a/src/paimon/core/operation/internal_read_context.h 
b/src/paimon/core/operation/internal_read_context.h
index cadef31c..ddbbb49f 100644
--- a/src/paimon/core/operation/internal_read_context.h
+++ b/src/paimon/core/operation/internal_read_context.h
@@ -111,6 +111,10 @@ class InternalReadContext {
         return read_context_->GetCacheConfig();
     }
 
+    WarmupLevel GetWarmupLevel() const {
+        return read_context_->GetWarmupLevel();
+    }
+
     /// Create a new InternalReadContext with a different read schema.
     /// Useful for creating a context with a minimal column set for 
specialized reads.
     /// All other settings (predicate, options, table_schema, etc.) are 
inherited
diff --git a/src/paimon/core/operation/merge_file_split_read.cpp 
b/src/paimon/core/operation/merge_file_split_read.cpp
index 570059ab..746d4d3a 100644
--- a/src/paimon/core/operation/merge_file_split_read.cpp
+++ b/src/paimon/core/operation/merge_file_split_read.cpp
@@ -116,6 +116,10 @@ class SortMergeKeyValueRecordReader : public 
KeyValueRecordReader {
         reader_->Close();
     }
 
+    /// A no-op: SortMergeReader has no warmup verb to forward to, so a 
section is warmed from
+    /// inside its own merge (LoserTree warms its leaves) and not from the 
section before it.
+    void Warmup() override {}
+
     std::shared_ptr<Metrics> GetReaderMetrics() const override {
         return reader_->GetReaderMetrics();
     }
diff --git a/src/paimon/core/operation/read_context.cpp 
b/src/paimon/core/operation/read_context.cpp
index 807a507b..7545c543 100644
--- a/src/paimon/core/operation/read_context.cpp
+++ b/src/paimon/core/operation/read_context.cpp
@@ -45,7 +45,7 @@ ReadContext::ReadContext(
     const std::shared_ptr<RealtimeContext>& realtime_context,
     const std::map<std::string, std::string>& options, bool 
read_ahead_cache_enabled,
     const CacheConfig& cache_config, const std::shared_ptr<Cache>& cache,
-    const std::shared_ptr<FormatTable>& format_table)
+    const std::shared_ptr<FormatTable>& format_table, WarmupLevel warmup_level)
     : path_(path),
       branch_(branch),
       read_field_names_(read_field_names),
@@ -68,7 +68,8 @@ ReadContext::ReadContext(
       read_ahead_cache_enabled_(read_ahead_cache_enabled),
       cache_config_(cache_config),
       cache_(cache),
-      format_table_(format_table) {}
+      format_table_(format_table),
+      warmup_level_(warmup_level) {}
 
 ReadContext::~ReadContext() {
     if (read_schema_ && read_schema_->release) {
@@ -114,6 +115,7 @@ class ReadContextBuilder::Impl {
         realtime_context_.reset();
         cache_config_ = CacheConfig();
         cache_.reset();
+        warmup_level_ = WarmupLevel::DECODED;
     }
 
  private:
@@ -144,6 +146,7 @@ class ReadContextBuilder::Impl {
     bool read_ahead_cache_enabled_ = true;
     CacheConfig cache_config_;
     std::shared_ptr<Cache> cache_;
+    WarmupLevel warmup_level_ = WarmupLevel::DECODED;
 };
 
 ReadContextBuilder::ReadContextBuilder(const std::string& path)
@@ -279,6 +282,11 @@ ReadContextBuilder& 
ReadContextBuilder::SetReadAheadCacheEnabled(bool enabled) {
     return *this;
 }
 
+ReadContextBuilder& ReadContextBuilder::SetWarmupLevel(WarmupLevel level) {
+    impl_->warmup_level_ = level;
+    return *this;
+}
+
 ReadContextBuilder& ReadContextBuilder::WithCacheConfig(const CacheConfig& 
cache_config) {
     impl_->cache_config_ = cache_config;
     return *this;
@@ -350,7 +358,7 @@ Result<std::unique_ptr<ReadContext>> 
ReadContextBuilder::Finish() {
         impl_->row_to_batch_thread_number_, impl_->table_schema_, 
impl_->memory_pool_,
         impl_->executor_, impl_->specific_file_system_, 
impl_->fs_scheme_to_identifier_map_,
         impl_->realtime_context_, impl_->options_, 
impl_->read_ahead_cache_enabled_,
-        impl_->cache_config_, impl_->cache_, impl_->format_table_);
+        impl_->cache_config_, impl_->cache_, impl_->format_table_, 
impl_->warmup_level_);
     if (impl_->read_schema_ && impl_->read_schema_->release) {
         ctx->SetReadSchema(std::move(impl_->read_schema_));
     }
diff --git a/src/paimon/core/operation/read_context_test.cpp 
b/src/paimon/core/operation/read_context_test.cpp
index 3d362352..572fd5a5 100644
--- a/src/paimon/core/operation/read_context_test.cpp
+++ b/src/paimon/core/operation/read_context_test.cpp
@@ -46,6 +46,7 @@ TEST(ReadContextTest, TestDefaultValue) {
     ASSERT_FALSE(ctx->EnablePredicateFilter());
     ASSERT_FALSE(ctx->EnablePrefetch());
     ASSERT_TRUE(ctx->ReadAheadCacheEnabled());
+    ASSERT_EQ(WarmupLevel::DECODED, ctx->GetWarmupLevel());
     ASSERT_EQ(600, ctx->GetPrefetchBatchCount());
     ASSERT_EQ(3, ctx->GetPrefetchMaxParallelNum());
     ASSERT_FALSE(ctx->EnableMultiThreadRowToBatch());
@@ -73,6 +74,7 @@ TEST(ReadContextTest, TestSetContent) {
     builder.EnablePredicateFilter(true);
     builder.EnablePrefetch(true);
     builder.SetReadAheadCacheEnabled(false);
+    builder.SetWarmupLevel(WarmupLevel::RAW);
     builder.SetPrefetchBatchCount(1200);
     builder.SetPrefetchMaxParallelNum(6);
     builder.EnableMultiThreadRowToBatch(true);
@@ -98,6 +100,7 @@ TEST(ReadContextTest, TestSetContent) {
     ASSERT_TRUE(ctx->EnablePredicateFilter());
     ASSERT_TRUE(ctx->EnablePrefetch());
     ASSERT_FALSE(ctx->ReadAheadCacheEnabled());
+    ASSERT_EQ(WarmupLevel::RAW, ctx->GetWarmupLevel());
     ASSERT_EQ(1200, ctx->GetPrefetchBatchCount());
     ASSERT_EQ(6, ctx->GetPrefetchMaxParallelNum());
     ASSERT_TRUE(ctx->EnableMultiThreadRowToBatch());
@@ -117,6 +120,24 @@ TEST(ReadContextTest, TestSetContent) {
     ASSERT_TRUE(ctx->GetCache());
 }
 
+TEST(ReadContextTest, TestSetWarmupLevel) {
+    for (WarmupLevel level : {WarmupLevel::NONE, WarmupLevel::RAW, 
WarmupLevel::DECODED}) {
+        ReadContextBuilder builder("table_root_path");
+        // The setter hands back the builder so it chains like every other 
setter on it.
+        ASSERT_EQ(&builder, &builder.SetWarmupLevel(level));
+        ASSERT_OK_AND_ASSIGN(auto ctx, builder.Finish());
+        ASSERT_EQ(level, ctx->GetWarmupLevel());
+    }
+
+    // Finish() resets the builder, so reusing one must not carry the previous 
warmup level over.
+    ReadContextBuilder builder("table_root_path");
+    builder.SetWarmupLevel(WarmupLevel::NONE);
+    ASSERT_OK_AND_ASSIGN(auto first_ctx, builder.Finish());
+    ASSERT_EQ(WarmupLevel::NONE, first_ctx->GetWarmupLevel());
+    ASSERT_OK_AND_ASSIGN(auto second_ctx, builder.Finish());
+    ASSERT_EQ(WarmupLevel::DECODED, second_ctx->GetWarmupLevel());
+}
+
 TEST(ReadContextTest, TestSetOptionsOverridesAddedOptions) {
     ReadContextBuilder builder("table_root_path");
     builder.AddOption("old", "value");
diff --git a/src/paimon/core/table/format/format_table_read.cpp 
b/src/paimon/core/table/format/format_table_read.cpp
index 86b1a880..cda8cbfe 100644
--- a/src/paimon/core/table/format/format_table_read.cpp
+++ b/src/paimon/core/table/format/format_table_read.cpp
@@ -337,6 +337,7 @@ Result<std::unique_ptr<FormatTableRead>> 
FormatTableRead::Create(
     read_options.prefetch_batch_count = read_context->GetPrefetchBatchCount();
     read_options.read_ahead_cache_enabled = 
read_context->ReadAheadCacheEnabled();
     read_options.cache_config = read_context->GetCacheConfig();
+    read_options.warmup_level = read_context->GetWarmupLevel();
 
     return CreateInternal(table, projection, read_context->GetMemoryPool(),
                           read_context->GetPredicate(), 
read_context->EnablePredicateFilter(),
diff --git a/src/paimon/core/table/system/audit_log_system_table.cpp 
b/src/paimon/core/table/system/audit_log_system_table.cpp
index 3b5f1c82..8de9742e 100644
--- a/src/paimon/core/table/system/audit_log_system_table.cpp
+++ b/src/paimon/core/table/system/audit_log_system_table.cpp
@@ -425,11 +425,31 @@ Result<std::unique_ptr<TableRead>> 
AuditLogSystemTable::NewChangelogRead(
         return Status::NotImplemented(Name(), " system table predicate 
pushdown is not supported");
     }
 
-    ReadContextBuilder builder(table_path_);
-    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Schema> base_read_schema, 
BaseReadSchema());
     using StringMap = std::map<std::string, std::string>;
     PAIMON_ASSIGN_OR_RAISE(StringMap read_options, ReadOptions());
     PAIMON_ASSIGN_OR_RAISE(CoreOptions core_options, 
CoreOptions::FromMap(read_options));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReadContext> data_context,
+                           CreateDataReadContext(context, read_options));
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<TableRead> data_read,
+                           TableRead::Create(std::move(data_context)));
+    auto* key_value_read = dynamic_cast<KeyValueTableRead*>(data_read.get());
+    if (!key_value_read) {
+        return Status::Invalid("audit_log system table requires key-value 
table read");
+    }
+    key_value_read->ForceKeepDelete(true);
+    bool include_sequence_number = 
core_options.TableReadSequenceNumberEnabled();
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Schema> output_schema, 
ArrowSchema());
+    return std::make_unique<ChangelogTableRead>(std::move(data_read), 
std::move(output_schema),
+                                                include_sequence_number, 
std::move(converter),
+                                                context->GetMemoryPool());
+}
+
+Result<std::unique_ptr<ReadContext>> 
AuditLogSystemTable::CreateDataReadContext(
+    const std::shared_ptr<ReadContext>& context,
+    const std::map<std::string, std::string>& read_options) const {
+    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Schema> base_read_schema, 
BaseReadSchema());
+    PAIMON_ASSIGN_OR_RAISE(CoreOptions core_options, 
CoreOptions::FromMap(read_options));
+    ReadContextBuilder builder(table_path_);
     builder.SetOptions(read_options)
         .SetReadFieldNames(base_read_schema->field_names())
         .WithBranch(core_options.GetBranch())
@@ -444,21 +464,9 @@ Result<std::unique_ptr<TableRead>> 
AuditLogSystemTable::NewChangelogRead(
         .SetRowToBatchThreadNumber(context->GetRowToBatchThreadNumber())
         .SetReadAheadCacheEnabled(context->ReadAheadCacheEnabled())
         .WithCacheConfig(context->GetCacheConfig())
+        .SetWarmupLevel(context->GetWarmupLevel())
         .WithCache(context->GetCache());
-
-    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReadContext> data_context, 
builder.Finish());
-    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<TableRead> data_read,
-                           TableRead::Create(std::move(data_context)));
-    auto* key_value_read = dynamic_cast<KeyValueTableRead*>(data_read.get());
-    if (!key_value_read) {
-        return Status::Invalid("audit_log system table requires key-value 
table read");
-    }
-    key_value_read->ForceKeepDelete(true);
-    bool include_sequence_number = 
core_options.TableReadSequenceNumberEnabled();
-    PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<arrow::Schema> output_schema, 
ArrowSchema());
-    return std::make_unique<ChangelogTableRead>(std::move(data_read), 
std::move(output_schema),
-                                                include_sequence_number, 
std::move(converter),
-                                                context->GetMemoryPool());
+    return builder.Finish();
 }
 
 Result<std::shared_ptr<arrow::Schema>> AuditLogSystemTable::BaseReadSchema() 
const {
diff --git a/src/paimon/core/table/system/audit_log_system_table.h 
b/src/paimon/core/table/system/audit_log_system_table.h
index e21e287c..780f5fa7 100644
--- a/src/paimon/core/table/system/audit_log_system_table.h
+++ b/src/paimon/core/table/system/audit_log_system_table.h
@@ -69,6 +69,14 @@ class AuditLogSystemTable : public SystemTable {
     Result<std::unique_ptr<TableRead>> NewChangelogRead(
         const std::shared_ptr<ReadContext>& context,
         std::shared_ptr<const ChangelogBatchConverter> converter) const;
+    /// Build the context for the data table underneath this changelog view.
+    ///
+    /// The builder starts from the defaults, so every setting the caller 
configured has to be
+    /// copied across explicitly: one that is not copied silently reverts to 
its default for
+    /// `$audit_log` and `$binlog`, and neither end reports it.
+    Result<std::unique_ptr<ReadContext>> CreateDataReadContext(
+        const std::shared_ptr<ReadContext>& context,
+        const std::map<std::string, std::string>& read_options) const;
     Result<std::shared_ptr<arrow::Schema>> BaseReadSchema() const;
     Result<std::map<std::string, std::string>> ReadOptions() const;
 
diff --git a/src/paimon/core/table/system/read_optimized_system_table.cpp 
b/src/paimon/core/table/system/read_optimized_system_table.cpp
index d7bfa091..8d569644 100644
--- a/src/paimon/core/table/system/read_optimized_system_table.cpp
+++ b/src/paimon/core/table/system/read_optimized_system_table.cpp
@@ -90,7 +90,7 @@ Result<std::unique_ptr<TableScan>> 
ReadOptimizedSystemTable::NewScan(
     return TableScan::Create(std::move(base_context));
 }
 
-Result<std::unique_ptr<TableRead>> ReadOptimizedSystemTable::NewRead(
+Result<std::unique_ptr<ReadContext>> 
ReadOptimizedSystemTable::CreateDataReadContext(
     const std::shared_ptr<ReadContext>& context) const {
     auto options = options_;
     std::string branch = context->GetBranch();
@@ -115,6 +115,7 @@ Result<std::unique_ptr<TableRead>> 
ReadOptimizedSystemTable::NewRead(
         
.WithFileSystemSchemeToIdentifierMap(context->GetFileSystemSchemeToIdentifierMap())
         .SetReadAheadCacheEnabled(context->ReadAheadCacheEnabled())
         .WithCacheConfig(context->GetCacheConfig())
+        .SetWarmupLevel(context->GetWarmupLevel())
         .WithCache(context->GetCache())
         .SetReadFieldNames(context->GetReadFieldNames())
         .SetReadFieldIds(context->GetReadFieldIds());
@@ -128,7 +129,13 @@ Result<std::unique_ptr<TableRead>> 
ReadOptimizedSystemTable::NewRead(
     if (context->GetSpecificTableSchema().has_value()) {
         builder.SetTableSchema(context->GetSpecificTableSchema().value());
     }
-    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReadContext> base_context, 
builder.Finish());
+    return builder.Finish();
+}
+
+Result<std::unique_ptr<TableRead>> ReadOptimizedSystemTable::NewRead(
+    const std::shared_ptr<ReadContext>& context) const {
+    PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ReadContext> base_context,
+                           CreateDataReadContext(context));
     return TableRead::Create(std::move(base_context));
 }
 
diff --git a/src/paimon/core/table/system/read_optimized_system_table.h 
b/src/paimon/core/table/system/read_optimized_system_table.h
index 14fadbfd..3123fe03 100644
--- a/src/paimon/core/table/system/read_optimized_system_table.h
+++ b/src/paimon/core/table/system/read_optimized_system_table.h
@@ -44,6 +44,14 @@ class ReadOptimizedSystemTable : public SystemTable {
         const std::shared_ptr<ReadContext>& context) const override;
 
  private:
+    /// Build the context for the data table underneath this read-optimized 
view.
+    ///
+    /// The builder starts from the defaults, so every setting the caller 
configured has to be
+    /// copied across explicitly: one that is not copied silently reverts to 
its default for `$ro`,
+    /// and neither end reports it.
+    Result<std::unique_ptr<ReadContext>> CreateDataReadContext(
+        const std::shared_ptr<ReadContext>& context) const;
+
     std::map<std::string, std::string> ReadOptimizedOptions() const;
 
     std::string table_path_;
diff --git a/src/paimon/core/table/system/system_table_test.cpp 
b/src/paimon/core/table/system/system_table_test.cpp
index 41ac2d8b..00a538f3 100644
--- a/src/paimon/core/table/system/system_table_test.cpp
+++ b/src/paimon/core/table/system/system_table_test.cpp
@@ -40,6 +40,7 @@
 #include "paimon/fs/file_system_factory.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/metrics.h"
+#include "paimon/read_context.h"
 #include "paimon/reader/batch_reader.h"
 #include "paimon/result.h"
 #include "paimon/status.h"
@@ -231,6 +232,49 @@ TEST(SystemTableTest, 
TestReadOptimizedSystemTablePathParsing) {
     ASSERT_EQ(parsed->system_table_name, ReadOptimizedSystemTable::kName);
 }
 
+// A system table builds a fresh ReadContext for the data table underneath it, 
starting from the
+// defaults, so a setting that is not copied across silently reverts: a caller 
that asked for NONE
+// or RAW would get DECODED back. `$ro` has its own builder chain and 
`$audit_log` and `$binlog`
+// share one, so all three are pinned here.
+TEST(SystemTableTest, TestNewReadPropagatesWarmupLevel) {
+    std::map<std::string, std::string> options = {{Options::FILE_SYSTEM, 
"local"},
+                                                  {Options::FILE_FORMAT, 
"orc"}};
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<TableSchema> table_schema,
+                         CreateTableSchemaForTest(options));
+
+    AuditLogSystemTable audit_log(/*fs=*/nullptr, "/tmp/table", table_schema, 
options);
+    BinlogSystemTable binlog(/*fs=*/nullptr, "/tmp/table", table_schema, 
options);
+    ReadOptimizedSystemTable read_optimized("/tmp/table", table_schema, 
options);
+
+    for (WarmupLevel level : {WarmupLevel::NONE, WarmupLevel::RAW, 
WarmupLevel::DECODED}) {
+        ReadContextBuilder builder("/tmp/table");
+        builder.SetOptions(options).SetWarmupLevel(level);
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> 
caller_unique_context, builder.Finish());
+        std::shared_ptr<ReadContext> 
caller_context(std::move(caller_unique_context));
+        ASSERT_EQ(level, caller_context->GetWarmupLevel());
+
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> ro_context,
+                             
read_optimized.CreateDataReadContext(caller_context));
+        EXPECT_EQ(level, ro_context->GetWarmupLevel()) << "$ro dropped the 
caller's WarmupLevel";
+
+        // The changelog chain is checked where the context is built rather 
than on the read: the
+        // ChangelogTableRead that wraps it is local to 
audit_log_system_table.cpp, so a test cannot
+        // name the type to reach the read underneath it. EXPECT rather than 
ASSERT, because the
+        // three chains are independent and one broken chain must not hide 
another.
+        ASSERT_OK_AND_ASSIGN(auto audit_log_options, audit_log.ReadOptions());
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> audit_log_context,
+                             audit_log.CreateDataReadContext(caller_context, 
audit_log_options));
+        EXPECT_EQ(level, audit_log_context->GetWarmupLevel())
+            << "$audit_log dropped the caller's WarmupLevel";
+
+        ASSERT_OK_AND_ASSIGN(auto binlog_options, binlog.ReadOptions());
+        ASSERT_OK_AND_ASSIGN(std::unique_ptr<ReadContext> binlog_context,
+                             binlog.CreateDataReadContext(caller_context, 
binlog_options));
+        EXPECT_EQ(level, binlog_context->GetWarmupLevel())
+            << "$binlog dropped the caller's WarmupLevel";
+    }
+}
+
 TEST(SystemTableTest, 
TestGlobalSystemTableWithoutCatalogReturnsNotImplemented) {
     ASSERT_OK_AND_ASSIGN(auto fs, FileSystemFactory::Get("local", "/tmp", {}));
     std::shared_ptr<FileSystem> shared_fs(std::move(fs));
diff --git a/src/paimon/testing/mock/mock_file_batch_reader.h 
b/src/paimon/testing/mock/mock_file_batch_reader.h
index 866fc636..e017d19f 100644
--- a/src/paimon/testing/mock/mock_file_batch_reader.h
+++ b/src/paimon/testing/mock/mock_file_batch_reader.h
@@ -201,10 +201,20 @@ class MockFileBatchReader : public 
PrefetchFileBatchReader {
     }
     void Close() override {}
 
+    /// Counts the hint instead of acting on it: this mock has no background 
work to start, so the
+    /// count is what lets a test tell a wrapper that forwards Warmup() from 
one that swallows it.
+    void Warmup() override {
+        warmup_count_++;
+    }
+
     std::vector<std::pair<uint64_t, uint64_t>> GetReadRanges() const {
         return read_ranges_;
     }
 
+    int32_t GetWarmupCount() const {
+        return warmup_count_;
+    }
+
     bool SupportPreciseBitmapSelection() const override {
         return false;
     }
@@ -266,6 +276,7 @@ class MockFileBatchReader : public PrefetchFileBatchReader {
     Status next_batch_status_;
     bool enable_randomize_batch_size_ = true;
     std::vector<std::pair<uint64_t, uint64_t>> read_ranges_;
+    int32_t warmup_count_ = 0;
     std::mt19937 random_engine_{std::random_device{}()};  // 
NOLINT(whitespace/braces)
 };
 

Reply via email to