diff --git a/libraries/multi-index-lru/include/userver/multi-index-lru/container.hpp b/libraries/multi-index-lru/include/userver/multi-index-lru/container.hpp index 628d853895cc..c576ebd8956e 100644 --- a/libraries/multi-index-lru/include/userver/multi-index-lru/container.hpp +++ b/libraries/multi-index-lru/include/userver/multi-index-lru/container.hpp @@ -3,60 +3,14 @@ /// @file userver/multi-index-lru/container.hpp /// @brief @copybrief multi_index_lru::Container -#include -#include -#include -#include - -#include -#include -#include +#include "impl/mpl_helpers.hpp" USERVER_NAMESPACE_BEGIN namespace multi_index_lru { -namespace impl { -template > -inline constexpr bool is_mpl_na = false; - -template -inline constexpr bool is_mpl_na().~na())>> = true; - -template -struct lazy_add_seq { - using type = boost::multi_index::indexed_by, Indices...>; -}; - -template -struct lazy_add_seq_no_last { -private: - template - static auto makeWithoutLast(std::index_sequence) { - using Tuple = std::tuple; - return boost::multi_index::indexed_by, std::tuple_element_t...>{}; - } - -public: - using type = decltype(makeWithoutLast(std::make_index_sequence{})); -}; - -template -struct add_seq_index {}; - -template -struct add_seq_index> { - using LastType = decltype((Indices{}, ...)); - - using type = typename std::conditional_t< - is_mpl_na, - lazy_add_seq_no_last, - lazy_add_seq>::type; -}; - -template -using add_seq_index_t = typename add_seq_index::type; -} // namespace impl +template > +class ExpirableContainer; /// @ingroup userver_containers /// @@ -69,8 +23,8 @@ class Container { {} template - bool emplace(Args&&... args) { - auto& seq_index = container_.template get<0>(); + auto emplace(Args&&... args) { + auto& seq_index = get_sequensed(); auto result = seq_index.emplace_front(std::forward(args)...); if (!result.second) { @@ -78,20 +32,20 @@ class Container { } else if (seq_index.size() > max_size_) { seq_index.pop_back(); } - return result.second; + return result; } - bool insert(const Value& value) { return emplace(value); } + bool insert(const Value& value) { return emplace(value).second; } - bool insert(Value&& value) { return emplace(std::move(value)); } + bool insert(Value&& value) { return emplace(std::move(value)).second; } template auto find(const Key& key) { - auto& primary_index = container_.template get(); + auto& primary_index = get_index(); auto it = primary_index.find(key); if (it != primary_index.end()) { - auto& seq_index = container_.template get<0>(); + auto& seq_index = get_sequensed(); auto seq_it = container_.template project<0>(it); seq_index.relocate(seq_index.begin(), seq_it); } @@ -99,14 +53,40 @@ class Container { return it; } + template + auto find_no_update(const Key& key) { + return get_index().find(key); + } + + template + auto equal_range(const Key& key) { + auto& primary_index = get_index(); + + auto [begin, end] = primary_index.equal_range(key); + auto it = begin; + + auto& seq_index = get_sequensed(); + while (it != end) { + seq_index.relocate(seq_index.begin(), project_to_sequenced(it)); + ++it; + } + + return std::pair{begin, end}; + } + + template + auto equal_range_no_update(const Key& key) { + return get_index().equal_range(key); + } + template bool contains(const Key& key) { - return this->template find(key) != container_.template get().end(); + return this->template find(key) != end(); } template bool erase(const Key& key) { - return container_.template get().erase(key) > 0; + return get_index().erase(key) > 0; } std::size_t size() const { return container_.size(); } @@ -115,7 +95,7 @@ class Container { void set_capacity(std::size_t new_capacity) { max_size_ = new_capacity; - auto& seq_index = container_.template get<0>(); + auto& seq_index = get_sequensed(); while (container_.size() > max_size_) { seq_index.pop_back(); } @@ -125,16 +105,44 @@ class Container { template auto end() { - return container_.template get().end(); + return get_index().end(); } private: - using ExtendedIndexSpecifierList = impl::add_seq_index_t; + using ExtendedIndexSpecifierList = impl::add_index_t< + boost::multi_index::sequenced<>, + IndexSpecifierList>; using BoostContainer = boost::multi_index::multi_index_container; BoostContainer container_; std::size_t max_size_; + + auto &get_sequensed() { + return container_.template get<0>(); + } + + const auto& get_sequensed() const { + return container_.template get<0>(); + } + + template + auto& get_index() { + return container_.template get(); + } + + template + const auto& get_index() const { + return container_.template get(); + } + + template + auto project_to_sequenced(IterT it) { + return container_.template project<0>(it); + } + + template + friend class ExpirableContainer; }; } // namespace multi_index_lru diff --git a/libraries/multi-index-lru/include/userver/multi-index-lru/expirable_container.hpp b/libraries/multi-index-lru/include/userver/multi-index-lru/expirable_container.hpp new file mode 100644 index 000000000000..3642a11f7d4a --- /dev/null +++ b/libraries/multi-index-lru/include/userver/multi-index-lru/expirable_container.hpp @@ -0,0 +1,153 @@ +#pragma once + +/// @file userver/multi-index-lru/expirable_container.hpp +/// @brief @copybrief multi_index_lru::ExpirableContainer + +#include "impl/mpl_helpers.hpp" +#include "container.hpp" + +#include + +USERVER_NAMESPACE_BEGIN + +namespace multi_index_lru { + +/// @ingroup userver_containers +/// +/// @brief MultiIndex LRU expirable container +template +class ExpirableContainer { +public: + explicit ExpirableContainer(size_t max_size, + std::chrono::milliseconds ttl) + : container_(max_size), ttl_(ttl) + { + UASSERT_MSG(ttl.count() > 0, "ttl must be positive"); + } + + template + auto emplace(Args&&... args) { + auto result = container_.emplace(std::forward(args)...); + + if (!result.second) { + result.first->last_accessed = std::chrono::steady_clock::now(); + } + + return result; + } + + bool insert(const Value& value) { return emplace(value).second; } + + bool insert(Value&& value) { return emplace(std::move(value)).second; } + + template + auto find(const Key& key) { + auto now = std::chrono::steady_clock::now(); + auto it = container_.template find(key); + + if (it != container_.template end()) { + if (now > it->last_accessed + ttl_) { + container_.template get_index().erase(it); + return end(); + } else { + it->last_accessed = now; + } + } + + return impl::TimestampedIteratorWrapper{it}; + } + + template + auto find_no_update(const Key& key) { + auto it = container_.template find_no_update(key); + return impl::TimestampedIteratorWrapper{it}; + } + + template + auto equal_range(const Key& key) { + auto now = std::chrono::steady_clock::now(); + auto& index = container_.template get_index(); + auto range = container_.template equal_range(key); + + auto it = range.first; + bool changed = false; + + while (it != range.second) { + if (now > it->last_accessed + ttl_) { + it = index.erase(it); + changed = true; + } else { + it->last_accessed = now; + ++it; + } + } + if (changed) { + range = index.equal_range(key); + } + return std::pair{impl::TimestampedIteratorWrapper{range.first}, + impl::TimestampedIteratorWrapper{range.second}}; + } + + template + auto equal_range_no_update(const Key& key) { + auto [begin, end] = container_.template equal_range_no_update(key); + return std::pair{impl::TimestampedIteratorWrapper{begin}, + impl::TimestampedIteratorWrapper{end}}; + } + + template + bool contains(const Key& key) { + return this->template find(key) != this->template end(); + } + + template + bool erase(const Key& key) { + return container_.template erase(key); + } + + std::size_t size() const { + return container_.size(); + } + bool empty() const { + return container_.empty(); + } + std::size_t capacity() const { return container_.capacity(); } + + void set_capacity(std::size_t new_capacity) { + container_.set_capacity(new_capacity); + } + + void clear() { + container_.clear(); + } + + template + auto end() { + return impl::TimestampedIteratorWrapper{container_.template end()}; + } + + void cleanup_expired() { + auto now = std::chrono::steady_clock::now(); + auto& seq_index = container_.get_sequensed(); + + while(!seq_index.empty()) { + auto it = seq_index.rbegin(); + if (now > it->last_accessed + ttl_) { + seq_index.pop_back(); + } else { + break; + } + } + } + +private: + using CacheItem = impl::TimestampedValue; + using CacheContainer = Container; + + CacheContainer container_; + std::chrono::milliseconds ttl_; +}; + +} // namespace multi_index_lru + +USERVER_NAMESPACE_END \ No newline at end of file diff --git a/libraries/multi-index-lru/include/userver/multi-index-lru/impl/mpl_helpers.hpp b/libraries/multi-index-lru/include/userver/multi-index-lru/impl/mpl_helpers.hpp new file mode 100644 index 000000000000..f38c76ac0c6b --- /dev/null +++ b/libraries/multi-index-lru/include/userver/multi-index-lru/impl/mpl_helpers.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace multi_index_lru { + +namespace impl { +template > +inline constexpr bool is_mpl_na = false; + +template +inline constexpr bool is_mpl_na().~na())>> = true; + +template +struct lazy_add_index { + using type = boost::multi_index::indexed_by; +}; + +template +struct lazy_add_index_no_last { +private: + template + static auto makeWithoutLast(std::index_sequence) { + using Tuple = std::tuple; + return boost::multi_index::indexed_by...>{}; + } + +public: + using type = decltype(makeWithoutLast(std::make_index_sequence{})); +}; + +template +struct add_index {}; + +template +struct add_index> { + using LastType = decltype((Indices{}, ...)); + + using type = typename std::conditional_t< + is_mpl_na, + lazy_add_index_no_last, + lazy_add_index>::type; +}; + +template +using add_index_t = typename add_index::type; + +template +struct TimestampedValue { + Value value; + mutable std::chrono::steady_clock::time_point last_accessed; + + TimestampedValue() = default; + + explicit TimestampedValue(const Value& val) + : value(val), last_accessed(std::chrono::steady_clock::now()) {} + + explicit TimestampedValue(Value&& val) + : value(std::move(val)), last_accessed(std::chrono::steady_clock::now()) {} + + operator Value&() { return value; } + operator const Value&() const { return value; } + + Value* operator->() { return &value; } + const Value* operator->() const { return &value; } + + Value& operator*() {return value; } + const Value& operator*() const {return value; } + + Value& get() { return value; } + const Value& get() const { return value; } +}; + +template +class TimestampedIteratorWrapper : public Iterator { +public: + using Iterator::Iterator; + TimestampedIteratorWrapper(Iterator iter) : Iterator(std::move(iter)) {} + + auto operator->() { + return *(this->Iterator::operator->()); + } + + auto operator->() const { + return *(this->Iterator::operator->()); + } +}; + +template +struct is_unique_index { +private: + template + static auto test(int) -> decltype(std::declval().find(std::declval()), + std::true_type{}); + + template + static std::false_type test(...); + +public: + static constexpr bool value = decltype(test(0))::value; +}; +} // namespace impl +} // namespace multi_index_lru + +USERVER_NAMESPACE_END \ No newline at end of file diff --git a/libraries/multi-index-lru/src/main_benchmark.cpp b/libraries/multi-index-lru/src/container_benchmark.cpp similarity index 97% rename from libraries/multi-index-lru/src/main_benchmark.cpp rename to libraries/multi-index-lru/src/container_benchmark.cpp index 8304e92a7709..ed91ddf6e7fb 100644 --- a/libraries/multi-index-lru/src/main_benchmark.cpp +++ b/libraries/multi-index-lru/src/container_benchmark.cpp @@ -7,6 +7,9 @@ #include #include +#include +#include +#include USERVER_NAMESPACE_BEGIN diff --git a/libraries/multi-index-lru/src/main_test.cpp b/libraries/multi-index-lru/src/container_test.cpp similarity index 60% rename from libraries/multi-index-lru/src/main_test.cpp rename to libraries/multi-index-lru/src/container_test.cpp index 8adc2ca79a5f..4ed680202073 100644 --- a/libraries/multi-index-lru/src/main_test.cpp +++ b/libraries/multi-index-lru/src/container_test.cpp @@ -1,10 +1,12 @@ #include +#include #include -#include #include #include +#include +#include USERVER_NAMESPACE_BEGIN @@ -42,7 +44,7 @@ class LRUUsersTest : public ::testing::Test { boost::multi_index::member>>>; }; -TEST_F(LRUUsersTest, BasicOperations) { +UTEST_F(LRUUsersTest, BasicOperations) { UserCache cache(3); // capacity == 3 // Test insertion @@ -54,25 +56,24 @@ TEST_F(LRUUsersTest, BasicOperations) { // Test find by id auto by_id = cache.find(1); - ASSERT_NE(by_id, cache.end()); + EXPECT_NE(by_id, cache.end()); EXPECT_EQ(by_id->name, "Alice"); // Test find by email auto by_email = cache.find("bob@test.com"); - ASSERT_NE(by_email, cache.end()); + EXPECT_NE(by_email, cache.end()); EXPECT_EQ(by_email->id, 2); // Test find by name auto by_name = cache.find("Charlie"); - ASSERT_NE(by_name, cache.end()); + EXPECT_NE(by_name, cache.end()); EXPECT_EQ(by_name->email, "charlie@test.com"); - // Test template find method auto it = cache.find("alice@test.com"); EXPECT_NE(it, cache.end()); } -TEST_F(LRUUsersTest, LRUEviction) { +UTEST_F(LRUUsersTest, LRUEviction) { UserCache cache(3); cache.emplace(User{1, "alice@test.com", "Alice"}); @@ -92,6 +93,89 @@ TEST_F(LRUUsersTest, LRUEviction) { EXPECT_TRUE((cache.contains(4))); // David added } +UTEST_F(LRUUsersTest, GetNoUpdateDoesNotChangeLru) { + UserCache cache(3); + + cache.emplace(User{1, "alice@test.com", "Alice"}); + cache.emplace(User{2, "bob@test.com", "Bob"}); + cache.emplace(User{3, "charlie@test.com", "Charlie"}); + + auto it = cache.find_no_update(1); // without updating + EXPECT_NE(it, cache.end()); + EXPECT_EQ(it->name, "Alice"); + + cache.emplace(User{4, "david@test.com", "David"}); + + EXPECT_FALSE((cache.contains(1))); // evicted + EXPECT_TRUE((cache.contains(2))); // remains + EXPECT_TRUE((cache.contains(3))); // remains + EXPECT_TRUE((cache.contains(4))); // added +} + +UTEST_F(LRUUsersTest, EqualRangeUpdatesLruForAllMatches) { + UserCache cache(4); + + cache.emplace(User{1, "john1@test.com", "John"}); + cache.emplace(User{2, "john2@test.com", "John"}); + cache.emplace(User{3, "alice@test.com", "Alice"}); + cache.emplace(User{4, "bob@test.com", "Bob"}); + + auto [begin, end] = cache.equal_range("John"); + int count = 0; + for (auto it = begin; it != end; ++it) { + ++count; + } + EXPECT_EQ(count, 2); + + cache.emplace(User{5, "eve@test.com", "Eve"}); + + EXPECT_TRUE((cache.contains(1))); // remains + EXPECT_TRUE((cache.contains(2))); // remains + EXPECT_FALSE((cache.contains(3))); // evicted + EXPECT_TRUE((cache.contains(4))); // remains + EXPECT_TRUE((cache.contains(5))); // added +} + +UTEST_F(LRUUsersTest, EqualRangeNoUpdateDoesNotChangeLru) { + UserCache cache(4); + + cache.emplace(User{1, "john1@test.com", "John"}); + cache.emplace(User{2, "john2@test.com", "John"}); + cache.emplace(User{3, "alice@test.com", "Alice"}); + cache.emplace(User{4, "bob@test.com", "Bob"}); + + auto [begin, end] = cache.equal_range_no_update("John"); + int count = 0; + for (auto it = begin; it != end; ++it) { + ++count; + } + EXPECT_EQ(count, 2); + + cache.emplace(User{5, "eve@test.com", "Eve"}); + + EXPECT_FALSE((cache.contains(1))); // evicted + EXPECT_TRUE((cache.contains(2))); // remains + EXPECT_TRUE((cache.contains(3))); // remains + EXPECT_TRUE((cache.contains(4))); // remains + EXPECT_TRUE((cache.contains(5))); // added +} + +UTEST_F(LRUUsersTest, EqualRangeWorksWithEmptyRange) { + UserCache cache(3); + cache.emplace(User{1, "alice@test.com", "Alice"}); + + auto [begin, end] = cache.equal_range("Nonexistent"); + EXPECT_EQ(begin, end); +} + +UTEST_F(LRUUsersTest, EqualRangeNoUpdateWorksWithEmptyRange) { + UserCache cache(3); + cache.emplace(User{1, "alice@test.com", "Alice"}); + + auto [begin, end] = cache.equal_range_no_update("Nonexistent"); + EXPECT_EQ(begin, end); +} + class ProductsTest : public ::testing::Test { protected: struct SkuTag {}; @@ -118,18 +202,18 @@ class ProductsTest : public ::testing::Test { boost::multi_index::member>>>; }; -TEST_F(ProductsTest, BasicProductOperations) { +UTEST_F(ProductsTest, BasicProductOperations) { ProductCache cache(2); cache.emplace(Product{"A1", "Laptop", 999.99}); cache.emplace(Product{"A2", "Mouse", 29.99}); auto laptop = cache.find("A1"); - ASSERT_NE(laptop, cache.end()); + EXPECT_NE(laptop, cache.end()); EXPECT_EQ(laptop->name, "Laptop"); } -TEST_F(ProductsTest, ProductEviction) { +UTEST_F(ProductsTest, ProductEviction) { ProductCache cache(2); cache.emplace(Product{"A1", "Laptop", 999.99}); diff --git a/libraries/multi-index-lru/src/expirable_container_benchmark.cpp b/libraries/multi-index-lru/src/expirable_container_benchmark.cpp new file mode 100644 index 000000000000..39a4bfae6a90 --- /dev/null +++ b/libraries/multi-index-lru/src/expirable_container_benchmark.cpp @@ -0,0 +1,197 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace benchmarks { + +const std::size_t kOperationsNumber = 100000; +const int kMaxIdSize = 50000; + +struct IdTag {}; +struct EmailTag {}; +struct NameTag {}; + +struct User { + int id; + std::string email; + std::string name; + + bool operator==(const User& other) const { + return id == other.id && email == other.email && name == other.name; + } +}; + +namespace { + +User GenerateUser() { + return User{ + utils::RandRange(0, kMaxIdSize), + "email" + std::to_string(utils::RandRange(0, kMaxIdSize)), + "name" + std::to_string(utils::RandRange(0, kMaxIdSize)) + }; +} + +int GenerateId() { + return utils::RandRange(0, kMaxIdSize); +} + +std::string GenerateName() { + return "name" + std::to_string(utils::RandRange(0, kMaxIdSize)); +} + +std::string GenerateEmail() { + return "email" + std::to_string(utils::RandRange(0, kMaxIdSize)); +} + +} // namespace + +using UserCache = multi_index_lru::ExpirableContainer< + User, + boost::multi_index::indexed_by< + boost::multi_index::ordered_unique< + boost::multi_index::tag, + boost::multi_index::member>, + boost::multi_index::ordered_unique< + boost::multi_index::tag, + boost::multi_index::member>, + boost::multi_index::ordered_non_unique< + boost::multi_index::tag, + boost::multi_index::member>>>; + +static void ExpirableFindEmplaceMix(benchmark::State& state) { + engine::RunStandalone([&] { + const std::size_t cache_size = state.range(0); + + UserCache cache(cache_size, std::chrono::seconds(5)); + + for (std::size_t i = 0; i < cache_size; ++i) { + cache.insert(GenerateUser()); + } + + const std::size_t read_ops = kOperationsNumber * 4 / 5; + const std::size_t write_ops = kOperationsNumber / 5; + + std::vector names(read_ops); + std::vector emails(read_ops); + std::vector ids(read_ops); + std::vector users(write_ops); + + for (std::size_t i = 0; i < read_ops; ++i) { + names[i] = GenerateName(); + emails[i] = GenerateEmail(); + ids[i] = GenerateId(); + } + + for (std::size_t i = 0; i < write_ops; ++i) { + users[i] = GenerateUser(); + } + + for (auto _ : state) { + for (std::size_t i = 0; i < read_ops; ++i) { + cache.find(names[i]); + cache.find(emails[i]); + cache.find(ids[i]); + } + + for (std::size_t i = 0; i < write_ops; ++i) { + cache.insert(users[i]); + } + } + }); +} + +BENCHMARK(ExpirableFindEmplaceMix) + ->RangeMultiplier(10) + ->Range(10, 1'000'000); + + +static void PrepareCache(UserCache& cache, std::size_t size) { + for (std::size_t i = 0; i < size; ++i) { + cache.insert(GenerateUser()); + } +} + +static void ExpirableGetOperations(benchmark::State& state) { + engine::RunStandalone([&] { + const std::size_t cache_size = state.range(0); + + UserCache cache(cache_size, std::chrono::minutes(10)); + PrepareCache(cache, cache_size); + + + for (auto _ : state) { + state.PauseTiming(); + + std::vector names(kOperationsNumber); + std::vector emails(kOperationsNumber); + std::vector ids(kOperationsNumber); + + for (std::size_t i = 0; i < kOperationsNumber; ++i) { + names[i] = GenerateName(); + emails[i] = GenerateEmail(); + ids[i] = GenerateId(); + } + + state.ResumeTiming(); + + for (std::size_t i = 0; i < kOperationsNumber; ++i) { + benchmark::DoNotOptimize(cache.find(names[i])); + benchmark::DoNotOptimize(cache.find(emails[i])); + benchmark::DoNotOptimize(cache.find(ids[i])); + } + } + + state.SetItemsProcessed(state.iterations() * kOperationsNumber * 3); + state.SetComplexityN(cache_size); + }); +} + +BENCHMARK(ExpirableGetOperations) + ->RangeMultiplier(10) + ->Range(100, 1'000'000); + + +static void ExpirableEmplaceOperations(benchmark::State& state) { + engine::RunStandalone([&] { + const std::size_t cache_size = state.range(0); + + UserCache cache(cache_size, std::chrono::minutes(10)); + PrepareCache(cache, cache_size); + + for (auto _ : state) { + state.PauseTiming(); + + std::vector users(kOperationsNumber); + for (std::size_t i = 0; i < kOperationsNumber; ++i) { + users[i] = GenerateUser(); + } + + state.ResumeTiming(); + + for (std::size_t i = 0; i < kOperationsNumber; ++i) { + cache.insert(users[i]); + } + } + + state.SetItemsProcessed(state.iterations() * kOperationsNumber); + state.SetComplexityN(cache_size); + }); +} + +BENCHMARK(ExpirableEmplaceOperations) + ->RangeMultiplier(10) + ->Range(100, 1'000'000); + +} // namespace benchmarks + +USERVER_NAMESPACE_END \ No newline at end of file diff --git a/libraries/multi-index-lru/src/expirable_container_test.cpp b/libraries/multi-index-lru/src/expirable_container_test.cpp new file mode 100644 index 000000000000..11699546ffd9 --- /dev/null +++ b/libraries/multi-index-lru/src/expirable_container_test.cpp @@ -0,0 +1,310 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +USERVER_NAMESPACE_BEGIN + +namespace { +class ExpirableUsersTest : public ::testing::Test { +protected: + void SetUp() override {} + + struct IdTag {}; + struct EmailTag {}; + struct NameTag {}; + + struct User { + int id; + std::string email; + std::string name; + + bool operator==(const User& other) const { + return id == other.id && email == other.email && name == other.name; + } + }; + + using UserCacheExpirable = multi_index_lru::ExpirableContainer< + User, + boost::multi_index::indexed_by< + boost::multi_index::ordered_unique< + boost::multi_index::tag, + boost::multi_index::member>, + boost::multi_index::ordered_unique< + boost::multi_index::tag, + boost::multi_index::member>, + boost::multi_index::ordered_non_unique< + boost::multi_index::tag, + boost::multi_index::member>>>; +}; + +UTEST_F(ExpirableUsersTest, BasicOperations) { + UserCacheExpirable cache(3, std::chrono::seconds(10)); // capacity=3, TTL=10s + + // Test insertion + EXPECT_TRUE(cache.insert(User{1, "alice@test.com", "Alice"})); + EXPECT_TRUE(cache.insert(User{2, "bob@test.com", "Bob"})); + EXPECT_TRUE(cache.insert(User{3, "charlie@test.com", "Charlie"})); + + EXPECT_EQ(cache.size(), 3); + EXPECT_EQ(cache.capacity(), 3); + EXPECT_FALSE(cache.empty()); + + // Test find by id (unique index) + auto alice_it = cache.find(1); + EXPECT_NE(alice_it, cache.end()); + EXPECT_EQ(alice_it->name, "Alice"); + + // Test find by email (unique index) + auto bob_it = cache.find("bob@test.com"); + EXPECT_NE(bob_it, cache.end()); + EXPECT_EQ(bob_it->id, 2); + + // Test find by name (non‑unique index) - returns first match + auto charlie_it = cache.find("Charlie"); + EXPECT_NE(charlie_it, cache.end()); + EXPECT_EQ(charlie_it->email, "charlie@test.com"); +} + +UTEST_F(ExpirableUsersTest, FindNoUpdate) { + UserCacheExpirable cache(3, std::chrono::seconds(10)); + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + cache.insert(User{3, "charlie@test.com", "Charlie"}); + + // Both finds should succeed + EXPECT_NE(cache.find(1), cache.end()); + EXPECT_NE(cache.find_no_update(1), cache.end()); +} + +UTEST_F(ExpirableUsersTest, LRUEviction) { + UserCacheExpirable cache(3, std::chrono::seconds(10)); + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + cache.insert(User{3, "charlie@test.com", "Charlie"}); + + // Access Alice and Charlie to make them recently used + EXPECT_NE(cache.find(1), cache.end()); + EXPECT_NE(cache.find(3), cache.end()); + + // Add fourth element - Bob should be evicted (LRU) + cache.insert(User{4, "david@test.com", "David"}); + + EXPECT_EQ(cache.find(2), cache.end()); // Bob evicted (LRU) + EXPECT_NE(cache.find(1), cache.end()); // Alice remains + EXPECT_NE(cache.find(3), cache.end()); // Charlie remains + EXPECT_NE(cache.find(4), cache.end()); // David added + EXPECT_EQ(cache.size(), 3); +} + +UTEST_F(ExpirableUsersTest, TTLExpiration) { + using namespace std::chrono_literals; + + UserCacheExpirable cache(100, 100ms); // Very short TTL for testing + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + + // Items should still exist + EXPECT_NE(cache.find(1), cache.end()); + EXPECT_NE(cache.find(2), cache.end()); + EXPECT_EQ(cache.size(), 2); + + // Wait for TTL to expire + engine::SleepFor(150ms); + + EXPECT_EQ(cache.find(1), cache.end()); + EXPECT_EQ(cache.find(2), cache.end()); + EXPECT_EQ(cache.size(), 0); +} + +UTEST_F(ExpirableUsersTest, TTLRefreshOnAccess) { + using namespace std::chrono_literals; + + UserCacheExpirable cache(100, 190ms); + + cache.insert(User{1, "alice@test.com", "Alice"}); + + // Wait a bit but not enough to expire + engine::SleepFor(99ms); + + // Access via find should refresh TTL + EXPECT_NE(cache.find(1), cache.end()); + + // Wait again - should still be alive due to refresh + engine::SleepFor(99ms); + EXPECT_NE(cache.find(1), cache.end()); + + // Wait for full TTL from last access + engine::SleepFor(200ms); + EXPECT_EQ(cache.find(1), cache.end()); +} + +UTEST_F(ExpirableUsersTest, EqualRangeOperations) { + using namespace std::chrono_literals; + + UserCacheExpirable cache(10, 1h); // Long TTL to avoid expiration + + // Insert multiple users with the same name + cache.insert(User{1, "john1@test.com", "John"}); + cache.insert(User{2, "john2@test.com", "John"}); + cache.insert(User{3, "john3@test.com", "John"}); + cache.insert(User{4, "alice@test.com", "Alice"}); + + // Test equal_range for non-unique index + auto [begin, end] = cache.equal_range("John"); + + // Count matches + int count = 0; + for (auto it = begin; it != end; ++it) { + ++count; + EXPECT_EQ(it->name, "John"); + } + EXPECT_EQ(count, 3); + + // Test equal_range for non-existent key + auto [begin_empty, end_empty] = cache.equal_range("NonExistent"); + EXPECT_EQ(begin_empty, end_empty); +} + +UTEST_F(ExpirableUsersTest, EqualRangeNoUpdate) { + using namespace std::chrono_literals; + + UserCacheExpirable cache(10, 1h); + + cache.insert(User{1, "john1@test.com", "John"}); + cache.insert(User{2, "john2@test.com", "John"}); + + // equal_range_no_update should work and find all matches + auto [begin, end] = cache.equal_range_no_update("John"); + + int count = 0; + for (auto it = begin; it != end; ++it) { + ++count; + EXPECT_TRUE(it->id == 1 || it->id == 2); + } + EXPECT_EQ(count, 2); +} + +UTEST_F(ExpirableUsersTest, EraseOperations) { + UserCacheExpirable cache(3, std::chrono::seconds(10)); + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + + EXPECT_TRUE(cache.erase(1)); + EXPECT_EQ(cache.find(1), cache.end()); + EXPECT_NE(cache.find(2), cache.end()); + EXPECT_EQ(cache.size(), 1); + + EXPECT_FALSE(cache.erase(999)); // Non-existent + EXPECT_EQ(cache.size(), 1); +} + +UTEST_F(ExpirableUsersTest, SetCapacity) { + UserCacheExpirable cache(5, std::chrono::seconds(10)); + + // Fill cache + for (int i = 1; i <= 5; ++i) { + cache.insert(User{i, std::to_string(i) + "@test.com", "User" + std::to_string(i)}); + } + EXPECT_EQ(cache.size(), 5); + EXPECT_EQ(cache.capacity(), 5); + + // Reduce capacity - should evict LRU items + cache.set_capacity(3); + EXPECT_EQ(cache.capacity(), 3); + + // Size should be <= new capacity + EXPECT_LE(cache.size(), 3); +} + +UTEST_F(ExpirableUsersTest, Clear) { + UserCacheExpirable cache(5, std::chrono::seconds(10)); + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + + EXPECT_EQ(cache.size(), 2); + EXPECT_FALSE(cache.empty()); + + cache.clear(); + + EXPECT_EQ(cache.size(), 0); + EXPECT_TRUE(cache.empty()); + EXPECT_EQ(cache.find(1), cache.end()); + EXPECT_EQ(cache.find(2), cache.end()); +} + +UTEST_F(ExpirableUsersTest, CleanupExpired) { + using namespace std::chrono_literals; + + UserCacheExpirable cache(5, 100ms); + + cache.insert(User{1, "alice@test.com", "Alice"}); + cache.insert(User{2, "bob@test.com", "Bob"}); + + // Wait for TTL to expire + engine::SleepFor(150ms); + + // cleanup_expired should remove expired items + cache.cleanup_expired(); + + EXPECT_EQ(cache.size(), 0); +} + +UTEST_F(ExpirableUsersTest, ThreadSafetyBasic) { + // Container is not thread-safe; external synchronization required. + UserCacheExpirable cache(100, std::chrono::seconds(10)); + engine::Mutex mutex; + + constexpr int kCoroutines = 4; + constexpr int kIterations = 100; + std::vector> tasks; + tasks.reserve(kCoroutines); + + for (int t = 0; t < kCoroutines; ++t) { + tasks.push_back(utils::Async("using cache", [&cache, &mutex, t]() { + for (int i = 0; i < kIterations; ++i) { + int id = t * kIterations + i; + + { + std::lock_guard lock(mutex); + cache.insert(User{id, std::to_string(id) + "@test.com", "User" + std::to_string(id)}); + } + + if (id % 3 == 0) { + std::lock_guard lock(mutex); + // Use find to check existence and update timestamp + cache.find(id); + } + + if (id % 5 == 0) { + std::lock_guard lock(mutex); + cache.erase(id - 1); + } + } + })); + } + + for (auto& task : tasks) { + task.Get(); + } + + std::lock_guard lock(mutex); + EXPECT_LE(cache.size(), 100); +} + +} // namespace + +USERVER_NAMESPACE_END \ No newline at end of file