| /* | |
| * 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 |