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 6c69e996 feat(core): add a block cache for the reads the prefetched
ranges do not cover (#272)
6c69e996 is described below
commit 6c69e996e0f983918650f8317310c79f8ab1d5ca
Author: Yonghao Fang <[email protected]>
AuthorDate: Thu Sep 10 09:18:37 2026 +0800
feat(core): add a block cache for the reads the prefetched ranges do not
cover (#272)
---
include/paimon/utils/prefetch_cache_config.h | 57 ++-
src/paimon/CMakeLists.txt | 2 +
src/paimon/common/io/cache_input_stream_test.cpp | 25 +-
src/paimon/common/memory/bytes_utils.h | 60 +++
src/paimon/common/metrics/atomic_counter_pair.h | 57 +++
.../reader/prefetch_file_batch_reader_impl.cpp | 9 +-
.../prefetch_file_batch_reader_impl_test.cpp | 18 +-
src/paimon/common/utils/file_block_cache.cpp | 168 +++++++
src/paimon/common/utils/file_block_cache.h | 155 +++++++
src/paimon/common/utils/file_block_cache_test.cpp | 328 ++++++++++++++
src/paimon/common/utils/read_ahead_cache.cpp | 153 ++++---
src/paimon/common/utils/read_ahead_cache.h | 60 ++-
src/paimon/common/utils/read_ahead_cache_test.cpp | 494 ++++++++++++++-------
src/paimon/core/operation/read_context_test.cpp | 6 +-
.../parquet/parquet_file_batch_reader_test.cpp | 142 +++++-
.../testing/utils/gated_async_input_stream.h | 139 ++++++
16 files changed, 1598 insertions(+), 275 deletions(-)
diff --git a/include/paimon/utils/prefetch_cache_config.h
b/include/paimon/utils/prefetch_cache_config.h
index 4bbf1ecd..bd1197a6 100644
--- a/include/paimon/utils/prefetch_cache_config.h
+++ b/include/paimon/utils/prefetch_cache_config.h
@@ -34,10 +34,8 @@ namespace paimon {
/// ReadAheadCache to balance memory usage, I/O efficiency, and latency hiding.
class PAIMON_EXPORT CacheConfig {
public:
- CacheConfig();
- CacheConfig(uint64_t range_size_limit, uint64_t hole_size_limit, uint64_t
pre_buffer_limit);
-
/// Returns the maximum allowed size (in bytes) for a single cached range.
+ /// Defaults to 32 MiB.
uint64_t GetRangeSizeLimit() const {
return range_size_limit_;
}
@@ -47,7 +45,8 @@ class PAIMON_EXPORT CacheConfig {
range_size_limit_ = range_size_limit;
}
- /// Returns the maximum gap size (in bytes) considered mergeable between
adjacent ranges.
+ /// Returns the maximum gap size (in bytes) considered mergeable between
+ /// adjacent ranges. Defaults to 8 KiB.
uint64_t GetHoleSizeLimit() const {
return hole_size_limit_;
}
@@ -57,7 +56,8 @@ class PAIMON_EXPORT CacheConfig {
hole_size_limit_ = hole_size_limit;
}
- /// Returns the maximum size to pre-buffer ahead of the current read
position.
+ /// Returns the maximum size to pre-buffer ahead of the current read
+ /// position. Defaults to 256 MiB.
uint64_t GetPreBufferLimit() const {
return pre_buffer_limit_;
}
@@ -67,10 +67,51 @@ class PAIMON_EXPORT CacheConfig {
pre_buffer_limit_ = pre_buffer_limit;
}
+ /// Returns the granularity (in bytes) of the block cache entries serving
the
+ /// small reads that the prefetched ranges do not cover. Defaults to 64
KiB.
+ uint64_t GetBlockSize() const {
+ return block_size_;
+ }
+
+ /// Sets the granularity (in bytes) of the block cache entries.
+ void SetBlockSize(uint64_t block_size) {
+ block_size_ = block_size;
+ }
+
+ /// Returns the maximum total size (in bytes) of the block cache entries of
+ /// one file. Zero disables the block cache. Defaults to 1 MiB.
+ uint64_t GetBlockCacheLimit() const {
+ return block_cache_limit_;
+ }
+
+ /// Sets the maximum total size (in bytes) of the block cache entries of
one
+ /// file. Zero disables the block cache.
+ void SetBlockCacheLimit(uint64_t block_cache_limit) {
+ block_cache_limit_ = block_cache_limit;
+ }
+
private:
- uint64_t range_size_limit_;
- uint64_t hole_size_limit_;
- uint64_t pre_buffer_limit_;
+ // 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.
+ // - 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;
+ 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
+ // shared at: small enough that a metadata read at the tail of a file is
+ // served by one block instead of straddling two, large enough that a block
+ // fetch does not pull in much more than the reads ask for.
+ uint64_t block_size_ = 64 * 1024;
+ // One block is enough for the metadata tail of a file; the limit only
+ // bounds the pathological case, as blocks are never evicted.
+ uint64_t block_cache_limit_ = 1024 * 1024;
};
} // namespace paimon
diff --git a/src/paimon/CMakeLists.txt b/src/paimon/CMakeLists.txt
index 6fc1deaa..b034b30b 100644
--- a/src/paimon/CMakeLists.txt
+++ b/src/paimon/CMakeLists.txt
@@ -181,6 +181,7 @@ set(PAIMON_COMMON_SRCS
common/data/shredding/shredding_file_reader.cpp
common/utils/delta_varint_compressor.cpp
common/utils/fields_comparator.cpp
+ common/utils/file_block_cache.cpp
common/utils/path_util.cpp
common/utils/range.cpp
common/utils/read_ahead_cache.cpp
@@ -687,6 +688,7 @@ if(PAIMON_BUILD_TESTS)
common/utils/roaring_bitmap64_test.cpp
common/utils/range_helper_test.cpp
common/utils/read_ahead_cache_test.cpp
+ common/utils/file_block_cache_test.cpp
common/io/cache/lru_cache_test.cpp
common/io/cache/cache_manager_test.cpp
common/utils/byte_range_combiner_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 0b14dc33..072af67b 100644
--- a/src/paimon/common/io/cache_input_stream_test.cpp
+++ b/src/paimon/common/io/cache_input_stream_test.cpp
@@ -40,7 +40,10 @@ namespace paimon::test {
class CacheInputStreamTest : public ::testing::Test {
public:
void SetUp() override {
- pool_ = GetDefaultPool();
+ // A pool of its own, so that a cache buffer outliving the pool it was
+ // allocated from shows up instead of being covered by the global pool,
+ // which never goes away.
+ pool_ = std::shared_ptr<MemoryPool>(GetMemoryPool());
test_dir_ = UniqueTestDirectory::Create();
ASSERT_TRUE(test_dir_);
content_ = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -60,9 +63,14 @@ class CacheInputStreamTest : public ::testing::Test {
std::shared_ptr<ReadAheadCache> CreateCache(std::vector<ByteRange> ranges)
{
auto stream = OpenFile();
- CacheConfig config(/*range_size_limit=*/1024,
- /*hole_size_limit=*/0, /*pre_buffer_limit=*/1024 *
1024);
- auto cache = std::make_shared<ReadAheadCache>(std::move(stream),
config, pool_);
+ CacheConfig config;
+ config.SetRangeSizeLimit(1024);
+ config.SetHoleSizeLimit(0);
+ config.SetPreBufferLimit(1024 * 1024);
+ // The file size is left unknown so the block cache stays off: these
+ // 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)));
return cache;
}
@@ -204,9 +212,12 @@ TEST_F(CacheInputStreamTest, TestReadAsyncCacheReadError) {
ASSERT_OK_AND_ASSIGN(auto fs, FileSystemFactory::Get("local",
file_path_, {}));
ASSERT_OK_AND_ASSIGN(auto cache_stream, fs->Open(file_path_));
ASSERT_OK_AND_ASSIGN(auto underlying, fs->Open(file_path_));
- CacheConfig config(/*range_size_limit=*/1024,
- /*hole_size_limit=*/0, /*pre_buffer_limit=*/1024 *
1024);
- auto cache = std::make_shared<ReadAheadCache>(std::move(cache_stream),
config, pool_);
+ CacheConfig config;
+ config.SetRangeSizeLimit(1024);
+ config.SetHoleSizeLimit(0);
+ 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}}));
// Now activate IOHook so that the prefetch IO (triggered by
cache_->Read -> PreBuffer)
diff --git a/src/paimon/common/memory/bytes_utils.h
b/src/paimon/common/memory/bytes_utils.h
new file mode 100644
index 00000000..bfc1c1ca
--- /dev/null
+++ b/src/paimon/common/memory/bytes_utils.h
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#pragma once
+
+#include <cstddef>
+#include <memory>
+#include <utility>
+
+#include "paimon/memory/bytes.h"
+#include "paimon/memory/memory_pool.h"
+
+namespace paimon {
+
+/// Allocate a shared buffer that keeps the pool it was allocated from alive.
+///
+/// A `Bytes` holds a raw pointer to its pool and frees its allocation through
+/// it, so it must not outlive the pool. Holding both in one owner is enough as
+/// long as that owner drops the buffer, but not once the buffer is handed to
an
+/// asynchronous read: the callback of the read owns a reference to the buffer,
+/// and a stream that destroys the callback after it has resolved the read -
the
+/// object store streams do - lets an IO thread drop the last reference to the
+/// buffer after the owner and the pool are already gone. Binding the pool to
+/// the buffer makes the buffer keep its own allocator alive, wherever its last
+/// reference is dropped.
+///
+/// @param size Number of bytes to allocate.
+/// @param pool Memory pool to allocate from, which the returned buffer keeps
+/// alive.
+inline std::shared_ptr<Bytes> AllocateBytesKeepingPoolAlive(
+ size_t size, const std::shared_ptr<MemoryPool>& pool) {
+ // The pool is declared before the buffer, so the holder destroys the
buffer
+ // first and the pool it was allocated from second.
+ struct BytesWithMemoryPool {
+ std::shared_ptr<MemoryPool> pool;
+ std::shared_ptr<Bytes> bytes;
+ };
+ auto holder = std::make_shared<BytesWithMemoryPool>(
+ BytesWithMemoryPool{pool, std::make_shared<Bytes>(size, pool.get())});
+ Bytes* bytes = holder->bytes.get();
+ return std::shared_ptr<Bytes>(std::move(holder), bytes);
+}
+
+} // namespace paimon
diff --git a/src/paimon/common/metrics/atomic_counter_pair.h
b/src/paimon/common/metrics/atomic_counter_pair.h
new file mode 100644
index 00000000..5a8297ac
--- /dev/null
+++ b/src/paimon/common/metrics/atomic_counter_pair.h
@@ -0,0 +1,57 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <atomic>
+#include <cstdint>
+
+namespace paimon {
+
+/// How many requests of one kind were made and how many bytes they covered,
+/// which is the shape every counter of the read path has. Grouping the two
+/// keeps a counter and its byte counter from drifting apart.
+///
+/// The atomics are relaxed throughout: the counters are only reported, never
+/// used to order anything, and the read path increments them on every request.
+struct AtomicCounterPair {
+ std::atomic<uint64_t> count{0};
+ std::atomic<uint64_t> bytes{0};
+
+ /// Record one request covering `size` bytes.
+ void Add(uint64_t size) {
+ count.fetch_add(1, std::memory_order_relaxed);
+ bytes.fetch_add(size, std::memory_order_relaxed);
+ }
+
+ void Reset() {
+ count.store(0, std::memory_order_relaxed);
+ bytes.store(0, std::memory_order_relaxed);
+ }
+
+ uint64_t Count() const {
+ return count.load(std::memory_order_relaxed);
+ }
+
+ uint64_t Bytes() const {
+ return bytes.load(std::memory_order_relaxed);
+ }
+};
+
+} // namespace paimon
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 f24dd2b5..51589ba3 100644
--- a/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
+++ b/src/paimon/common/reader/prefetch_file_batch_reader_impl.cpp
@@ -215,6 +215,9 @@ Result<std::unique_ptr<PrefetchFileBatchReaderImpl>>
PrefetchFileBatchReaderImpl
if (batch_size <= 0) {
return Status::Invalid("batch size should be greater than 0.");
}
+ if (data_file_size < 0) {
+ return Status::Invalid("data file size should not be negative.");
+ }
if (reader_builder == nullptr) {
return Status::Invalid("reader_builder should not be nullptr.");
}
@@ -236,7 +239,11 @@ Result<std::unique_ptr<PrefetchFileBatchReaderImpl>>
PrefetchFileBatchReaderImpl
if (io_metrics) {
input_stream = std::make_shared<MetricsInputStream>(input_stream,
io_metrics);
}
- cache = std::make_shared<ReadAheadCache>(input_stream, cache_config,
pool);
+ // The file size lets the cache align its blocks to the end of the
file,
+ // where the metadata the readers read before any range is registered
+ // lives. A zero size means unknown and disables the block cache.
+ cache = std::make_shared<ReadAheadCache>(input_stream, cache_config,
+
static_cast<uint64_t>(data_file_size), pool);
}
std::vector<std::future<Result<std::unique_ptr<FileBatchReader>>>> futures;
for (uint32_t i = 0; i < prefetch_max_parallel_num; i++) {
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 02f9ba8d..5e0c40d4 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
@@ -664,10 +664,10 @@ TEST_F(PrefetchFileBatchReaderImplTest,
WorkloopSetReadStatusWhenCacheInitFailed
int32_t batch_size = 5;
int32_t prefetch_max_parallel_num = 1;
MockFormatReaderBuilder reader_builder(data_array, data_type_, batch_size);
- CacheConfig invalid_cache_config(
- /*range_size_limit=*/4 * 1024,
- /*hole_size_limit=*/8 * 1024,
- /*pre_buffer_limit=*/128 * 1024);
+ CacheConfig invalid_cache_config;
+ invalid_cache_config.SetRangeSizeLimit(4 * 1024);
+ invalid_cache_config.SetHoleSizeLimit(8 * 1024);
+ invalid_cache_config.SetPreBufferLimit(128 * 1024);
ASSERT_OK_AND_ASSIGN(
auto reader,
@@ -930,6 +930,16 @@ TEST_F(PrefetchFileBatchReaderImplTest, TestInvalidCase) {
/*initialize_read_ranges=*/true,
/*read_ahead_cache_enabled=*/true, CacheConfig(),
/*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)));
}
+ {
+ ASSERT_NOK_WITH_MSG(
+ PrefetchFileBatchReaderImpl::Create(
+ data_file_path, /*data_file_size=*/-1, &reader_builder,
mock_fs_,
+ prefetch_max_parallel_num, batch_size,
prefetch_max_parallel_num * 2,
+ /*enable_adaptive_prefetch_strategy=*/false, executor_,
+ /*initialize_read_ranges=*/true,
/*read_ahead_cache_enabled=*/true, CacheConfig(),
+ /*enable_io_metrics=*/false, pool_, GetArrowPool(pool_)),
+ "data file size should not be negative");
+ }
{
ASSERT_NOK(PrefetchFileBatchReaderImpl::Create(
data_file_path, /*data_file_size=*/0, &reader_builder, mock_fs_,
diff --git a/src/paimon/common/utils/file_block_cache.cpp
b/src/paimon/common/utils/file_block_cache.cpp
new file mode 100644
index 00000000..829ffb23
--- /dev/null
+++ b/src/paimon/common/utils/file_block_cache.cpp
@@ -0,0 +1,168 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "paimon/common/utils/file_block_cache.h"
+
+#include <cstring>
+
+#include "paimon/common/memory/bytes_utils.h"
+
+namespace paimon {
+
+FileBlockCache::FileBlockCache(const std::shared_ptr<InputStream>& stream,
uint64_t file_size,
+ uint64_t block_size, uint64_t capacity,
+ const std::shared_ptr<MemoryPool>& memory_pool)
+ : stream_(stream),
+ file_size_(file_size),
+ block_size_(block_size),
+ capacity_(capacity),
+ memory_pool_(memory_pool) {}
+
+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_);
+ for (auto& block : blocks_) {
+ block.second->future.wait();
+ }
+}
+
+bool FileBlockCache::Read(const ByteRange& range, char* dest) {
+ if (!CanServe(range)) {
+ return false;
+ }
+ const uint64_t index = IndexOf(range.offset);
+ std::shared_ptr<Block> block;
+ bool dispatch = false;
+ {
+ // 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_);
+ auto it = blocks_.find(index);
+ if (it != blocks_.end()) {
+ block = it->second;
+ } else {
+ const ByteRange block_range = RangeOf(index);
+ // Blocks are never evicted, so an exhausted capacity means this
read
+ // goes back to the caller instead of replacing a cached block.
+ if (cached_bytes_ + block_range.length > capacity_) {
+ return false;
+ }
+ block = std::make_shared<Block>();
+ block->range = block_range;
+ // The buffer keeps the pool alive, for the fetch callbacks that a
+ // stream destroys later than it resolves them.
+ block->buffer = AllocateBytesKeepingPoolAlive(block_range.length,
memory_pool_);
+ block->promise = std::make_shared<std::promise<Status>>();
+ block->future = block->promise->get_future().share();
+ blocks_.emplace(index, block);
+ cached_bytes_ += block_range.length;
+ dispatch = true;
+ }
+ }
+ if (dispatch) {
+ Fetch(block);
+ }
+ // Wait and copy OUTSIDE the lock, so that a reader waiting for a fetch
does
+ // not keep the other readers out of the map.
+ if (!block->future.get().ok()) {
+ // A block fetch reads more than the caller asked for, so its failure
must
+ // not fail the caller's read: the read goes back to the caller, which
+ // reports the real error itself if its own bytes cannot be read
either.
+ // This is what keeps a block whose range the file does not have - the
+ // file metadata records a size larger than the physical file - from
+ // failing the reads of the region it covers.
+ //
+ // The block is left in place with its failed future, so the later
reads
+ // of that block are declined without fetching it again.
+ return false;
+ }
+ std::memcpy(dest, block->buffer->data() + (range.offset -
block->range.offset), range.length);
+ hits_.Add(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_) {
+ block.second->future.wait();
+ }
+ blocks_.clear();
+ cached_bytes_ = 0;
+}
+
+void FileBlockCache::ResetCounters() {
+ hits_.Reset();
+ fetches_.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();
+ return counters;
+}
+
+bool FileBlockCache::CanServe(const ByteRange& range) const {
+ if (capacity_ == 0 || block_size_ == 0 || file_size_ == 0) {
+ return false;
+ }
+ if (range.length == 0 || range.length > block_size_) {
+ return false;
+ }
+ // A read reaching past EOF is left to the caller: serving it would mean
+ // short-reading into the block buffer.
+ if (range.offset >= file_size_ || range.length > file_size_ -
range.offset) {
+ return false;
+ }
+ // A read straddling two blocks would need both of them to be present; it
is
+ // left to the caller instead, which keeps one block per served read.
+ return IndexOf(range.offset) == IndexOf(range.offset + range.length - 1);
+}
+
+uint64_t FileBlockCache::IndexOf(uint64_t offset) const {
+ // Counted from the end of the file, so that block 0 is the last block.
+ return (file_size_ - 1 - offset) / block_size_;
+}
+
+ByteRange FileBlockCache::RangeOf(uint64_t index) const {
+ const uint64_t end = file_size_ - index * block_size_;
+ const uint64_t offset = end > block_size_ ? end - block_size_ : 0;
+ return {offset, end - offset};
+}
+
+void FileBlockCache::Fetch(const std::shared_ptr<Block>& block) {
+ fetches_.Add(block->range.length);
+ auto promise = block->promise;
+ auto buffer = block->buffer;
+ // The buffer and the promise are captured, so the async read keeps its
+ // destination and the future it resolves alive. The buffer keeps the
memory
+ // pool alive as well, so a callback outliving this cache still frees the
+ // buffer against a live pool.
+ stream_->ReadAsync(buffer->data(), static_cast<int64_t>(buffer->size()),
+ static_cast<int64_t>(block->range.offset),
+ [promise, buffer](Status status) {
promise->set_value(status); });
+}
+
+} // namespace paimon
diff --git a/src/paimon/common/utils/file_block_cache.h
b/src/paimon/common/utils/file_block_cache.h
new file mode 100644
index 00000000..3a2d5737
--- /dev/null
+++ b/src/paimon/common/utils/file_block_cache.h
@@ -0,0 +1,155 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <future>
+#include <memory>
+#include <mutex>
+#include <unordered_map>
+
+#include "paimon/common/metrics/atomic_counter_pair.h"
+#include "paimon/common/utils/read_ahead_cache.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/memory/bytes.h"
+#include "paimon/memory/memory_pool.h"
+#include "paimon/status.h"
+#include "paimon/visibility.h"
+
+namespace paimon {
+
+/// A cache of fixed-size blocks of one file, the fallback below the prefetched
+/// ranges: a read that no registered range covers is served here at block
+/// granularity instead of being left to the caller, which would read it from
the
+/// underlying stream uncached and pay for it again on the next reader. It is a
+/// general mechanism over the whole file - any reader, any offset, any round
of
+/// registered ranges - not a cache of one particular kind of read.
+///
+/// Blocks are aligned to the END of the file: block 0 is
+/// [file_size - block_size, file_size). The reads served here concentrate at
the
+/// tail of a file: a reader reads the metadata of its file before it knows
which
+/// ranges to register, and every reader of the file reads the same bytes. End
+/// alignment gives that region whole blocks - a read of the last block_size
bytes
+/// of a file is one block whatever the file size - and leaves the only partial
+/// block at the head of the file. It also keeps every block inside the file,
as
+/// long as the given file size is the size of the file: a block fetch failing
+/// because the file is shorter than that only costs the caching of that block,
+/// see Read().
+///
+/// One block serves one read: a read longer than a block, or straddling two,
is
+/// declined and left to the caller instead of being fetched in pieces, so a
+/// served read stays one block, one fetch and one copy.
+///
+/// A block is published before its fetch is dispatched, so concurrent readers
of
+/// the same block wait for that one fetch instead of issuing their own.
+///
+/// Blocks are never evicted: once the capacity is reached Read() declines
+/// instead of replacing a block. That keeps every dispatched fetch reachable
+/// through its block, so Release() and the destructor can wait for the fetches
+/// still writing into the block buffers.
+class PAIMON_EXPORT FileBlockCache {
+ public:
+ /// Requests served by a block and fetches issued for the blocks
themselves,
+ /// reported by the owner of this cache through its own metrics. A
snapshot:
+ /// the counters keep being recorded while it is read.
+ struct Counters {
+ uint64_t hits = 0;
+ uint64_t hit_bytes = 0;
+ uint64_t fetches = 0;
+ uint64_t fetch_bytes = 0;
+ };
+
+ /// @param stream The stream the blocks are fetched from.
+ /// @param file_size Size of the file behind `stream`, which the blocks are
+ /// aligned to the end of. Must not be zero.
+ /// @param block_size Granularity of the blocks. Must not be zero.
+ /// @param capacity Maximum total size of the cached blocks, in bytes.
+ /// @param memory_pool The pool the block buffers are allocated from.
+ FileBlockCache(const std::shared_ptr<InputStream>& stream, uint64_t
file_size,
+ uint64_t block_size, uint64_t capacity,
+ const std::shared_ptr<MemoryPool>& memory_pool);
+ ~FileBlockCache();
+
+ /// Serve the given range out of its block, fetching that block first if it
+ /// is not cached yet.
+ /// @param range The byte range to read.
+ /// @param dest Destination buffer with at least `range.length` bytes.
+ /// @return true if the range was served and `dest` was filled; false when
+ /// one block cannot serve the range (it straddles two blocks, is larger
than
+ /// a block or reaches past EOF), when the capacity is exhausted or when
the
+ /// fetch of the block failed, leaving `dest` untouched so the caller can
read
+ /// the bytes itself. A fetch failure is never reported to the caller: the
+ /// block reads more than the caller asked for, so the caller reads its own
+ /// bytes instead and reports the failure itself if they cannot be read
+ /// either. A block whose fetch failed is not fetched again.
+ bool Read(const ByteRange& range, char* dest);
+
+ /// Drop all cached blocks, waiting for the fetches still writing into
their
+ /// buffers. The counters are kept readable for the owner's metrics.
+ void Release();
+
+ /// Zero the counters while keeping the cached blocks, which cache the file
+ /// rather than a round of reads.
+ void ResetCounters();
+
+ Counters GetCounters() const;
+
+ private:
+ /// A cached block. Blocks are handed out as shared_ptr so that a reader
+ /// keeps its block alive once it has released the lock.
+ struct Block {
+ ByteRange range;
+ std::shared_ptr<Bytes> buffer;
+ std::shared_ptr<std::promise<Status>> promise;
+ // shared_future, as every reader of the block waits on it.
+ std::shared_future<Status> future;
+ };
+
+ /// Whether one block can serve the given range.
+ bool CanServe(const ByteRange& range) const;
+ /// Index of the block holding `offset`, counted from the END of the file,
so
+ /// that block 0 is the last block. `offset` must be inside the file.
+ uint64_t IndexOf(uint64_t offset) const;
+ /// Range of the block with the given index, clamped at the start of the
file.
+ ByteRange RangeOf(uint64_t index) const;
+ /// Fetch the block into its buffer and resolve its promise with the
outcome.
+ /// Must be called after the block has been published, and only by the
reader
+ /// that published it.
+ void Fetch(const std::shared_ptr<Block>& block);
+
+ std::shared_ptr<InputStream> stream_;
+ uint64_t file_size_;
+ uint64_t block_size_;
+ uint64_t capacity_;
+ std::shared_ptr<MemoryPool> memory_pool_;
+ // 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_;
+ 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_;
+};
+
+} // namespace paimon
diff --git a/src/paimon/common/utils/file_block_cache_test.cpp
b/src/paimon/common/utils/file_block_cache_test.cpp
new file mode 100644
index 00000000..aadcc404
--- /dev/null
+++ b/src/paimon/common/utils/file_block_cache_test.cpp
@@ -0,0 +1,328 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include "paimon/common/utils/file_block_cache.h"
+
+#include <chrono>
+#include <fstream>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <utility>
+
+#include "gtest/gtest.h"
+#include "paimon/common/factories/io_hook.h"
+#include "paimon/common/utils/scope_guard.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/fs/file_system_factory.h"
+#include "paimon/testing/utils/gated_async_input_stream.h"
+#include "paimon/testing/utils/testharness.h"
+
+namespace paimon::test {
+
+namespace {
+
+constexpr char kContent[] = "abcdefghijklmnopqrstuvwxyz";
+// 26 bytes in blocks of 8: block 0 = [18, 26), block 1 = [10, 18),
+// block 2 = [2, 10) and block 3 = [0, 2), truncated at the start of the file.
+constexpr uint64_t kBlockSize = 8;
+
+// A pool of its own for every cache, so that a buffer outliving the pool it
was
+// allocated from shows up instead of being covered by the global pool, which
+// never goes away.
+std::shared_ptr<MemoryPool> TestPool() {
+ return std::shared_ptr<MemoryPool>(GetMemoryPool());
+}
+
+// Write the test content into a fresh directory and open it for reading. The
+// directory is returned so that it outlives the stream.
+std::shared_ptr<InputStream>
OpenTestFile(std::unique_ptr<UniqueTestDirectory>* dir) {
+ *dir = UniqueTestDirectory::Create();
+ EXPECT_TRUE(*dir);
+ std::string path = (*dir)->Str() + "/data_file";
+ std::ofstream file(path, std::ios::binary);
+ EXPECT_TRUE(file.is_open());
+ file.write(kContent, sizeof(kContent) - 1);
+ EXPECT_FALSE(file.fail());
+ file.close();
+
+ Result<std::unique_ptr<FileSystem>> fs = FileSystemFactory::Get("local",
path, {});
+ EXPECT_OK(fs.status());
+ Result<std::unique_ptr<InputStream>> in = fs.value()->Open(path);
+ EXPECT_OK(in.status());
+ return std::move(in).value();
+}
+
+// Assert that the range is served out of a block with the expected content.
+void AssertServed(const ByteRange& range, const std::string& expected,
FileBlockCache* cache) {
+ std::string dest(range.length, 'X');
+ ASSERT_TRUE(cache->Read(range, dest.data())) << expected;
+ ASSERT_EQ(expected, std::string_view(dest.data(), range.length));
+}
+
+// Assert that the cache declines the range and leaves the destination
untouched,
+// so that the caller can read the bytes itself.
+void AssertDeclined(const ByteRange& range, FileBlockCache* cache) {
+ std::string dest(range.length, 'X');
+ ASSERT_FALSE(cache->Read(range, dest.data()));
+ ASSERT_EQ(std::string(dest.size(), 'X'), dest);
+}
+
+} // namespace
+
+// The tail of the file is fetched once and then serves every later read
falling
+// into it, the way the footer read of a parquet file serves the page index
reads
+// of the readers sharing the cache.
+TEST(TestFileBlockCache, TestServesRepeatedTailReads) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/1024, TestPool());
+
+ // The whole last block, as the footer read of a parquet file does.
+ AssertServed({18, 8}, "stuvwxyz", &cache);
+ // Five small reads inside that block, as the page index reads do.
+ AssertServed({25, 1}, "z", &cache);
+ AssertServed({20, 2}, "uv", &cache);
+ AssertServed({18, 1}, "s", &cache);
+ AssertServed({22, 4}, "wxyz", &cache);
+ AssertServed({19, 3}, "tuv", &cache);
+
+ // One fetch of one block served all six reads.
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 1u);
+ ASSERT_EQ(counters.fetch_bytes, 8u);
+ ASSERT_EQ(counters.hits, 6u);
+ ASSERT_EQ(counters.hit_bytes, 8u + 1u + 2u + 1u + 4u + 3u);
+}
+
+// The lowest block is truncated at the start of the file, so no block fetch
+// reads past either end of the file.
+TEST(TestFileBlockCache, TestClampsLowestBlockAtFileStart) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/1024, TestPool());
+
+ AssertServed({0, 2}, "ab", &cache);
+ AssertServed({1, 1}, "b", &cache);
+
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 1u);
+ ASSERT_EQ(counters.fetch_bytes, 2u);
+ ASSERT_EQ(counters.hits, 2u);
+}
+
+// A reader racing the fetch of a block must wait on the published block
instead
+// of issuing a second fetch for the same bytes.
+TEST(TestFileBlockCache, TestSingleFlightForConcurrentReads) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ auto gated = std::make_shared<GatedAsyncInputStream>(OpenTestFile(&dir));
+ FileBlockCache cache(gated, sizeof(kContent) - 1, kBlockSize,
/*capacity=*/1024, TestPool());
+
+ // The first reader publishes block 0 = [18, 26) and blocks on its held
fetch.
+ std::thread first([&cache]() {
+ std::string dest(4, 'X');
+ EXPECT_TRUE(cache.Read({18, 4}, dest.data()));
+ EXPECT_EQ("stuv", std::string_view(dest.data(), 4));
+ });
+ while (gated->AsyncReadCount() == 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ }
+
+ // The second reader finds the published block and waits for the same
fetch.
+ std::thread second([&cache]() {
+ std::string dest(4, 'X');
+ EXPECT_TRUE(cache.Read({22, 4}, dest.data()));
+ EXPECT_EQ("wxyz", std::string_view(dest.data(), 4));
+ });
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ ASSERT_EQ(gated->AsyncReadCount(), 1);
+ gated->ReleaseAll();
+ first.join();
+ second.join();
+
+ ASSERT_EQ(gated->AsyncReadCount(), 1);
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 1u);
+ ASSERT_EQ(counters.hits, 2u);
+}
+
+// Blocks are never evicted, so a read whose block does not fit into the
capacity
+// is declined instead of replacing a cached block.
+TEST(TestFileBlockCache, TestExhaustedCapacityDeclinesNewBlocks) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ // The capacity holds exactly one block.
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/kBlockSize, TestPool());
+
+ AssertServed({18, 4}, "stuv", &cache);
+ // Block 1 = [10, 18) does not fit anymore.
+ AssertDeclined({10, 4}, &cache);
+ // The block already cached still serves its reads.
+ AssertServed({20, 2}, "uv", &cache);
+
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 1u);
+ ASSERT_EQ(counters.fetch_bytes, 8u);
+ ASSERT_EQ(counters.hits, 2u);
+}
+
+// Reads that one block cannot serve are declined: a read straddling two
blocks,
+// a read larger than a block and a read reaching past EOF.
+TEST(TestFileBlockCache, TestDeclinesStraddlingAndOversizedReads) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/1024, TestPool());
+
+ // [16, 20) straddles block 1 = [10, 18) and block 0 = [18, 26).
+ AssertDeclined({16, 4}, &cache);
+ // Larger than one block.
+ AssertDeclined({0, 12}, &cache);
+ // Reaches past the end of the file.
+ AssertDeclined({24, 4}, &cache);
+ // Starts past the end of the file.
+ AssertDeclined({26, 2}, &cache);
+
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 0u);
+ ASSERT_EQ(counters.hits, 0u);
+}
+
+// A zero capacity or a zero block size turns the cache off entirely.
+TEST(TestFileBlockCache, TestDisabledByZeroCapacityOrBlockSize) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ std::shared_ptr<InputStream> in = OpenTestFile(&dir);
+
+ FileBlockCache no_capacity(in, sizeof(kContent) - 1, kBlockSize,
/*capacity=*/0, TestPool());
+ AssertDeclined({18, 4}, &no_capacity);
+ ASSERT_EQ(no_capacity.GetCounters().fetches, 0u);
+
+ FileBlockCache no_block_size(in, sizeof(kContent) - 1, /*block_size=*/0,
/*capacity=*/1024,
+ TestPool());
+ AssertDeclined({18, 4}, &no_block_size);
+ ASSERT_EQ(no_block_size.GetCounters().fetches, 0u);
+}
+
+// ResetCounters() keeps the cached blocks, which cache the file rather than a
+// round of reads, while Release() drops them and makes the next read fetch
again.
+TEST(TestFileBlockCache, TestResetCountersKeepsBlocksAndReleaseDropsThem) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/1024, TestPool());
+
+ AssertServed({18, 4}, "stuv", &cache);
+ ASSERT_EQ(cache.GetCounters().fetches, 1u);
+
+ cache.ResetCounters();
+ ASSERT_EQ(cache.GetCounters().hits, 0u);
+ ASSERT_EQ(cache.GetCounters().fetches, 0u);
+ // The block is still there, so this read needs no fetch.
+ AssertServed({18, 4}, "stuv", &cache);
+ ASSERT_EQ(cache.GetCounters().hits, 1u);
+ ASSERT_EQ(cache.GetCounters().fetches, 0u);
+
+ cache.Release();
+ AssertServed({18, 4}, "stuv", &cache);
+ ASSERT_EQ(cache.GetCounters().hits, 2u);
+ ASSERT_EQ(cache.GetCounters().fetches, 1u);
+}
+
+// A failed fetch is not reported to the caller, which reads its own bytes
+// instead, and the failed block is not fetched again.
+TEST(TestFileBlockCache, TestFetchErrorDeclinesTheReads) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1, kBlockSize,
+ /*capacity=*/1024, TestPool());
+ auto io_hook = paimon::IOHook::GetInstance();
+ paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
+ io_hook->Reset(0, paimon::IOHook::Mode::RETURN_ERROR);
+
+ AssertDeclined({18, 4}, &cache);
+ // The failed block is kept, so the later reads of it are declined without
a
+ // second fetch, even once the reads would succeed again.
+ io_hook->Clear();
+ AssertDeclined({20, 2}, &cache);
+ // The other blocks are unaffected.
+ AssertServed({10, 4}, "klmn", &cache);
+
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 2u);
+ ASSERT_EQ(counters.hits, 1u);
+}
+
+// A file size larger than the physical file - the size recorded by the file
+// metadata is not necessarily the size of the file - makes the fetch of the
+// block reaching past the end of the file fail. That must cost no more than
the
+// caching of that block: the reads it would serve are declined and read by the
+// caller itself.
+TEST(TestFileBlockCache, TestFileSizeLargerThanTheFileOnlyLosesTheLastBlock) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ // 30 instead of 26, so block 0 = [22, 30) has 4 bytes the file does not
have.
+ FileBlockCache cache(OpenTestFile(&dir), sizeof(kContent) - 1 + 4,
kBlockSize,
+ /*capacity=*/1024, TestPool());
+
+ AssertDeclined({22, 4}, &cache);
+ // No second fetch of the block that cannot be read.
+ AssertDeclined({23, 2}, &cache);
+ // The blocks that the file does have still serve their reads.
+ AssertServed({10, 4}, "klmn", &cache);
+
+ const FileBlockCache::Counters counters = cache.GetCounters();
+ ASSERT_EQ(counters.fetches, 2u);
+ ASSERT_EQ(counters.hits, 1u);
+}
+
+// An object store stream destroys the callback of a read after it has resolved
+// it, so the last reference to a block buffer can be dropped by an IO thread
+// after the cache - and the memory pool it holds - is already gone. The buffer
+// must keep its pool alive until then, or it frees its allocation against a
+// destroyed pool.
+TEST(TestFileBlockCache, TestBlockBufferKeepsPoolAliveAfterCacheIsGone) {
+ std::unique_ptr<UniqueTestDirectory> dir;
+ auto gated = std::make_shared<GatedAsyncInputStream>(OpenTestFile(&dir));
+ std::weak_ptr<MemoryPool> weak_pool;
+ {
+ std::shared_ptr<MemoryPool> pool = TestPool();
+ weak_pool = pool;
+ // Declared after the pool, so the cache is destroyed before the last
+ // reference of this test to the pool is dropped.
+ FileBlockCache cache(gated, sizeof(kContent) - 1, kBlockSize,
/*capacity=*/1024, pool);
+
+ std::thread reader([&cache]() {
+ std::string dest(4, 'X');
+ EXPECT_TRUE(cache.Read({18, 4}, dest.data()));
+ EXPECT_EQ("stuv", std::string_view(dest.data(), 4));
+ });
+ while (gated->AsyncReadCount() == 0) {
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ }
+ // The fetch is completed, but the stream keeps its callback - and
with it
+ // a reference to the block buffer - alive.
+ gated->ReleaseAllKeepingCallbacks();
+ reader.join();
+ }
+
+ // The cache and the pool reference of this test are gone, so the callback
+ // holds the last reference to the buffer, which must still hold the pool.
+ ASSERT_FALSE(weak_pool.expired());
+ gated->DropCompletedCallbacks();
+ ASSERT_TRUE(weak_pool.expired());
+}
+
+} // namespace paimon::test
diff --git a/src/paimon/common/utils/read_ahead_cache.cpp
b/src/paimon/common/utils/read_ahead_cache.cpp
index a74b3520..6e517aaa 100644
--- a/src/paimon/common/utils/read_ahead_cache.cpp
+++ b/src/paimon/common/utils/read_ahead_cache.cpp
@@ -29,7 +29,10 @@
#include <future>
#include <shared_mutex>
+#include "paimon/common/memory/bytes_utils.h"
+#include "paimon/common/metrics/atomic_counter_pair.h"
#include "paimon/common/utils/byte_range_combiner.h"
+#include "paimon/common/utils/file_block_cache.h"
#include "paimon/common/utils/math.h"
#include "paimon/memory/bytes.h"
#include "paimon/metrics.h"
@@ -79,29 +82,9 @@ void CopyRangeFromEntries(const
std::vector<RangeCacheEntry>& covering, const By
} // namespace
-CacheConfig::CacheConfig(uint64_t range_size_limit, uint64_t hole_size_limit,
- uint64_t pre_buffer_limit)
- : range_size_limit_(range_size_limit),
- hole_size_limit_(hole_size_limit),
- pre_buffer_limit_(pre_buffer_limit) {}
-
-CacheConfig::CacheConfig()
- // 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.
- // - 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.
- : CacheConfig(/*range_size_limit=*/32 * 1024 * 1024,
- /*hole_size_limit=*/8 * 1024,
- /*pre_buffer_limit=*/256 * 1024 * 1024) {}
-
class ReadAheadCache::Impl {
public:
- Impl(const std::shared_ptr<InputStream>& stream, const CacheConfig& config,
+ Impl(const std::shared_ptr<InputStream>& stream, const CacheConfig&
config, uint64_t file_size,
const std::shared_ptr<MemoryPool>& memory_pool);
~Impl();
@@ -121,14 +104,6 @@ class ReadAheadCache::Impl {
/// so the caller may use them after releasing the lock.
std::vector<RangeCacheEntry> FindCoveringEntries(const ByteRange& range);
void PreBuffer(uint64_t offset);
- void CountHit(uint64_t size) {
- hits_.fetch_add(1, std::memory_order_relaxed);
- hit_bytes_.fetch_add(size, std::memory_order_relaxed);
- }
- void CountMiss(uint64_t size) {
- misses_.fetch_add(1, std::memory_order_relaxed);
- miss_bytes_.fetch_add(size, std::memory_order_relaxed);
- }
/// Mark, publish and fetch the pending ranges at the given indices.
///
@@ -138,6 +113,10 @@ class ReadAheadCache::Impl {
/// 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();
+
std::shared_ptr<InputStream> stream_;
CacheConfig config_;
// Ordered by offset (so as to find a matching region by binary search)
@@ -147,18 +126,18 @@ class ReadAheadCache::Impl {
std::vector<std::atomic<bool>> is_cached_;
std::vector<ByteRange> pending_ranges_;
bool is_initialized_ = false;
- // Statistics of the Read() requests issued to the cache, aggregated over
- // all streams sharing this cache.
- std::atomic<uint64_t> read_count_{0};
- std::atomic<uint64_t> read_bytes_{0};
- std::atomic<uint64_t> hits_{0};
- std::atomic<uint64_t> hit_bytes_{0};
- std::atomic<uint64_t> misses_{0};
- std::atomic<uint64_t> miss_bytes_{0};
- // Prefetch IO statistics: how many requests and bytes were actually issued
- // to the underlying stream.
- std::atomic<uint64_t> io_count_{0};
- std::atomic<uint64_t> io_bytes_{0};
+ // 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_;
};
void ReadAheadCache::Impl::Cache(std::vector<size_t> pending_indices) {
@@ -178,7 +157,7 @@ void ReadAheadCache::Impl::Cache(std::vector<size_t>
pending_indices) {
const ByteRange& range = pending_ranges_[idx];
auto promise = std::make_shared<std::promise<Status>>();
auto future = promise->get_future();
- auto buffer = std::make_shared<Bytes>(range.length,
memory_pool_.get());
+ auto buffer = AllocateBytesKeepingPoolAlive(range.length,
memory_pool_);
fetches.push_back({range, buffer, promise});
new_entries.emplace_back(range, std::move(buffer),
std::move(future));
}
@@ -242,33 +221,50 @@ void ReadAheadCache::Impl::PreBuffer(uint64_t offset) {
}
ReadAheadCache::Impl::Impl(const std::shared_ptr<InputStream>& stream, const
CacheConfig& config,
- const std::shared_ptr<MemoryPool>& memory_pool)
- : stream_(stream), config_(config), memory_pool_(memory_pool) {}
+ 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.
+ if (file_size > 0 && config_.GetBlockSize() > 0 &&
config_.GetBlockCacheLimit() > 0) {
+ block_cache_ = std::make_unique<FileBlockCache>(stream, file_size,
config_.GetBlockSize(),
+
config_.GetBlockCacheLimit(), memory_pool);
+ }
+}
ReadAheadCache::Impl::~Impl() {
std::unique_lock<std::shared_mutex> lock(rw_mutex_);
for (auto& entry : entries_) {
entry.future.wait();
}
+ // The block cache waits for its own fetches when it is destroyed.
}
void ReadAheadCache::Impl::Reset() {
- ReleaseBuffers();
- read_count_.store(0, std::memory_order_relaxed);
- read_bytes_.store(0, std::memory_order_relaxed);
- hits_.store(0, std::memory_order_relaxed);
- hit_bytes_.store(0, std::memory_order_relaxed);
- misses_.store(0, std::memory_order_relaxed);
- miss_bytes_.store(0, std::memory_order_relaxed);
- io_count_.store(0, std::memory_order_relaxed);
- io_bytes_.store(0, std::memory_order_relaxed);
+ ReleasePrefetchBuffers();
+ reads_.Reset();
+ hits_.Reset();
+ misses_.Reset();
+ ios_.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.
+ block_cache_->ResetCounters();
+ }
}
void ReadAheadCache::Impl::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 async callback can outlive the stream or the
- // memory pool its buffer belongs to.
+ // 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();
}
@@ -285,16 +281,22 @@ void
ReadAheadCache::Impl::CollectMetrics(std::shared_ptr<Metrics>* metrics) con
return;
}
auto& m = *metrics;
- m->SetCounter(ReadAheadCacheMetrics::READ_COUNT,
read_count_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::READ_BYTES,
read_bytes_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::READ_HITS,
hits_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES,
- hit_bytes_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::READ_MISSES,
misses_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::READ_MISS_BYTES,
- miss_bytes_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::IO_COUNT,
io_count_.load(std::memory_order_relaxed));
- m->SetCounter(ReadAheadCacheMetrics::IO_BYTES,
io_bytes_.load(std::memory_order_relaxed));
+ 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());
+ // 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 =
+ block_cache_ != nullptr ? block_cache_->GetCounters() :
FileBlockCache::Counters{};
+ m->SetCounter(ReadAheadCacheMetrics::BLOCK_HITS, blocks.hits);
+ 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);
}
void ReadAheadCache::Impl::Warmup() {
@@ -345,12 +347,18 @@ Result<bool> ReadAheadCache::Impl::Read(const ByteRange&
range, char* dest) {
if (range.length == 0) {
return true;
}
- read_count_.fetch_add(1, std::memory_order_relaxed);
- read_bytes_.fetch_add(range.length, std::memory_order_relaxed);
+ reads_.Add(range.length);
PreBuffer(range.offset);
std::vector<RangeCacheEntry> covering = FindCoveringEntries(range);
if (covering.empty()) {
- CountMiss(range.length);
+ // No registered range covers this read: the block cache can still
serve
+ // it, and then serve the readers of the other streams sharing this
cache
+ // that are about to read the same bytes.
+ if (block_cache_ != nullptr && block_cache_->Read(range, dest)) {
+ // The block cache counts its own hits, see CollectMetrics().
+ return true;
+ }
+ misses_.Add(range.length);
return false;
}
// Wait OUTSIDE the lock: the futures resolve when the prefetch stream's
@@ -360,7 +368,7 @@ 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);
- CountHit(range.length);
+ hits_.Add(range.length);
return true;
}
@@ -373,15 +381,14 @@ 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); });
- io_count_.fetch_add(1, std::memory_order_relaxed);
- io_bytes_.fetch_add(fetch.range.length, std::memory_order_relaxed);
+ ios_.Add(fetch.range.length);
}
}
ReadAheadCache::ReadAheadCache(const std::shared_ptr<InputStream>& stream,
- const CacheConfig& config,
+ const CacheConfig& config, uint64_t file_size,
const std::shared_ptr<MemoryPool>& memory_pool)
- : impl_(std::make_unique<Impl>(stream, config, memory_pool)) {}
+ : impl_(std::make_unique<Impl>(stream, config, file_size, memory_pool)) {}
ReadAheadCache::~ReadAheadCache() = default;
diff --git a/src/paimon/common/utils/read_ahead_cache.h
b/src/paimon/common/utils/read_ahead_cache.h
index f039c22e..e44c277d 100644
--- a/src/paimon/common/utils/read_ahead_cache.h
+++ b/src/paimon/common/utils/read_ahead_cache.h
@@ -48,9 +48,31 @@ class PAIMON_EXPORT ReadAheadCacheMetrics {
static inline const char READ_HIT_BYTES[] =
"read-ahead-cache.read.hit-bytes";
static inline const char READ_MISSES[] = "read-ahead-cache.read.misses";
static inline const char READ_MISS_BYTES[] =
"read-ahead-cache.read.miss-bytes";
- /// Number of prefetch IO requests actually issued to the underlying
stream.
+ /// Number of Read() requests served by the block cache, and the bytes they
+ /// copied out of it. A read is counted either as a hit, a block hit or a
+ /// miss, so `read.count = read.hits + block.hits + read.misses` for the
reads
+ /// that complete; a read whose prefetch fetch failed is counted in
read.count
+ /// only, as it is served by neither.
+ ///
+ /// A block hit is a read served out of a block, not a read that avoided
IO:
+ /// the read that finds no block waits for the fetch it dispatches and is
+ /// counted here too. Comparing with block.fetches tells the two apart.
+ static inline const char BLOCK_HITS[] = "read-ahead-cache.block.hits";
+ static inline const char BLOCK_HIT_BYTES[] =
"read-ahead-cache.block.hit-bytes";
+ /// Block fetches issued to the underlying stream, and their bytes. Both
are
+ /// a subset of the io counters below, so comparing them tells how many
bytes
+ /// the block granularity added on top of the requested ones.
+ static inline const char BLOCK_FETCHES[] =
"read-ahead-cache.block.fetches";
+ static inline const char BLOCK_FETCH_BYTES[] =
"read-ahead-cache.block.fetch-bytes";
+ /// Number of IO requests the cache itself issued to the underlying stream:
+ /// the prefetch fetches plus the block fetches. The `io.async.*` metrics
of
+ /// the prefetch reader count those same requests one layer below, but they
+ /// also count the async reads a sub-reader falls back to after this cache
+ /// declined them, so `io.async.requests >= io.count` rather than the two
+ /// agreeing.
static inline const char IO_COUNT[] = "read-ahead-cache.io.count";
- /// Total bytes requested by the prefetch IOs issued to the underlying
stream.
+ /// Total bytes requested by the IOs the cache itself issued to the
+ /// underlying stream.
static inline const char IO_BYTES[] = "read-ahead-cache.io.bytes";
};
@@ -85,11 +107,24 @@ struct PAIMON_EXPORT ByteRange {
/// The cache never evicts: every published range stays cached until
/// ReleaseBuffers() or Reset(). It is meant to hold the prefetched ranges of
/// a single data file, whose size is bounded by the reader's scan scope.
+///
+/// Reads that the prefetched ranges do not cover - a reader reads the metadata
+/// of its file before any range is registered - are served by a FileBlockCache
+/// 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().
class PAIMON_EXPORT ReadAheadCache {
public:
/// Construct a read cache with given options
+ /// @param stream The stream the cache fetches from.
+ /// @param config The cache configuration.
+ /// @param file_size Size of the file behind `stream`, used to align the
+ /// block cache to the end of the file. Zero means unknown and disables the
+ /// block cache.
+ /// @param memory_pool The pool the cached buffers are allocated from.
ReadAheadCache(const std::shared_ptr<InputStream>& stream, const
CacheConfig& config,
- const std::shared_ptr<MemoryPool>& memory_pool);
+ uint64_t file_size, const std::shared_ptr<MemoryPool>&
memory_pool);
~ReadAheadCache();
/// Initialize the cache with given byte ranges to be cached.
@@ -104,9 +139,12 @@ class PAIMON_EXPORT ReadAheadCache {
///
/// Multi-segment hits are copied into `dest` segment by segment, without
/// an intermediate assembled buffer.
+ ///
+ /// A range that no registered range covers may still be served by the
block
+ /// cache, see the class documentation.
/// @param range The byte range to read.
/// @param dest Destination buffer with at least `range.length` bytes.
- /// @return true if the range was served from the cache and `dest` was
+ /// @return true if the range was served by the cache and `dest` was
/// filled; false on cache miss (`dest` is left untouched).
Result<bool> Read(const ByteRange& range, char* dest);
@@ -115,11 +153,11 @@ class PAIMON_EXPORT ReadAheadCache {
/// when the first Read() arrives, racing the caller's own miss fetch.
void Warmup();
- /// Collect hit/miss counters of Read() calls and the prefetch IO
- /// counters into the given metrics as counters named after
+ /// 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/misses; prefetch fetches dispatched by the cache itself are
- /// counted in the fetch counters instead.
+ /// as hits, block hits or misses; the fetches the cache dispatches itself
+ /// 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;
@@ -129,6 +167,9 @@ class PAIMON_EXPORT ReadAheadCache {
/// 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.
+ ///
+ /// 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.
void Reset();
/// Release all cached buffers and pending ranges while keeping the
hit/miss
@@ -136,7 +177,8 @@ class PAIMON_EXPORT ReadAheadCache {
///
/// Unlike Reset(), the counters recorded by Read() remain readable through
/// CollectMetrics() afterwards, so this is safe to call when the owning
reader
- /// is closed while its metrics are still being aggregated.
+ /// is closed while its metrics are still being aggregated. The block
cache is
+ /// released too, as the file is not read again.
void ReleaseBuffers();
private:
diff --git a/src/paimon/common/utils/read_ahead_cache_test.cpp
b/src/paimon/common/utils/read_ahead_cache_test.cpp
index e7900b7b..60fe8709 100644
--- a/src/paimon/common/utils/read_ahead_cache_test.cpp
+++ b/src/paimon/common/utils/read_ahead_cache_test.cpp
@@ -21,7 +21,7 @@
#include <chrono>
#include <fstream>
-#include <mutex>
+#include <memory>
#include <thread>
#include <vector>
@@ -31,40 +31,62 @@
#include "paimon/common/utils/scope_guard.h"
#include "paimon/fs/file_system.h"
#include "paimon/fs/file_system_factory.h"
+#include "paimon/testing/utils/gated_async_input_stream.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
-// Helper to create a test file, write content, and return a ready
ReadAheadCache.
-struct TestCacheEnv {
- std::string path;
- std::shared_ptr<paimon::ReadAheadCache> cache;
- std::shared_ptr<paimon::MemoryPool> pool;
-};
+// The range limits the tests exercise. The defaults are sized for real data
+// files, which the small test files would never reach.
+CacheConfig TestCacheConfig(uint64_t range_size_limit, uint64_t
hole_size_limit,
+ uint64_t pre_buffer_limit) {
+ CacheConfig config;
+ config.SetRangeSizeLimit(range_size_limit);
+ config.SetHoleSizeLimit(hole_size_limit);
+ config.SetPreBufferLimit(pre_buffer_limit);
+ return config;
+}
-TestCacheEnv CreateTestFileAndCache(const std::string& filename, const
std::string& content,
- const paimon::CacheConfig& config,
- std::vector<paimon::ByteRange> ranges) {
- auto dir = UniqueTestDirectory::Create();
- EXPECT_TRUE(dir);
- std::string path = dir->Str() + "/" + filename;
+// A pool of its own for every cache, so that a buffer outliving the pool it
was
+// allocated from shows up instead of being covered by the global pool, which
+// never goes away.
+std::shared_ptr<MemoryPool> TestPool() {
+ return std::shared_ptr<MemoryPool>(GetMemoryPool());
+}
+
+// Write the given content into a fresh directory and open it for reading. The
+// directory is returned so that it can outlive the stream.
+std::shared_ptr<InputStream>
OpenTestFile(std::unique_ptr<UniqueTestDirectory>* dir,
+ const std::string& filename, const
std::string& content) {
+ *dir = UniqueTestDirectory::Create();
+ EXPECT_TRUE(*dir);
+ std::string path = (*dir)->Str() + "/" + filename;
std::ofstream file(path, std::ios::binary);
EXPECT_TRUE(file.is_open());
file.write(content.data(), content.size());
EXPECT_FALSE(file.fail());
file.close();
- auto fs_result = FileSystemFactory::Get("local", path, {});
- EXPECT_TRUE(fs_result.ok());
- auto fs = std::move(fs_result).value();
- auto in_result = fs->Open(path);
- EXPECT_TRUE(in_result.ok());
- auto in = std::move(in_result).value();
+ EXPECT_OK_AND_ASSIGN(std::unique_ptr<FileSystem> fs,
FileSystemFactory::Get("local", path, {}));
+ EXPECT_OK_AND_ASSIGN(std::unique_ptr<InputStream> in, fs->Open(path));
+ return std::move(in);
+}
- auto pool = GetDefaultPool();
- auto cache = std::make_shared<ReadAheadCache>(std::move(in), config, pool);
+// Create a test file with the given content and return a ready ReadAheadCache
+// on it. `file_size` defaults to 0, i.e. unknown, which keeps the block cache
+// off: a read that no registered range covers stays a plain miss. The block
+// cache tests pass the real size of the file.
+std::shared_ptr<ReadAheadCache> CreateTestFileAndCache(const std::string&
filename,
+ const std::string&
content,
+ const CacheConfig&
config,
+ std::vector<ByteRange>
ranges,
+ uint64_t file_size = 0)
{
+ std::unique_ptr<UniqueTestDirectory> dir;
+ 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)));
- return {path, cache, pool};
+ return cache;
}
// Assert that reading the range is a cache hit filling the destination with
@@ -74,7 +96,7 @@ void AssertReadEquals(const ByteRange& range, const
std::string& expected, ReadA
bool hit = false;
ASSERT_OK_AND_ASSIGN(hit, cache->Read(range, dest.data()));
ASSERT_TRUE(hit) << expected;
- EXPECT_EQ(expected, std::string_view(dest.data(), range.length));
+ ASSERT_EQ(expected, std::string_view(dest.data(), range.length));
}
// Assert that reading the range misses and leaves the destination untouched.
@@ -83,84 +105,18 @@ void AssertReadMiss(const ByteRange& range,
ReadAheadCache* cache) {
bool hit = true;
ASSERT_OK_AND_ASSIGN(hit, cache->Read(range, dest.data()));
ASSERT_FALSE(hit);
- EXPECT_EQ(std::string(dest.size(), 'X'), dest);
+ ASSERT_EQ(std::string(dest.size(), 'X'), dest);
}
-// An InputStream wrapper that holds ReadAsync callbacks until ReleaseAll() is
-// called, letting tests observe the cache while prefetch IOs are in flight.
-class GatedAsyncInputStream : public InputStream {
- public:
- explicit GatedAsyncInputStream(std::shared_ptr<InputStream> inner) :
inner_(std::move(inner)) {}
-
- Status Close() override {
- return inner_->Close();
- }
- Status Seek(int64_t offset, SeekOrigin origin) override {
- return inner_->Seek(offset, origin);
- }
- Result<int64_t> GetPos() const override {
- return inner_->GetPos();
- }
- Result<int64_t> Read(char* buffer, int64_t size) override {
- return inner_->Read(buffer, size);
- }
- Result<int64_t> Read(char* buffer, int64_t size, int64_t offset) override {
- return inner_->Read(buffer, size, offset);
- }
- void ReadAsync(char* buffer, int64_t size, int64_t offset,
- std::function<void(Status)>&& callback) override {
- std::lock_guard<std::mutex> lock(mutex_);
- async_read_count_++;
- pending_.push_back({buffer, size, offset, std::move(callback)});
- }
- Result<std::string> GetUri() const override {
- return inner_->GetUri();
- }
- Result<int64_t> Length() const override {
- return inner_->Length();
- }
-
- int AsyncReadCount() {
- std::lock_guard<std::mutex> lock(mutex_);
- return async_read_count_;
- }
-
- /// Complete all held fetches against the underlying stream.
- void ReleaseAll() {
- std::vector<PendingRead> taken;
- {
- std::lock_guard<std::mutex> lock(mutex_);
- taken = std::move(pending_);
- pending_.clear();
- }
- for (auto& read : taken) {
- Result<int64_t> res = inner_->Read(read.buffer, read.size,
read.offset);
- read.callback(res.ok() ? Status::OK() : res.status());
- }
- }
-
- private:
- struct PendingRead {
- char* buffer;
- int64_t size;
- int64_t offset;
- std::function<void(Status)> callback;
- };
-
- std::shared_ptr<InputStream> inner_;
- std::mutex mutex_;
- std::vector<PendingRead> pending_;
- int async_read_count_ = 0;
-};
-
TEST(TestReadAheadCache, TestBasics) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
+ CacheConfig config =
+ TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache(
+ std::shared_ptr<ReadAheadCache> cache_ptr = CreateTestFileAndCache(
"data_file", content, config,
{{1, 2}, {3, 2}, {8, 2}, {10, 4}, {14, 0}, {15, 4}, {20, 2}, {25, 0}});
- auto& cache = *env.cache;
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({20, 2}, "uv", &cache);
AssertReadEquals({1, 2}, "bc", &cache);
@@ -183,13 +139,14 @@ TEST(TestReadAheadCache, TestBasics) {
// Test that a read spanning several adjacent cache entries is served from the
// contiguous run of entries and counted as a single hit.
TEST(TestReadAheadCache, TestMultiSegmentContiguousHit) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ 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
// into three adjacent entries: {0,10}, {10,10} and {20,5}.
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 25}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 25}});
+ ReadAheadCache& cache = *cache_ptr;
// Spans all three entries.
AssertReadEquals({5, 20}, "fghijklmnopqrstuvwxy", &cache);
@@ -217,11 +174,12 @@ TEST(TestReadAheadCache, TestMultiSegmentContiguousHit) {
// Test repeated reads to the same range to ensure cache reuse.
TEST(TestReadAheadCache, TestRepeatedReadCacheReuse) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/64);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/64);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5},
{7, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}, {7, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({0, 5}, "abcde", &cache);
AssertReadEquals({0, 5}, "abcde", &cache);
@@ -230,11 +188,12 @@ TEST(TestReadAheadCache, TestRepeatedReadCacheReuse) {
// The cache never evicts: every prefetched range stays cached until
// ReleaseBuffers()/Reset(), regardless of how much data accumulates.
TEST(TestReadAheadCache, TestNoEvictionKeepsAllRanges) {
- CacheConfig config(/*range_size_limit=*/5, /*hole_size_limit=*/2,
- /*pre_buffer_limit=*/10);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/5,
/*hole_size_limit=*/2,
+ /*pre_buffer_limit=*/10);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5},
{8, 5}, {16, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5},
{16, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({0, 5}, "abcde", &cache);
@@ -246,11 +205,13 @@ TEST(TestReadAheadCache, TestNoEvictionKeepsAllRanges) {
// Test that Read() hits and misses are recorded in the cache metrics.
TEST(TestReadAheadCache, TestMetrics) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
+ CacheConfig config =
+ TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5},
{8, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({0, 5}, "abcde", &cache);
// Out of any cached range: a miss.
@@ -286,11 +247,13 @@ TEST(TestReadAheadCache, TestMetrics) {
// Test that ReleaseBuffers() drops the cached data but keeps the hit/miss
counters
// readable, while Reset() zeroes them as well.
TEST(TestReadAheadCache, TestReleaseBuffersKeepsMetrics) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
+ CacheConfig config =
+ TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2, /*pre_buffer_limit=*/128 * 1024
* 1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({0, 5}, "abcde", &cache);
@@ -341,28 +304,30 @@ TEST(TestReadAheadCache, TestReleaseBuffersKeepsMetrics) {
// a miss: the entry exists from the moment its fetch is submitted and its
// future carries the IO error.
TEST(TestReadAheadCache, TestPrefetchIOErrorPropagation) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto io_hook = paimon::IOHook::GetInstance();
// Single entry: the prefetch is the first IO after the hook is armed.
{
- auto env = CreateTestFileAndCache("data_file", content, config, {{0,
10}});
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 10}});
paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
io_hook->Reset(0, paimon::IOHook::Mode::RETURN_ERROR);
std::string dest(5, 'X');
- ASSERT_NOK_WITH_MSG(env.cache->Read({0, 5}, dest.data()),
+ ASSERT_NOK_WITH_MSG(cache_ptr->Read({0, 5}, dest.data()),
"io hook triggered io error at position");
}
// Several adjacent entries: the error of any segment aborts the read.
{
- auto env = CreateTestFileAndCache("data_file", content, config, {{0,
25}});
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 25}});
paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
io_hook->Reset(1, paimon::IOHook::Mode::RETURN_ERROR);
std::string dest(20, 'X');
- ASSERT_NOK_WITH_MSG(env.cache->Read({0, 20}, dest.data()),
+ ASSERT_NOK_WITH_MSG(cache_ptr->Read({0, 20}, dest.data()),
"io hook triggered io error at position");
}
}
@@ -371,56 +336,50 @@ TEST(TestReadAheadCache, TestPrefetchIOErrorPropagation) {
// issues no further IO, while without Warmup() the first Read() triggers the
// prefetch itself.
TEST(TestReadAheadCache, TestWarmupPrefetchesBeforeFirstRead) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env1 = CreateTestFileAndCache("data_file", content, config, {{0, 5},
{8, 5}});
- env1.cache->Warmup();
- auto env2 = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
+ std::shared_ptr<ReadAheadCache> cache1 =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5}});
+ cache1->Warmup();
+ std::shared_ptr<ReadAheadCache> cache2 =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}});
auto io_hook = paimon::IOHook::GetInstance();
paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
// Any new IO fails: the warmed-up reads must be served without fetching.
io_hook->Reset(0, paimon::IOHook::Mode::RETURN_ERROR);
- AssertReadEquals({0, 5}, "abcde", env1.cache.get());
- AssertReadEquals({8, 5}, "ijklm", env1.cache.get());
+ AssertReadEquals({0, 5}, "abcde", cache1.get());
+ AssertReadEquals({8, 5}, "ijklm", cache1.get());
// Without Warmup() the first Read() starts the prefetch and sees the
error.
std::string dest(5, 'X');
- ASSERT_NOK(env2.cache->Read({0, 5}, dest.data()));
+ ASSERT_NOK(cache2->Read({0, 5}, dest.data()));
}
// Warmup() without any pending ranges is a safe no-op.
TEST(TestReadAheadCache, TestWarmupWithEmptyRanges) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {});
- env.cache->Warmup();
- AssertReadMiss({0, 5}, env.cache.get());
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {});
+ cache_ptr->Warmup();
+ AssertReadMiss({0, 5}, cache_ptr.get());
}
// A reader racing an in-flight prefetch must find the published entry and wait
// on its future instead of missing and re-fetching the same bytes: entries are
// published under the lock before their fetch is dispatched.
TEST(TestReadAheadCache, TestInFlightEntryServesRacingReader) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto dir = UniqueTestDirectory::Create();
- ASSERT_TRUE(dir);
- std::string path = dir->Str() + "/data_file";
- std::ofstream file(path, std::ios::binary);
- ASSERT_TRUE(file.is_open());
- file.write(content.data(), content.size());
- ASSERT_FALSE(file.fail());
- file.close();
- ASSERT_OK_AND_ASSIGN(auto fs, FileSystemFactory::Get("local", path, {}));
- ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs->Open(path));
- auto gated = std::make_shared<GatedAsyncInputStream>(std::move(in));
+ std::unique_ptr<UniqueTestDirectory> dir;
+ auto gated = std::make_shared<GatedAsyncInputStream>(OpenTestFile(&dir,
"data_file", content));
- ReadAheadCache cache(gated, config, GetDefaultPool());
+ ReadAheadCache cache(gated, config, /*file_size=*/0, TestPool());
ASSERT_OK(cache.Init({{0, 5}}));
cache.Warmup();
@@ -453,14 +412,48 @@ TEST(TestReadAheadCache,
TestInFlightEntryServesRacingReader) {
ASSERT_EQ(io_count, 1u);
}
+// An object store stream destroys the callback of a read after it has resolved
+// it, so the last reference to a prefetch buffer can be dropped by an IO
thread
+// after the cache - and the memory pool it holds - is already gone. The buffer
+// must keep its pool alive until then, or it frees its allocation against a
+// destroyed pool.
+TEST(TestReadAheadCache, TestPrefetchBufferKeepsPoolAliveAfterCacheIsGone) {
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
+ std::unique_ptr<UniqueTestDirectory> dir;
+ auto gated = std::make_shared<GatedAsyncInputStream>(
+ OpenTestFile(&dir, "data_file", "abcdefghijklmnopqrstuvwxyz"));
+ std::weak_ptr<MemoryPool> weak_pool;
+ {
+ std::shared_ptr<MemoryPool> pool = TestPool();
+ weak_pool = pool;
+ // 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}}));
+ cache.Warmup();
+ ASSERT_EQ(gated->AsyncReadCount(), 1);
+ // The fetch is completed, but the stream keeps its callback - and
with it
+ // a reference to the entry buffer - alive.
+ gated->ReleaseAllKeepingCallbacks();
+ }
+
+ // The cache and the pool reference of this test are gone, so the callback
+ // holds the last reference to the buffer, which must still hold the pool.
+ ASSERT_FALSE(weak_pool.expired());
+ gated->DropCompletedCallbacks();
+ ASSERT_TRUE(weak_pool.expired());
+}
+
// Test that pre_buffer_limit truncates the prefetch window: only ranges within
// the window are fetched at once, later reads fetch the remaining batches.
TEST(TestReadAheadCache, TestPreBufferWindowLimit) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/0, /*pre_buffer_limit=*/10);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/0,
/*pre_buffer_limit=*/10);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 10},
{16, 10}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 10}, {16,
10}});
+ ReadAheadCache& cache = *cache_ptr;
auto io_hook = paimon::IOHook::GetInstance();
paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
@@ -483,11 +476,12 @@ TEST(TestReadAheadCache, TestPreBufferWindowLimit) {
// Test that Init() rejects a second call until the cache is reset.
TEST(TestReadAheadCache, TestDoubleInit) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}});
+ ReadAheadCache& cache = *cache_ptr;
Status status = cache.Init({{8, 5}});
ASSERT_FALSE(status.ok());
@@ -498,11 +492,12 @@ TEST(TestReadAheadCache, TestDoubleInit) {
// Test that the cache can be re-initialized after Reset() and serves the new
ranges.
TEST(TestReadAheadCache, TestReinitAfterReset) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({0, 5}, "abcde", &cache);
@@ -517,25 +512,186 @@ TEST(TestReadAheadCache, TestReinitAfterReset) {
// Test that Init() merges ranges separated by a small hole, so a read
// spanning the hole is served by the single coalesced entry.
TEST(TestReadAheadCache, TestInitCoalescesSmallHoles) {
- CacheConfig config(/*range_size_limit=*/1024,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/1024,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
// Byte 5 sits in a 1-byte hole, within hole_size_limit: one entry {0,11}.
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5},
{6, 5}});
- auto& cache = *env.cache;
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}, {6, 5}});
+ ReadAheadCache& cache = *cache_ptr;
AssertReadEquals({4, 3}, "efg", &cache);
}
// CollectMetrics() with a null metrics output is a safe no-op.
TEST(TestReadAheadCache, TestCollectMetricsWithNullMetrics) {
- CacheConfig config(/*range_size_limit=*/10,
- /*hole_size_limit=*/2, /*pre_buffer_limit=*/1024);
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
+ /*hole_size_limit=*/2,
/*pre_buffer_limit=*/1024);
std::string content = "abcdefghijklmnopqrstuvwxyz";
- auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
- env.cache->CollectMetrics(/*metrics=*/nullptr);
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {{0, 5}});
+ cache_ptr->CollectMetrics(/*metrics=*/nullptr);
std::shared_ptr<Metrics> null_metrics;
- env.cache->CollectMetrics(&null_metrics);
+ cache_ptr->CollectMetrics(&null_metrics);
+}
+
+// Read the io counters of the cache.
+void GetIOCounters(ReadAheadCache* cache, uint64_t* io_count, uint64_t*
io_bytes) {
+ std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+ cache->CollectMetrics(&metrics);
+ ASSERT_OK_AND_ASSIGN(*io_count,
metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
+ ASSERT_OK_AND_ASSIGN(*io_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::IO_BYTES));
+}
+
+// Counters of the block cache, the companion of GetIOCounters().
+struct BlockCounters {
+ uint64_t hits = 0;
+ uint64_t hit_bytes = 0;
+ uint64_t fetches = 0;
+ uint64_t fetch_bytes = 0;
+};
+
+void GetBlockCounters(ReadAheadCache* cache, BlockCounters* counters) {
+ std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+ cache->CollectMetrics(&metrics);
+ ASSERT_OK_AND_ASSIGN(counters->hits,
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_HITS));
+ ASSERT_OK_AND_ASSIGN(counters->hit_bytes,
+
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_HIT_BYTES));
+ ASSERT_OK_AND_ASSIGN(counters->fetches,
+
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_FETCHES));
+ ASSERT_OK_AND_ASSIGN(counters->fetch_bytes,
+
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_FETCH_BYTES));
+}
+
+// A cache configuration with the block cache enabled at the given granularity.
+// The registered ranges are irrelevant to the block cache tests: they read the
+// bytes the parquet reader reads before any range is registered.
+CacheConfig BlockCacheConfig(uint64_t block_size, uint64_t block_cache_limit) {
+ CacheConfig config = TestCacheConfig(/*range_size_limit=*/10,
/*hole_size_limit=*/2,
+ /*pre_buffer_limit=*/1024);
+ config.SetBlockSize(block_size);
+ config.SetBlockCacheLimit(block_cache_limit);
+ return config;
+}
+
+// The block cache serves the reads that no registered range covers, and such a
+// read is counted as a block hit rather than as a miss. See FileBlockCache and
+// its own test for the block semantics themselves.
+TEST(TestReadAheadCache, TestBlockCacheServesUncoveredReads) {
+ std::string content = "abcdefghijklmnopqrstuvwxyz";
+ // Blocks are aligned to the end of the file: block 0 is [18, 26).
+ CacheConfig config = BlockCacheConfig(/*block_size=*/8,
/*block_cache_limit=*/1024);
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {},
content.size());
+ ReadAheadCache& cache = *cache_ptr;
+
+ // The whole last block, as the footer read of a parquet file does, then
two
+ // small reads inside it, as the page index reads of the other readers do.
+ AssertReadEquals({18, 8}, "stuvwxyz", &cache);
+ AssertReadEquals({20, 2}, "uv", &cache);
+ AssertReadEquals({25, 1}, "z", &cache);
+
+ BlockCounters blocks;
+ GetBlockCounters(&cache, &blocks);
+ ASSERT_EQ(blocks.fetches, 1u);
+ ASSERT_EQ(blocks.fetch_bytes, 8u);
+ ASSERT_EQ(blocks.hits, 3u);
+ ASSERT_EQ(blocks.hit_bytes, 8u + 2u + 1u);
+
+ // The block fetches are issued to the underlying stream too, so they are
+ // part of the io counters.
+ uint64_t io_count = 0;
+ uint64_t io_bytes = 0;
+ GetIOCounters(&cache, &io_count, &io_bytes);
+ ASSERT_EQ(io_count, 1u);
+ ASSERT_EQ(io_bytes, 8u);
+
+ // A block hit is neither a hit of a registered range nor a miss.
+ std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+ cache.CollectMetrics(&metrics);
+ ASSERT_OK_AND_ASSIGN(uint64_t read_count,
+
metrics->GetCounter(ReadAheadCacheMetrics::READ_COUNT));
+ ASSERT_EQ(read_count, 3u);
+ ASSERT_OK_AND_ASSIGN(uint64_t hits,
metrics->GetCounter(ReadAheadCacheMetrics::READ_HITS));
+ ASSERT_EQ(hits, 0u);
+ ASSERT_OK_AND_ASSIGN(uint64_t misses,
metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
+ ASSERT_EQ(misses, 0u);
+}
+
+// A read the block cache declines stays a plain miss left to the caller.
+TEST(TestReadAheadCache, TestBlockCacheDeclinedReadIsAMiss) {
+ std::string content = "abcdefghijklmnopqrstuvwxyz";
+ CacheConfig config = BlockCacheConfig(/*block_size=*/8,
/*block_cache_limit=*/1024);
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {},
content.size());
+ ReadAheadCache& cache = *cache_ptr;
+
+ // Larger than one block, so the block cache leaves it to the caller.
+ AssertReadMiss({0, 12}, &cache);
+
+ BlockCounters blocks;
+ GetBlockCounters(&cache, &blocks);
+ ASSERT_EQ(blocks.fetches, 0u);
+ ASSERT_EQ(blocks.hits, 0u);
+ std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+ cache.CollectMetrics(&metrics);
+ ASSERT_OK_AND_ASSIGN(uint64_t misses,
metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
+ ASSERT_EQ(misses, 1u);
+}
+
+// The blocks belong to the file rather than to a registration round: they
+// survive Reset() and are only released by ReleaseBuffers().
+TEST(TestReadAheadCache, TestBlockCacheSurvivesReset) {
+ std::string content = "abcdefghijklmnopqrstuvwxyz";
+ CacheConfig config = BlockCacheConfig(/*block_size=*/8,
/*block_cache_limit=*/1024);
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, config, {},
content.size());
+ ReadAheadCache& cache = *cache_ptr;
+
+ AssertReadEquals({18, 4}, "stuv", &cache);
+
+ // Reset() drops the registered ranges and zeroes the counters.
+ cache.Reset();
+ AssertReadEquals({18, 4}, "stuv", &cache);
+ BlockCounters blocks;
+ GetBlockCounters(&cache, &blocks);
+ ASSERT_EQ(blocks.hits, 1u);
+ ASSERT_EQ(blocks.fetches, 0u);
+ uint64_t io_count = 0;
+ uint64_t io_bytes = 0;
+ GetIOCounters(&cache, &io_count, &io_bytes);
+ ASSERT_EQ(io_count, 0u);
+
+ // ReleaseBuffers() drops the blocks, so the same read fetches again.
+ cache.ReleaseBuffers();
+ AssertReadEquals({18, 4}, "stuv", &cache);
+ GetBlockCounters(&cache, &blocks);
+ ASSERT_EQ(blocks.hits, 2u);
+ ASSERT_EQ(blocks.fetches, 1u);
+}
+
+// A zero block cache limit or an unknown file size leaves the block cache out
+// entirely: an uncovered read is a plain miss left to the caller.
+TEST(TestReadAheadCache, TestBlockCacheDisabled) {
+ std::string content = "abcdefghijklmnopqrstuvwxyz";
+ CacheConfig zero_limit = BlockCacheConfig(/*block_size=*/8,
/*block_cache_limit=*/0);
+ std::shared_ptr<ReadAheadCache> cache_ptr =
+ CreateTestFileAndCache("data_file", content, zero_limit, {},
content.size());
+ AssertReadMiss({18, 4}, cache_ptr.get());
+ AssertReadMiss({18, 4}, cache_ptr.get());
+ BlockCounters blocks;
+ GetBlockCounters(cache_ptr.get(), &blocks);
+ ASSERT_EQ(blocks.fetches, 0u);
+ ASSERT_EQ(blocks.hits, 0u);
+
+ // An unknown file size cannot be aligned to, so it disables the cache too.
+ CacheConfig enabled = BlockCacheConfig(/*block_size=*/8,
/*block_cache_limit=*/1024);
+ std::shared_ptr<ReadAheadCache> unknown_size_cache =
+ CreateTestFileAndCache("data_file", content, enabled, {},
/*file_size=*/0);
+ AssertReadMiss({18, 4}, unknown_size_cache.get());
+ GetBlockCounters(unknown_size_cache.get(), &blocks);
+ ASSERT_EQ(blocks.fetches, 0u);
+ ASSERT_EQ(blocks.hits, 0u);
}
} // namespace paimon::test
diff --git a/src/paimon/core/operation/read_context_test.cpp
b/src/paimon/core/operation/read_context_test.cpp
index 1174568f..3d362352 100644
--- a/src/paimon/core/operation/read_context_test.cpp
+++ b/src/paimon/core/operation/read_context_test.cpp
@@ -59,8 +59,10 @@ TEST(ReadContextTest, TestSetContent) {
ReadContextBuilder builder("table_root_path");
std::shared_ptr<MemoryPool> memory_pool = GetDefaultPool();
std::shared_ptr<Executor> executor = CreateDefaultExecutor();
- CacheConfig cache_config(/*range_size_limit=*/512, /*hole_size_limit=*/128,
- /*pre_buffer_limit=*/2048);
+ CacheConfig cache_config;
+ cache_config.SetRangeSizeLimit(512);
+ cache_config.SetHoleSizeLimit(128);
+ cache_config.SetPreBufferLimit(2048);
builder.AddOption("key", "value");
builder.SetReadFieldNames({"f1", "f2"});
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 c52ceba9..bed06bb7 100644
--- a/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp
+++ b/src/paimon/format/parquet/parquet_file_batch_reader_test.cpp
@@ -21,6 +21,7 @@
#include <algorithm>
#include <atomic>
#include <functional>
+#include <future>
#include <iostream>
#include <limits>
#include <memory>
@@ -1794,14 +1795,22 @@ TEST_F(ParquetFileBatchReaderTest,
TestPreBufferRangeFeedsReadAheadCache) {
WriteArray(file_path_, src_array, arrow_schema, /*write_batch_size=*/10,
/*enable_dictionary=*/true, /*max_row_group_length=*/10);
+ // A pool of its own, so that a buffer outliving the pool it was allocated
+ // from shows up instead of being covered by the global pool, which never
+ // goes away. Declared first, so that it outlives the cache and the reader.
+ std::shared_ptr<MemoryPool> pool(GetMemoryPool());
+
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> cache_stream,
fs_->Open(file_path_));
- auto cache = std::make_shared<ReadAheadCache>(cache_stream, CacheConfig(),
GetDefaultPool());
+ // The file size is left unknown so the block cache stays off: this test is
+ // about the pre-buffered ranges feeding the cache.
+ auto cache =
+ std::make_shared<ReadAheadCache>(cache_stream, CacheConfig(),
/*file_size=*/0, pool);
ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> reader_stream,
fs_->Open(file_path_));
auto cache_input_stream =
std::make_shared<CacheInputStream>(std::move(reader_stream), cache);
std::map<std::string, std::string> options;
ParquetReaderBuilder builder(options, batch_size_);
- builder.WithMemoryPool(GetDefaultPool());
+ builder.WithMemoryPool(pool);
ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> base_reader,
builder.Build(cache_input_stream));
auto parquet_batch_reader =
dynamic_cast<ParquetFileBatchReader*>(base_reader.get());
@@ -2154,4 +2163,133 @@ TEST_F(ParquetFileBatchReaderTest,
TestDictionaryPassthroughRequiresEveryRowGrou
reader->Close();
}
+// Counts the positional reads landing in the tail region of the file, i.e. the
+// footer and the page index of a parquet file. The counter is shared by all
the
+// streams of one test, so it totals the reads reaching the storage.
+class TailReadCountingInputStream : public InputStream {
+ public:
+ TailReadCountingInputStream(std::unique_ptr<InputStream> input, int64_t
tail_begin,
+ std::shared_ptr<std::atomic<int32_t>>
tail_read_count)
+ : input_(std::move(input)),
+ tail_begin_(tail_begin),
+ tail_read_count_(std::move(tail_read_count)) {}
+
+ Status Seek(int64_t offset, SeekOrigin origin) override {
+ return input_->Seek(offset, origin);
+ }
+ Result<int64_t> GetPos() const override {
+ return input_->GetPos();
+ }
+ Result<int64_t> Read(char* buffer, int64_t size) override {
+ return input_->Read(buffer, size);
+ }
+ Result<int64_t> Read(char* buffer, int64_t size, int64_t offset) override {
+ CountIfInTail(offset);
+ return input_->Read(buffer, size, offset);
+ }
+ void ReadAsync(char* buffer, int64_t size, int64_t offset,
+ std::function<void(Status)>&& callback) override {
+ CountIfInTail(offset);
+ return input_->ReadAsync(buffer, size, offset, std::move(callback));
+ }
+ Status Close() override {
+ return input_->Close();
+ }
+ Result<std::string> GetUri() const override {
+ return input_->GetUri();
+ }
+ Result<int64_t> Length() const override {
+ return input_->Length();
+ }
+
+ private:
+ void CountIfInTail(int64_t offset) {
+ if (offset >= tail_begin_) {
+ tail_read_count_->fetch_add(1);
+ }
+ }
+
+ std::unique_ptr<InputStream> input_;
+ int64_t tail_begin_;
+ std::shared_ptr<std::atomic<int32_t>> tail_read_count_;
+};
+
+// The sub-readers of a prefetch reader share one ReadAheadCache and read the
+// footer and the page index before any pre-buffer range is registered. The
block
+// cache turns all those tail reads into a single read of the last block.
+TEST_F(ParquetFileBatchReaderTest, TestBlockCacheSharesTailReadsAcrossReaders)
{
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32())};
+ auto src_array = MakeSequentialIntData(60000);
+ auto arrow_schema = arrow::schema(fields);
+ WriteArray(file_path_, src_array, arrow_schema, /*write_batch_size=*/1024,
+ /*enable_dictionary=*/false, /*max_row_group_length=*/30000);
+
+ ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> length_stream,
fs_->Open(file_path_));
+ ASSERT_OK_AND_ASSIGN(int64_t file_length, length_stream->Length());
+ ASSERT_OK(length_stream->Close());
+ // The tail region is the last block of the cache, which is also the footer
+ // read size of the parquet reader.
+ const int64_t tail_begin = file_length -
static_cast<int64_t>(CacheConfig().GetBlockSize());
+ ASSERT_GT(tail_begin, 0);
+
+ // A pool of its own, so that a buffer outliving the pool it was allocated
+ // from shows up instead of being covered by the global pool, which never
+ // goes away. Declared first, so that it outlives the cache and the
readers.
+ std::shared_ptr<MemoryPool> pool(GetMemoryPool());
+
+ auto tail_read_count = std::make_shared<std::atomic<int32_t>>(0);
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> cache_stream,
fs_->Open(file_path_));
+ auto counting_cache_stream = std::make_shared<TailReadCountingInputStream>(
+ std::move(cache_stream), tail_begin, tail_read_count);
+ auto cache = std::make_shared<ReadAheadCache>(counting_cache_stream,
CacheConfig(),
+
static_cast<uint64_t>(file_length), pool);
+
+ std::map<std::string, std::string> options;
+ options[PARQUET_READ_ENABLE_PAGE_INDEX_FILTER] = "true";
+ ParquetReaderBuilder builder(options, batch_size_);
+ builder.WithMemoryPool(pool);
+
+ constexpr int32_t kReaderCount = 3;
+ std::vector<std::future<Result<std::unique_ptr<FileBatchReader>>>> futures;
+ for (int32_t i = 0; i < kReaderCount; ++i) {
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<InputStream> reader_stream,
fs_->Open(file_path_));
+ auto counting =
std::make_unique<TailReadCountingInputStream>(std::move(reader_stream),
+
tail_begin, tail_read_count);
+ std::shared_ptr<InputStream> cache_input_stream =
+ std::make_shared<CacheInputStream>(std::move(counting), cache);
+ futures.push_back(std::async(std::launch::async, [&builder,
cache_input_stream]() {
+ return builder.Build(cache_input_stream);
+ }));
+ }
+ std::vector<std::unique_ptr<FileBatchReader>> readers;
+ for (auto& future : futures) {
+ ASSERT_OK_AND_ASSIGN(std::unique_ptr<FileBatchReader> reader,
future.get());
+ readers.push_back(std::move(reader));
+ }
+
+ // Only the last 1000 rows match, so the page index of the last row group
is
+ // read to filter its pages.
+ std::shared_ptr<Predicate> predicate = PredicateBuilder::GreaterOrEqual(
+ /*field_index=*/0, /*field_name=*/"f0", FieldType::INT,
Literal(59000));
+ for (auto& reader : readers) {
+ auto parquet_batch_reader =
dynamic_cast<ParquetFileBatchReader*>(reader.get());
+ ASSERT_TRUE(parquet_batch_reader);
+ std::unique_ptr<ArrowSchema> c_schema =
std::make_unique<ArrowSchema>();
+ ASSERT_TRUE(arrow::ExportSchema(*arrow_schema, c_schema.get()).ok());
+ ASSERT_OK(parquet_batch_reader->SetReadSchema(c_schema.get(),
predicate, std::nullopt));
+ }
+
+ // One block fetch served the footer read and the page index reads of all
the
+ // readers.
+ ASSERT_EQ(tail_read_count->load(), 1);
+ std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
+ cache->CollectMetrics(&metrics);
+ ASSERT_OK_AND_ASSIGN(uint64_t block_fetches,
+
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_FETCHES));
+ ASSERT_EQ(block_fetches, 1u);
+ ASSERT_OK_AND_ASSIGN(uint64_t block_hits,
+
metrics->GetCounter(ReadAheadCacheMetrics::BLOCK_HITS));
+ ASSERT_GT(block_hits, 1u);
+}
+
} // namespace paimon::parquet::test
diff --git a/src/paimon/testing/utils/gated_async_input_stream.h
b/src/paimon/testing/utils/gated_async_input_stream.h
new file mode 100644
index 00000000..3115a3e8
--- /dev/null
+++ b/src/paimon/testing/utils/gated_async_input_stream.h
@@ -0,0 +1,139 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "paimon/fs/file_system.h"
+
+namespace paimon::test {
+
+/// An InputStream wrapper that holds the ReadAsync callbacks until
ReleaseAll()
+/// is called, letting tests observe a cache while its fetches are in flight.
+class GatedAsyncInputStream : public InputStream {
+ public:
+ explicit GatedAsyncInputStream(std::shared_ptr<InputStream> inner) :
inner_(std::move(inner)) {}
+
+ Status Close() override {
+ return inner_->Close();
+ }
+ Status Seek(int64_t offset, SeekOrigin origin) override {
+ return inner_->Seek(offset, origin);
+ }
+ Result<int64_t> GetPos() const override {
+ return inner_->GetPos();
+ }
+ Result<int64_t> Read(char* buffer, int64_t size) override {
+ return inner_->Read(buffer, size);
+ }
+ Result<int64_t> Read(char* buffer, int64_t size, int64_t offset) override {
+ return inner_->Read(buffer, size, offset);
+ }
+ void ReadAsync(char* buffer, int64_t size, int64_t offset,
+ std::function<void(Status)>&& callback) override {
+ std::lock_guard<std::mutex> lock(mutex_);
+ async_read_count_++;
+ pending_.push_back({buffer, size, offset, std::move(callback)});
+ }
+ Result<std::string> GetUri() const override {
+ return inner_->GetUri();
+ }
+ Result<int64_t> Length() const override {
+ return inner_->Length();
+ }
+
+ int32_t AsyncReadCount() const {
+ std::lock_guard<std::mutex> lock(mutex_);
+ return async_read_count_;
+ }
+
+ /// Complete all held fetches against the underlying stream.
+ void ReleaseAll() {
+ for (auto& read : TakePending()) {
+ Result<int64_t> res = inner_->Read(read.buffer, read.size,
read.offset);
+ read.callback(res.ok() ? Status::OK() : res.status());
+ }
+ }
+
+ /// Complete all held fetches against the underlying stream but keep their
+ /// callbacks alive, the way an object store stream destroys the callback
of a
+ /// read later than it resolves it: whatever the callback captured stays
alive
+ /// until DropCompletedCallbacks() is called.
+ void ReleaseAllKeepingCallbacks() {
+ std::vector<PendingRead> taken = TakePending();
+ for (auto& read : taken) {
+ Result<int64_t> res = inner_->Read(read.buffer, read.size,
read.offset);
+ read.callback(res.ok() ? Status::OK() : res.status());
+ }
+ std::lock_guard<std::mutex> lock(mutex_);
+ for (auto& read : taken) {
+ completed_.push_back(std::move(read));
+ }
+ }
+
+ /// Destroy the callbacks kept alive by ReleaseAllKeepingCallbacks().
+ void DropCompletedCallbacks() {
+ std::vector<PendingRead> taken;
+ {
+ std::lock_guard<std::mutex> lock(mutex_);
+ taken = std::move(completed_);
+ completed_.clear();
+ }
+ }
+
+ /// Fail all held fetches without touching the underlying stream, so that a
+ /// test can observe how a cache reports a failed fetch.
+ void FailAll(const Status& status) {
+ for (auto& read : TakePending()) {
+ read.callback(status);
+ }
+ }
+
+ private:
+ struct PendingRead {
+ char* buffer;
+ int64_t size;
+ int64_t offset;
+ std::function<void(Status)> callback;
+ };
+
+ std::vector<PendingRead> TakePending() {
+ std::lock_guard<std::mutex> lock(mutex_);
+ std::vector<PendingRead> taken = std::move(pending_);
+ pending_.clear();
+ return taken;
+ }
+
+ std::shared_ptr<InputStream> inner_;
+ mutable std::mutex mutex_;
+ std::vector<PendingRead> pending_;
+ // The fetches completed by ReleaseAllKeepingCallbacks(), held to keep
their
+ // callbacks alive.
+ std::vector<PendingRead> completed_;
+ int32_t async_read_count_ = 0;
+};
+
+} // namespace paimon::test