blob: 7417d91e1012677a1633cee799b9d0a9d5307ea6 [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/sst/block_cache.h"
#include <cstdint>
#include <cstring>
#include <memory>
#include <string>
#include "gtest/gtest.h"
#include "paimon/common/io/cache/cache_manager.h"
#include "paimon/common/io/cache/lru_cache.h"
#include "paimon/memory/memory_pool.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
class BlockCacheTest : public ::testing::Test {
public:
void SetUp() override {
dir_ = UniqueTestDirectory::Create();
fs_ = dir_->GetFileSystem();
pool_ = GetDefaultPool();
}
void TearDown() override {}
Status WriteTestFile(const std::string& path, int32_t num_blocks, int32_t block_size) const {
PAIMON_ASSIGN_OR_RAISE(auto out, fs_->Create(path, false));
for (int32_t i = 0; i < num_blocks; i++) {
auto segment = MemorySegment::AllocateHeapMemory(block_size, pool_.get());
std::memset(segment.MutableData(), i & 0xFF, block_size);
PAIMON_RETURN_NOT_OK(out->Write(segment.MutableData(), block_size));
}
PAIMON_RETURN_NOT_OK(out->Flush());
PAIMON_RETURN_NOT_OK(out->Close());
return Status::OK();
}
Result<MemorySegment> GetBlock(int32_t block_id, int32_t block_size, BlockCache* block_cache,
bool is_index = false) const {
return block_cache->GetBlock(/*position=*/block_id * block_size, /*length=*/block_size,
is_index,
/*decompress_func=*/nullptr);
}
bool ContainsBlock(int32_t block_id, int32_t block_size, BlockCache* block_cache,
bool is_index = false) const {
return block_cache->ContainsBlock(/*position=*/block_id * block_size, /*length=*/block_size,
is_index);
}
private:
std::unique_ptr<UniqueTestDirectory> dir_;
std::shared_ptr<FileSystem> fs_;
std::shared_ptr<MemoryPool> pool_;
};
/// Verifies that the first GetBlock call reads from IO and subsequent calls return from the
/// local blocks_ cache without re-reading.
TEST_F(BlockCacheTest, TestBasicCacheHit) {
const int32_t block_size = 64;
const int32_t num_blocks = 4;
auto file_path = dir_->Str() + "/basic_hit.data";
ASSERT_OK(WriteTestFile(file_path, num_blocks, block_size));
auto cache_manager = std::make_shared<CacheManager>(block_size * num_blocks * 2, 0.0);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs_->Open(file_path));
BlockCache block_cache(file_path, in, cache_manager, pool_);
// Initially blocks_ is empty
ASSERT_EQ(block_cache.BlocksSize(), 0);
// First access: populates both blocks_ and LRU
ASSERT_OK_AND_ASSIGN(auto seg1, GetBlock(0, block_size, &block_cache));
ASSERT_EQ(seg1.Size(), block_size);
ASSERT_EQ(seg1.Get(0), static_cast<char>(0));
ASSERT_EQ(block_cache.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 1);
// Second access: returns from blocks_, no new LRU entry
ASSERT_OK_AND_ASSIGN(auto seg2, GetBlock(0, block_size, &block_cache));
ASSERT_EQ(seg2.Size(), block_size);
ASSERT_EQ(block_cache.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 1);
// Load a different block
ASSERT_OK_AND_ASSIGN(auto seg3, GetBlock(1, block_size, &block_cache));
ASSERT_EQ(seg3.Get(0), static_cast<char>(1));
ASSERT_EQ(block_cache.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
}
/// Verifies that when LRU evicts an entry due to capacity pressure, the eviction callback
/// removes the corresponding entry from BlockCache's blocks_ map.
TEST_F(BlockCacheTest, TestLruEvictionSyncsWithBlocks) {
const int32_t block_size = 100;
auto file_path = dir_->Str() + "/eviction.data";
ASSERT_OK(WriteTestFile(file_path, 5, block_size));
// Cache can hold at most 2 blocks (200 bytes)
auto cache_manager = std::make_shared<CacheManager>(block_size * 2, 0.0);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs_->Open(file_path));
BlockCache block_cache(file_path, in, cache_manager, pool_);
// Load block 0 at position 0
ASSERT_OK_AND_ASSIGN(auto seg0, GetBlock(0, block_size, &block_cache));
ASSERT_EQ(seg0.Get(0), static_cast<char>(0));
ASSERT_EQ(block_cache.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
// Load block 1 at position block_size
ASSERT_OK_AND_ASSIGN(auto seg1, GetBlock(1, block_size, &block_cache));
ASSERT_EQ(seg1.Get(0), static_cast<char>(1));
ASSERT_EQ(block_cache.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
// Load block 2: evicts block 0 (LRU) from both LRU and blocks_
ASSERT_OK_AND_ASSIGN(auto seg2, GetBlock(2, block_size, &block_cache));
ASSERT_EQ(seg2.Get(0), static_cast<char>(2));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
ASSERT_EQ(block_cache.BlocksSize(), 2);
// block 0 should be evicted from blocks_
ASSERT_FALSE(ContainsBlock(0, block_size, &block_cache));
// block 1 and block 2 should remain
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(2, block_size, &block_cache));
// Re-access block 0: triggers fresh IO read, evicts block 1 (now LRU)
ASSERT_OK_AND_ASSIGN(auto seg0_reloaded, GetBlock(0, block_size, &block_cache));
ASSERT_EQ(seg0_reloaded.Get(0), static_cast<char>(0));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
ASSERT_EQ(block_cache.BlocksSize(), 2);
// block 1 should now be evicted
ASSERT_FALSE(ContainsBlock(1, block_size, &block_cache));
// block 0 and block 2 should remain
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(2, block_size, &block_cache));
}
/// Verifies that Close() invalidates all entries from both blocks_ and the LRU cache.
TEST_F(BlockCacheTest, TestClose) {
const int32_t block_size = 64;
auto file_path = dir_->Str() + "/close.data";
ASSERT_OK(WriteTestFile(file_path, 3, block_size));
auto cache_manager = std::make_shared<CacheManager>(block_size * 10, 0.0);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs_->Open(file_path));
BlockCache block_cache(file_path, in, cache_manager, pool_);
// Load 3 blocks and verify blocks_ keys
for (int32_t i = 0; i < 3; i++) {
ASSERT_OK_AND_ASSIGN(auto seg, GetBlock(i, block_size, &block_cache));
ASSERT_EQ(seg.Get(0), static_cast<char>(i));
}
ASSERT_EQ(block_cache.BlocksSize(), 3);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(2, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 3);
block_cache.Close();
// After Close, both blocks_ and LRU should be empty
ASSERT_EQ(block_cache.BlocksSize(), 0);
ASSERT_FALSE(ContainsBlock(0, block_size, &block_cache));
ASSERT_FALSE(ContainsBlock(1, block_size, &block_cache));
ASSERT_FALSE(ContainsBlock(2, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 0);
}
/// Verifies that two BlockCache instances sharing the same CacheManager have independent blocks_
/// maps, but eviction in the shared LRU only affects the owning BlockCache's blocks_.
TEST_F(BlockCacheTest, TestSharedCacheManagerEvictionIsolation) {
const int32_t block_size = 100;
auto file_path_a = dir_->Str() + "/file_a.data";
auto file_path_b = dir_->Str() + "/file_b.data";
ASSERT_OK(WriteTestFile(file_path_a, 3, block_size));
ASSERT_OK(WriteTestFile(file_path_b, 3, block_size));
// Shared cache can hold 3 blocks total
auto cache_manager = std::make_shared<CacheManager>(block_size * 3, 0.0);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in_a, fs_->Open(file_path_a));
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in_b, fs_->Open(file_path_b));
BlockCache cache_a(file_path_a, in_a, cache_manager, pool_);
BlockCache cache_b(file_path_b, in_b, cache_manager, pool_);
// Load 2 blocks from file_a
ASSERT_OK_AND_ASSIGN(auto seg_a0, GetBlock(0, block_size, &cache_a));
ASSERT_OK_AND_ASSIGN(auto seg_a1, GetBlock(1, block_size, &cache_a));
ASSERT_EQ(cache_a.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &cache_a));
ASSERT_TRUE(ContainsBlock(1, block_size, &cache_a));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
// Load 1 block from file_b (total 3, at capacity)
ASSERT_OK_AND_ASSIGN(auto seg_b0, GetBlock(0, block_size, &cache_b));
ASSERT_EQ(cache_b.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &cache_b));
ASSERT_EQ(cache_manager->DataCache()->Size(), 3);
// Load another block from file_b: should evict file_a's block 0 (the LRU entry)
ASSERT_OK_AND_ASSIGN(auto seg_b1, GetBlock(1, block_size, &cache_b));
ASSERT_EQ(cache_manager->DataCache()->Size(), 3);
// cache_b should have 2 entries
ASSERT_EQ(cache_b.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &cache_b));
ASSERT_TRUE(ContainsBlock(1, block_size, &cache_b));
// cache_a's block 0 was evicted by LRU callback, only block 1 remains
ASSERT_EQ(cache_a.BlocksSize(), 1);
ASSERT_FALSE(ContainsBlock(0, block_size, &cache_a));
ASSERT_TRUE(ContainsBlock(1, block_size, &cache_a));
// Re-access file_a's block 0: triggers fresh IO read, evicts file_a's block 1 (now LRU)
ASSERT_OK_AND_ASSIGN(auto seg_a0_reloaded, GetBlock(0, block_size, &cache_a));
ASSERT_EQ(seg_a0_reloaded.Get(0), static_cast<char>(0));
ASSERT_EQ(cache_a.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &cache_a));
ASSERT_FALSE(ContainsBlock(1, block_size, &cache_a));
// cache_b should be unaffected
ASSERT_EQ(cache_b.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &cache_b));
ASSERT_TRUE(ContainsBlock(1, block_size, &cache_b));
cache_a.Close();
cache_b.Close();
}
/// Verifies the REFRESH_COUNT mechanism interacts correctly with LRU eviction ordering.
/// After refreshing a block (re-inserting into LRU front), it should not be the first to be
/// evicted when capacity pressure occurs.
TEST_F(BlockCacheTest, TestRefreshPreventsEviction) {
const int32_t block_size = 100;
auto file_path = dir_->Str() + "/refresh_eviction.data";
ASSERT_OK(WriteTestFile(file_path, 4, block_size));
// Cache can hold 2 blocks
auto cache_manager = std::make_shared<CacheManager>(block_size * 2, 0.0);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs_->Open(file_path));
BlockCache block_cache(file_path, in, cache_manager, pool_);
// Load block 0 and block 1
ASSERT_OK_AND_ASSIGN(auto seg0, GetBlock(0, block_size, &block_cache));
ASSERT_OK_AND_ASSIGN(auto seg1, GetBlock(1, block_size, &block_cache));
ASSERT_EQ(block_cache.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
// Access block 0 REFRESH_COUNT times to trigger a refresh (moves it to LRU front)
for (int32_t i = 1; i < CacheManager::REFRESH_COUNT; i++) {
ASSERT_OK_AND_ASSIGN(seg0, GetBlock(0, block_size, &block_cache));
}
// This 11th access triggers refresh, moving block 0 to LRU front
ASSERT_OK_AND_ASSIGN(seg0, GetBlock(0, block_size, &block_cache));
// blocks_ should still have both entries after refresh
ASSERT_EQ(block_cache.BlocksSize(), 2);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache));
// Load block 2: should evict block 1 (not block 0, since block 0 was just refreshed)
ASSERT_OK_AND_ASSIGN(auto seg2, GetBlock(2, block_size, &block_cache));
ASSERT_EQ(cache_manager->DataCache()->Size(), 2);
ASSERT_EQ(block_cache.BlocksSize(), 2);
// block 0 should still be in blocks_ (was refreshed to LRU front)
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache));
// block 1 should be evicted from blocks_
ASSERT_FALSE(ContainsBlock(1, block_size, &block_cache));
// block 2 should be in blocks_
ASSERT_TRUE(ContainsBlock(2, block_size, &block_cache));
// Block 0 should still be accessible from blocks_ cache
ASSERT_OK_AND_ASSIGN(seg0, GetBlock(0, block_size, &block_cache));
ASSERT_EQ(seg0.Get(0), static_cast<char>(0));
// Block 1 was evicted, re-accessing triggers IO read
ASSERT_OK_AND_ASSIGN(seg1, GetBlock(1, block_size, &block_cache));
ASSERT_EQ(seg1.Get(0), static_cast<char>(1));
}
TEST_F(BlockCacheTest, TestIndexAndDataCache) {
const int32_t block_size = 100;
const int32_t num_blocks = 6;
auto file_path = dir_->Str() + "/index_and_data.data";
ASSERT_OK(WriteTestFile(file_path, num_blocks, block_size));
// index cache max weight = 100
// data cache max weight = 300
auto cache_manager = std::make_shared<CacheManager>(block_size * 4, 0.25);
ASSERT_OK_AND_ASSIGN(std::shared_ptr<InputStream> in, fs_->Open(file_path));
BlockCache block_cache(file_path, in, cache_manager, pool_);
// First access for seg0, index
ASSERT_OK_AND_ASSIGN(auto seg0, GetBlock(0, block_size, &block_cache, /*is_index=*/true));
ASSERT_EQ(seg0.Get(0), static_cast<char>(0));
ASSERT_EQ(block_cache.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(0, block_size, &block_cache, /*is_index=*/true));
ASSERT_EQ(cache_manager->DataCache()->Size(), 0);
ASSERT_EQ(cache_manager->IndexCache()->Size(), 1);
// Second access for seg1, index, seg0 will be evicted
ASSERT_OK_AND_ASSIGN(auto seg1, GetBlock(1, block_size, &block_cache, /*is_index=*/true));
ASSERT_EQ(seg1.Get(0), static_cast<char>(1));
ASSERT_EQ(block_cache.BlocksSize(), 1);
ASSERT_TRUE(ContainsBlock(1, block_size, &block_cache, /*is_index=*/true));
ASSERT_FALSE(ContainsBlock(0, block_size, &block_cache, /*is_index=*/true));
ASSERT_EQ(cache_manager->DataCache()->Size(), 0);
ASSERT_EQ(cache_manager->IndexCache()->Size(), 1);
// Fills data cache
for (int32_t i = 2; i < 5; i++) {
ASSERT_OK_AND_ASSIGN(auto seg, GetBlock(i, block_size, &block_cache, /*is_index=*/false));
ASSERT_EQ(seg.Get(0), static_cast<char>(i));
ASSERT_EQ(block_cache.BlocksSize(), 1 + i - 1);
ASSERT_TRUE(ContainsBlock(i, block_size, &block_cache, /*is_index=*/false));
ASSERT_EQ(cache_manager->IndexCache()->Size(), 1);
ASSERT_EQ(cache_manager->DataCache()->Size(), i - 1);
}
ASSERT_OK_AND_ASSIGN(auto seg5, GetBlock(5, block_size, &block_cache, /*is_index=*/false));
ASSERT_EQ(seg5.Get(0), static_cast<char>(5));
ASSERT_EQ(block_cache.BlocksSize(), 4);
ASSERT_TRUE(ContainsBlock(5, block_size, &block_cache, /*is_index=*/false));
ASSERT_TRUE(ContainsBlock(4, block_size, &block_cache, /*is_index=*/false));
ASSERT_TRUE(ContainsBlock(3, block_size, &block_cache, /*is_index=*/false));
ASSERT_FALSE(ContainsBlock(2, block_size, &block_cache, /*is_index=*/false));
ASSERT_EQ(cache_manager->DataCache()->Size(), 3);
ASSERT_EQ(cache_manager->IndexCache()->Size(), 1);
}
} // namespace paimon::test