| /* | |
| * 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. | |
| */ | |
| #pragma once | |
| #include <chrono> | |
| #include <cstdint> | |
| #include <functional> | |
| #include <list> | |
| #include <memory> | |
| #include <mutex> | |
| #include <optional> | |
| #include <shared_mutex> | |
| #include <unordered_map> | |
| #include <utility> | |
| #include "fmt/format.h" | |
| #include "paimon/result.h" | |
| #include "paimon/traits.h" | |
| namespace paimon { | |
| /// A generic LRU cache with support for weight-based eviction, time-based expiration, | |
| /// and removal callbacks. | |
| /// | |
| /// Uses std::list + unordered_map for O(1) get/put/evict: | |
| /// - list stores entries in LRU order (most recently used at front) | |
| /// - map stores key -> list::iterator for O(1) lookup | |
| /// | |
| /// @tparam K Key type | |
| /// @tparam V Value type | |
| /// @tparam Hash Hash function for K (default: std::hash<K>) | |
| /// @tparam KeyEqual Equality function for K (default: std::equal_to<K>) | |
| /// | |
| /// @note Thread-safe: all public methods are protected by a read-write lock. | |
| template <typename K, typename V, typename Hash = std::hash<K>, | |
| typename KeyEqual = std::equal_to<K>> | |
| class GenericLruCache { | |
| public: | |
| /// Cause of a cache entry removal, passed to the removal callback. | |
| enum class RemovalCause { | |
| EXPLICIT, // Removed by Invalidate() or InvalidateAll() | |
| SIZE, // Evicted because total weight exceeded max_weight | |
| EXPIRED, // Evicted because the entry expired (expireAfterAccess) | |
| REPLACED // Replaced by a new value for the same key via Put() | |
| }; | |
| using WeighFunc = std::function<int64_t(const K&, const V&)>; | |
| using RemovalCallback = std::function<void(const K&, const V&, RemovalCause)>; | |
| /// Configuration options for the cache. | |
| struct Options { | |
| /// Maximum total weight of all entries. Entries are evicted (LRU) when exceeded. | |
| int64_t max_weight = INT64_MAX; | |
| /// Time in milliseconds after last access before an entry expires. | |
| /// A value < 0 disables expiration. | |
| int64_t expire_after_access_ms = -1; | |
| /// Function to compute the weight of an entry. If nullptr, each entry has weight 1. | |
| WeighFunc weigh_func = nullptr; | |
| /// Callback invoked when an entry is removed (evicted, invalidated, or replaced). | |
| /// If nullptr, no callback is invoked. | |
| RemovalCallback removal_callback = nullptr; | |
| }; | |
| explicit GenericLruCache(Options options) : options_(std::move(options)) {} | |
| /// Look up a key in the cache. On hit, promotes the entry to the front (most recently | |
| /// used) and updates its access time. Returns std::nullopt on miss or if the entry | |
| /// has expired. | |
| std::optional<V> GetIfPresent(const K& key) { | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| return FindPromoteOrExpire(key); | |
| } | |
| /// Look up a key. On miss, load via the supplier, insert into cache, and return. | |
| /// If the supplier returns an error, the error is propagated and nothing is cached. | |
| Result<V> Get(const K& key, std::function<Result<V>(const K&)> supplier) { | |
| { | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| auto cached = FindPromoteOrExpire(key); | |
| if (cached.has_value()) { | |
| return std::move(cached.value()); | |
| } | |
| } | |
| // Cache miss: load via supplier outside the lock | |
| PAIMON_ASSIGN_OR_RAISE(V value, supplier(key)); | |
| int64_t weight = ComputeWeight(key, value); | |
| if (weight > options_.max_weight) { | |
| return value; | |
| } | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| // Double-check: another thread may have inserted while we were loading | |
| auto cached = FindPromoteOrExpire(key); | |
| if (cached.has_value()) { | |
| return std::move(cached.value()); | |
| } | |
| InsertEntry(key, value, weight); | |
| EvictIfNeeded(); | |
| return value; | |
| } | |
| /// Insert or update an entry. If the key already exists, the old value is replaced | |
| /// and the REPLACED callback is invoked. Triggers eviction if needed. | |
| /// @return Status::Invalid if the entry's weight exceeds max_weight, Status::OK otherwise. | |
| Status Put(const K& key, V value) { | |
| int64_t weight = ComputeWeight(key, value); | |
| if (weight > options_.max_weight) { | |
| return Status::Invalid( | |
| fmt::format("Entry weight {} exceeds cache max weight {}, entry will not be cached", | |
| weight, options_.max_weight)); | |
| } | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| auto it = lru_map_.find(key); | |
| if (it != lru_map_.end()) { | |
| if (ValuesEqual(it->second->value, value)) { | |
| Promote(it->second); | |
| return Status::OK(); | |
| } | |
| ReplaceEntry(it->second, std::move(value), weight); | |
| } else { | |
| InsertEntry(key, std::move(value), weight); | |
| } | |
| EvictIfNeeded(); | |
| return Status::OK(); | |
| } | |
| /// Remove a specific entry. Invokes the EXPLICIT removal callback if the key exists. | |
| void Invalidate(const K& key) { | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| auto it = lru_map_.find(key); | |
| if (it != lru_map_.end()) { | |
| RemoveEntry(it->second, RemovalCause::EXPLICIT); | |
| } | |
| } | |
| /// Remove all entries. Each entry's EXPLICIT removal callback is invoked. | |
| void InvalidateAll() { | |
| std::unique_lock<std::shared_mutex> lock(mutex_); | |
| while (!lru_list_.empty()) { | |
| RemoveEntry(std::prev(lru_list_.end()), RemovalCause::EXPLICIT); | |
| } | |
| current_weight_ = 0; | |
| } | |
| /// @return The number of entries currently in the cache. | |
| size_t Size() const { | |
| std::shared_lock<std::shared_mutex> lock(mutex_); | |
| return lru_map_.size(); | |
| } | |
| /// @return The current total weight of all entries. | |
| int64_t GetCurrentWeight() const { | |
| std::shared_lock<std::shared_mutex> lock(mutex_); | |
| return current_weight_; | |
| } | |
| /// @return The maximum weight configured for this cache. | |
| int64_t GetMaxWeight() const { | |
| return options_.max_weight; | |
| } | |
| private: | |
| struct CacheEntry { | |
| K key; | |
| V value; | |
| int64_t weight; | |
| std::chrono::steady_clock::time_point last_access_time; | |
| }; | |
| using EntryList = std::list<CacheEntry>; | |
| using EntryMap = std::unordered_map<K, typename EntryList::iterator, Hash, KeyEqual>; | |
| /// Look up a key, promote if found and not expired, or remove if expired. | |
| /// Must be called with mutex_ held. | |
| /// @return The value if found and valid, std::nullopt otherwise. | |
| std::optional<V> FindPromoteOrExpire(const K& key) { | |
| auto it = lru_map_.find(key); | |
| if (it == lru_map_.end()) { | |
| return std::nullopt; | |
| } | |
| auto list_it = it->second; | |
| if (IsExpired(list_it->last_access_time)) { | |
| RemoveEntry(list_it, RemovalCause::EXPIRED); | |
| return std::nullopt; | |
| } | |
| Promote(list_it); | |
| return list_it->value; | |
| } | |
| /// Move an entry to the front of the LRU list and update its access time. | |
| void Promote(typename EntryList::iterator list_it) { | |
| list_it->last_access_time = std::chrono::steady_clock::now(); | |
| lru_list_.splice(lru_list_.begin(), lru_list_, list_it); | |
| } | |
| /// Insert a new entry at the front of the LRU list. | |
| void InsertEntry(const K& key, V value, int64_t weight) { | |
| lru_list_.emplace_front( | |
| CacheEntry{key, std::move(value), weight, std::chrono::steady_clock::now()}); | |
| lru_map_[key] = lru_list_.begin(); | |
| current_weight_ += weight; | |
| } | |
| /// Compare two values for equality. For pointers, compares the underlying | |
| /// pointer first, then dereferences and compares the pointed-to objects. | |
| /// For other types, uses operator==. | |
| static bool ValuesEqual(const V& lhs, const V& rhs) { | |
| if constexpr (is_pointer<V>::value) { | |
| if (lhs == rhs) { | |
| return true; | |
| } | |
| if (!lhs || !rhs) { | |
| return false; | |
| } | |
| return *lhs == *rhs; | |
| } else { | |
| return lhs == rhs; | |
| } | |
| } | |
| /// Replace the value of an existing entry, invoke the REPLACED callback for the old value, | |
| /// and promote the entry to the front. | |
| void ReplaceEntry(typename EntryList::iterator list_it, V new_value, int64_t new_weight) { | |
| current_weight_ -= list_it->weight; | |
| K key = list_it->key; | |
| V old_value = std::move(list_it->value); | |
| list_it->value = std::move(new_value); | |
| list_it->weight = new_weight; | |
| list_it->last_access_time = std::chrono::steady_clock::now(); | |
| current_weight_ += new_weight; | |
| lru_list_.splice(lru_list_.begin(), lru_list_, list_it); | |
| InvokeCallback(key, old_value, RemovalCause::REPLACED); | |
| } | |
| /// Remove an entry from the cache and invoke the removal callback. | |
| void RemoveEntry(typename EntryList::iterator list_it, RemovalCause cause) { | |
| lru_map_.erase(list_it->key); | |
| K key = std::move(list_it->key); | |
| V value = std::move(list_it->value); | |
| current_weight_ -= list_it->weight; | |
| lru_list_.erase(list_it); | |
| InvokeCallback(key, value, cause); | |
| } | |
| /// Evict expired entries from the tail, then evict by weight if still over capacity. | |
| void EvictIfNeeded() { | |
| EvictExpired(); | |
| while (current_weight_ > options_.max_weight && !lru_list_.empty()) { | |
| RemoveEntry(std::prev(lru_list_.end()), RemovalCause::SIZE); | |
| } | |
| } | |
| /// Evict expired entries from the tail of the LRU list. | |
| /// Since the tail has the oldest access time, we can stop as soon as we find | |
| /// a non-expired entry. | |
| void EvictExpired() { | |
| if (options_.expire_after_access_ms < 0) { | |
| return; | |
| } | |
| auto now = std::chrono::steady_clock::now(); | |
| while (!lru_list_.empty()) { | |
| auto it = std::prev(lru_list_.end()); | |
| if (!IsExpired(it->last_access_time, now)) { | |
| break; | |
| } | |
| RemoveEntry(it, RemovalCause::EXPIRED); | |
| } | |
| } | |
| /// Compute the weight of an entry using the configured weigh function. | |
| int64_t ComputeWeight(const K& key, const V& value) const { | |
| if (options_.weigh_func) { | |
| return options_.weigh_func(key, value); | |
| } | |
| return 1; | |
| } | |
| /// Check if an entry has expired based on its last access time. | |
| bool IsExpired( | |
| const std::chrono::steady_clock::time_point& last_access_time, | |
| const std::chrono::steady_clock::time_point& now = std::chrono::steady_clock::now()) const { | |
| if (options_.expire_after_access_ms < 0) { | |
| return false; | |
| } | |
| auto elapsed = | |
| std::chrono::duration_cast<std::chrono::milliseconds>(now - last_access_time); | |
| return elapsed.count() >= options_.expire_after_access_ms; | |
| } | |
| /// Invoke the removal callback if one is configured. | |
| void InvokeCallback(const K& key, const V& value, RemovalCause cause) { | |
| if (options_.removal_callback) { | |
| options_.removal_callback(key, value, cause); | |
| } | |
| } | |
| Options options_; | |
| int64_t current_weight_ = 0; | |
| EntryList lru_list_; | |
| EntryMap lru_map_; | |
| mutable std::shared_mutex mutex_; | |
| }; | |
| } // namespace paimon |