blob: 318f115998f7256183356adedaed9b77f071eb59 [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/generic_lru_cache.h"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "gtest/gtest.h"
#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
class GenericLruCacheTest : public ::testing::Test {
public:
using StringIntCache = GenericLruCache<std::string, int>;
using StringStringCache = GenericLruCache<std::string, std::string>;
using IntIntCache = GenericLruCache<int, int>;
using StringSharedPtrCache = GenericLruCache<std::string, std::shared_ptr<int>>;
using RemovalCause = StringIntCache::RemovalCause;
struct RemovalRecord {
std::string key;
std::string value;
RemovalCause cause;
};
};
// ==================== Basic Operations ====================
TEST_F(GenericLruCacheTest, ConstructorAndDefaults) {
{
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_EQ(cache.Size(), 0);
ASSERT_EQ(cache.GetCurrentWeight(), 0);
ASSERT_EQ(cache.GetMaxWeight(), INT64_MAX);
}
{
StringIntCache::Options options;
options.max_weight = 42;
StringIntCache cache(options);
ASSERT_EQ(cache.GetMaxWeight(), 42);
}
}
// ==================== GetIfPresent ====================
TEST_F(GenericLruCacheTest, GetIfPresentMissAndHit) {
StringIntCache::Options options;
StringIntCache cache(options);
auto result = cache.GetIfPresent("nonexistent");
ASSERT_FALSE(result.has_value());
ASSERT_OK(cache.Put("key1", 100));
result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 100);
}
TEST_F(GenericLruCacheTest, GetIfPresentExpired) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.expire_after_access_ms = 50;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
ASSERT_EQ(cache.Size(), 1);
std::this_thread::sleep_for(std::chrono::milliseconds(80));
auto result = cache.GetIfPresent("key1");
ASSERT_FALSE(result.has_value());
ASSERT_EQ(cache.Size(), 0);
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "key1");
ASSERT_EQ(removals[0].cause, RemovalCause::EXPIRED);
}
TEST_F(GenericLruCacheTest, GetIfPresentPromotesEntry) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.max_weight = 2;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
// Access "a" to promote it
auto result = cache.GetIfPresent("a");
ASSERT_TRUE(result.has_value());
// Insert "c": should evict "b" (LRU), not "a"
ASSERT_OK(cache.Put("c", 3));
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "b");
ASSERT_EQ(removals[0].cause, RemovalCause::SIZE);
}
// ==================== Get with supplier ====================
TEST_F(GenericLruCacheTest, GetCacheMissLoadsViaSupplier) {
StringIntCache::Options options;
StringIntCache cache(options);
int32_t supplier_calls = 0;
auto supplier = [&](const std::string& key) -> Result<int> {
supplier_calls++;
return 42;
};
ASSERT_OK_AND_ASSIGN(auto value, cache.Get("key1", supplier));
ASSERT_EQ(value, 42);
ASSERT_EQ(supplier_calls, 1);
ASSERT_EQ(cache.Size(), 1);
}
TEST_F(GenericLruCacheTest, GetCacheHitSkipsSupplier) {
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
int32_t supplier_calls = 0;
auto supplier = [&](const std::string& key) -> Result<int> {
supplier_calls++;
return 999;
};
ASSERT_OK_AND_ASSIGN(auto value, cache.Get("key1", supplier));
ASSERT_EQ(value, 100);
ASSERT_EQ(supplier_calls, 0);
}
TEST_F(GenericLruCacheTest, GetSupplierError) {
StringIntCache::Options options;
StringIntCache cache(options);
auto supplier = [](const std::string& key) -> Result<int> {
return Status::IOError("load failed");
};
ASSERT_NOK_WITH_MSG(cache.Get("key1", supplier), "load failed");
ASSERT_EQ(cache.Size(), 0);
}
TEST_F(GenericLruCacheTest, GetWeightExceedsMaxReturnsWithoutCaching) {
StringStringCache::Options options;
options.max_weight = 5;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
StringStringCache cache(options);
auto supplier = [](const std::string& key) -> Result<std::string> {
return std::string("this_is_a_very_long_value");
};
ASSERT_OK_AND_ASSIGN(auto value, cache.Get("key1", supplier));
ASSERT_EQ(value, "this_is_a_very_long_value");
ASSERT_EQ(cache.Size(), 0);
}
TEST_F(GenericLruCacheTest, GetTriggersEviction) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.max_weight = 2;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
ASSERT_EQ(cache.Size(), 2);
auto supplier = [](const std::string& key) -> Result<int> { return 3; };
ASSERT_OK_AND_ASSIGN(auto value, cache.Get("c", supplier));
ASSERT_EQ(value, 3);
ASSERT_EQ(cache.Size(), 2);
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "a");
ASSERT_EQ(removals[0].cause, RemovalCause::SIZE);
}
// ==================== Put ====================
TEST_F(GenericLruCacheTest, PutNewEntry) {
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
ASSERT_EQ(cache.Size(), 1);
ASSERT_EQ(cache.GetCurrentWeight(), 1);
auto result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 100);
}
TEST_F(GenericLruCacheTest, PutReplaceWithDifferentValue) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
ASSERT_OK(cache.Put("key1", 200));
ASSERT_EQ(cache.Size(), 1);
auto result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 200);
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "key1");
ASSERT_EQ(removals[0].value, "100");
ASSERT_EQ(removals[0].cause, RemovalCause::REPLACED);
}
TEST_F(GenericLruCacheTest, PutReplaceWithSameValuePromotes) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.max_weight = 2;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
// Put same value for "a" — should promote without REPLACED callback
ASSERT_OK(cache.Put("a", 1));
ASSERT_TRUE(removals.empty());
// Insert "c": should evict "b" (LRU after "a" was promoted)
ASSERT_OK(cache.Put("c", 3));
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "b");
ASSERT_EQ(removals[0].cause, RemovalCause::SIZE);
}
TEST_F(GenericLruCacheTest, PutWeightExceedsMaxReturnsInvalid) {
StringStringCache::Options options;
options.max_weight = 5;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
StringStringCache cache(options);
ASSERT_NOK_WITH_MSG(cache.Put("key1", "this_is_too_long"),
"Entry weight 16 exceeds cache max weight 5, entry will not be cached");
ASSERT_EQ(cache.Size(), 0);
}
TEST_F(GenericLruCacheTest, PutTriggersWeightEviction) {
std::vector<RemovalRecord> removals;
StringStringCache::Options options;
options.max_weight = 10;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
options.removal_callback = [&](const std::string& key, const std::string& value, auto cause) {
removals.push_back({key, value, static_cast<RemovalCause>(cause)});
};
StringStringCache cache(options);
ASSERT_OK(cache.Put("a", "aaaa")); // weight 4
ASSERT_OK(cache.Put("b", "bbbbb")); // weight 5, total 9
ASSERT_EQ(cache.GetCurrentWeight(), 9);
// Insert "c" with weight 5: total would be 14 > 10, evict "a" (4), total becomes 10
ASSERT_OK(cache.Put("c", "ccccc"));
ASSERT_EQ(cache.Size(), 2);
ASSERT_EQ(cache.GetCurrentWeight(), 10);
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "a");
ASSERT_EQ(removals[0].cause, RemovalCause::SIZE);
}
TEST_F(GenericLruCacheTest, PutMultipleEvictions) {
std::vector<std::string> evicted_keys;
StringStringCache::Options options;
options.max_weight = 10;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
options.removal_callback = [&](const std::string& key, const std::string& value, auto cause) {
evicted_keys.push_back(key);
};
StringStringCache cache(options);
ASSERT_OK(cache.Put("a", "aaa")); // weight 3
ASSERT_OK(cache.Put("b", "bbb")); // weight 3
ASSERT_OK(cache.Put("c", "ccc")); // weight 3, total 9
// Insert "d" with weight 9: total would be 18 > 10, evict a(3), b(3), c(3) then add d(9)
ASSERT_OK(cache.Put("d", "ddddddddd"));
ASSERT_EQ(cache.Size(), 1);
ASSERT_EQ(cache.GetCurrentWeight(), 9);
ASSERT_EQ(evicted_keys.size(), 3);
ASSERT_EQ(evicted_keys[0], "a");
ASSERT_EQ(evicted_keys[1], "b");
ASSERT_EQ(evicted_keys[2], "c");
}
// ==================== Invalidate ====================
TEST_F(GenericLruCacheTest, InvalidateExistingKey) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
ASSERT_EQ(cache.Size(), 1);
cache.Invalidate("key1");
ASSERT_EQ(cache.Size(), 0);
ASSERT_EQ(cache.GetCurrentWeight(), 0);
ASSERT_EQ(removals.size(), 1);
ASSERT_EQ(removals[0].key, "key1");
ASSERT_EQ(removals[0].cause, RemovalCause::EXPLICIT);
}
TEST_F(GenericLruCacheTest, InvalidateNonExistentKey) {
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
cache.Invalidate("nonexistent");
ASSERT_EQ(cache.Size(), 1);
}
// ==================== InvalidateAll ====================
TEST_F(GenericLruCacheTest, InvalidateAllClearsEverything) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
ASSERT_OK(cache.Put("c", 3));
ASSERT_EQ(cache.Size(), 3);
cache.InvalidateAll();
ASSERT_EQ(cache.Size(), 0);
ASSERT_EQ(cache.GetCurrentWeight(), 0);
ASSERT_EQ(removals.size(), 3);
for (const auto& record : removals) {
ASSERT_EQ(record.cause, RemovalCause::EXPLICIT);
}
}
TEST_F(GenericLruCacheTest, InvalidateAllOnEmptyCache) {
StringIntCache::Options options;
StringIntCache cache(options);
cache.InvalidateAll();
ASSERT_EQ(cache.Size(), 0);
ASSERT_EQ(cache.GetCurrentWeight(), 0);
}
// ==================== Weight Function ====================
TEST_F(GenericLruCacheTest, DefaultWeightIsOne) {
StringIntCache::Options options;
options.max_weight = 3;
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
ASSERT_OK(cache.Put("c", 3));
ASSERT_EQ(cache.GetCurrentWeight(), 3);
ASSERT_EQ(cache.Size(), 3);
// Adding one more should evict the LRU entry
ASSERT_OK(cache.Put("d", 4));
ASSERT_EQ(cache.Size(), 3);
ASSERT_EQ(cache.GetCurrentWeight(), 3);
ASSERT_FALSE(cache.GetIfPresent("a").has_value());
}
TEST_F(GenericLruCacheTest, WeightUpdatedOnReplace) {
StringStringCache::Options options;
options.max_weight = 100;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
StringStringCache cache(options);
ASSERT_OK(cache.Put("a", std::string(30, 'x')));
ASSERT_EQ(cache.GetCurrentWeight(), 30);
// Replace with larger value
ASSERT_OK(cache.Put("a", std::string(70, 'y')));
ASSERT_EQ(cache.GetCurrentWeight(), 70);
ASSERT_EQ(cache.Size(), 1);
}
// ==================== Expiration ====================
TEST_F(GenericLruCacheTest, ExpirationDisabledByDefault) {
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
std::this_thread::sleep_for(std::chrono::milliseconds(10));
auto result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 100);
}
TEST_F(GenericLruCacheTest, ExpirationOnGet) {
StringIntCache::Options options;
options.expire_after_access_ms = 50;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
// Access before expiration
std::this_thread::sleep_for(std::chrono::milliseconds(2));
auto result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
// Wait for expiration
std::this_thread::sleep_for(std::chrono::milliseconds(80));
result = cache.GetIfPresent("key1");
ASSERT_FALSE(result.has_value());
ASSERT_EQ(cache.Size(), 0);
}
TEST_F(GenericLruCacheTest, ExpirationOnGetWithSupplier) {
StringIntCache::Options options;
options.expire_after_access_ms = 50;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
std::this_thread::sleep_for(std::chrono::milliseconds(80));
int32_t supplier_calls = 0;
auto supplier = [&](const std::string& key) -> Result<int> {
supplier_calls++;
return 200;
};
ASSERT_OK_AND_ASSIGN(auto value, cache.Get("key1", supplier));
ASSERT_EQ(value, 200);
ASSERT_EQ(supplier_calls, 1);
}
TEST_F(GenericLruCacheTest, AccessResetsExpirationTimer) {
StringIntCache::Options options;
options.expire_after_access_ms = 100;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
// Access at 40ms to reset the timer
std::this_thread::sleep_for(std::chrono::milliseconds(40));
auto result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
// At 80ms from last access (40ms from the GetIfPresent), should still be valid
std::this_thread::sleep_for(std::chrono::milliseconds(40));
result = cache.GetIfPresent("key1");
ASSERT_TRUE(result.has_value());
// Wait for full expiration from last access
std::this_thread::sleep_for(std::chrono::milliseconds(150));
result = cache.GetIfPresent("key1");
ASSERT_FALSE(result.has_value());
}
TEST_F(GenericLruCacheTest, ExpiredEntriesEvictedOnPut) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.expire_after_access_ms = 50;
options.max_weight = 100;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
std::this_thread::sleep_for(std::chrono::milliseconds(80));
// Put triggers EvictIfNeeded which calls EvictExpired
ASSERT_OK(cache.Put("c", 3));
// "a" and "b" should have been expired
int32_t expired_count = 0;
for (const auto& record : removals) {
if (record.cause == RemovalCause::EXPIRED) {
expired_count++;
}
}
ASSERT_EQ(expired_count, 2);
ASSERT_EQ(cache.Size(), 1);
}
// ==================== Removal Callback ====================
TEST_F(GenericLruCacheTest, NoCallbackConfigured) {
StringIntCache::Options options;
StringIntCache cache(options);
ASSERT_OK(cache.Put("key1", 100));
cache.Invalidate("key1");
ASSERT_EQ(cache.Size(), 0);
}
TEST_F(GenericLruCacheTest, AllRemovalCauses) {
std::vector<RemovalRecord> removals;
StringIntCache::Options options;
options.max_weight = 2;
options.expire_after_access_ms = 50;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
removals.push_back({key, std::to_string(value), static_cast<RemovalCause>(cause)});
};
StringIntCache cache(options);
// REPLACED: put same key with different value
ASSERT_OK(cache.Put("r", 1));
ASSERT_OK(cache.Put("r", 2));
ASSERT_EQ(removals.back().cause, RemovalCause::REPLACED);
// EXPLICIT: invalidate
cache.Invalidate("r");
ASSERT_EQ(removals.back().cause, RemovalCause::EXPLICIT);
// SIZE: evict due to weight
ASSERT_OK(cache.Put("s1", 10));
ASSERT_OK(cache.Put("s2", 20));
ASSERT_OK(cache.Put("s3", 30));
ASSERT_EQ(removals.back().cause, RemovalCause::SIZE);
// EXPIRED: wait and access
cache.InvalidateAll();
removals.clear();
ASSERT_OK(cache.Put("e", 99));
std::this_thread::sleep_for(std::chrono::milliseconds(80));
ASSERT_FALSE(cache.GetIfPresent("e").has_value());
ASSERT_EQ(removals.back().cause, RemovalCause::EXPIRED);
}
// ==================== valuesequal with shared_ptr ====================
TEST_F(GenericLruCacheTest, SharedPtrSamePointerNoReplace) {
using Cause = StringSharedPtrCache::RemovalCause;
std::vector<Cause> causes;
StringSharedPtrCache::Options options;
options.removal_callback = [&](const std::string& key, const std::shared_ptr<int>& value,
auto cause) { causes.push_back(static_cast<Cause>(cause)); };
StringSharedPtrCache cache(options);
auto ptr = std::make_shared<int>(42);
ASSERT_OK(cache.Put("key1", ptr));
// Put same pointer — ValuesEqual returns true, should promote without REPLACED
ASSERT_OK(cache.Put("key1", ptr));
ASSERT_TRUE(causes.empty());
ASSERT_EQ(cache.Size(), 1);
}
TEST_F(GenericLruCacheTest, SharedPtrDifferentPointerSameValueNoReplace) {
using Cause = StringSharedPtrCache::RemovalCause;
std::vector<Cause> causes;
StringSharedPtrCache::Options options;
options.removal_callback = [&](const std::string& key, const std::shared_ptr<int>& value,
auto cause) { causes.push_back(static_cast<Cause>(cause)); };
StringSharedPtrCache cache(options);
auto ptr1 = std::make_shared<int>(42);
auto ptr2 = std::make_shared<int>(42);
ASSERT_NE(ptr1.get(), ptr2.get());
ASSERT_OK(cache.Put("key1", ptr1));
// Different pointer but same dereferenced value — ValuesEqual returns true
ASSERT_OK(cache.Put("key1", ptr2));
ASSERT_TRUE(causes.empty());
}
TEST_F(GenericLruCacheTest, SharedPtrDifferentValueReplaces) {
using Cause = StringSharedPtrCache::RemovalCause;
std::vector<Cause> causes;
StringSharedPtrCache::Options options;
options.removal_callback = [&](const std::string& key, const std::shared_ptr<int>& value,
auto cause) { causes.push_back(static_cast<Cause>(cause)); };
StringSharedPtrCache cache(options);
ASSERT_OK(cache.Put("key1", std::make_shared<int>(1)));
ASSERT_OK(cache.Put("key1", std::make_shared<int>(2)));
ASSERT_EQ(causes.size(), 1);
ASSERT_EQ(causes[0], Cause::REPLACED);
}
TEST_F(GenericLruCacheTest, SharedPtrNullptrComparison) {
using Cause = StringSharedPtrCache::RemovalCause;
std::vector<Cause> causes;
StringSharedPtrCache::Options options;
options.removal_callback = [&](const std::string& key, const std::shared_ptr<int>& value,
auto cause) { causes.push_back(static_cast<Cause>(cause)); };
StringSharedPtrCache cache(options);
// Put nullptr
ASSERT_OK(cache.Put("key1", nullptr));
// Put nullptr again — same value, should not replace
ASSERT_OK(cache.Put("key1", nullptr));
ASSERT_TRUE(causes.empty());
// Put non-null — different from nullptr, should replace
ASSERT_OK(cache.Put("key1", std::make_shared<int>(1)));
ASSERT_EQ(causes.size(), 1);
ASSERT_EQ(causes[0], Cause::REPLACED);
// Put nullptr again — different from non-null, should replace
causes.clear();
ASSERT_OK(cache.Put("key1", nullptr));
ASSERT_EQ(causes.size(), 1);
ASSERT_EQ(causes[0], Cause::REPLACED);
}
// ==================== Custom Hash and KeyEqual ====================
TEST_F(GenericLruCacheTest, CustomHashAndKeyEqual) {
struct CaseInsensitiveHash {
size_t operator()(const std::string& str) const {
std::string lower = str;
for (auto& ch : lower) {
ch = static_cast<char>(std::tolower(ch));
}
return std::hash<std::string>{}(lower);
}
};
struct CaseInsensitiveEqual {
bool operator()(const std::string& lhs, const std::string& rhs) const {
if (lhs.size() != rhs.size()) return false;
for (size_t i = 0; i < lhs.size(); i++) {
if (std::tolower(lhs[i]) != std::tolower(rhs[i])) return false;
}
return true;
}
};
using CICache = GenericLruCache<std::string, int, CaseInsensitiveHash, CaseInsensitiveEqual>;
CICache::Options options;
CICache cache(options);
ASSERT_OK(cache.Put("Hello", 1));
ASSERT_EQ(cache.Size(), 1);
// "hello" should match "Hello" with case-insensitive comparison
auto result = cache.GetIfPresent("hello");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 1);
// Put with different case should replace
ASSERT_OK(cache.Put("HELLO", 2));
ASSERT_EQ(cache.Size(), 1);
result = cache.GetIfPresent("Hello");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 2);
}
// ==================== Thread Safety ====================
TEST_F(GenericLruCacheTest, ConcurrentPutAndGet) {
IntIntCache::Options options;
options.max_weight = 10000;
IntIntCache cache(options);
constexpr int32_t num_threads = 8;
constexpr int32_t ops_per_thread = 200;
std::vector<std::thread> threads;
std::atomic<int32_t> errors{0};
for (int32_t t = 0; t < num_threads; t++) {
threads.emplace_back([&, t]() {
for (int32_t i = 0; i < ops_per_thread; i++) {
int32_t key = t * ops_per_thread + i;
auto status = cache.Put(key, key * 10);
if (!status.ok()) {
errors++;
}
}
});
}
for (auto& thread : threads) {
thread.join();
}
ASSERT_EQ(errors.load(), 0);
ASSERT_EQ(static_cast<int32_t>(cache.Size()), num_threads * ops_per_thread);
// Concurrent reads
threads.clear();
for (int32_t t = 0; t < num_threads; t++) {
threads.emplace_back([&, t]() {
for (int32_t i = 0; i < ops_per_thread; i++) {
int32_t key = t * ops_per_thread + i;
auto result = cache.GetIfPresent(key);
if (!result.has_value() || result.value() != key * 10) {
errors++;
}
}
});
}
for (auto& thread : threads) {
thread.join();
}
ASSERT_EQ(errors.load(), 0);
}
TEST_F(GenericLruCacheTest, ConcurrentGetWithSupplier) {
IntIntCache::Options options;
options.max_weight = 10000;
IntIntCache cache(options);
constexpr int32_t num_threads = 8;
constexpr int32_t ops_per_thread = 100;
std::atomic<int> supplier_calls{0};
std::vector<std::thread> threads;
for (int32_t t = 0; t < num_threads; t++) {
threads.emplace_back([&, t]() {
for (int32_t i = 0; i < ops_per_thread; i++) {
int32_t key = t * ops_per_thread + i;
auto supplier = [&, key](const int&) -> Result<int> {
supplier_calls++;
return key * 10;
};
auto result = cache.Get(key, supplier);
ASSERT_TRUE(result.ok());
ASSERT_EQ(result.value(), key * 10);
}
});
}
for (auto& thread : threads) {
thread.join();
}
ASSERT_EQ(static_cast<int>(cache.Size()), num_threads * ops_per_thread);
}
TEST_F(GenericLruCacheTest, ConcurrentInvalidate) {
IntIntCache::Options options;
IntIntCache cache(options);
for (int32_t i = 0; i < 100; i++) {
ASSERT_OK(cache.Put(i, i));
}
std::vector<std::thread> threads;
for (int32_t t = 0; t < 4; t++) {
threads.emplace_back([&, t]() {
for (int32_t i = t * 25; i < (t + 1) * 25; i++) {
cache.Invalidate(i);
}
});
}
for (auto& thread : threads) {
thread.join();
}
ASSERT_EQ(cache.Size(), 0);
}
// ==================== Edge Cases ====================
TEST_F(GenericLruCacheTest, PutAndGetSingleEntry) {
StringIntCache::Options options;
options.max_weight = 1;
StringIntCache cache(options);
ASSERT_OK(cache.Put("only", 42));
ASSERT_EQ(cache.Size(), 1);
auto result = cache.GetIfPresent("only");
ASSERT_TRUE(result.has_value());
ASSERT_EQ(result.value(), 42);
// Adding another entry should evict the first
ASSERT_OK(cache.Put("new", 99));
ASSERT_EQ(cache.Size(), 1);
ASSERT_FALSE(cache.GetIfPresent("only").has_value());
ASSERT_TRUE(cache.GetIfPresent("new").has_value());
}
TEST_F(GenericLruCacheTest, ReplaceUpdatesWeight) {
StringStringCache::Options options;
options.max_weight = 100;
options.weigh_func = [](const std::string& key, const std::string& value) -> int64_t {
return static_cast<int64_t>(value.size());
};
StringStringCache cache(options);
ASSERT_OK(cache.Put("a", std::string(50, 'x')));
ASSERT_EQ(cache.GetCurrentWeight(), 50);
// Replace with smaller value
ASSERT_OK(cache.Put("a", std::string(20, 'y')));
ASSERT_EQ(cache.GetCurrentWeight(), 20);
// Replace with larger value
ASSERT_OK(cache.Put("a", std::string(80, 'z')));
ASSERT_EQ(cache.GetCurrentWeight(), 80);
}
TEST_F(GenericLruCacheTest, EvictionOrderIsLru) {
std::vector<std::string> evicted_keys;
StringIntCache::Options options;
options.max_weight = 3;
options.removal_callback = [&](const std::string& key, const int& value, auto cause) {
if (static_cast<RemovalCause>(cause) == RemovalCause::SIZE) {
evicted_keys.push_back(key);
}
};
StringIntCache cache(options);
ASSERT_OK(cache.Put("a", 1));
ASSERT_OK(cache.Put("b", 2));
ASSERT_OK(cache.Put("c", 3));
// Access "a" and "b" to make "c" the LRU
cache.GetIfPresent("a");
cache.GetIfPresent("b");
// Insert "d": should evict "c" (LRU)
ASSERT_OK(cache.Put("d", 4));
ASSERT_EQ(evicted_keys.size(), 1);
ASSERT_EQ(evicted_keys[0], "c");
}
} // namespace paimon::test