diff --git a/endpoint/store/include/privmx/endpoint/store/cache/CacheBackendInMemory.hpp b/endpoint/store/include/privmx/endpoint/store/cache/CacheBackendInMemory.hpp new file mode 100644 index 00000000..15471590 --- /dev/null +++ b/endpoint/store/include/privmx/endpoint/store/cache/CacheBackendInMemory.hpp @@ -0,0 +1,42 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#ifndef _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_IN_MEMORY_HPP_ +#define _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_IN_MEMORY_HPP_ + +#include +#include +#include +#include + +#include + +namespace privmx { +namespace endpoint { +namespace store { + +class CacheBackendInMemory : public CacheBackendInterface { +public: + std::optional get(const std::string& key) override; + void put(const std::string& key, const core::Buffer& value) override; + void del(const std::string& key) override; + void clear() override; + std::unique_ptr seek(const std::string& prefix) override; + +private: + std::map _store; +}; + +} // namespace store +} // namespace endpoint +} // namespace privmx + +#endif // _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_IN_MEMORY_HPP_ diff --git a/endpoint/store/include/privmx/endpoint/store/cache/SlruBackedCache.hpp b/endpoint/store/include/privmx/endpoint/store/cache/SlruBackedCache.hpp new file mode 100644 index 00000000..aeaaecd3 --- /dev/null +++ b/endpoint/store/include/privmx/endpoint/store/cache/SlruBackedCache.hpp @@ -0,0 +1,78 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#ifndef _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_BACKED_CACHE_HPP_ +#define _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_BACKED_CACHE_HPP_ + +#include +#include +#include +#include + +#include +#include +#include + +namespace privmx { +namespace endpoint { +namespace store { + +/** + * CacheInterface implementation backed by a persistent CacheBackendInterface, + * with a configurable size cap and SLRU eviction. + * + * Both cached values and SLRU metadata are stored in the backend, so eviction + * order and segment membership survive process restarts. On construction the + * SLRU state is restored by reading the metadata from the backend. + * + * Internal backend key layout (invisible to callers): + * "d;" + user_key → raw cached value + * "slru;" + user_key → 17-byte metadata: [seq: uint64_le][size: uint64_le][segment: uint8] + * + * Both prefixes use ';' which never appears in UUIDs, Base58, or Base64, + * so data keys and metadata keys never collide with each other or with external entries. + */ +class SlruBackedCache : public CacheInterface { +public: + static constexpr double DEFAULT_PROTECTED_RATIO = 0.8; + + explicit SlruBackedCache(CacheBackendInterface& backend, + size_t maxBytes = CacheBackendInterface::DEFAULT_MAX_BYTES, + double protectedRatio = DEFAULT_PROTECTED_RATIO); + + std::optional get(const std::string& key) override; + void put(const std::string& key, const core::Buffer& data) override; + void del(const std::string& key) override; + +private: + static constexpr const char* DATA_PREFIX = "d;"; + static constexpr const char* META_PREFIX = "slru;"; + + static std::string dataKey(const std::string& key); + static std::string metaKey(const std::string& key); + static core::Buffer encodeMeta(uint64_t seq, uint64_t size, Segment segment); + static std::tuple decodeMeta(const core::Buffer& raw); + + void loadFromBackend(); + void persistMeta(const std::string& key, size_t size, Segment segment); + void evictUntilFit(size_t incomingSize); + + CacheBackendInterface& _backend; + size_t _maxBytes; + uint64_t _seq = 0; + SlruPolicy _slru; +}; + +} // namespace store +} // namespace endpoint +} // namespace privmx + +#endif // _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_BACKED_CACHE_HPP_ diff --git a/endpoint/store/include/privmx/endpoint/store/cache/SlruPolicy.hpp b/endpoint/store/include/privmx/endpoint/store/cache/SlruPolicy.hpp new file mode 100644 index 00000000..aa391469 --- /dev/null +++ b/endpoint/store/include/privmx/endpoint/store/cache/SlruPolicy.hpp @@ -0,0 +1,104 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#ifndef _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_POLICY_HPP_ +#define _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_POLICY_HPP_ + +#include +#include +#include +#include +#include + +namespace privmx { +namespace endpoint { +namespace store { + +enum class Segment : uint8_t { Probationary = 0, Protected = 1 }; + +/** + * In-memory Segmented LRU (SLRU) eviction policy tracker. + * + * Entries live in one of two segments: + * - Probationary: newly inserted or demoted entries; eviction target. + * - Protected: entries promoted after a second access; shielded from eviction. + * + * On touch: + * - New entry → head of Probationary + * - Probationary hit → promoted to head of Protected; + * if Protected overflows its budget, its LRU tail + * is demoted to head of Probationary + * - Protected hit → moved to head of Protected + * + * evictOne() always targets the tail of Probationary; if Probationary is + * empty it falls back to the tail of Protected. + * + * All operations are O(1). + */ +class SlruPolicy { +public: + struct Entry { + std::string key; + size_t size; + }; + + /** + * @param maxBytes Total byte budget for both segments combined. + * @param protectedRatio Fraction of maxBytes reserved for the Protected segment [0, 1]. + */ + explicit SlruPolicy(size_t maxBytes, double protectedRatio = 0.8); + + /** + * Marks @p key as most-recently-used, applying promotion logic. + * Returns the segment the key ended up in. + */ + Segment touch(const std::string& key, size_t size); + + /** + * Inserts @p key directly into @p segment without triggering promotion. + * Used when restoring persisted state on startup. + */ + void restore(const std::string& key, size_t size, Segment segment); + + /** + * Removes and returns the eviction candidate (tail of Probationary, + * or tail of Protected if Probationary is empty). + * Returns std::nullopt if both segments are empty. + */ + std::optional evictOne(); + + void remove(const std::string& key); + bool contains(const std::string& key) const; + size_t totalSize() const; + bool empty() const; + +private: + struct SegmentState { + std::list list; // front = MRU, back = LRU + std::unordered_map::iterator> index; + size_t totalSize = 0; + size_t maxBytes = 0; + }; + + void insertHead(SegmentState& seg, const std::string& key, size_t size); + void removeFrom(SegmentState& seg, + std::unordered_map::iterator>::iterator indexIt); + void rebalanceProtected(); + + SegmentState _probationary; + SegmentState _protected; +}; + +} // namespace store +} // namespace endpoint +} // namespace privmx + +#endif // _PRIVMXLIB_ENDPOINT_STORE_CACHE_SLRU_POLICY_HPP_ diff --git a/endpoint/store/include_pub/privmx/endpoint/store/cache/CacheBackendInterface.hpp b/endpoint/store/include_pub/privmx/endpoint/store/cache/CacheBackendInterface.hpp new file mode 100644 index 00000000..3fb2f43d --- /dev/null +++ b/endpoint/store/include_pub/privmx/endpoint/store/cache/CacheBackendInterface.hpp @@ -0,0 +1,105 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#ifndef _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_INTERFACE_HPP_ +#define _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_INTERFACE_HPP_ + +#include +#include +#include +#include + +#include + +namespace privmx { +namespace endpoint { +namespace store { + +/** + * Low-level storage backend for cache implementations. + * + * CacheInterface implementations delegate raw persistence to this backend. + * The backend operates on plain string keys and binary values; any namespacing, + * eviction, or size accounting is handled by the CacheInterface layer above. + */ +class CacheBackendInterface { +public: + /** Default maximum total size of cached data (512 MB) used by backend-backed cache implementations. */ + static constexpr size_t DEFAULT_MAX_BYTES = 512ULL * 1024 * 1024; + + /** + * Forward-only iterator over a range of entries sharing a common key prefix. + * Obtained via CacheBackendInterface::seek(). + */ + class Iterator { + public: + virtual ~Iterator() = default; + + /** + * Returns true if the iterator points to a valid entry. + * Must be checked before calling key(), value(), or next(). + */ + virtual bool valid() const = 0; + + /** + * Advances the iterator to the next matching entry. + * Calling next() when valid() is false is undefined behaviour. + */ + virtual void next() = 0; + + /** + * Returns the key of the current entry. + * Calling key() when valid() is false is undefined behaviour. + */ + virtual const std::string& key() const = 0; + + /** + * Returns the value of the current entry. + * Calling value() when valid() is false is undefined behaviour. + */ + virtual core::Buffer value() const = 0; + }; + + virtual ~CacheBackendInterface() = default; + + /** + * Returns the stored value for the given key, or std::nullopt if absent. + */ + virtual std::optional get(const std::string& key) = 0; + + /** + * Inserts or replaces the value for the given key. + */ + virtual void put(const std::string& key, const core::Buffer& value) = 0; + + /** + * Removes the entry for the given key. No-op if the key does not exist. + */ + virtual void del(const std::string& key) = 0; + + /** + * Removes all entries from the backend. + */ + virtual void clear() = 0; + + /** + * Returns an iterator positioned at the first entry whose key is >= @p prefix, + * iterating in key order until the end of all entries. + * An empty prefix iterates over all entries from the beginning. + */ + virtual std::unique_ptr seek(const std::string& prefix) = 0; +}; + +} // namespace store +} // namespace endpoint +} // namespace privmx + +#endif // _PRIVMXLIB_ENDPOINT_STORE_CACHE_BACKEND_INTERFACE_HPP_ diff --git a/endpoint/store/include_pub/privmx/endpoint/store/cache/GlobalCache.hpp b/endpoint/store/include_pub/privmx/endpoint/store/cache/GlobalCache.hpp index 6b31cb1f..44780884 100644 --- a/endpoint/store/include_pub/privmx/endpoint/store/cache/GlobalCache.hpp +++ b/endpoint/store/include_pub/privmx/endpoint/store/cache/GlobalCache.hpp @@ -15,6 +15,7 @@ limitations under the License. #include #include +#include #include namespace privmx { @@ -38,7 +39,8 @@ class GlobalCache { public: /** * Enables or disables the built-in in-memory cache. - * Must be called before Connection::connect(). Has no effect if setChunksCache() was already called. + * Must be called before Connection::connect(). Has no effect if setChunksCache() or + * setChunksCacheBackend() was already called. * * @param enabled true to use the built-in CacheInMemory (default), false to use a no-op cache */ @@ -46,12 +48,23 @@ class GlobalCache { /** * Sets a custom cache implementation for the entire runtime. - * Must be called before Connection::connect(). Takes precedence over setChunksCacheEnabled(). + * Must be called before Connection::connect(). Takes precedence over all other setup methods. * * @param cache custom CacheInterface implementation (e.g. disk-backed or Redis cache) */ static void setChunksCache(std::shared_ptr cache); + /** + * Configures the built-in SLRU cache backed by the given persistent backend. + * Must be called before Connection::connect(). Takes precedence over setChunksCacheEnabled() + * but is overridden by setChunksCache(). + * + * @param backend storage backend (e.g. LevelDB wrapper); GlobalCache takes shared ownership + * @param maxBytes maximum total size of cached data before SLRU eviction kicks in + */ + static void setChunksCacheBackend(std::shared_ptr backend, + size_t maxBytes = CacheBackendInterface::DEFAULT_MAX_BYTES); + /** * Returns the shared cache instance, initializing it on the first call. * After the first call the instance is frozen — subsequent setChunksCache() @@ -64,6 +77,8 @@ class GlobalCache { private: static bool _isChunksCacheEnabled; static std::shared_ptr _chunksCacheImpl; + static std::shared_ptr _chunksCacheBackend; + static size_t _chunksCacheMaxBytes; static std::once_flag _chunksCacheInitFlag; }; diff --git a/endpoint/store/src/cache/CacheBackendInMemory.cpp b/endpoint/store/src/cache/CacheBackendInMemory.cpp new file mode 100644 index 00000000..4a23d804 --- /dev/null +++ b/endpoint/store/src/cache/CacheBackendInMemory.cpp @@ -0,0 +1,68 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#include + +namespace privmx { +namespace endpoint { +namespace store { + +namespace { + +class IteratorImpl : public CacheBackendInterface::Iterator { +public: + using MapIterator = std::map::iterator; + + IteratorImpl(MapIterator cur, MapIterator end) : _cur(cur), _end(end) {} + + bool valid() const override { return _cur != _end; } + + void next() override { ++_cur; } + + const std::string& key() const override { return _cur->first; } + + core::Buffer value() const override { return _cur->second; } + +private: + MapIterator _cur; + MapIterator _end; +}; + +} // namespace + +std::optional CacheBackendInMemory::get(const std::string& key) { + auto it = _store.find(key); + if (it == _store.end()) { + return std::nullopt; + } + return it->second; +} + +void CacheBackendInMemory::put(const std::string& key, const core::Buffer& value) { + _store[key] = value; +} + +void CacheBackendInMemory::del(const std::string& key) { + _store.erase(key); +} + +void CacheBackendInMemory::clear() { + _store.clear(); +} + +std::unique_ptr CacheBackendInMemory::seek(const std::string& prefix) { + auto begin = prefix.empty() ? _store.begin() : _store.lower_bound(prefix); + return std::make_unique(begin, _store.end()); +} + +} // namespace store +} // namespace endpoint +} // namespace privmx diff --git a/endpoint/store/src/cache/GlobalCache.cpp b/endpoint/store/src/cache/GlobalCache.cpp index a480048a..bd4df497 100644 --- a/endpoint/store/src/cache/GlobalCache.cpp +++ b/endpoint/store/src/cache/GlobalCache.cpp @@ -9,15 +9,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -#include +#include #include #include +#include using namespace privmx::endpoint::store; bool GlobalCache::_isChunksCacheEnabled = true; std::shared_ptr GlobalCache::_chunksCacheImpl = nullptr; +std::shared_ptr GlobalCache::_chunksCacheBackend = nullptr; +size_t GlobalCache::_chunksCacheMaxBytes = CacheBackendInterface::DEFAULT_MAX_BYTES; std::once_flag GlobalCache::_chunksCacheInitFlag; void GlobalCache::setChunksCacheEnabled(bool enabled) { @@ -28,12 +31,23 @@ void GlobalCache::setChunksCache(std::shared_ptr cache) { _chunksCacheImpl = std::move(cache); } +void GlobalCache::setChunksCacheBackend(std::shared_ptr backend, size_t maxBytes) { + _chunksCacheBackend = std::move(backend); + _chunksCacheMaxBytes = maxBytes; +} + std::shared_ptr GlobalCache::getChunksCacheInstance() { std::call_once(_chunksCacheInitFlag, []() { if (_chunksCacheImpl) return; + if (_chunksCacheBackend) { + _chunksCacheImpl = std::make_shared(*_chunksCacheBackend, _chunksCacheMaxBytes); + return; + } if (_isChunksCacheEnabled) { - _chunksCacheImpl = std::make_shared(); + auto backend = std::make_shared(); + _chunksCacheBackend = backend; + _chunksCacheImpl = std::make_shared(*backend, _chunksCacheMaxBytes); } else { _chunksCacheImpl = std::make_shared(); } diff --git a/endpoint/store/src/cache/SlruBackedCache.cpp b/endpoint/store/src/cache/SlruBackedCache.cpp new file mode 100644 index 00000000..711a1271 --- /dev/null +++ b/endpoint/store/src/cache/SlruBackedCache.cpp @@ -0,0 +1,126 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#include + +#include +#include +#include + +namespace privmx { +namespace endpoint { +namespace store { + +SlruBackedCache::SlruBackedCache(CacheBackendInterface& backend, size_t maxBytes, double protectedRatio) + : _backend(backend), _maxBytes(maxBytes), _slru(maxBytes, protectedRatio) { + loadFromBackend(); +} + +std::optional SlruBackedCache::get(const std::string& key) { + auto data = _backend.get(dataKey(key)); + if (!data) return std::nullopt; + size_t size = data->size(); + auto seg = _slru.touch(key, size); + persistMeta(key, size, seg); + return data; +} + +void SlruBackedCache::put(const std::string& key, const core::Buffer& data) { + size_t newSize = data.size(); + _slru.remove(key); + evictUntilFit(newSize); + _backend.put(dataKey(key), data); + auto seg = _slru.touch(key, newSize); + persistMeta(key, newSize, seg); +} + +void SlruBackedCache::del(const std::string& key) { + _slru.remove(key); + _backend.del(dataKey(key)); + _backend.del(metaKey(key)); +} + +// --- private --- + +std::string SlruBackedCache::dataKey(const std::string& key) { + return std::string(DATA_PREFIX) + key; +} + +std::string SlruBackedCache::metaKey(const std::string& key) { + return std::string(META_PREFIX) + key; +} + +core::Buffer SlruBackedCache::encodeMeta(uint64_t seq, uint64_t size, Segment segment) { + char buf[17]; + std::memcpy(buf, &seq, 8); + std::memcpy(buf + 8, &size, 8); + buf[16] = static_cast(segment); + return core::Buffer::from(buf, 17); +} + +std::tuple SlruBackedCache::decodeMeta(const core::Buffer& raw) { + if (raw.size() < 17) return {0, 0, Segment::Probationary}; + uint64_t seq = 0, size = 0; + std::memcpy(&seq, raw.data(), 8); + std::memcpy(&size, raw.data() + 8, 8); + auto seg = static_cast(static_cast(raw.data()[16])); + return {seq, size, seg}; +} + +void SlruBackedCache::loadFromBackend() { + using Record = std::tuple; + std::vector records; + std::vector orphanedMetaKeys; + + const std::string metaSeekPrefix(META_PREFIX); + auto it = _backend.seek(metaSeekPrefix); + while (it->valid() && it->key().compare(0, metaSeekPrefix.size(), metaSeekPrefix) == 0) { + std::string userKey = it->key().substr(metaSeekPrefix.size()); + auto [seq, size, seg] = decodeMeta(it->value()); + + if (_backend.get(dataKey(userKey))) { + records.emplace_back(seq, std::move(userKey), static_cast(size), seg); + if (seq > _seq) _seq = seq; + } else { + orphanedMetaKeys.push_back(it->key()); + } + it->next(); + } + + for (const auto& k : orphanedMetaKeys) { + _backend.del(k); + } + + // Sort ascending by seq so restore() ends with the MRU entry at each segment's head. + std::sort(records.begin(), records.end()); + + for (auto& [seq, key, size, seg] : records) { + _slru.restore(key, size, seg); + } +} + +void SlruBackedCache::persistMeta(const std::string& key, size_t size, Segment segment) { + ++_seq; + _backend.put(metaKey(key), encodeMeta(_seq, static_cast(size), segment)); +} + +void SlruBackedCache::evictUntilFit(size_t incomingSize) { + while (!_slru.empty() && _slru.totalSize() + incomingSize > _maxBytes) { + auto evicted = _slru.evictOne(); + if (!evicted) break; + _backend.del(dataKey(evicted->key)); + _backend.del(metaKey(evicted->key)); + } +} + +} // namespace store +} // namespace endpoint +} // namespace privmx diff --git a/endpoint/store/src/cache/SlruPolicy.cpp b/endpoint/store/src/cache/SlruPolicy.cpp new file mode 100644 index 00000000..7cd62765 --- /dev/null +++ b/endpoint/store/src/cache/SlruPolicy.cpp @@ -0,0 +1,118 @@ +/* +PrivMX Endpoint. +Copyright © 2026 Simplito sp. z o.o. + +This file is part of the PrivMX Platform (https://privmx.dev). +This software is Licensed under the PrivMX Free License. + +See the License for the specific language governing permissions and +limitations under the License. +*/ + +#include + +namespace privmx { +namespace endpoint { +namespace store { + +SlruPolicy::SlruPolicy(size_t maxBytes, double protectedRatio) { + _protected.maxBytes = static_cast(maxBytes * protectedRatio); + _probationary.maxBytes = maxBytes - _protected.maxBytes; +} + +void SlruPolicy::insertHead(SegmentState& seg, const std::string& key, size_t size) { + seg.list.push_front({key, size}); + seg.index[key] = seg.list.begin(); + seg.totalSize += size; +} + +void SlruPolicy::removeFrom(SegmentState& seg, + std::unordered_map::iterator>::iterator indexIt) { + seg.totalSize -= indexIt->second->size; + seg.list.erase(indexIt->second); + seg.index.erase(indexIt); +} + +void SlruPolicy::rebalanceProtected() { + while (_protected.totalSize > _protected.maxBytes && !_protected.list.empty()) { + Entry tail = _protected.list.back(); + removeFrom(_protected, _protected.index.find(tail.key)); + insertHead(_probationary, tail.key, tail.size); + } +} + +Segment SlruPolicy::touch(const std::string& key, size_t size) { + { + auto it = _protected.index.find(key); + if (it != _protected.index.end()) { + removeFrom(_protected, it); + insertHead(_protected, key, size); + return Segment::Protected; + } + } + { + auto it = _probationary.index.find(key); + if (it != _probationary.index.end()) { + removeFrom(_probationary, it); + insertHead(_protected, key, size); + rebalanceProtected(); + return Segment::Protected; + } + } + insertHead(_probationary, key, size); + return Segment::Probationary; +} + +void SlruPolicy::restore(const std::string& key, size_t size, Segment segment) { + if (segment == Segment::Protected) { + insertHead(_protected, key, size); + } else { + insertHead(_probationary, key, size); + } +} + +std::optional SlruPolicy::evictOne() { + if (!_probationary.list.empty()) { + Entry entry = _probationary.list.back(); + removeFrom(_probationary, _probationary.index.find(entry.key)); + return entry; + } + if (!_protected.list.empty()) { + Entry entry = _protected.list.back(); + removeFrom(_protected, _protected.index.find(entry.key)); + return entry; + } + return std::nullopt; +} + +void SlruPolicy::remove(const std::string& key) { + { + auto it = _protected.index.find(key); + if (it != _protected.index.end()) { + removeFrom(_protected, it); + return; + } + } + { + auto it = _probationary.index.find(key); + if (it != _probationary.index.end()) { + removeFrom(_probationary, it); + } + } +} + +bool SlruPolicy::contains(const std::string& key) const { + return _protected.index.count(key) || _probationary.index.count(key); +} + +size_t SlruPolicy::totalSize() const { + return _protected.totalSize + _probationary.totalSize; +} + +bool SlruPolicy::empty() const { + return _probationary.list.empty() && _protected.list.empty(); +} + +} // namespace store +} // namespace endpoint +} // namespace privmx