blob: e7900b7b6335789a35f6f0a2bffe12b01ebb28f6 [file]
/*
* 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/read_ahead_cache.h"
#include <chrono>
#include <fstream>
#include <mutex>
#include <thread>
#include <vector>
#include "gtest/gtest.h"
#include "paimon/common/factories/io_hook.h"
#include "paimon/common/metrics/metrics_impl.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/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;
};
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;
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();
auto pool = GetDefaultPool();
auto cache = std::make_shared<ReadAheadCache>(std::move(in), config, pool);
EXPECT_OK(cache->Init(std::move(ranges)));
return {path, cache, pool};
}
// Assert that reading the range is a cache hit filling the destination with
// the expected content.
void AssertReadEquals(const ByteRange& range, const std::string& expected, ReadAheadCache* cache) {
std::string dest(std::max<size_t>(range.length, 1), 'X');
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 that reading the range misses and leaves the destination untouched.
void AssertReadMiss(const ByteRange& range, ReadAheadCache* cache) {
std::string dest(std::max<size_t>(range.length, 1), 'X');
bool hit = true;
ASSERT_OK_AND_ASSIGN(hit, cache->Read(range, dest.data()));
ASSERT_FALSE(hit);
EXPECT_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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = 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;
AssertReadEquals({20, 2}, "uv", &cache);
AssertReadEquals({1, 2}, "bc", &cache);
AssertReadEquals({3, 2}, "de", &cache);
AssertReadEquals({8, 2}, "ij", &cache);
AssertReadEquals({10, 4}, "klmn", &cache);
AssertReadEquals({15, 4}, "pqrs", &cache);
AssertReadEquals({19, 3}, "tuv", &cache);
// Zero-sized reads are immediate hits touching nothing.
AssertReadEquals({14, 0}, "", &cache);
AssertReadEquals({25, 0}, "", &cache);
// Non-cached ranges miss and leave the destination untouched.
AssertReadMiss({20, 3}, &cache);
AssertReadMiss({0, 3}, &cache);
AssertReadMiss({25, 2}, &cache);
}
// 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);
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;
// Spans all three entries.
AssertReadEquals({5, 20}, "fghijklmnopqrstuvwxy", &cache);
// Spans the first two entries only, trimming both ends of the run.
AssertReadEquals({5, 10}, "fghijklmno", &cache);
// Runs past the end of the last entry: no contiguous cover, a miss.
AssertReadMiss({5, 21}, &cache);
// A multi-segment hit counts once with the full requested length.
std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
cache.CollectMetrics(&metrics);
ASSERT_OK_AND_ASSIGN(uint64_t hits, metrics->GetCounter(ReadAheadCacheMetrics::READ_HITS));
ASSERT_EQ(hits, 2u);
ASSERT_OK_AND_ASSIGN(uint64_t hit_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES));
ASSERT_EQ(hit_bytes, 30u);
ASSERT_OK_AND_ASSIGN(uint64_t misses, metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
ASSERT_EQ(misses, 1u);
ASSERT_OK_AND_ASSIGN(uint64_t miss_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_MISS_BYTES));
ASSERT_EQ(miss_bytes, 21u);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}, {7, 5}});
auto& cache = *env.cache;
AssertReadEquals({0, 5}, "abcde", &cache);
AssertReadEquals({0, 5}, "abcde", &cache);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5}, {16, 5}});
auto& cache = *env.cache;
AssertReadEquals({0, 5}, "abcde", &cache);
// Reading further ranges keeps the earlier ones cached.
AssertReadEquals({8, 5}, "ijklm", &cache);
AssertReadEquals({16, 5}, "qrstu", &cache);
AssertReadEquals({0, 5}, "abcde", &cache);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}, {8, 5}});
auto& cache = *env.cache;
AssertReadEquals({0, 5}, "abcde", &cache);
// Out of any cached range: a miss.
AssertReadMiss({20, 3}, &cache);
std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
cache.CollectMetrics(&metrics);
// Both Read() requests are counted, regardless of hit or miss.
ASSERT_OK_AND_ASSIGN(uint64_t read_count,
metrics->GetCounter(ReadAheadCacheMetrics::READ_COUNT));
ASSERT_EQ(read_count, 2u);
ASSERT_OK_AND_ASSIGN(uint64_t read_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_BYTES));
ASSERT_EQ(read_bytes, 8u);
ASSERT_OK_AND_ASSIGN(uint64_t hits, metrics->GetCounter(ReadAheadCacheMetrics::READ_HITS));
ASSERT_EQ(hits, 1u);
ASSERT_OK_AND_ASSIGN(uint64_t hit_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES));
ASSERT_EQ(hit_bytes, 5u);
ASSERT_OK_AND_ASSIGN(uint64_t misses, metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
ASSERT_EQ(misses, 1u);
ASSERT_OK_AND_ASSIGN(uint64_t miss_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_MISS_BYTES));
ASSERT_EQ(miss_bytes, 3u);
// The hit prefetches both pending ranges in one window: two IO requests
// for 10 bytes in total; the miss issues no further fetch.
ASSERT_OK_AND_ASSIGN(uint64_t io_count, metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
ASSERT_EQ(io_count, 2u);
ASSERT_OK_AND_ASSIGN(uint64_t io_bytes, metrics->GetCounter(ReadAheadCacheMetrics::IO_BYTES));
ASSERT_EQ(io_bytes, 10u);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
auto& cache = *env.cache;
AssertReadEquals({0, 5}, "abcde", &cache);
cache.ReleaseBuffers();
// The previously cached range is gone: the read now misses.
AssertReadMiss({0, 5}, &cache);
std::shared_ptr<Metrics> metrics = std::make_shared<MetricsImpl>();
cache.CollectMetrics(&metrics);
// The read counters survive ReleaseBuffers() as well.
ASSERT_OK_AND_ASSIGN(uint64_t read_count,
metrics->GetCounter(ReadAheadCacheMetrics::READ_COUNT));
ASSERT_EQ(read_count, 2u);
ASSERT_OK_AND_ASSIGN(uint64_t read_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_BYTES));
ASSERT_EQ(read_bytes, 10u);
ASSERT_OK_AND_ASSIGN(uint64_t hits, metrics->GetCounter(ReadAheadCacheMetrics::READ_HITS));
ASSERT_EQ(hits, 1u);
ASSERT_OK_AND_ASSIGN(uint64_t hit_bytes,
metrics->GetCounter(ReadAheadCacheMetrics::READ_HIT_BYTES));
ASSERT_EQ(hit_bytes, 5u);
ASSERT_OK_AND_ASSIGN(uint64_t misses, metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
ASSERT_EQ(misses, 1u);
// The io counters survive ReleaseBuffers() as well.
ASSERT_OK_AND_ASSIGN(uint64_t io_count, metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
ASSERT_EQ(io_count, 1u);
ASSERT_OK_AND_ASSIGN(uint64_t io_bytes, metrics->GetCounter(ReadAheadCacheMetrics::IO_BYTES));
ASSERT_EQ(io_bytes, 5u);
// Reset() clears the counters too.
cache.Reset();
std::shared_ptr<Metrics> reset_metrics = std::make_shared<MetricsImpl>();
cache.CollectMetrics(&reset_metrics);
ASSERT_OK_AND_ASSIGN(read_count, reset_metrics->GetCounter(ReadAheadCacheMetrics::READ_COUNT));
ASSERT_EQ(read_count, 0u);
ASSERT_OK_AND_ASSIGN(hits, reset_metrics->GetCounter(ReadAheadCacheMetrics::READ_HITS));
ASSERT_EQ(hits, 0u);
ASSERT_OK_AND_ASSIGN(misses, reset_metrics->GetCounter(ReadAheadCacheMetrics::READ_MISSES));
ASSERT_EQ(misses, 0u);
ASSERT_OK_AND_ASSIGN(io_count, reset_metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
ASSERT_EQ(io_count, 0u);
ASSERT_OK_AND_ASSIGN(io_bytes, reset_metrics->GetCounter(ReadAheadCacheMetrics::IO_BYTES));
ASSERT_EQ(io_bytes, 0u);
}
// Test that a failed prefetch surfaces as an error Status from Read(), not as
// 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);
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}});
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()),
"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}});
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()),
"io hook triggered io error at position");
}
}
// Test that Warmup() fetches the pending ranges up front so the first Read()
// 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);
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}});
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());
// 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()));
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {});
env.cache->Warmup();
AssertReadMiss({0, 5}, env.cache.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);
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));
ReadAheadCache cache(gated, config, GetDefaultPool());
ASSERT_OK(cache.Init({{0, 5}}));
cache.Warmup();
// The prefetch entry is published, but its fetch is still held.
ASSERT_EQ(gated->AsyncReadCount(), 1);
// A racing reader blocks on the in-flight entry's future and is served
// from it once the fetch completes, without triggering a second fetch.
std::thread reader([&cache, &gated]() {
std::string dest(5, 'X');
Result<bool> res = cache.Read({0, 5}, dest.data());
EXPECT_TRUE(res.ok());
if (res.ok()) {
EXPECT_TRUE(res.value());
}
EXPECT_EQ("abcde", std::string_view(dest.data(), 5));
EXPECT_EQ(gated->AsyncReadCount(), 1);
});
// Give the reader time to block on the in-flight entry's future before
// completing the fetch.
std::this_thread::sleep_for(std::chrono::milliseconds(100));
gated->ReleaseAll();
reader.join();
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, 0u);
ASSERT_OK_AND_ASSIGN(uint64_t io_count, metrics->GetCounter(ReadAheadCacheMetrics::IO_COUNT));
ASSERT_EQ(io_count, 1u);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 10}, {16, 10}});
auto& cache = *env.cache;
auto io_hook = paimon::IOHook::GetInstance();
paimon::ScopeGuard guard([&io_hook]() { io_hook->Clear(); });
// IOCount() only counts while armed; INT64_MAX never triggers the error mode.
io_hook->Reset(INT64_MAX, paimon::IOHook::Mode::RETURN_ERROR);
AssertReadEquals({0, 10}, "abcdefghij", &cache);
// The second range did not fit into the window: only one prefetch IO.
ASSERT_EQ(io_hook->IOCount(), 1);
AssertReadEquals({16, 10}, "qrstuvwxyz", &cache);
// The second read triggered exactly one more prefetch IO.
ASSERT_EQ(io_hook->IOCount(), 2);
// The range is cached now: re-reading it issues no IO at all.
io_hook->Reset(INT64_MAX, paimon::IOHook::Mode::RETURN_ERROR);
AssertReadEquals({16, 10}, "qrstuvwxyz", &cache);
ASSERT_EQ(io_hook->IOCount(), 0);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
auto& cache = *env.cache;
Status status = cache.Init({{8, 5}});
ASSERT_FALSE(status.ok());
// The original ranges still work.
AssertReadEquals({0, 5}, "abcde", &cache);
}
// 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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
auto& cache = *env.cache;
AssertReadEquals({0, 5}, "abcde", &cache);
cache.Reset();
ASSERT_OK(cache.Init({{3, 4}}));
AssertReadEquals({3, 4}, "defg", &cache);
// The old ranges are gone.
AssertReadMiss({20, 2}, &cache);
}
// 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);
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;
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);
std::string content = "abcdefghijklmnopqrstuvwxyz";
auto env = CreateTestFileAndCache("data_file", content, config, {{0, 5}});
env.cache->CollectMetrics(/*metrics=*/nullptr);
std::shared_ptr<Metrics> null_metrics;
env.cache->CollectMetrics(&null_metrics);
}
} // namespace paimon::test