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 b8b8cfe7 perf(prefetch): prefetch the late-materialization payload 
ranges mid-read (#342)
b8b8cfe7 is described below

commit b8b8cfe7eaafc0a8b642d8b0126565ba3c7e2ee9
Author: Yonghao Fang <[email protected]>
AuthorDate: Thu Sep 17 13:15:21 2026 +0800

    perf(prefetch): prefetch the late-materialization payload ranges mid-read 
(#342)
---
 include/paimon/reader/prefetch_file_batch_reader.h |  15 +
 include/paimon/utils/prefetch_cache_config.h       |  26 +-
 src/paimon/CMakeLists.txt                          |   1 -
 src/paimon/common/io/cache_input_stream_test.cpp   |   4 +-
 src/paimon/common/metrics/atomic_counter_pair.h    |   2 +-
 .../late_materializing_file_batch_reader.cpp       |  20 +
 .../reader/late_materializing_file_batch_reader.h  |  21 +-
 .../late_materializing_file_batch_reader_test.cpp  | 137 +++++++
 .../reader/prefetch_file_batch_reader_impl.cpp     |  71 +++-
 .../reader/prefetch_file_batch_reader_impl.h       |  17 +-
 .../prefetch_file_batch_reader_impl_test.cpp       |   4 +-
 src/paimon/common/utils/file_block_cache.cpp       |  57 ++-
 src/paimon/common/utils/file_block_cache.h         |   8 +-
 src/paimon/common/utils/read_ahead_cache.cpp       | 417 ++++++++++-----------
 src/paimon/common/utils/read_ahead_cache.h         | 157 ++++++--
 src/paimon/common/utils/read_ahead_cache_test.cpp  | 241 ++++++++++--
 .../core/operation/commit/retry_waiter_test.cpp    |  50 ---
 .../parquet/page_filtered_row_group_reader.cpp     |  26 +-
 .../parquet/parquet_file_batch_reader_test.cpp     |   2 +-
 src/paimon/testing/mock/mock_file_batch_reader.h   |  26 ++
 src/paimon/testing/mock/mock_file_system.h         |   7 +-
 .../testing/mock/mock_format_reader_builder.h      |  21 +-
 22 files changed, 946 insertions(+), 384 deletions(-)

diff --git a/include/paimon/reader/prefetch_file_batch_reader.h 
b/include/paimon/reader/prefetch_file_batch_reader.h
index 84227995..1969194b 100644
--- a/include/paimon/reader/prefetch_file_batch_reader.h
+++ b/include/paimon/reader/prefetch_file_batch_reader.h
@@ -19,6 +19,7 @@
 
 #pragma once
 
+#include <functional>
 #include <memory>
 #include <utility>
 #include <vector>
@@ -135,6 +136,20 @@ class PAIMON_EXPORT PrefetchFileBatchReader : public 
FileBatchReader {
     virtual Result<std::vector<std::pair<uint64_t, uint64_t>>> 
PreBufferRange() {
         return std::vector<std::pair<uint64_t, uint64_t>>{};
     }
+
+    /// Callback a reader reports byte ranges through when they only become 
known after reading has
+    /// started, so the prefetch layer can register them with its shared 
read-ahead cache.
+    ///
+    /// PreBufferRange() covers what is known up front; a reader whose ranges 
depend on data it
+    /// has already read - the late-materialization payload pass only knows 
which pages hold the
+    /// matched rows once the probe pass has run - reports them through this 
callback instead.
+    using PreBufferRangeCallback =
+        std::function<void(std::vector<std::pair<uint64_t, uint64_t>>&&)>;
+
+    /// Installs the callback above, or clears it when `callback` is empty. By 
default a reader has
+    /// no late byte ranges to report and ignores the callback.
+    /// @param callback The callback to report late byte ranges through.
+    virtual void SetPreBufferRangeCallback(PreBufferRangeCallback callback) {}
 };
 
 }  // namespace paimon
diff --git a/include/paimon/utils/prefetch_cache_config.h 
b/include/paimon/utils/prefetch_cache_config.h
index 9f20fbf2..d767fd92 100644
--- a/include/paimon/utils/prefetch_cache_config.h
+++ b/include/paimon/utils/prefetch_cache_config.h
@@ -34,8 +34,9 @@ namespace paimon {
 /// ReadAheadCache to balance memory usage, I/O efficiency, and latency hiding.
 class PAIMON_EXPORT CacheConfig {
  public:
-    /// Returns the maximum allowed size (in bytes) for a single cached range.
-    /// Defaults to 32 MiB.
+    /// Returns the maximum allowed size (in bytes) for a single cached range, 
both for the ranges
+    /// registered up front and, as the cap, for the ones registered mid-read.
+    /// Defaults to 8 MiB.
     uint64_t GetRangeSizeLimit() const {
         return range_size_limit_;
     }
@@ -46,7 +47,7 @@ class PAIMON_EXPORT CacheConfig {
     }
 
     /// Returns the maximum gap size (in bytes) considered mergeable between
-    /// adjacent ranges. Defaults to 8 KiB.
+    /// adjacent ranges. Defaults to 512 KiB.
     uint64_t GetHoleSizeLimit() const {
         return hole_size_limit_;
     }
@@ -93,15 +94,24 @@ class PAIMON_EXPORT CacheConfig {
  private:
     // The defaults are aligned with the reader's request granularity and with
     // realistic data file sizes:
-    // - range_size_limit matches the parquet reader's 32 MiB request blocks
-    //   (Arrow ReadRangeCache's own range limit); a smaller limit cuts entries
-    //   below the request size, so a request can never be served from one 
piece.
+    // - range_size_limit bounds a single coalesced cached range, both for the
+    //   ranges registered up front at Init and for the ones registered 
mid-read.
+    //   One range is one prefetch IO, so a smaller limit spreads a large pass
+    //   over several concurrent requests instead of one long serial one; a 
read
+    //   spanning several adjacent ranges is still served as one hit, as they 
are
+    //   contiguous. 8 MiB keeps a just-in-time mid-read pass concurrent while
+    //   staying large enough to amortize each request's round trip.
     // - pre_buffer_limit must exceed the LARGEST single read a reader issues
     //   (coalesced column-chunk reads of ~128 MiB were observed): fetches are
     //   only dispatched up to this window, so a request reaching past it can
     //   never be served and falls back to a second fetch of the same bytes.
-    uint64_t range_size_limit_ = 32 * 1024 * 1024;
-    uint64_t hole_size_limit_ = 8 * 1024;
+    // - hole_size_limit trades bytes against requests: coalescing across a gap
+    //   reads the gap too, but saves a request, and on remote storage a 
request
+    //   costs a round trip whatever its size. The limit is therefore well 
above
+    //   the page-sized gaps a filtered read leaves between the pages it keeps,
+    //   which would otherwise each cost a request of their own.
+    uint64_t range_size_limit_ = 8 * 1024 * 1024;
+    uint64_t hole_size_limit_ = 512 * 1024;
     uint64_t pre_buffer_limit_ = 256 * 1024 * 1024;
     // Blocks are aligned to the END of the file, so a block never reaches past
     // EOF. 64 KiB is the granularity the reads no prefetched range covers are
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index e32b3a9e..f463d674 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -902,7 +902,6 @@ if(PAIMON_BUILD_TESTS)
                     core/operation/commit/row_tracking_commit_utils_test.cpp
                     core/operation/commit/realtime_commit_properties_test.cpp
                     core/operation/commit/sequence_snapshot_properties_test.cpp
-                    core/operation/commit/retry_waiter_test.cpp
                     core/operation/key_value_file_store_write_test.cpp
                     core/operation/internal_read_context_test.cpp
                     core/operation/abstract_split_read_test.cpp
diff --git a/src/paimon/common/io/cache_input_stream_test.cpp 
b/src/paimon/common/io/cache_input_stream_test.cpp
index 072af67b..4c05c57c 100644
--- a/src/paimon/common/io/cache_input_stream_test.cpp
+++ b/src/paimon/common/io/cache_input_stream_test.cpp
@@ -71,7 +71,7 @@ class CacheInputStreamTest : public ::testing::Test {
         // tests exercise the fallback of CacheInputStream on a cache miss.
         auto cache =
             std::make_shared<ReadAheadCache>(std::move(stream), config, 
/*file_size=*/0, pool_);
-        EXPECT_OK(cache->Init(std::move(ranges)));
+        EXPECT_OK(cache->AddRanges(std::move(ranges)).status());
         return cache;
     }
 
@@ -218,7 +218,7 @@ TEST_F(CacheInputStreamTest, TestReadAsyncCacheReadError) {
         config.SetPreBufferLimit(1024 * 1024);
         auto cache = std::make_shared<ReadAheadCache>(std::move(cache_stream), 
config,
                                                       /*file_size=*/0, pool_);
-        ASSERT_OK(cache->Init(std::vector<ByteRange>{{0, 10}}));
+        ASSERT_OK(cache->AddRanges(std::vector<ByteRange>{{0, 10}}).status());
 
         // Now activate IOHook so that the prefetch IO (triggered by 
cache_->Read -> PreBuffer)
         // will fail at the i-th IO operation
diff --git a/src/paimon/common/metrics/atomic_counter_pair.h 
b/src/paimon/common/metrics/atomic_counter_pair.h
index 5a8297ac..27fd1729 100644
--- a/src/paimon/common/metrics/atomic_counter_pair.h
+++ b/src/paimon/common/metrics/atomic_counter_pair.h
@@ -35,7 +35,7 @@ struct AtomicCounterPair {
     std::atomic<uint64_t> bytes{0};
 
     /// Record one request covering `size` bytes.
-    void Add(uint64_t size) {
+    void Record(uint64_t size) {
         count.fetch_add(1, std::memory_order_relaxed);
         bytes.fetch_add(size, std::memory_order_relaxed);
     }
diff --git a/src/paimon/common/reader/late_materializing_file_batch_reader.cpp 
b/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
index 5554b92b..10bdaea1 100644
--- a/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
+++ b/src/paimon/common/reader/late_materializing_file_batch_reader.cpp
@@ -73,6 +73,7 @@ Result<FileBatchReader::ReadBatch> 
LateMaterializingFileBatchReader::NextBatch()
             PAIMON_RETURN_NOT_OK(
                 SetInnerReadSchema(payload_schema_, /*predicate=*/nullptr, 
matched_bitmap_));
             state_ = kRunning;
+            PAIMON_RETURN_NOT_OK(ReportPayloadPreBufferRanges());
         }
     }
 
@@ -262,6 +263,25 @@ Status 
LateMaterializingFileBatchReader::SetInnerReadSchema(
     return Status::OK();
 }
 
+Status LateMaterializingFileBatchReader::ReportPayloadPreBufferRanges() {
+    // The payload byte ranges only exist now: setting the payload schema 
refined the inner
+    // reader's target row groups down to the pages holding the matched rows. 
Reporting them
+    // here lets the shared read-ahead cache fetch them while this pass is 
still assembling
+    // its first batch, instead of every payload read waiting for its own IO.
+    if (!pre_buffer_range_callback_ || prefetch_inner_ == nullptr) {
+        return Status::OK();
+    }
+    // The error is propagated rather than swallowed: the ranges come from the 
file metadata, so a
+    // failure here means the reads about to follow would fail too.
+    using ByteRanges = std::vector<std::pair<uint64_t, uint64_t>>;
+    PAIMON_ASSIGN_OR_RAISE(ByteRanges ranges, 
prefetch_inner_->PreBufferRange());
+    if (ranges.empty()) {
+        return Status::OK();
+    }
+    pre_buffer_range_callback_(std::move(ranges));
+    return Status::OK();
+}
+
 Status LateMaterializingFileBatchReader::SetReadSchema(
     ::ArrowSchema* read_schema, const std::shared_ptr<Predicate>& predicate,
     const std::optional<RoaringBitmap32>& selection_bitmap) {
diff --git a/src/paimon/common/reader/late_materializing_file_batch_reader.h 
b/src/paimon/common/reader/late_materializing_file_batch_reader.h
index 7a225bca..962acf6e 100644
--- a/src/paimon/common/reader/late_materializing_file_batch_reader.h
+++ b/src/paimon/common/reader/late_materializing_file_batch_reader.h
@@ -93,14 +93,22 @@ class LateMaterializingFileBatchReader : public 
PrefetchFileBatchReader {
     Status SetReadRanges(const std::vector<std::pair<uint64_t, uint64_t>>& 
read_ranges) override;
 
     Result<std::vector<std::pair<uint64_t, uint64_t>>> PreBufferRange() 
override {
-        // TODO(zhouhongfeng.zhf): PrebufferRange (called by 
PrefetchFileBatchReader) only read the
-        // probe data, consider read the payload data as well.
+        // Deliberately only the probe ranges: this is asked once before any 
read, when the payload
+        // pass still has no idea which pages hold the matched rows. The 
payload ranges are
+        // reported through pre_buffer_range_callback_ once the probe pass has 
run, see
+        // ReportPayloadPreBufferRanges().
         if (prefetch_inner_ == nullptr) {
             return std::vector<std::pair<uint64_t, uint64_t>>{};
         }
         return prefetch_inner_->PreBufferRange();
     }
 
+    void SetPreBufferRangeCallback(PreBufferRangeCallback callback) override {
+        // Not forwarded to prefetch_inner_: a format reader has no late 
ranges of its own, and
+        // forwarding would report the same ranges twice.
+        pre_buffer_range_callback_ = std::move(callback);
+    }
+
  private:
     LateMaterializingFileBatchReader(std::unique_ptr<FileBatchReader> inner,
                                      PrefetchFileBatchReader* prefetch_inner,
@@ -141,6 +149,11 @@ class LateMaterializingFileBatchReader : public 
PrefetchFileBatchReader {
                               const std::shared_ptr<Predicate>& predicate,
                               const std::optional<RoaringBitmap32>& selection);
 
+    /// Report the byte ranges the payload pass is about to read to 
pre_buffer_range_callback_, so
+    /// that the prefetch layer can fetch them ahead of the pass. A no-op 
without a callback or
+    /// without a prefetch-capable inner reader.
+    Status ReportPayloadPreBufferRanges();
+
     /// Returns the inner reader's prefetch interface, or an error when the 
format reader does not
     /// implement it (avro and blob do not).
     Result<PrefetchFileBatchReader*> GetPrefetchReaderOrRaise(
@@ -163,6 +176,10 @@ class LateMaterializingFileBatchReader : public 
PrefetchFileBatchReader {
     /// inner_ is never reassigned, so the cast is resolved once in Create().
     PrefetchFileBatchReader* prefetch_inner_ = nullptr;
     std::shared_ptr<arrow::MemoryPool> arrow_pool_;
+    /// Where the payload byte ranges are reported. Part of how this reader is 
wired into the
+    /// prefetch layer rather than of a read generation, so Reset() leaves it 
alone.
+    /// TODO(yonghao.fyh): refactor pre buffer cache in the future
+    PreBufferRangeCallback pre_buffer_range_callback_;
     LatMatState state_ = kInit;
     std::shared_ptr<arrow::Schema> full_schema_;
     // projection holding only the predicate fields; nullptr when probing is 
not applicable
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 0849836c..3501e619 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
@@ -765,4 +765,141 @@ TEST_F(LateMaterializingFileBatchReaderTest, 
FailsOnPredicateTypeMismatch) {
         "mismatches");
 }
 
+// The payload byte ranges are only known once the probe pass has run, so they 
are reported through
+// the callback rather than through PreBufferRange(), and early enough to 
still be prefetched:
+// before the first payload batch leaves the reader.
+TEST_F(LateMaterializingFileBatchReaderTest, 
PayloadPreBufferRangesReportedToCallback) {
+    auto data = BuildData({0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
+    auto mock = std::make_unique<MockFileBatchReader>(data, full_type_, 
/*batch_size=*/3);
+    // Aliased because a type carrying a comma cannot be passed to the 
ASSERT_OK_AND_ASSIGN macro.
+    using ByteRanges = std::vector<std::pair<uint64_t, uint64_t>>;
+    const ByteRanges probe_ranges = {{0, 1024}};
+    const ByteRanges payload_ranges = {{100000, 2048}, {200000, 4096}};
+    mock->SetPreBufferRangesPerSchema({probe_ranges, payload_ranges});
+    ASSERT_OK_AND_ASSIGN(auto reader, LateMaterializingFileBatchReader::Create(
+                                          std::move(mock), 
GetArrowPool(pool_)));
+    int32_t batches = 0;
+    std::vector<ByteRanges> reported;
+    std::vector<int32_t> batches_when_reported;
+    reader->SetPreBufferRangeCallback([&](ByteRanges&& ranges) {
+        reported.push_back(std::move(ranges));
+        batches_when_reported.push_back(batches);
+    });
+    auto predicate =
+        PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(4l));
+    ASSERT_OK(SetReadSchema(reader.get(), arrow::schema(full_fields_), 
predicate, std::nullopt));
+    // What is known up front is the probe projection's ranges, not the 
payload's.
+    ASSERT_OK_AND_ASSIGN(ByteRanges up_front, reader->PreBufferRange());
+    EXPECT_EQ(up_front, probe_ranges);
+
+    while (true) {
+        ASSERT_OK_AND_ASSIGN(FileBatchReader::ReadBatch batch, 
reader->NextBatch());
+        if (BatchReader::IsEofBatch(batch)) {
+            break;
+        }
+        // Import the batch to run its release callback, as a real consumer 
would.
+        auto& [c_array, c_schema] = batch;
+        ASSERT_TRUE(arrow::ImportArray(c_array.get(), c_schema.get()).ok());
+        batches++;
+    }
+    EXPECT_GT(batches, 0);  // the payload pass did run
+    ASSERT_EQ(reported.size(), 1u);
+    EXPECT_EQ(reported[0], payload_ranges);
+    // Reported while the payload pass was still assembling its first batch, 
which is the whole
+    // point: reporting it later would leave the first payload reads waiting 
for their own IO.
+    EXPECT_EQ(batches_when_reported[0], 0);
+}
+
+// Without a predicate there is no payload pass, so there is no late range to 
report.
+TEST_F(LateMaterializingFileBatchReaderTest, 
NoCallbackReportWithoutLateMaterialization) {
+    auto data = BuildData({0, 1, 2, 3, 4});
+    auto mock = std::make_unique<MockFileBatchReader>(data, full_type_, 
/*batch_size=*/2);
+    mock->SetPreBufferRangesPerSchema({{{0, 1024}}, {{100000, 2048}}});
+    ASSERT_OK_AND_ASSIGN(auto reader, LateMaterializingFileBatchReader::Create(
+                                          std::move(mock), 
GetArrowPool(pool_)));
+    int32_t calls = 0;
+    reader->SetPreBufferRangeCallback(
+        [&calls](std::vector<std::pair<uint64_t, uint64_t>>&&) { calls++; });
+    ASSERT_OK(SetReadSchema(reader.get(), arrow::schema(full_fields_), 
/*predicate=*/nullptr,
+                            std::nullopt));
+
+    ASSERT_OK_AND_ASSIGN(std::vector<Row> rows, Collect(reader.get()));
+    ASSERT_EQ(rows.size(), 5u);
+    EXPECT_EQ(calls, 0);
+}
+
+// The probe pass matched nothing, so the payload pass never runs and reports 
no range.
+TEST_F(LateMaterializingFileBatchReaderTest, 
EmptyMatchDoesNotReportPayloadRanges) {
+    auto data = BuildData({0, 1, 2, 3, 4});
+    auto mock = std::make_unique<MockFileBatchReader>(data, full_type_, 
/*batch_size=*/2);
+    mock->SetPreBufferRangesPerSchema({{{0, 1024}}, {{100000, 2048}}});
+    ASSERT_OK_AND_ASSIGN(auto reader, LateMaterializingFileBatchReader::Create(
+                                          std::move(mock), 
GetArrowPool(pool_)));
+    int32_t calls = 0;
+    reader->SetPreBufferRangeCallback(
+        [&calls](std::vector<std::pair<uint64_t, uint64_t>>&&) { calls++; });
+    auto predicate =
+        PredicateBuilder::GreaterThan(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(100l));
+    ASSERT_OK(SetReadSchema(reader.get(), arrow::schema(full_fields_), 
predicate, std::nullopt));
+
+    ASSERT_OK_AND_ASSIGN(std::vector<Row> rows, Collect(reader.get()));
+    EXPECT_TRUE(rows.empty());
+    EXPECT_EQ(calls, 0);
+}
+
+// The payload ranges come from the file metadata, so failing to compute them 
means the payload
+// reads would fail too: the error surfaces from the read instead of being 
swallowed.
+TEST_F(LateMaterializingFileBatchReaderTest, 
PayloadPreBufferRangeErrorFailsRead) {
+    auto data = BuildData({0, 1, 2, 3, 4});
+    auto mock = std::make_unique<MockFileBatchReader>(data, full_type_, 
/*batch_size=*/2);
+    mock->SetPreBufferRangeStatus(Status::IOError("pre-buffer range 
unavailable"));
+    ASSERT_OK_AND_ASSIGN(auto reader, LateMaterializingFileBatchReader::Create(
+                                          std::move(mock), 
GetArrowPool(pool_)));
+    int32_t calls = 0;
+    reader->SetPreBufferRangeCallback(
+        [&calls](std::vector<std::pair<uint64_t, uint64_t>>&&) { calls++; });
+    auto predicate =
+        PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(2l));
+    ASSERT_OK(SetReadSchema(reader.get(), arrow::schema(full_fields_), 
predicate, std::nullopt));
+
+    ASSERT_NOK_WITH_MSG(reader->NextBatch(), "pre-buffer range unavailable");
+    EXPECT_EQ(calls, 0);
+}
+
+// End to end below the prefetch layer: the ranges the inner LM reader reports 
mid-read must reach
+// the shared read-ahead cache and be fetched, on top of the probe ranges 
registered up front.
+TEST_F(LateMaterializingFileBatchReaderTest, 
PrefetchInnerRegistersPayloadPreBufferRanges) {
+    auto data = BuildData({0, 1, 2, 3, 4, 5, 6, 7, 8, 9});
+    auto format_builder =
+        std::make_unique<MockFormatReaderBuilder>(data, full_type_, 
/*batch_size=*/3);
+    // Far enough apart that the cache's hole-size limit keeps them as two 
separate ranges.
+    format_builder->SetPreBufferRangesPerSchema({{{0, 1024}}, {{100000, 
2048}}});
+    LateMaterializingReaderBuilder builder(std::move(format_builder), 
GetArrowPool(pool_));
+    auto mock_fs = std::make_shared<MockFileSystem>();
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<Executor> executor, 
CreateDefaultExecutor(2));
+    ASSERT_OK_AND_ASSIGN(
+        std::unique_ptr<PrefetchFileBatchReaderImpl> impl,
+        PrefetchFileBatchReaderImpl::Create(
+            /*data_file_path=*/"", /*data_file_size=*/0, &builder, mock_fs,
+            /*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=*/true, CacheConfig(),
+            /*enable_io_metrics=*/false, WarmupLevel::DECODED, pool_, 
GetArrowPool(pool_)));
+    auto predicate =
+        PredicateBuilder::GreaterOrEqual(/*field_index=*/0, "k", 
FieldType::BIGINT, Literal(4l));
+    ::ArrowSchema c_schema;
+    ASSERT_TRUE(arrow::ExportSchema(*arrow::schema(full_fields_), 
&c_schema).ok());
+    ASSERT_OK(impl->SetReadSchema(&c_schema, predicate, std::nullopt));
+
+    ASSERT_OK_AND_ASSIGN(std::shared_ptr<arrow::StructArray> result, 
CollectStruct(impl.get()));
+    ASSERT_TRUE(result);
+    ASSERT_EQ(result->length(), 6);  // k = 4..9
+    std::shared_ptr<Metrics> metrics = impl->GetReaderMetrics();
+    ASSERT_TRUE(metrics);
+    ASSERT_OK_AND_ASSIGN(uint64_t io_bytes, 
metrics->GetCounter(ReadAheadCacheMetrics::IO_BYTES));
+    // Both the probe ranges registered before the read and the payload ranges 
registered during it.
+    EXPECT_EQ(io_bytes, 1024u + 2048u);
+    impl->Close();
+}
+
 }  // namespace paimon::test
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 2b2c7ec8..4e5e540e 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
@@ -322,6 +322,16 @@ 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, warmup_level, arrow_pool));
+    for (const auto& sub_reader : reader->readers_) {
+        // The sub-readers whose byte ranges only become known once they have 
read some data (the
+        // late-materialization payload pass) report them here instead of 
through PreBufferRange(),
+        // which is asked once per read-range generation before any read. 
Capturing the impl raw is
+        // safe: it owns readers_, and CleanUp() joins the background thread 
before it goes away.
+        sub_reader->SetPreBufferRangeCallback(
+            [impl = reader.get()](std::vector<std::pair<uint64_t, uint64_t>>&& 
ranges) {
+                impl->RegisterLatePreBufferRanges(std::move(ranges));
+            });
+    }
     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.
@@ -356,6 +366,7 @@ PrefetchFileBatchReaderImpl::PrefetchFileBatchReaderImpl(
 
 PrefetchFileBatchReaderImpl::~PrefetchFileBatchReaderImpl() {
     (void)CleanUp();
+    ClearPreBufferRangeCallbacks();
 }
 
 Status PrefetchFileBatchReaderImpl::SetReadSchema(
@@ -510,7 +521,9 @@ Status PrefetchFileBatchReaderImpl::CleanUp() {
     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's registered ranges, so the next read-range generation must be 
allowed to Init again.
+    // Clearing the flag also ends this generation's mid-read reports: a 
sub-reader reporting from
+    // here on registers nothing until the next generation warms the cache 
again.
     cache_warmed_.store(false);
     clean_prefetch_queue();
     prefetch_metrics_->queue_depth.store(0, kMetricsMemoryOrder);
@@ -766,10 +779,10 @@ 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.
+    // 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;
     }
@@ -782,16 +795,53 @@ void PrefetchFileBatchReaderImpl::WarmCacheOnce() {
     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);
+    Result<std::optional<uint64_t>> registered = 
cache_->AddRanges(std::move(ranges));
+    if (!registered.ok()) {
+        SetReadStatus(registered.status());
         return;
     }
-    // Init() only registers the ranges, so without this the first cache fetch 
races the readers'
-    // first reads instead of running ahead of them.
+    // AddRanges() 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::RegisterLatePreBufferRanges(
+    std::vector<std::pair<uint64_t, uint64_t>>&& read_ranges) {
+    if (!cache_ || read_ranges.empty()) {
+        return;
+    }
+    if (!cache_warmed_.load()) {
+        // The read-range generation these ranges belong to has ended, so 
nothing is going to read
+        // them. WarmCacheOnce() warms the next generation.
+        return;
+    }
+    std::vector<ByteRange> ranges;
+    ranges.reserve(read_ranges.size());
+    for (const auto& read_range : read_ranges) {
+        ranges.emplace_back(read_range.first, read_range.second);
+    }
+    Result<std::optional<uint64_t>> first_added = 
cache_->AddRanges(std::move(ranges));
+    if (!first_added.ok()) {
+        SetReadStatus(first_added.status());
+        return;
+    }
+    if (!first_added.value().has_value()) {
+        // Every range was already registered, so there is nothing new to 
fetch.
+        return;
+    }
+    // Fetch from the first new range rather than from the start: the ranges 
registered before it
+    // belong to the pass that has already run.
+    cache_->Warmup(first_added.value().value());
+}
+
+void PrefetchFileBatchReaderImpl::ClearPreBufferRangeCallbacks() {
+    // Belt and braces: the callbacks capture this, and by now CleanUp() has 
joined the background
+    // thread, so no sub-reader can report a range any more.
+    for (const auto& sub_reader : readers_) {
+        sub_reader->SetPreBufferRangeCallback(nullptr);
+    }
+}
+
 void PrefetchFileBatchReaderImpl::Warmup() {
     if (warmup_level_ == WarmupLevel::NONE) {
         return;
@@ -1032,6 +1082,7 @@ void PrefetchFileBatchReaderImpl::Close() {
     for (const auto& reader : readers_) {
         reader->Close();
     }
+    ClearPreBufferRangeCallbacks();
 }
 
 }  // namespace paimon
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 b0b0c6f7..164cf6c6 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.h
@@ -141,6 +141,19 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
     /// SetReadStatus() rather than returned: a warmup hint for a file that 
may never be read must
     /// not fail an in-flight read.
     void WarmCacheOnce();
+
+    /// Registers byte ranges a sub-reader discovered mid-read (the 
late-materialization payload
+    /// pass) with the shared cache and starts fetching them from there. A 
no-op without a cache, or
+    /// once the read-range generation the ranges belong to has ended 
(CleanUp() cleared
+    /// cache_warmed_): a report that outlives its generation - a cache reset 
by
+    /// SetReadSchema()/RefreshReadRanges(), or released by Close() - 
registers nothing instead of
+    /// prefetching bytes nobody reads. Errors are recorded via 
SetReadStatus() for the same reason
+    /// as in WarmCacheOnce().
+    void RegisterLatePreBufferRanges(std::vector<std::pair<uint64_t, 
uint64_t>>&& read_ranges);
+
+    /// Detaches the callbacks installed on the sub-readers, so that a 
sub-reader outliving this
+    /// reader cannot call back into it. Called once the background thread has 
been joined.
+    void ClearPreBufferRangeCallbacks();
     void SetReadStatus(const Status& status);
     Status GetReadStatus() const;
     Result<bool> IsEofRange(const std::pair<uint64_t, uint64_t>& read_range) 
const;
@@ -194,7 +207,9 @@ class PrefetchFileBatchReaderImpl : public 
PrefetchFileBatchReader {
     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_.
+    // generation, and gates the mid-read range reports of that generation: 
CleanUp() clears it, so
+    // a sub-reader reporting after the generation ended registers nothing. 
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_;
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 aa0bd2e4..7d0879d2 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
@@ -1316,8 +1316,8 @@ TEST_P(PrefetchFileBatchReaderImplTest, 
TestWarmupLevelRaw) {
                                         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".
+    // A second Warmup() must be a no-op: the one-shot guard prevents opening 
the cache round
+    // twice, which would otherwise fail with "Cache has already been 
initialized".
     reader->Warmup();
 
     std::shared_ptr<Metrics> metrics = reader->GetReaderMetrics();
diff --git a/src/paimon/common/utils/file_block_cache.cpp 
b/src/paimon/common/utils/file_block_cache.cpp
index 829ffb23..453a76eb 100644
--- a/src/paimon/common/utils/file_block_cache.cpp
+++ b/src/paimon/common/utils/file_block_cache.cpp
@@ -20,6 +20,8 @@
 #include "paimon/common/utils/file_block_cache.h"
 
 #include <cstring>
+#include <mutex>
+#include <shared_mutex>
 
 #include "paimon/common/memory/bytes_utils.h"
 
@@ -37,7 +39,7 @@ FileBlockCache::FileBlockCache(const 
std::shared_ptr<InputStream>& stream, uint6
 FileBlockCache::~FileBlockCache() {
     // The fetches write into the block buffers, so they must not outlive the
     // stream they read from.
-    std::lock_guard<std::mutex> lock(mutex_);
+    std::lock_guard<std::shared_mutex> lock(mutex_);
     for (auto& block : blocks_) {
         block.second->future.wait();
     }
@@ -50,11 +52,22 @@ bool FileBlockCache::Read(const ByteRange& range, char* 
dest) {
     const uint64_t index = IndexOf(range.offset);
     std::shared_ptr<Block> block;
     bool dispatch = false;
+    // Hit fast path under the read lock: concurrent reads of already-cached 
blocks
+    // proceed in parallel instead of serializing on an exclusive lock.
     {
-        // Publishing the promise-backed block under the lock before its fetch 
is
-        // dispatched is what makes concurrent readers of the same block wait 
for
-        // that one fetch instead of issuing their own.
-        std::lock_guard<std::mutex> lock(mutex_);
+        std::shared_lock<std::shared_mutex> read_lock(mutex_);
+        auto it = blocks_.find(index);
+        if (it != blocks_.end()) {
+            block = it->second;
+        }
+    }
+    if (!block) {
+        // Miss: take the write lock and re-check, since another reader may 
have
+        // inserted the block while the read lock was dropped. Publishing the
+        // promise-backed block under the lock before its fetch is dispatched 
is what
+        // makes concurrent readers of the same block wait for that one fetch 
instead
+        // of issuing their own.
+        std::unique_lock<std::shared_mutex> write_lock(mutex_);
         auto it = blocks_.find(index);
         if (it != blocks_.end()) {
             block = it->second;
@@ -95,32 +108,38 @@ bool FileBlockCache::Read(const ByteRange& range, char* 
dest) {
         return false;
     }
     std::memcpy(dest, block->buffer->data() + (range.offset - 
block->range.offset), range.length);
-    hits_.Add(range.length);
+    hit_metrics_.Record(range.length);
     return true;
 }
 
 void FileBlockCache::Release() {
-    std::lock_guard<std::mutex> lock(mutex_);
-    // Blocks are never evicted, so waiting on blocks_ covers every dispatched
-    // fetch before the buffers they write into go away.
-    for (auto& block : blocks_) {
+    std::unordered_map<uint64_t, std::shared_ptr<Block>> to_release;
+    {
+        std::lock_guard<std::shared_mutex> lock(mutex_);
+        // Swap the blocks out under the lock instead of waiting on them in 
place: an
+        // in-flight fetch may take arbitrarily long, and holding the lock 
while
+        // waiting would stall concurrent readers.
+        to_release.swap(blocks_);
+        cached_bytes_ = 0;
+    }
+    // Wait OUTSIDE the lock. Blocks are never evicted, so this covers every
+    // dispatched fetch before the buffers they write into go away.
+    for (auto& block : to_release) {
         block.second->future.wait();
     }
-    blocks_.clear();
-    cached_bytes_ = 0;
 }
 
 void FileBlockCache::ResetCounters() {
-    hits_.Reset();
-    fetches_.Reset();
+    hit_metrics_.Reset();
+    fetch_metrics_.Reset();
 }
 
 FileBlockCache::Counters FileBlockCache::GetCounters() const {
     Counters counters;
-    counters.hits = hits_.Count();
-    counters.hit_bytes = hits_.Bytes();
-    counters.fetches = fetches_.Count();
-    counters.fetch_bytes = fetches_.Bytes();
+    counters.hits = hit_metrics_.Count();
+    counters.hit_bytes = hit_metrics_.Bytes();
+    counters.fetches = fetch_metrics_.Count();
+    counters.fetch_bytes = fetch_metrics_.Bytes();
     return counters;
 }
 
@@ -153,7 +172,7 @@ ByteRange FileBlockCache::RangeOf(uint64_t index) const {
 }
 
 void FileBlockCache::Fetch(const std::shared_ptr<Block>& block) {
-    fetches_.Add(block->range.length);
+    fetch_metrics_.Record(block->range.length);
     auto promise = block->promise;
     auto buffer = block->buffer;
     // The buffer and the promise are captured, so the async read keeps its
diff --git a/src/paimon/common/utils/file_block_cache.h 
b/src/paimon/common/utils/file_block_cache.h
index 3a2d5737..10443b23 100644
--- a/src/paimon/common/utils/file_block_cache.h
+++ b/src/paimon/common/utils/file_block_cache.h
@@ -22,7 +22,7 @@
 #include <cstdint>
 #include <future>
 #include <memory>
-#include <mutex>
+#include <shared_mutex>
 #include <unordered_map>
 
 #include "paimon/common/metrics/atomic_counter_pair.h"
@@ -142,14 +142,14 @@ class PAIMON_EXPORT FileBlockCache {
     // Blocks are aligned, so keying them by index keeps them disjoint by
     // construction and needs no ordering. A plain mutex is enough: only the
     // reads that no prefetched range covers touch the map, and they are few.
-    mutable std::mutex mutex_;
+    mutable std::shared_mutex mutex_;
     std::unordered_map<uint64_t, std::shared_ptr<Block>> blocks_;
     // Bytes held by blocks_, guarded by mutex_ and bounded by capacity_.
     uint64_t cached_bytes_ = 0;
     // The requests served out of a block, and the block fetches issued to the
     // underlying stream.
-    AtomicCounterPair hits_;
-    AtomicCounterPair fetches_;
+    AtomicCounterPair hit_metrics_;
+    AtomicCounterPair fetch_metrics_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/utils/read_ahead_cache.cpp 
b/src/paimon/common/utils/read_ahead_cache.cpp
index 6e517aaa..68dc1803 100644
--- a/src/paimon/common/utils/read_ahead_cache.cpp
+++ b/src/paimon/common/utils/read_ahead_cache.cpp
@@ -23,11 +23,12 @@
 #include "paimon/common/utils/read_ahead_cache.h"
 
 #include <algorithm>
-#include <atomic>
 #include <cassert>
 #include <cstring>
 #include <future>
+#include <optional>
 #include <shared_mutex>
+#include <utility>
 
 #include "paimon/common/memory/bytes_utils.h"
 #include "paimon/common/metrics/atomic_counter_pair.h"
@@ -39,23 +40,8 @@
 
 namespace paimon {
 
-struct RangeCacheEntry {
-    ByteRange range;
-    std::shared_ptr<Bytes> buffer;
-    std::shared_future<Status> future;  // use shared_future in case of 
multiple get calls
-
-    RangeCacheEntry() = default;
-    RangeCacheEntry(const ByteRange& range, std::shared_ptr<Bytes> buffer,
-                    std::future<Status> future)
-        : range(range), buffer(std::move(buffer)), 
future(std::move(future).share()) {}
-
-    friend bool operator<(const RangeCacheEntry& left, const RangeCacheEntry& 
right) {
-        return left.range.offset < right.range.offset;
-    }
-};
-
 // Everything needed to dispatch the prefetch IO of an entry AFTER the entry
-// has been published into entries_: the promise resolves the entry's future
+// has been published into ranges_: the promise resolves the entry's future
 // and the buffer capture keeps the destination alive for the async IO.
 struct PendingFetch {
     ByteRange range;
@@ -82,146 +68,154 @@ void CopyRangeFromEntries(const 
std::vector<RangeCacheEntry>& covering, const By
 
 }  // namespace
 
-class ReadAheadCache::Impl {
- public:
-    Impl(const std::shared_ptr<InputStream>& stream, const CacheConfig& 
config, uint64_t file_size,
-         const std::shared_ptr<MemoryPool>& memory_pool);
-    ~Impl();
-
-    Status Init(std::vector<ByteRange>&& ranges);
-    Result<bool> Read(const ByteRange& range, char* dest);
-    void Reset();
-    void ReleaseBuffers();
-    void Warmup();
-    void CollectMetrics(std::shared_ptr<Metrics>* metrics) const;
-
- private:
-    /// Dispatch the prefetch IOs for entries that have already been published
-    /// into entries_.
-    void DispatchFetches(const std::vector<PendingFetch>& fetches);
-    /// Find the entries fully covering the given range under the read lock.
-    /// Returns an empty vector on miss. Entries are copied (shared buffers)
-    /// so the caller may use them after releasing the lock.
-    std::vector<RangeCacheEntry> FindCoveringEntries(const ByteRange& range);
-    void PreBuffer(uint64_t offset);
-
-    /// Mark, publish and fetch the pending ranges at the given indices.
-    ///
-    /// Marking is_cached_ and publishing the promise-backed entries happen
-    /// atomically under the write lock, before any IO is dispatched, so a
-    /// reader racing the prefetch waits on the in-flight entries instead of
-    /// re-fetching the same bytes.
-    void Cache(std::vector<size_t> pending_indices);
-
-    /// Clear the prefetch state, waiting for the fetches still writing into 
the
-    /// entry buffers. Leaves the block cache untouched.
-    void ReleasePrefetchBuffers();
+Result<std::optional<uint64_t>> 
ReadAheadCache::AddRanges(std::vector<ByteRange>&& ranges) {
+    // Both an up-front registration and a mid-read one are cut at the plain 
size limit.
+    PAIMON_ASSIGN_OR_RAISE(
+        std::vector<ByteRange> new_ranges,
+        ByteRangeCombiner::CoalesceByteRanges(std::move(ranges), 
config_.GetHoleSizeLimit(),
+                                              config_.GetRangeSizeLimit()));
+    for (const auto& new_range : new_ranges) {
+        PAIMON_RETURN_NOT_OK(ValidateValueInRange<int64_t>(new_range.offset, 
"range offset"));
+        PAIMON_RETURN_NOT_OK(ValidateValueInRange<int64_t>(new_range.length, 
"range length"));
+    }
 
-    std::shared_ptr<InputStream> stream_;
-    CacheConfig config_;
-    // Ordered by offset (so as to find a matching region by binary search)
-    std::vector<RangeCacheEntry> entries_;
-    std::shared_ptr<MemoryPool> memory_pool_;
-    std::shared_mutex rw_mutex_;
-    std::vector<std::atomic<bool>> is_cached_;
-    std::vector<ByteRange> pending_ranges_;
-    bool is_initialized_ = false;
-    // Caches the reads that no registered range covers, or null when the block
-    // cache is disabled. Owns its own locking and counters.
-    std::unique_ptr<FileBlockCache> block_cache_;
-    // The Read() requests issued to the cache and how they were served,
-    // aggregated over all the streams sharing this cache. A read is counted
-    // either as a hit, a block cache hit or a miss.
-    AtomicCounterPair reads_;
-    AtomicCounterPair hits_;
-    AtomicCounterPair misses_;
-    // The prefetch IO actually issued to the underlying stream. The block 
cache
-    // counts its own fetches, which CollectMetrics() adds to these.
-    AtomicCounterPair ios_;
-};
+    // Locked: a registration rewrites the very ranges_ a concurrent Read() 
serves from, so it
+    // must not race the read finding its covering entries.
+    std::unique_lock<std::shared_mutex> lock(rw_mutex_);
+    return MergeRangesLocked(std::move(new_ranges));
+}
 
-void ReadAheadCache::Impl::Cache(std::vector<size_t> pending_indices) {
-    std::vector<RangeCacheEntry> new_entries;
-    std::vector<PendingFetch> fetches;
-    // Mark is_cached_, publish the promise-backed entries and only then
-    // dispatch the IOs. The mark and the publication happen atomically under
-    // the write lock: a reader racing the prefetch observes is_cached_=true
-    // only once the covering entries are already visible, so it waits on
-    // their futures instead of issuing a duplicate underlying read.
-    {
-        std::unique_lock<std::shared_mutex> lock(rw_mutex_);
-        for (size_t idx : pending_indices) {
-            if (is_cached_[idx].exchange(true)) {
+std::optional<uint64_t> 
ReadAheadCache::MergeRangesLocked(std::vector<ByteRange>&& new_ranges) {
+    // Merge the two sorted, internally disjoint lists, keeping the result 
disjoint so that
+    // FindCoveringEntries() may keep walking it: of a new range, only the 
parts no registered
+    // range covers are registered. Already registered entries are moved over 
as-is, so a range
+    // that was published keeps its buffer and future and is never fetched 
again.
+    std::vector<RangeCacheEntry> merged;
+    merged.reserve(ranges_.size() + new_ranges.size());
+    std::optional<uint64_t> first_added;
+    size_t old_idx = 0;
+    auto keep_registered = [&]() {
+        merged.push_back(std::move(ranges_[old_idx]));
+        ++old_idx;
+    };
+    auto register_new = [&](uint64_t offset, uint64_t length) {
+        if (!first_added.has_value()) {
+            first_added = offset;
+        }
+        merged.emplace_back(ByteRange(offset, length));
+    };
+    for (const ByteRange& candidate : new_ranges) {
+        const uint64_t candidate_end = candidate.offset + candidate.length;
+        // Start of the part of the candidate that is not registered yet.
+        uint64_t cursor = candidate.offset;
+        while (cursor < candidate_end) {
+            if (old_idx == ranges_.size()) {
+                register_new(cursor, candidate_end - cursor);
+                break;
+            }
+            const ByteRange registered = ranges_[old_idx].range;
+            const uint64_t registered_end = registered.offset + 
registered.length;
+            if (registered_end <= cursor) {
+                // Entirely before what is left of the candidate, so its place 
in the merged list
+                // is settled.
+                keep_registered();
                 continue;
             }
-            const ByteRange& range = pending_ranges_[idx];
-            auto promise = std::make_shared<std::promise<Status>>();
-            auto future = promise->get_future();
-            auto buffer = AllocateBytesKeepingPoolAlive(range.length, 
memory_pool_);
-            fetches.push_back({range, buffer, promise});
-            new_entries.emplace_back(range, std::move(buffer), 
std::move(future));
-        }
-        if (!new_entries.empty()) {
-            // Entries are never evicted: the cache holds every published
-            // range until ReleaseBuffers()/Reset(), so an in-flight fetch
-            // always keeps its entry and thus its future reachable.
-            std::vector<RangeCacheEntry> merged(entries_.size() + 
new_entries.size());
-            std::merge(entries_.begin(), entries_.end(), new_entries.begin(), 
new_entries.end(),
-                       merged.begin());
-            entries_ = std::move(merged);
+            if (registered.offset >= candidate_end) {
+                // The candidate ends before this one starts: the rest of it 
is new.
+                register_new(cursor, candidate_end - cursor);
+                break;
+            }
+            // Overlap: the hole before the registered range is new, and the 
overlapping part is
+            // dropped because it is already being fetched. The registered 
range is left for the
+            // next iteration to settle, as a later candidate may still start 
before it.
+            if (registered.offset > cursor) {
+                register_new(cursor, registered.offset - cursor);
+            }
+            cursor = registered_end;
         }
     }
-    DispatchFetches(fetches);
-}
-
-Status ReadAheadCache::Impl::Init(std::vector<ByteRange>&& ranges) {
-    if (is_initialized_) {
-        return Status::Invalid("Cache has already been initialized");
-    }
-    PAIMON_ASSIGN_OR_RAISE(
-        std::vector<ByteRange> pending_ranges,
-        ByteRangeCombiner::CoalesceByteRanges(std::move(ranges), 
config_.GetHoleSizeLimit(),
-                                              config_.GetRangeSizeLimit()));
-    for (const auto& pending_range : pending_ranges) {
-        
PAIMON_RETURN_NOT_OK(ValidateValueInRange<int64_t>(pending_range.offset, "range 
offset"));
-        
PAIMON_RETURN_NOT_OK(ValidateValueInRange<int64_t>(pending_range.length, "range 
length"));
-    }
-    pending_ranges_ = pending_ranges;
-    is_cached_ = std::vector<std::atomic<bool>>(pending_ranges_.size());
-    for (auto& is_cached : is_cached_) {
-        is_cached.store(false);
+    while (old_idx < ranges_.size()) {
+        keep_registered();
     }
-    is_initialized_ = true;
-    return Status::OK();
+    // Always move the merged list back: keep_registered() has already moved 
the existing entries'
+    // buffer/future out of ranges_, so returning early without this would 
drop the published state
+    // (and the future to wait on) even when nothing new was registered.
+    ranges_ = std::move(merged);
+    return first_added;
 }
 
-void ReadAheadCache::Impl::PreBuffer(uint64_t offset) {
-    auto it = std::lower_bound(pending_ranges_.begin(), pending_ranges_.end(), 
offset,
-                               [](const ByteRange& range, uint64_t offset) {
-                                   return range.offset + range.length <= 
offset;
+bool ReadAheadCache::WindowHasUnpublishedLocked(uint64_t offset) const {
+    auto it = std::lower_bound(ranges_.begin(), ranges_.end(), offset,
+                               [](const RangeCacheEntry& entry, uint64_t 
offset) {
+                                   return entry.range.offset + 
entry.range.length <= offset;
                                });
-    if (it == pending_ranges_.end() || it->offset > offset) {
-        return;
+    if (it == ranges_.end() || it->range.offset > offset) {
+        return false;
     }
-
-    size_t start_idx = std::distance(pending_ranges_.begin(), it);
-    std::vector<size_t> pending_indices;
     size_t total_bytes = 0;
-    for (size_t i = start_idx; i < pending_ranges_.size(); ++i) {
-        total_bytes += pending_ranges_[i].length;
+    for (auto entry = it; entry != ranges_.end(); ++entry) {
+        total_bytes += entry->range.length;
         if (total_bytes > config_.GetPreBufferLimit()) {
             break;
         }
-        pending_indices.push_back(i);
+        if (!entry->Published()) {
+            return true;
+        }
     }
+    return false;
+}
 
-    if (!pending_indices.empty()) {
-        Cache(std::move(pending_indices));
+void ReadAheadCache::PreBuffer(uint64_t offset) {
+    // Fast path under the read lock: once the whole prefetch window is 
published there is nothing
+    // left to fetch, and returning here keeps the hot Read() path off the 
write lock, so concurrent
+    // readers' lookups are not serialized behind a publish that has nothing 
to publish.
+    {
+        std::shared_lock<std::shared_mutex> read_lock(rw_mutex_);
+        if (!WindowHasUnpublishedLocked(offset)) {
+            return;
+        }
     }
+    std::vector<PendingFetch> fetches;
+    {
+        std::unique_lock<std::shared_mutex> lock(rw_mutex_);
+        auto it = std::lower_bound(ranges_.begin(), ranges_.end(), offset,
+                                   [](const RangeCacheEntry& entry, uint64_t 
offset) {
+                                       return entry.range.offset + 
entry.range.length <= offset;
+                                   });
+        if (it == ranges_.end() || it->range.offset > offset) {
+            return;
+        }
+
+        size_t total_bytes = 0;
+        for (auto entry = it; entry != ranges_.end(); ++entry) {
+            total_bytes += entry->range.length;
+            if (total_bytes > config_.GetPreBufferLimit()) {
+                break;
+            }
+            if (entry->Published()) {
+                continue;
+            }
+            auto promise = std::make_shared<std::promise<Status>>();
+            auto future = promise->get_future();
+            auto buffer = AllocateBytesKeepingPoolAlive(entry->range.length, 
memory_pool_);
+            fetches.push_back({entry->range, buffer, promise});
+            // Published IN PLACE under the write lock, before the IO is 
dispatched below: a
+            // reader racing the prefetch then finds the entry and waits on 
its future instead of
+            // issuing a second fetch for the same bytes. Entries are never 
evicted, so an
+            // in-flight fetch always keeps its entry and thus its future 
reachable.
+            entry->buffer = std::move(buffer);
+            entry->future = std::move(future).share();
+        }
+    }
+    // Dispatched OUTSIDE the lock, so that a fetch completing inline does not 
deadlock against a
+    // Read() waiting for it.
+    DispatchFetches(fetches);
 }
 
-ReadAheadCache::Impl::Impl(const std::shared_ptr<InputStream>& stream, const 
CacheConfig& config,
-                           uint64_t file_size, const 
std::shared_ptr<MemoryPool>& memory_pool)
+ReadAheadCache::ReadAheadCache(const std::shared_ptr<InputStream>& stream,
+                               const CacheConfig& config, uint64_t file_size,
+                               const std::shared_ptr<MemoryPool>& memory_pool)
     : stream_(stream), config_(config), memory_pool_(memory_pool) {
     // An unknown file size cannot be aligned to, and a zero limit or block 
size
     // means the block cache is turned off: leave it null in those cases.
@@ -231,20 +225,22 @@ ReadAheadCache::Impl::Impl(const 
std::shared_ptr<InputStream>& stream, const Cac
     }
 }
 
-ReadAheadCache::Impl::~Impl() {
+ReadAheadCache::~ReadAheadCache() {
     std::unique_lock<std::shared_mutex> lock(rw_mutex_);
-    for (auto& entry : entries_) {
-        entry.future.wait();
+    for (auto& entry : ranges_) {
+        if (entry.Published()) {
+            entry.future.wait();
+        }
     }
     // The block cache waits for its own fetches when it is destroyed.
 }
 
-void ReadAheadCache::Impl::Reset() {
+void ReadAheadCache::Reset() {
     ReleasePrefetchBuffers();
-    reads_.Reset();
-    hits_.Reset();
-    misses_.Reset();
-    ios_.Reset();
+    read_metrics_.Reset();
+    hit_metrics_.Reset();
+    miss_metrics_.Reset();
+    io_metrics_.Reset();
     if (block_cache_ != nullptr) {
         // Only the counters: the blocks cache the file, not the registered
         // ranges, and a reader resetting the cache reads the same file again.
@@ -252,41 +248,47 @@ void ReadAheadCache::Impl::Reset() {
     }
 }
 
-void ReadAheadCache::Impl::ReleaseBuffers() {
+void ReadAheadCache::ReleaseBuffers() {
     ReleasePrefetchBuffers();
     if (block_cache_ != nullptr) {
         block_cache_->Release();
     }
 }
 
-void ReadAheadCache::Impl::ReleasePrefetchBuffers() {
-    std::unique_lock<std::shared_mutex> lock(rw_mutex_);
-    // Entries are never evicted, so waiting on entries_ covers every
-    // dispatched fetch: no fetch is still writing into an entry buffer when 
the
-    // buffers go away. The buffers keep the memory pool alive themselves, for
-    // the callbacks that a stream destroys later than it resolves them.
-    for (auto& entry : entries_) {
-        entry.future.wait();
+void ReadAheadCache::ReleasePrefetchBuffers() {
+    std::vector<RangeCacheEntry> to_release;
+    {
+        std::unique_lock<std::shared_mutex> lock(rw_mutex_);
+        // Swap the entries out under the lock instead of waiting on them in 
place: an in-flight
+        // fetch may take arbitrarily long, and holding the write lock while 
waiting would stall
+        // every concurrent reader. Once swapped, the entries are owned 
locally and no other thread
+        // can observe or mutate them. Entries are never evicted, so waiting 
on the published ones
+        // covers every dispatched fetch: no fetch is still writing into an 
entry buffer when the
+        // buffers go away. The buffers keep the memory pool alive themselves, 
for the callbacks
+        // that a stream destroys later than it resolves them.
+        to_release.swap(ranges_);
+    }
+    // Wait OUTSIDE the lock; the buffers are released when to_release 
destructs.
+    for (auto& entry : to_release) {
+        if (entry.Published()) {
+            entry.future.wait();
+        }
     }
-    entries_.clear();
-    is_cached_.clear();
-    pending_ranges_.clear();
-    is_initialized_ = false;
     // The read/io counters are deliberately kept: a reader closed at EOF must
     // still be able to report them through CollectMetrics().
 }
 
-void ReadAheadCache::Impl::CollectMetrics(std::shared_ptr<Metrics>* metrics) 
const {
+void ReadAheadCache::CollectMetrics(std::shared_ptr<Metrics>* metrics) const {
     if (metrics == nullptr || !*metrics) {
         return;
     }
     auto& m = *metrics;
-    m->SetCounter(ReadAheadCacheMetrics::READ_COUNT, reads_.Count());
-    m->SetCounter(ReadAheadCacheMetrics::READ_BYTES, reads_.Bytes());
-    m->SetCounter(ReadAheadCacheMetrics::READ_HITS, hits_.Count());
-    m->SetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES, hits_.Bytes());
-    m->SetCounter(ReadAheadCacheMetrics::READ_MISSES, misses_.Count());
-    m->SetCounter(ReadAheadCacheMetrics::READ_MISS_BYTES, misses_.Bytes());
+    m->SetCounter(ReadAheadCacheMetrics::READ_COUNT, read_metrics_.Count());
+    m->SetCounter(ReadAheadCacheMetrics::READ_BYTES, read_metrics_.Bytes());
+    m->SetCounter(ReadAheadCacheMetrics::READ_HITS, hit_metrics_.Count());
+    m->SetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES, hit_metrics_.Bytes());
+    m->SetCounter(ReadAheadCacheMetrics::READ_MISSES, miss_metrics_.Count());
+    m->SetCounter(ReadAheadCacheMetrics::READ_MISS_BYTES, 
miss_metrics_.Bytes());
     // The block cache keeps its own counters. Its fetches also go to the
     // underlying stream, so they are part of the io counters too.
     const FileBlockCache::Counters blocks =
@@ -295,28 +297,40 @@ void 
ReadAheadCache::Impl::CollectMetrics(std::shared_ptr<Metrics>* metrics) con
     m->SetCounter(ReadAheadCacheMetrics::BLOCK_HIT_BYTES, blocks.hit_bytes);
     m->SetCounter(ReadAheadCacheMetrics::BLOCK_FETCHES, blocks.fetches);
     m->SetCounter(ReadAheadCacheMetrics::BLOCK_FETCH_BYTES, 
blocks.fetch_bytes);
-    m->SetCounter(ReadAheadCacheMetrics::IO_COUNT, ios_.Count() + 
blocks.fetches);
-    m->SetCounter(ReadAheadCacheMetrics::IO_BYTES, ios_.Bytes() + 
blocks.fetch_bytes);
+    m->SetCounter(ReadAheadCacheMetrics::IO_COUNT, io_metrics_.Count() + 
blocks.fetches);
+    m->SetCounter(ReadAheadCacheMetrics::IO_BYTES, io_metrics_.Bytes() + 
blocks.fetch_bytes);
 }
 
-void ReadAheadCache::Impl::Warmup() {
-    // Init() only registers the pending ranges; without this the first fetch
+void ReadAheadCache::Warmup() {
+    // AddRanges() only registers the pending ranges; without this the first 
fetch
     // starts when the first Read() arrives, racing the reader's own miss 
fetch.
-    if (!pending_ranges_.empty()) {
-        PreBuffer(pending_ranges_.front().offset);
+    uint64_t from_offset = 0;
+    {
+        std::shared_lock<std::shared_mutex> lock(rw_mutex_);
+        if (ranges_.empty()) {
+            return;
+        }
+        from_offset = ranges_.front().range.offset;
     }
+    PreBuffer(from_offset);
+}
+
+void ReadAheadCache::Warmup(uint64_t from_offset) {
+    PreBuffer(from_offset);
 }
 
-std::vector<RangeCacheEntry> ReadAheadCache::Impl::FindCoveringEntries(const 
ByteRange& range) {
+std::vector<RangeCacheEntry> ReadAheadCache::FindCoveringEntries(const 
ByteRange& range) {
     std::vector<RangeCacheEntry> covering;
     std::shared_lock<std::shared_mutex> lock(rw_mutex_);
     // Find the entry holding the start of the range: the first entry whose
     // end is beyond range.offset (entries are disjoint and sorted by offset).
-    auto it = std::lower_bound(entries_.begin(), entries_.end(), range.offset,
+    auto it = std::lower_bound(ranges_.begin(), ranges_.end(), range.offset,
                                [](const RangeCacheEntry& e, uint64_t offset) {
                                    return e.range.offset + e.range.length <= 
offset;
                                });
-    if (it == entries_.end() || it->range.offset > range.offset) {
+    // An unpublished covering entry means its prefetch has not been dispatched
+    // yet, so the bytes are not there to serve: a miss.
+    if (it == ranges_.end() || it->range.offset > range.offset || 
!it->Published()) {
         return covering;
     }
     if (it->range.Contains(range)) {
@@ -324,15 +338,15 @@ std::vector<RangeCacheEntry> 
ReadAheadCache::Impl::FindCoveringEntries(const Byt
         return covering;
     }
     // The request spans several adjacent entries (a column chunk larger than
-    // one coalesced range): collect the contiguous run and check it covers
-    // the whole request. Entries are published before their fetch is
-    // dispatched, so a reader racing the prefetch waits for the in-flight
-    // fetch instead of issuing a second one for the same bytes.
+    // one coalesced range): collect the contiguous run of published entries 
and
+    // check it covers the whole request. Entries are published before their
+    // fetch is dispatched, so a reader racing the prefetch waits for the
+    // in-flight fetch instead of issuing a second one for the same bytes.
     uint64_t covered_end = it->range.offset + it->range.length;
     covering.push_back(*it);
     auto next = std::next(it);
-    while (covered_end < range.offset + range.length && next != entries_.end() 
&&
-           next->range.offset == covered_end) {
+    while (covered_end < range.offset + range.length && next != ranges_.end() 
&&
+           next->range.offset == covered_end && next->Published()) {
         covered_end = next->range.offset + next->range.length;
         covering.push_back(*next);
         ++next;
@@ -343,11 +357,11 @@ std::vector<RangeCacheEntry> 
ReadAheadCache::Impl::FindCoveringEntries(const Byt
     return covering;
 }
 
-Result<bool> ReadAheadCache::Impl::Read(const ByteRange& range, char* dest) {
+Result<bool> ReadAheadCache::Read(const ByteRange& range, char* dest) {
     if (range.length == 0) {
         return true;
     }
-    reads_.Add(range.length);
+    read_metrics_.Record(range.length);
     PreBuffer(range.offset);
     std::vector<RangeCacheEntry> covering = FindCoveringEntries(range);
     if (covering.empty()) {
@@ -358,7 +372,7 @@ Result<bool> ReadAheadCache::Impl::Read(const ByteRange& 
range, char* dest) {
             // The block cache counts its own hits, see CollectMetrics().
             return true;
         }
-        misses_.Add(range.length);
+        miss_metrics_.Record(range.length);
         return false;
     }
     // Wait OUTSIDE the lock: the futures resolve when the prefetch stream's
@@ -368,11 +382,11 @@ Result<bool> ReadAheadCache::Impl::Read(const ByteRange& 
range, char* dest) {
     }
     // The data copy runs OUTSIDE the lock for the same reason.
     CopyRangeFromEntries(covering, range, dest);
-    hits_.Add(range.length);
+    hit_metrics_.Record(range.length);
     return true;
 }
 
-void ReadAheadCache::Impl::DispatchFetches(const std::vector<PendingFetch>& 
fetches) {
+void ReadAheadCache::DispatchFetches(const std::vector<PendingFetch>& fetches) 
{
     for (const auto& fetch : fetches) {
         auto promise = fetch.promise;
         auto buffer = fetch.buffer;
@@ -381,39 +395,8 @@ void ReadAheadCache::Impl::DispatchFetches(const 
std::vector<PendingFetch>& fetc
         stream_->ReadAsync(
             buffer->data(), read_size, read_offset,
             [promise, buffer](Status status) mutable { 
promise->set_value(status); });
-        ios_.Add(fetch.range.length);
+        io_metrics_.Record(fetch.range.length);
     }
 }
 
-ReadAheadCache::ReadAheadCache(const std::shared_ptr<InputStream>& stream,
-                               const CacheConfig& config, uint64_t file_size,
-                               const std::shared_ptr<MemoryPool>& memory_pool)
-    : impl_(std::make_unique<Impl>(stream, config, file_size, memory_pool)) {}
-
-ReadAheadCache::~ReadAheadCache() = default;
-
-Status ReadAheadCache::Init(std::vector<ByteRange>&& ranges) {
-    return impl_->Init(std::move(ranges));
-}
-
-Result<bool> ReadAheadCache::Read(const ByteRange& range, char* dest) {
-    return impl_->Read(range, dest);
-}
-
-void ReadAheadCache::Reset() {
-    return impl_->Reset();
-}
-
-void ReadAheadCache::ReleaseBuffers() {
-    return impl_->ReleaseBuffers();
-}
-
-void ReadAheadCache::Warmup() {
-    impl_->Warmup();
-}
-
-void ReadAheadCache::CollectMetrics(std::shared_ptr<Metrics>* metrics) const {
-    impl_->CollectMetrics(metrics);
-}
-
 }  // namespace paimon
diff --git a/src/paimon/common/utils/read_ahead_cache.h 
b/src/paimon/common/utils/read_ahead_cache.h
index e44c277d..4eec7383 100644
--- a/src/paimon/common/utils/read_ahead_cache.h
+++ b/src/paimon/common/utils/read_ahead_cache.h
@@ -23,9 +23,14 @@
 #pragma once
 
 #include <cstdint>
+#include <future>
 #include <memory>
+#include <optional>
+#include <shared_mutex>
+#include <utility>
 #include <vector>
 
+#include "paimon/common/metrics/atomic_counter_pair.h"
 #include "paimon/fs/file_system.h"
 #include "paimon/memory/memory_pool.h"
 #include "paimon/result.h"
@@ -36,6 +41,9 @@
 namespace paimon {
 
 class Metrics;
+class FileBlockCache;
+class Bytes;
+struct PendingFetch;
 
 /// Metric names for the read-ahead cache.
 class PAIMON_EXPORT ReadAheadCacheMetrics {
@@ -98,11 +106,36 @@ struct PAIMON_EXPORT ByteRange {
     }
 };
 
-/// A read cache designed to hide IO latencies when reading.
-/// Prefetching strategy: When a range is read, the cache will prefetch up to
-/// `pre_buffer_range_count` additional adjacent ranges ahead of the requested 
offset. This helps
-/// hide I/O latency for sequential access. Example: If you read range [0, 
100), and
-/// pre_buffer_range_count=2, the next two configured ranges will also be 
prefetched.
+/// A registered range of the cache. `buffer` is null until the prefetch for 
the
+/// range is published (dispatched); once published it holds the destination 
the
+/// async IO writes into and `future` resolves when that IO completes. So
+/// `Published()` distinguishes "registered, not fetched yet" from "fetched".
+struct RangeCacheEntry {
+    ByteRange range;
+    std::shared_ptr<Bytes> buffer;
+    std::shared_future<Status> future;  // use shared_future in case of 
multiple get calls
+
+    RangeCacheEntry() = default;
+    /// Register a range without publishing it: no buffer, no fetch yet.
+    explicit RangeCacheEntry(const ByteRange& range) : range(range) {}
+    RangeCacheEntry(const ByteRange& range, std::shared_ptr<Bytes> buffer,
+                    std::future<Status> future)
+        : range(range), buffer(std::move(buffer)), 
future(std::move(future).share()) {}
+
+    /// True once the prefetch for this range has been published (buffer 
assigned).
+    bool Published() const {
+        return buffer != nullptr;
+    }
+
+    friend bool operator<(const RangeCacheEntry& left, const RangeCacheEntry& 
right) {
+        return left.range.offset < right.range.offset;
+    }
+};
+
+/// A read cache designed to hide IO latency when reading.
+/// Prefetching strategy: a Read() (or Warmup()) at an offset publishes and 
fetches the registered
+/// ranges from that offset forward until their cumulative size exceeds 
`pre_buffer_limit`, so a
+/// sequential read runs ahead of the caller instead of fetching one range at 
a time.
 ///
 /// The cache never evicts: every published range stays cached until
 /// ReleaseBuffers() or Reset(). It is meant to hold the prefetched ranges of
@@ -113,7 +146,7 @@ struct PAIMON_EXPORT ByteRange {
 /// instead of being left to the caller. That block cache is owned by this one
 /// and shares its lifetime: it is configured from `block_size` and
 /// `block_cache_limit`, it survives Reset() - the blocks belong to the file
-/// rather than to a registration round - and it is released by 
ReleaseBuffers().
+/// rather than to the registered ranges - and it is released by 
ReleaseBuffers().
 class PAIMON_EXPORT ReadAheadCache {
  public:
     /// Construct a read cache with given options
@@ -127,14 +160,23 @@ class PAIMON_EXPORT ReadAheadCache {
                    uint64_t file_size, const std::shared_ptr<MemoryPool>& 
memory_pool);
     ~ReadAheadCache();
 
-    /// Initialize the cache with given byte ranges to be cached.
-    /// @param ranges The byte ranges to be cached.
-    /// @return Status of the operation.
-    /// @note This method must be called before any Read() calls. Ranges will 
be coalesced based
-    /// on the cache configuration.
-    Status Init(std::vector<ByteRange>&& ranges);
+    /// Register byte ranges to prefetch, merging them into the ranges already 
registered.
+    ///
+    /// The cache's only registration entry point; safe to call repeatedly and 
concurrently with
+    /// Read(). Ranges known up front are registered before any Read(); ranges 
that only become
+    /// known while reading (a late-materialization payload pass learns its 
pages after the probe
+    /// pass) are registered as they are discovered. The list stays disjoint 
and offset-ordered, so
+    /// only the part of a new range that no registered range covers is added 
- the overlap is
+    /// dropped, as those bytes are already being fetched - and each 
registration is cut into ranges
+    /// of at most `range_size_limit` bytes (one prefetch IO each) so a large 
pass is fetched
+    /// concurrently. Which ranges to register, and not reporting those of an 
ended read, is the
+    /// caller's concern.
+    ///
+    /// @param ranges The byte ranges to register.
+    /// @return The offset of the first newly registered range, or nullopt 
when nothing was added.
+    Result<std::optional<uint64_t>> AddRanges(std::vector<ByteRange>&& ranges);
 
-    /// Read a range previously provided to Init(), copying the cached data
+    /// Read a range previously registered through AddRanges(), copying the 
cached data
     /// directly into the given destination buffer.
     ///
     /// Multi-segment hits are copied into `dest` segment by segment, without
@@ -148,31 +190,33 @@ class PAIMON_EXPORT ReadAheadCache {
     /// filled; false on cache miss (`dest` is left untouched).
     Result<bool> Read(const ByteRange& range, char* dest);
 
-    /// Start fetching the first batch of pending ranges immediately.
-    /// Init() only registers the ranges; without Warmup() the first fetch 
starts
-    /// when the first Read() arrives, racing the caller's own miss fetch.
+    /// Start fetching from the first registered range forward immediately. 
AddRanges() only
+    /// registers the ranges; without Warmup() the first fetch starts when the 
first Read() arrives,
+    /// racing the caller's own miss fetch.
     void Warmup();
 
-    /// Collect the counters of the Read() calls, of the block cache and of the
-    /// IOs into the given metrics as counters named after
-    /// `ReadAheadCacheMetrics`. Only reads issued through Read() are counted
-    /// as hits, block hits or misses; the fetches the cache dispatches itself
-    /// are counted in the io counters instead.
+    /// Start fetching the registered ranges from `from_offset` forward, 
bounded by the
+    /// pre-buffer limit. Warmup() is this call made from the first registered 
range.
+    /// @param from_offset The offset to start fetching from, typically one 
AddRanges() returned.
+    void Warmup(uint64_t from_offset);
+
+    /// Collect the counters of the Read() calls, of the block cache and of 
the IOs into the given
+    /// metrics as counters named after `ReadAheadCacheMetrics`. Only reads 
issued through Read()
+    /// are counted as hits, block hits or misses; the fetches the cache 
dispatches are counted in
+    /// the io counters instead.
     /// @param metrics The metrics to write the counters into. A null
     /// pointer or a null shared pointer is a no-op.
     void CollectMetrics(std::shared_ptr<Metrics>* metrics) const;
 
-    /// Reset the cache to its initial state, clearing all cached data and 
configuration.
-    ///
-    /// This method waits for all ongoing asynchronous read operations to 
complete,
-    /// clears all cached entries, and resets the internal state so that 
Init() can be called again.
-    /// After calling Reset, the cache can be safely re-initialized with new 
ranges.
+    /// Drop the prefetched entries and zero all counters, so a fresh set of 
ranges can be
+    /// registered with AddRanges(). Waits for the in-flight fetches before 
their buffers go away.
     ///
-    /// The block cache is kept: it caches the file rather than the registered
-    /// ranges, and a reader reusing the cache reads the same file again.
+    /// The block cache is kept - it caches the file rather than the 
registered ranges, and a reader
+    /// reusing the cache reads the same file again - but its counters are 
zeroed too. Unlike
+    /// ReleaseBuffers(), the counters do not survive.
     void Reset();
 
-    /// Release all cached buffers and pending ranges while keeping the 
hit/miss
+    /// Release all cached buffers and registered ranges while keeping the 
hit/miss
     /// counters intact.
     ///
     /// Unlike Reset(), the counters recorded by Read() remain readable through
@@ -182,8 +226,59 @@ class PAIMON_EXPORT ReadAheadCache {
     void ReleaseBuffers();
 
  private:
-    class Impl;
-    std::unique_ptr<Impl> impl_;
+    /// Merge coalesced, validated `new_ranges` into the disjoint, 
offset-ordered ranges_, moving
+    /// the already registered entries (and thus their published 
buffer/future) over as-is, and
+    /// return the offset of the first newly registered range (nullopt when 
nothing was added).
+    /// The caller holds the write lock.
+    std::optional<uint64_t> MergeRangesLocked(std::vector<ByteRange>&& 
new_ranges);
+
+    /// Dispatch the prefetch IOs for entries that have already been published 
into ranges_.
+    void DispatchFetches(const std::vector<PendingFetch>& fetches);
+
+    /// Find the published entries fully covering the given range under the 
read lock.
+    /// Returns an empty vector on miss. Entries are copied (shared buffers)
+    /// so the caller may use them after releasing the lock.
+    std::vector<RangeCacheEntry> FindCoveringEntries(const ByteRange& range);
+
+    /// True when the prefetch window starting at `offset` still holds an 
entry whose fetch has not
+    /// been published. The caller holds either lock; PreBuffer() uses it 
under the read lock to
+    /// skip the write lock entirely when there is nothing left to fetch.
+    bool WindowHasUnpublishedLocked(uint64_t offset) const;
+
+    /// Publish and fetch the registered ranges from the given offset forward, 
bounded by the
+    /// pre-buffer limit.
+    ///
+    /// Selecting the ranges and publishing them (filling buffer/future in 
place in ranges_) all
+    /// happen under the write lock, before any IO is dispatched: AddRanges() 
rewrites ranges_
+    /// from another thread, and a reader racing the prefetch must observe an 
entry published only
+    /// once it is already visible in ranges_, so it waits on the in-flight 
entry instead of
+    /// re-fetching the same bytes.
+    void PreBuffer(uint64_t offset);
+
+    /// Clear the prefetch state, waiting for the fetches still writing into 
the
+    /// entry buffers. Leaves the block cache untouched.
+    void ReleasePrefetchBuffers();
+
+    std::shared_ptr<InputStream> stream_;
+    CacheConfig config_;
+    // Every registered range, ordered by offset and disjoint (so a matching
+    // region is found by binary search). An entry whose buffer is still null 
is
+    // registered but not fetched yet; publishing fills buffer/future in place.
+    std::vector<RangeCacheEntry> ranges_;
+    std::shared_ptr<MemoryPool> memory_pool_;
+    mutable std::shared_mutex rw_mutex_;
+    // Caches the reads that no registered range covers, or null when the block
+    // cache is disabled. Owns its own locking and counters.
+    std::unique_ptr<FileBlockCache> block_cache_;
+    // The Read() requests issued to the cache and how they were served,
+    // aggregated over all the streams sharing this cache. A read is counted
+    // either as a hit, a block cache hit or a miss.
+    AtomicCounterPair read_metrics_;
+    AtomicCounterPair hit_metrics_;
+    AtomicCounterPair miss_metrics_;
+    // The prefetch IO actually issued to the underlying stream. The block 
cache
+    // counts its own fetches, which CollectMetrics() adds to these.
+    AtomicCounterPair io_metrics_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/utils/read_ahead_cache_test.cpp 
b/src/paimon/common/utils/read_ahead_cache_test.cpp
index 60fe8709..c9f7454a 100644
--- a/src/paimon/common/utils/read_ahead_cache_test.cpp
+++ b/src/paimon/common/utils/read_ahead_cache_test.cpp
@@ -22,6 +22,7 @@
 #include <chrono>
 #include <fstream>
 #include <memory>
+#include <optional>
 #include <thread>
 #include <vector>
 
@@ -37,7 +38,9 @@
 namespace paimon::test {
 
 // The range limits the tests exercise. The defaults are sized for real data
-// files, which the small test files would never reach.
+// files, which the small test files would never reach. The ranges registered
+// mid-read are cut at the same limit as the ones registered up front, so a 
test
+// that does not care about the late granularity sees one range per 
registration.
 CacheConfig TestCacheConfig(uint64_t range_size_limit, uint64_t 
hole_size_limit,
                             uint64_t pre_buffer_limit) {
     CacheConfig config;
@@ -85,7 +88,7 @@ std::shared_ptr<ReadAheadCache> CreateTestFileAndCache(const 
std::string& filena
     std::shared_ptr<InputStream> in = OpenTestFile(&dir, filename, content);
     std::shared_ptr<ReadAheadCache> cache =
         std::make_shared<ReadAheadCache>(in, config, file_size, TestPool());
-    EXPECT_OK(cache->Init(std::move(ranges)));
+    EXPECT_OK(cache->AddRanges(std::move(ranges)).status());
     return cache;
 }
 
@@ -108,6 +111,13 @@ void AssertReadMiss(const ByteRange& range, 
ReadAheadCache* cache) {
     ASSERT_EQ(std::string(dest.size(), 'X'), dest);
 }
 
+// Report the counter of the given metric name, or zero when it was not 
collected.
+uint64_t CounterOf(const char* name, ReadAheadCache* cache) {
+    std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+    cache->CollectMetrics(&metrics);
+    return metrics->GetCounter(name).value_or(0);
+}
+
 TEST(TestReadAheadCache, TestBasics) {
     CacheConfig config =
         TestCacheConfig(/*range_size_limit=*/10,
@@ -142,7 +152,7 @@ TEST(TestReadAheadCache, TestMultiSegmentContiguousHit) {
     CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
                                          /*hole_size_limit=*/2, 
/*pre_buffer_limit=*/1024);
     std::string content = "abcdefghijklmnopqrstuvwxyz";
-    // A single 25-byte range exceeds range_size_limit, so Init() coalesces it
+    // A single 25-byte range exceeds range_size_limit, so AddRanges() 
coalesces and splits it
     // into three adjacent entries: {0,10}, {10,10} and {20,5}.
     std::shared_ptr<ReadAheadCache> cache_ptr =
         CreateTestFileAndCache("data_file", content, config, {{0, 25}});
@@ -380,7 +390,7 @@ TEST(TestReadAheadCache, 
TestInFlightEntryServesRacingReader) {
     auto gated = std::make_shared<GatedAsyncInputStream>(OpenTestFile(&dir, 
"data_file", content));
 
     ReadAheadCache cache(gated, config, /*file_size=*/0, TestPool());
-    ASSERT_OK(cache.Init({{0, 5}}));
+    ASSERT_OK(cache.AddRanges({{0, 5}}).status());
     cache.Warmup();
 
     // The prefetch entry is published, but its fetch is still held.
@@ -430,7 +440,7 @@ TEST(TestReadAheadCache, 
TestPrefetchBufferKeepsPoolAliveAfterCacheIsGone) {
         // Declared after the pool, so the cache is destroyed before the last
         // reference of this test to the pool is dropped.
         ReadAheadCache cache(gated, config, /*file_size=*/0, pool);
-        ASSERT_OK(cache.Init({{0, 5}}));
+        ASSERT_OK(cache.AddRanges({{0, 5}}).status());
         cache.Warmup();
         ASSERT_EQ(gated->AsyncReadCount(), 1);
         // The fetch is completed, but the stream keeps its callback - and 
with it
@@ -474,8 +484,8 @@ TEST(TestReadAheadCache, TestPreBufferWindowLimit) {
     ASSERT_EQ(io_hook->IOCount(), 0);
 }
 
-// Test that Init() rejects a second call until the cache is reset.
-TEST(TestReadAheadCache, TestDoubleInit) {
+// Test that the cache can be re-initialized after Reset() and serves the new 
ranges.
+TEST(TestReadAheadCache, TestReinitAfterReset) {
     CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
                                          /*hole_size_limit=*/2, 
/*pre_buffer_limit=*/1024);
     std::string content = "abcdefghijklmnopqrstuvwxyz";
@@ -483,35 +493,220 @@ TEST(TestReadAheadCache, TestDoubleInit) {
         CreateTestFileAndCache("data_file", content, config, {{0, 5}});
     ReadAheadCache& cache = *cache_ptr;
 
-    Status status = cache.Init({{8, 5}});
-    ASSERT_FALSE(status.ok());
-
-    // The original ranges still work.
     AssertReadEquals({0, 5}, "abcde", &cache);
+
+    cache.Reset();
+    ASSERT_OK(cache.AddRanges({{3, 4}}).status());
+    AssertReadEquals({3, 4}, "defg", &cache);
+
+    // The old ranges are gone.
+    AssertReadMiss({20, 2}, &cache);
 }
 
-// Test that the cache can be re-initialized after Reset() and serves the new 
ranges.
-TEST(TestReadAheadCache, TestReinitAfterReset) {
+// Test that AddRanges() registers ranges that only become known after the 
cache was
+// initialized, so the reads that follow are served from the cache.
+TEST(TestReadAheadCache, TestAddRangesRegistersNewRanges) {
     CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
-                                         /*hole_size_limit=*/2, 
/*pre_buffer_limit=*/1024);
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    AssertReadMiss({16, 10}, &cache);
+
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{16, 10}}));
+    ASSERT_TRUE(first_added.has_value());
+    ASSERT_EQ(16u, first_added.value());
+
+    AssertReadEquals({16, 10}, "qrstuvwxyz", &cache);
+    // The range registered first is still served.
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+}
+
+// Of a new range intersecting an already registered one, only the part the 
registered ranges do
+// not cover is registered: the registered ranges stay disjoint, and the read 
the new range asked
+// for is served instead of dropped along with it.
+TEST(TestReadAheadCache, TestAddRangesClipsIntersectingRange) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{5, 10}}));
+    ASSERT_TRUE(first_added.has_value());
+    // [5, 10) is already registered, so the registration starts at the first 
byte that is not.
+    ASSERT_EQ(10u, first_added.value());
+
+    // Served by the registered range and the newly registered one together, 
as they are adjacent.
+    AssertReadEquals({5, 10}, "fghijklmno", &cache);
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+}
+
+// A new range fully covered by the registered ones registers nothing at all.
+TEST(TestReadAheadCache, TestAddRangesDropsCoveredRange) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{2, 4}}));
+    ASSERT_FALSE(first_added.has_value());
+
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+}
+
+// The registered ranges stay offset-ordered and disjoint when the new ranges 
interleave with them,
+// so that a read still finds its covering entries.
+TEST(TestReadAheadCache, TestAddRangesInterleavesWithRegisteredRanges) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/26,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{4, 4}, {16, 
4}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    // Two ranges each straddling a registered one, plus one entirely in the 
hole between them.
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added,
+                         cache.AddRanges({{2, 4}, {10, 2}, {18, 6}}));
+    ASSERT_TRUE(first_added.has_value());
+    ASSERT_EQ(2u, first_added.value());
+
+    AssertReadEquals({2, 6}, "cdefgh", &cache);
+    AssertReadEquals({10, 2}, "kl", &cache);
+    AssertReadEquals({16, 8}, "qrstuvwx", &cache);
+    // The holes nobody registered are still misses.
+    AssertReadMiss({12, 4}, &cache);
+}
+
+// A registered range larger than the range size limit is cut into pieces, so 
it is fetched by
+// several concurrent requests rather than by one long one. The pieces are 
adjacent, so a read
+// spanning them is still served.
+TEST(TestReadAheadCache, TestAddRangesSplitsLargeRange) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/5,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
     std::string content = "abcdefghijklmnopqrstuvwxyz";
     std::shared_ptr<ReadAheadCache> cache_ptr =
         CreateTestFileAndCache("data_file", content, config, {{0, 5}});
     ReadAheadCache& cache = *cache_ptr;
 
-    AssertReadEquals({0, 5}, "abcde", &cache);
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{10, 15}}));
+    ASSERT_TRUE(first_added.has_value());
+    ASSERT_EQ(10u, first_added.value());
 
-    cache.Reset();
-    ASSERT_OK(cache.Init({{3, 4}}));
-    AssertReadEquals({3, 4}, "defg", &cache);
+    cache.Warmup(first_added.value());
+    // One request per piece, instead of one request for the whole 15 bytes.
+    ASSERT_EQ(3u, CounterOf(ReadAheadCacheMetrics::IO_COUNT, &cache));
 
-    // The old ranges are gone.
-    AssertReadMiss({20, 2}, &cache);
+    AssertReadEquals({10, 15}, "klmnopqrstuvwxy", &cache);
+    ASSERT_EQ(3u, CounterOf(ReadAheadCacheMetrics::IO_COUNT, &cache));
+}
+
+// AddRanges() rebuilds the per-range cached flags, so a range fetched before 
the call is not
+// fetched a second time.
+TEST(TestReadAheadCache, TestAddRangesKeepsCachedRanges) {
+    // A window of exactly one range, so that reading the first one never 
reaches the second.
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/10);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    cache.Warmup();
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+
+    auto io_count_of = [&cache]() { return 
CounterOf(ReadAheadCacheMetrics::IO_COUNT, &cache); };
+    const uint64_t io_count_before = io_count_of();
+    ASSERT_EQ(1u, io_count_before);
+
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{16, 10}}));
+    ASSERT_TRUE(first_added.has_value());
+
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+    ASSERT_EQ(io_count_before, io_count_of());
+}
+
+// Re-registering a range the cache already covers adds nothing, and must 
leave the published entry
+// intact: the earlier fetch stays valid, so a later read of the same range is 
still a hit and
+// issues no further IO. A no-op registration that dropped the buffer would 
turn this read into a
+// miss (and lose the future to wait on an in-flight fetch).
+TEST(TestReadAheadCache, TestReRegisteringCoveredRangeKeepsCache) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/10);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    cache.Warmup();
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+    auto io_count_of = [&cache]() { return 
CounterOf(ReadAheadCacheMetrics::IO_COUNT, &cache); };
+    const uint64_t io_count_before = io_count_of();
+    ASSERT_EQ(1u, io_count_before);
+
+    // [2,6) is already covered by [0,10), so nothing new is registered.
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({{2, 6}}));
+    ASSERT_FALSE(first_added.has_value());
+
+    // The published entry survived the no-op registration: still a hit, no 
new IO.
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+    ASSERT_EQ(io_count_before, io_count_of());
+}
+
+// An empty registration is likewise a no-op that keeps the already published 
entries.
+TEST(TestReadAheadCache, TestEmptyAddRangesKeepsCache) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/10);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 10}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    cache.Warmup();
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+    auto io_count_of = [&cache]() { return 
CounterOf(ReadAheadCacheMetrics::IO_COUNT, &cache); };
+    const uint64_t io_count_before = io_count_of();
+    ASSERT_EQ(1u, io_count_before);
+
+    ASSERT_OK_AND_ASSIGN(std::optional<uint64_t> first_added, 
cache.AddRanges({}));
+    ASSERT_FALSE(first_added.has_value());
+
+    AssertReadEquals({0, 10}, "abcdefghij", &cache);
+    ASSERT_EQ(io_count_before, io_count_of());
+}
+
+// Warmup(offset) starts fetching from the given range rather than from the 
first one, which is
+// what a pass registering its ranges mid-read needs: the earlier ranges 
belong to the pass that
+// has already run.
+TEST(TestReadAheadCache, TestWarmupFromOffset) {
+    CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+                                         /*hole_size_limit=*/0, 
/*pre_buffer_limit=*/1024);
+    std::string content = "abcdefghijklmnopqrstuvwxyz";
+    std::shared_ptr<ReadAheadCache> cache_ptr =
+        CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5}, 
{16, 5}});
+    ReadAheadCache& cache = *cache_ptr;
+
+    auto io_hook = paimon::IOHook::GetInstance();
+    paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
+    // IOCount() only counts while armed; INT64_MAX never triggers the error 
mode.
+    io_hook->Reset(INT64_MAX, paimon::IOHook::Mode::RETURN_ERROR);
+
+    cache.Warmup(/*from_offset=*/16);
+    // Only the range at 16 was fetched: the two before it were skipped.
+    ASSERT_EQ(io_hook->IOCount(), 1);
+
+    AssertReadEquals({16, 5}, "qrstu", &cache);
+    ASSERT_EQ(io_hook->IOCount(), 1);
 }
 
-// Test that Init() merges ranges separated by a small hole, so a read
+// Test that AddRanges() merges ranges separated by a small hole, so a read
 // spanning the hole is served by the single coalesced entry.
-TEST(TestReadAheadCache, TestInitCoalescesSmallHoles) {
+TEST(TestReadAheadCache, TestCoalescesSmallHoles) {
     CacheConfig config = TestCacheConfig(/*range_size_limit=*/1024,
                                          /*hole_size_limit=*/2, 
/*pre_buffer_limit=*/1024);
     std::string content = "abcdefghijklmnopqrstuvwxyz";
@@ -639,7 +834,7 @@ TEST(TestReadAheadCache, TestBlockCacheDeclinedReadIsAMiss) 
{
     ASSERT_EQ(misses, 1u);
 }
 
-// The blocks belong to the file rather than to a registration round: they
+// The blocks belong to the file rather than to the registered ranges: they
 // survive Reset() and are only released by ReleaseBuffers().
 TEST(TestReadAheadCache, TestBlockCacheSurvivesReset) {
     std::string content = "abcdefghijklmnopqrstuvwxyz";
diff --git a/src/paimon/core/operation/commit/retry_waiter_test.cpp 
b/src/paimon/core/operation/commit/retry_waiter_test.cpp
deleted file mode 100644
index 2d8ea105..00000000
--- a/src/paimon/core/operation/commit/retry_waiter_test.cpp
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * 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/core/operation/commit/retry_waiter.h"
-
-#include <chrono>
-
-#include "gtest/gtest.h"
-
-namespace paimon::test {
-
-TEST(RetryWaiterTest, TestRetryWaitWithZeroBoundsReturnsQuickly) {
-    RetryWaiter waiter(/*min_retry_wait_ms=*/0, /*max_retry_wait_ms=*/0);
-
-    auto begin = std::chrono::steady_clock::now();
-    waiter.RetryWait(/*retry_count=*/3);
-    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
-        std::chrono::steady_clock::now() - begin);
-
-    ASSERT_LT(elapsed.count(), 20);
-}
-
-TEST(RetryWaiterTest, TestRetryWaitWithLargeRetryCountIsClamped) {
-    RetryWaiter waiter(/*min_retry_wait_ms=*/10, /*max_retry_wait_ms=*/10);
-
-    auto begin = std::chrono::steady_clock::now();
-    waiter.RetryWait(/*retry_count=*/1024);
-    auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
-        std::chrono::steady_clock::now() - begin);
-
-    ASSERT_GE(elapsed.count(), 8);
-}
-
-}  // namespace paimon::test
diff --git a/src/paimon/format/parquet/page_filtered_row_group_reader.cpp 
b/src/paimon/format/parquet/page_filtered_row_group_reader.cpp
index 70048b12..78f756fa 100644
--- a/src/paimon/format/parquet/page_filtered_row_group_reader.cpp
+++ b/src/paimon/format/parquet/page_filtered_row_group_reader.cpp
@@ -20,6 +20,7 @@
 #include "paimon/format/parquet/page_filtered_row_group_reader.h"
 
 #include <algorithm>
+#include <cstdint>
 #include <limits>
 #include <optional>
 
@@ -44,11 +45,15 @@ namespace paimon::parquet {
 
 namespace {
 
-/// Ceiling on the value bytes a leaf may reserve up front from column chunk 
metadata alone.
-/// The estimate it caps is a heuristic over footer fields, which are 
attacker-controlled and
-/// need not describe the pages this read touches, so it must not turn into an 
unbounded eager
-/// allocation. Past this size the builder's doubling is already amortized 
against a large read.
-constexpr int64_t kMaxMetadataValueBytesReservation = int64_t{16} * 1024 * 
1024;
+/// Multiple of a column chunk's compressed size that a leaf may reserve up 
front in value bytes.
+/// The estimate is a heuristic over footer fields, and 
`total_uncompressed_size` is a bare claim a
+/// forged footer can inflate to make a leaf eagerly allocate gigabytes for a 
near-empty file.
+/// `total_compressed_size` is instead checked against the file length before 
any read (Arrow's
+/// ComputeColumnChunkRange, and GetDataPageLayout below, bound a chunk and 
its pages by it), so a
+/// factor of it is bounded by bytes that physically exist. A leaf 
decompressing within the factor
+/// is reserved in full; a more compressible one is capped and left to the 
builder's amortized
+/// doubling, which is cheap against the I/O and decode of a read that large.
+constexpr int64_t kMaxReservationDecompressionFactor = 8;
 
 struct DataPageLayout {
     int64_t column_chunk_offset;
@@ -373,6 +378,8 @@ Result<std::shared_ptr<arrow::ChunkedArray>> 
PageFilteredRowGroupReader::ReadFil
         const int64_t reserve_values = effective_ranges.RowCount();
         int64_t reserve_value_bytes = 0;
         const int64_t chunk_bytes = column_chunk ? 
column_chunk->total_uncompressed_size() : 0;
+        const int64_t chunk_compressed_bytes =
+            column_chunk ? column_chunk->total_compressed_size() : 0;
         const int64_t chunk_values = column_chunk ? column_chunk->num_values() 
: 0;
         if (chunk_bytes > 0 && chunk_values > 0 && reserve_values > 0) {
             // A heuristic, not a bound. total_uncompressed_size is 
uncompressed but still
@@ -383,11 +390,16 @@ Result<std::shared_ptr<arrow::ChunkedArray>> 
PageFilteredRowGroupReader::ReadFil
             // including the pages this selection skips, so it misleads when 
wide values sit in
             // skipped pages. Either direction only costs performance — the 
reservation is a
             // hint the builder grows past when short — but they are why the 
result is capped
-            // instead of trusted. Fixed-width leaves ignore the byte count 
entirely.
+            // instead of trusted, by a factor of the file-bounded compressed 
size rather than the
+            // forgeable uncompressed one (see 
kMaxReservationDecompressionFactor). Fixed-width
+            // leaves ignore the byte count entirely.
             const double avg = static_cast<double>(chunk_bytes) / 
static_cast<double>(chunk_values);
             reserve_value_bytes = std::min(
                 {SaturatingDoubleToInteger<int64_t>(avg * 
static_cast<double>(reserve_values)),
-                 chunk_bytes, kMaxMetadataValueBytesReservation});
+                 chunk_bytes,
+                 SaturatingDoubleToInteger<int64_t>(
+                     static_cast<double>(chunk_compressed_bytes) *
+                     
static_cast<double>(kMaxReservationDecompressionFactor))});
         }
 
         PAIMON_RETURN_NOT_OK(ExecuteSkipReadPattern(col_idx, effective_ranges, 
effective_total,
diff --git a/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp 
b/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp
index bed06bb7..3c1875ae 100644
--- a/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp
+++ b/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp
@@ -1827,7 +1827,7 @@ TEST_F(ParquetFileBatchReaderTest, 
TestPreBufferRangeFeedsReadAheadCache) {
     for (const auto& range : pre_buffer_ranges) {
         byte_ranges.emplace_back(range.first, range.second);
     }
-    ASSERT_OK(cache->Init(std::move(byte_ranges)));
+    ASSERT_OK(cache->AddRanges(std::move(byte_ranges)).status());
     // Dispatch the prefetch immediately so every pre-buffered range is covered
     // before the reads below consume them.
     cache->Warmup();
diff --git a/src/paimon/testing/mock/mock_file_batch_reader.h 
b/src/paimon/testing/mock/mock_file_batch_reader.h
index e017d19f..8bc2348c 100644
--- a/src/paimon/testing/mock/mock_file_batch_reader.h
+++ b/src/paimon/testing/mock/mock_file_batch_reader.h
@@ -77,6 +77,26 @@ class MockFileBatchReader : public PrefetchFileBatchReader {
         enable_randomize_batch_size_ = enabled;
     }
 
+    /// Hand out a different set of pre-buffer ranges per read schema, so that 
a test can tell the
+    /// passes of a reader that switches schemas mid-file apart: the first 
SetReadSchema() selects
+    /// index 0, the second index 1, and a pass beyond the configured sets 
reports no range.
+    void SetPreBufferRangesPerSchema(
+        std::vector<std::vector<std::pair<uint64_t, uint64_t>>> 
ranges_by_schema) {
+        pre_buffer_ranges_by_schema_ = std::move(ranges_by_schema);
+    }
+
+    void SetPreBufferRangeStatus(const Status& status) {
+        pre_buffer_range_status_ = status;
+    }
+
+    Result<std::vector<std::pair<uint64_t, uint64_t>>> PreBufferRange() 
override {
+        PAIMON_RETURN_NOT_OK(pre_buffer_range_status_);
+        if (schema_generation_ == 0 || schema_generation_ > 
pre_buffer_ranges_by_schema_.size()) {
+            return std::vector<std::pair<uint64_t, uint64_t>>{};
+        }
+        return pre_buffer_ranges_by_schema_[schema_generation_ - 1];
+    }
+
     Status SetReadSchema(::ArrowSchema* read_schema, const 
std::shared_ptr<Predicate>& predicate,
                          const std::optional<RoaringBitmap32>& 
selection_bitmap) override {
         PAIMON_ASSIGN_OR_RAISE_FROM_ARROW(std::shared_ptr<arrow::Schema> 
arrow_schema,
@@ -87,6 +107,7 @@ class MockFileBatchReader : public PrefetchFileBatchReader {
         // late-materialization reader moving from its probe pass to its 
payload pass, rely on it.
         current_pos_ = 0;
         previous_batch_first_row_num_ = std::numeric_limits<uint64_t>::max();
+        schema_generation_++;
         return Status::OK();
     }
 
@@ -276,6 +297,11 @@ class MockFileBatchReader : public PrefetchFileBatchReader 
{
     Status next_batch_status_;
     bool enable_randomize_batch_size_ = true;
     std::vector<std::pair<uint64_t, uint64_t>> read_ranges_;
+    // The pre-buffer ranges to report, one set per SetReadSchema() call, and 
how many of those
+    // calls have been made so far.
+    std::vector<std::vector<std::pair<uint64_t, uint64_t>>> 
pre_buffer_ranges_by_schema_;
+    size_t schema_generation_ = 0;
+    Status pre_buffer_range_status_;
     int32_t warmup_count_ = 0;
     std::mt19937 random_engine_{std::random_device{}()};  // 
NOLINT(whitespace/braces)
 };
diff --git a/src/paimon/testing/mock/mock_file_system.h 
b/src/paimon/testing/mock/mock_file_system.h
index 8df35827..70442080 100644
--- a/src/paimon/testing/mock/mock_file_system.h
+++ b/src/paimon/testing/mock/mock_file_system.h
@@ -43,8 +43,13 @@ class MockInputStream : public InputStream {
     Result<int64_t> Read(char* buffer, int64_t size, int64_t offset) override {
         return 0;
     }
+    /// Completes the read inline. Leaving the callback uncalled would leave 
the promise a
+    /// read-ahead cache attaches to it unresolved forever, so anything 
waiting for that fetch -
+    /// the cache releasing its buffers, or a read hitting the range - would 
hang.
     void ReadAsync(char* buffer, int64_t size, int64_t offset,
-                   std::function<void(Status)>&& callback) override {}
+                   std::function<void(Status)>&& callback) override {
+        callback(Status::OK());
+    }
 
     Status Close() override {
         return Status::OK();
diff --git a/src/paimon/testing/mock/mock_format_reader_builder.h 
b/src/paimon/testing/mock/mock_format_reader_builder.h
index 16819611..ddabe8ac 100644
--- a/src/paimon/testing/mock/mock_format_reader_builder.h
+++ b/src/paimon/testing/mock/mock_format_reader_builder.h
@@ -19,6 +19,8 @@
 #pragma once
 
 #include <memory>
+#include <utility>
+#include <vector>
 
 #include "arrow/type.h"
 #include "paimon/format/reader_builder.h"
@@ -44,13 +46,23 @@ class MockFormatReaderBuilder : public ReaderBuilder {
         return this;
     }
 
+    /// Configure every reader this builder creates, see
+    /// MockFileBatchReader::SetPreBufferRangesPerSchema().
+    void SetPreBufferRangesPerSchema(
+        std::vector<std::vector<std::pair<uint64_t, uint64_t>>> 
ranges_by_schema) {
+        pre_buffer_ranges_by_schema_ = std::move(ranges_by_schema);
+    }
+
     Result<std::unique_ptr<FileBatchReader>> Build(
         const std::shared_ptr<InputStream>& path) const override {
-        if (bitmap_) {
-            return std::make_unique<MockFileBatchReader>(data_, schema_, 
bitmap_.value(),
-                                                         read_batch_size_);
+        std::unique_ptr<MockFileBatchReader> reader =
+            bitmap_ ? std::make_unique<MockFileBatchReader>(data_, schema_, 
bitmap_.value(),
+                                                            read_batch_size_)
+                    : std::make_unique<MockFileBatchReader>(data_, schema_, 
read_batch_size_);
+        if (!pre_buffer_ranges_by_schema_.empty()) {
+            reader->SetPreBufferRangesPerSchema(pre_buffer_ranges_by_schema_);
         }
-        return std::make_unique<MockFileBatchReader>(data_, schema_, 
read_batch_size_);
+        return reader;
     }
 
  private:
@@ -58,6 +70,7 @@ class MockFormatReaderBuilder : public ReaderBuilder {
     std::shared_ptr<arrow::DataType> schema_;
     std::optional<RoaringBitmap32> bitmap_;
     int32_t read_batch_size_;
+    std::vector<std::vector<std::pair<uint64_t, uint64_t>>> 
pre_buffer_ranges_by_schema_;
 };
 
 }  // namespace paimon::test

Reply via email to