blob: aadcc404c62d46dfebe5e72d7b0630bc751f9b62 [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/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