Преглед на файлове

Add LRU memory eviction with disk-backed recovery

- Add lastAccessedAt tracking to Document for LRU ordering
- Add pinned option to CollectionOptions to protect critical collections
- Implement background eviction thread with configurable thresholds
- Add transparent document recovery from WAL on access to evicted docs
- Add alterCollection() method for updating collection options
- Add alter_collection operation to migration runner
Fszontagh преди 5 месеца
родител
ревизия
7abcd3b00b

+ 22 - 1
service/src/database_service.cpp

@@ -228,6 +228,15 @@ DatabaseService::Config DatabaseService::loadConfig(const std::filesystem::path&
             config.migrations.directory = migDir;
         }
 
+        // Memory eviction settings
+        if (db.contains("memory")) {
+            auto& memory = db["memory"];
+            config.maxMemoryMb = memory.value("max_memory_mb", config.maxMemoryMb);
+            config.evictionThresholdPercent = memory.value("eviction_threshold_percent", config.evictionThresholdPercent);
+            config.evictionTargetPercent = memory.value("eviction_target_percent", config.evictionTargetPercent);
+            config.evictionCheckIntervalMs = memory.value("eviction_check_interval_ms", config.evictionCheckIntervalMs);
+        }
+
         // Persistence settings
         if (db.contains("persistence")) {
             auto& persistence = db["persistence"];
@@ -282,9 +291,13 @@ DatabaseService::Config DatabaseService::loadConfig(const std::filesystem::path&
 }
 
 void DatabaseService::setupComponents() {
-    // Create memory store
+    // Create memory store with eviction config
     MemoryStore::Config storeConfig;
     storeConfig.nodeId = config_.nodeId;
+    storeConfig.maxMemoryBytes = config_.maxMemoryMb * 1024ULL * 1024ULL;
+    storeConfig.evictionThresholdPercent = config_.evictionThresholdPercent;
+    storeConfig.evictionTargetPercent = config_.evictionTargetPercent;
+    storeConfig.evictionCheckIntervalMs = config_.evictionCheckIntervalMs;
     store_ = std::make_unique<MemoryStore>(storeConfig);
 
     // Create persistence manager
@@ -359,6 +372,14 @@ void DatabaseService::setupComponents() {
         }
     });
 
+    // Set up document load callback for LRU eviction recovery
+    store_->setDocumentLoadCallback([this](const std::string& collection,
+                                           const std::string& id,
+                                           uint64_t walSequence) -> std::optional<Document> {
+        // Load document from WAL via persistence manager
+        return persistence_->loadDocument(collection, id, 0);  // Search from beginning
+    });
+
     // Create encryption manager
     EncryptionManager::Config encryptConfig;
     encryptConfig.enabled = config_.encryptionEnabled;

+ 6 - 0
service/src/database_service.hpp

@@ -43,6 +43,12 @@ public:
         std::filesystem::path dataDirectory;
         std::filesystem::path keyFilePath;
 
+        // Memory eviction settings
+        uint64_t maxMemoryMb = 800;              // Max memory before eviction
+        uint32_t evictionThresholdPercent = 80;  // Start evicting at this %
+        uint32_t evictionTargetPercent = 60;     // Evict down to this %
+        uint32_t evictionCheckIntervalMs = 5000; // How often to check
+
         // Persistence settings
         uint32_t walSyncIntervalMs = 100;
         uint32_t snapshotIntervalSec = 3600;

+ 11 - 0
service/src/document.hpp

@@ -26,6 +26,7 @@ struct Document {
     std::vector<std::string> encryptedFields;   // List of encrypted field paths
     std::string createdBy;                       // User ID who created the document
     std::string updatedBy;                       // User ID who last updated the document
+    uint64_t lastAccessedAt = 0;                 // Last read access timestamp (for LRU eviction)
 
     // Check if document has expired
     [[nodiscard]] bool isExpired() const {
@@ -73,6 +74,7 @@ struct Document {
         j["encryptedFields"] = encryptedFields;
         j["createdBy"] = createdBy;
         j["updatedBy"] = updatedBy;
+        j["lastAccessedAt"] = lastAccessedAt;
         return j;
     }
 
@@ -91,6 +93,12 @@ struct Document {
         doc.encryptedFields = j.value("encryptedFields", std::vector<std::string>{});
         doc.createdBy = j.value("createdBy", "");
         doc.updatedBy = j.value("updatedBy", "");
+        // Backward-compatible: default to updatedAt (or createdAt) for existing documents
+        if (j.contains("lastAccessedAt")) {
+            doc.lastAccessedAt = j.value("lastAccessedAt", 0ULL);
+        } else {
+            doc.lastAccessedAt = doc.updatedAt > 0 ? doc.updatedAt : doc.createdAt;
+        }
         return doc;
     }
 };
@@ -146,6 +154,7 @@ struct CollectionOptions {
     bool encrypted = false;                      // Encrypt all documents by default
     std::vector<std::string> sensitiveFields;   // Fields to always encrypt
     uint32_t maxVersions = 0;                    // Max version history per document (0 = unlimited)
+    bool pinned = false;                         // If true, never evict documents from this collection
 
     [[nodiscard]] nlohmann::json toJson() const {
         nlohmann::json j;
@@ -154,6 +163,7 @@ struct CollectionOptions {
         j["encrypted"] = encrypted;
         j["sensitiveFields"] = sensitiveFields;
         j["maxVersions"] = maxVersions;
+        j["pinned"] = pinned;
         return j;
     }
 
@@ -164,6 +174,7 @@ struct CollectionOptions {
         opts.encrypted = j.value("encrypted", false);
         opts.sensitiveFields = j.value("sensitiveFields", std::vector<std::string>{});
         opts.maxVersions = j.value("maxVersions", 0U);
+        opts.pinned = j.value("pinned", false);  // Backward-compatible default
         return opts;
     }
 };

+ 295 - 0
service/src/memory_store.cpp

@@ -25,6 +25,12 @@ void MemoryStore::start() {
 
     // Start expiration thread
     expirationThread_ = std::thread(&MemoryStore::expirationLoop, this);
+
+    // Start eviction thread
+    evictionThread_ = std::thread(&MemoryStore::evictionLoop, this);
+
+    spdlog::info("MemoryStore started (max memory: {} MB, eviction threshold: {}%)",
+                 config_.maxMemoryBytes / (1024 * 1024), config_.evictionThresholdPercent);
 }
 
 void MemoryStore::stop() {
@@ -35,6 +41,9 @@ void MemoryStore::stop() {
     if (expirationThread_.joinable()) {
         expirationThread_.join();
     }
+    if (evictionThread_.joinable()) {
+        evictionThread_.join();
+    }
 }
 
 void MemoryStore::setEventCallback(EventCallback callback) {
@@ -103,6 +112,26 @@ bool MemoryStore::dropCollection(const std::string& name) {
     return true;
 }
 
+bool MemoryStore::alterCollection(const std::string& name, const CollectionOptions& options) {
+    std::shared_lock<std::shared_mutex> globalLock(globalMutex_);
+
+    auto it = collections_.find(name);
+    if (it == collections_.end()) {
+        return false;
+    }
+
+    std::unique_lock<std::shared_mutex> collLock(it->second->mutex);
+
+    // Update collection options
+    it->second->options = options;
+    it->second->updatedAt = currentTimeMs();
+
+    spdlog::debug("Altered collection '{}': pinned={}, encrypted={}",
+                  name, options.pinned, options.encrypted);
+
+    return true;
+}
+
 std::vector<std::string> MemoryStore::listCollections() const {
     std::shared_lock<std::shared_mutex> lock(globalMutex_);
 
@@ -205,6 +234,10 @@ std::string MemoryStore::insert(const std::string& collection, Document doc) {
 std::optional<Document> MemoryStore::get(const std::string& collection, const std::string& id) const {
     const CollectionData* coll = getCollection(collection);
     if (!coll) {
+        // Check if document was evicted (collection may not exist in memory)
+        if (const_cast<MemoryStore*>(this)->isDocumentEvicted(collection, id)) {
+            return const_cast<MemoryStore*>(this)->loadEvictedDocument(collection, id);
+        }
         return std::nullopt;
     }
 
@@ -212,6 +245,11 @@ std::optional<Document> MemoryStore::get(const std::string& collection, const st
 
     auto it = coll->documents.find(id);
     if (it == coll->documents.end()) {
+        lock.unlock();
+        // Check if document was evicted
+        if (const_cast<MemoryStore*>(this)->isDocumentEvicted(collection, id)) {
+            return const_cast<MemoryStore*>(this)->loadEvictedDocument(collection, id);
+        }
         return std::nullopt;
     }
 
@@ -220,6 +258,9 @@ std::optional<Document> MemoryStore::get(const std::string& collection, const st
         return std::nullopt;
     }
 
+    // Update last access time (const_cast needed since get() is const for API compatibility)
+    const_cast<Document&>(it->second).lastAccessedAt = currentTimeMs();
+
     return it->second;
 }
 
@@ -1555,4 +1596,258 @@ const MemoryStore::CollectionData* MemoryStore::getCollection(const std::string&
     return nullptr;
 }
 
+// ===== LRU Eviction Implementation =====
+
+void MemoryStore::setDocumentLoadCallback(DocumentLoadCallback callback) {
+    std::lock_guard<std::mutex> lock(callbackMutex_);
+    documentLoadCallback_ = std::move(callback);
+}
+
+void MemoryStore::evictionLoop() {
+    while (running_.load()) {
+        std::this_thread::sleep_for(
+            std::chrono::milliseconds(config_.evictionCheckIntervalMs)
+        );
+
+        if (!running_.load()) break;
+
+        // Check memory usage
+        auto stats = getStats();
+        uint64_t threshold = config_.maxMemoryBytes * config_.evictionThresholdPercent / 100;
+
+        if (stats.estimatedMemoryBytes > threshold) {
+            uint64_t target = config_.maxMemoryBytes * config_.evictionTargetPercent / 100;
+            uint64_t bytesToFree = stats.estimatedMemoryBytes - target;
+
+            spdlog::info("Memory at {} MB ({}%), starting eviction to free {} MB",
+                        stats.estimatedMemoryBytes / (1024 * 1024),
+                        stats.estimatedMemoryBytes * 100 / config_.maxMemoryBytes,
+                        bytesToFree / (1024 * 1024));
+
+            uint64_t evicted = evictDocuments();
+
+            spdlog::info("Eviction complete: {} documents evicted", evicted);
+        }
+    }
+}
+
+uint64_t MemoryStore::evictDocuments() {
+    auto stats = getStats();
+    uint64_t target = config_.maxMemoryBytes * config_.evictionTargetPercent / 100;
+
+    if (stats.estimatedMemoryBytes <= target) {
+        return 0;  // No eviction needed
+    }
+
+    uint64_t bytesToFree = stats.estimatedMemoryBytes - target;
+    auto candidates = selectDocumentsForEviction(bytesToFree);
+
+    uint64_t evicted = 0;
+    for (const auto& [collection, id] : candidates) {
+        // Get current WAL sequence (we'll use version as proxy since we don't have direct WAL access)
+        CollectionData* coll = getOrCreateCollection(collection);
+        uint64_t walSequence = 0;
+
+        {
+            std::shared_lock<std::shared_mutex> lock(coll->mutex);
+            auto it = coll->documents.find(id);
+            if (it != coll->documents.end()) {
+                // Use version as a proxy for WAL sequence
+                // The actual WAL sequence will be determined by the persistence layer
+                walSequence = it->second.version;
+            }
+        }
+
+        if (evictDocument(collection, id, walSequence)) {
+            evicted++;
+        }
+    }
+
+    return evicted;
+}
+
+std::vector<std::pair<std::string, std::string>> MemoryStore::selectDocumentsForEviction(uint64_t bytesToFree) {
+    // Collect all documents with their access times from non-pinned collections
+    // tuple: (lastAccessedAt, estimatedSize, collection, id)
+    std::vector<std::tuple<uint64_t, uint64_t, std::string, std::string>> docs;
+
+    {
+        std::shared_lock<std::shared_mutex> globalLock(globalMutex_);
+
+        for (const auto& [collName, coll] : collections_) {
+            // Skip pinned collections
+            if (coll->options.pinned) {
+                continue;
+            }
+
+            std::shared_lock<std::shared_mutex> collLock(coll->mutex);
+            for (const auto& [id, doc] : coll->documents) {
+                // Use lastAccessedAt, falling back to updatedAt or createdAt
+                uint64_t accessTime = doc.lastAccessedAt > 0 ? doc.lastAccessedAt :
+                                      (doc.updatedAt > 0 ? doc.updatedAt : doc.createdAt);
+                uint64_t size = estimateDocumentSize(doc);
+                docs.emplace_back(accessTime, size, collName, id);
+            }
+        }
+    }
+
+    // Sort by access time (oldest first = least recently used)
+    std::sort(docs.begin(), docs.end());
+
+    // Select documents until we have enough to free
+    std::vector<std::pair<std::string, std::string>> candidates;
+    uint64_t totalSize = 0;
+
+    for (const auto& [accessTime, size, coll, id] : docs) {
+        if (totalSize >= bytesToFree) break;
+        candidates.emplace_back(coll, id);
+        totalSize += size;
+    }
+
+    return candidates;
+}
+
+bool MemoryStore::evictDocument(const std::string& collection, const std::string& id, uint64_t walSequence) {
+    CollectionData* coll = getOrCreateCollection(collection);
+
+    std::unique_lock<std::shared_mutex> collLock(coll->mutex);
+
+    auto it = coll->documents.find(id);
+    if (it == coll->documents.end()) {
+        return false;  // Document not found
+    }
+
+    // Create stub for recovery
+    EvictedDocumentStub stub;
+    stub.id = id;
+    stub.collection = collection;
+    stub.version = it->second.version;
+    stub.walSequence = walSequence;
+    stub.estimatedSize = estimateDocumentSize(it->second);
+    stub.evictedAt = currentTimeMs();
+
+    // Remove from expiration index
+    if (it->second.expiresAt > 0) {
+        removeFromExpirationIndex(*coll, id, it->second.expiresAt);
+    }
+
+    // Remove document from memory
+    coll->documents.erase(it);
+
+    collLock.unlock();
+
+    // Store stub in evicted map
+    {
+        std::unique_lock<std::shared_mutex> evictLock(evictionMutex_);
+        evictedDocs_[collection][id] = stub;
+    }
+
+    // Update stats
+    {
+        std::lock_guard<std::mutex> statsLock(statsMutex_);
+        stats_.totalDocuments--;
+        stats_.evictedCount++;
+        stats_.evictionCount++;
+    }
+
+    SPDLOG_DEBUG("Evicted document {}/{} (size: {} bytes)", collection, id, stub.estimatedSize);
+
+    return true;
+}
+
+std::optional<Document> MemoryStore::loadEvictedDocument(const std::string& collection, const std::string& id) {
+    EvictedDocumentStub stub;
+
+    // Get stub
+    {
+        std::shared_lock<std::shared_mutex> lock(evictionMutex_);
+        auto collIt = evictedDocs_.find(collection);
+        if (collIt == evictedDocs_.end()) {
+            return std::nullopt;
+        }
+        auto stubIt = collIt->second.find(id);
+        if (stubIt == collIt->second.end()) {
+            return std::nullopt;
+        }
+        stub = stubIt->second;
+    }
+
+    // Load from persistence via callback
+    std::optional<Document> doc;
+    {
+        std::lock_guard<std::mutex> lock(callbackMutex_);
+        if (documentLoadCallback_) {
+            doc = documentLoadCallback_(collection, id, stub.walSequence);
+        }
+    }
+
+    if (!doc) {
+        spdlog::warn("Failed to recover evicted document {}/{}", collection, id);
+        return std::nullopt;
+    }
+
+    // Re-insert into memory
+    CollectionData* coll = getOrCreateCollection(collection);
+    {
+        std::unique_lock<std::shared_mutex> collLock(coll->mutex);
+
+        doc->collection = collection;
+        doc->lastAccessedAt = currentTimeMs();  // Mark as recently accessed
+
+        // Add to expiration index if TTL is set
+        if (doc->expiresAt > 0) {
+            addToExpirationIndex(*coll, id, doc->expiresAt);
+        }
+
+        coll->documents[id] = *doc;
+    }
+
+    // Remove from evicted map
+    {
+        std::unique_lock<std::shared_mutex> evictLock(evictionMutex_);
+        auto collIt = evictedDocs_.find(collection);
+        if (collIt != evictedDocs_.end()) {
+            collIt->second.erase(id);
+            if (collIt->second.empty()) {
+                evictedDocs_.erase(collIt);
+            }
+        }
+    }
+
+    // Update stats
+    {
+        std::lock_guard<std::mutex> statsLock(statsMutex_);
+        stats_.totalDocuments++;
+        stats_.evictedCount--;
+        stats_.recoveryCount++;
+    }
+
+    spdlog::debug("Recovered evicted document {}/{}", collection, id);
+
+    return doc;
+}
+
+bool MemoryStore::isDocumentEvicted(const std::string& collection, const std::string& id) const {
+    std::shared_lock<std::shared_mutex> lock(evictionMutex_);
+    auto collIt = evictedDocs_.find(collection);
+    if (collIt == evictedDocs_.end()) {
+        return false;
+    }
+    return collIt->second.find(id) != collIt->second.end();
+}
+
+std::optional<MemoryStore::EvictedDocumentStub> MemoryStore::getEvictedStub(
+    const std::string& collection, const std::string& id) const {
+    std::shared_lock<std::shared_mutex> lock(evictionMutex_);
+    auto collIt = evictedDocs_.find(collection);
+    if (collIt == evictedDocs_.end()) {
+        return std::nullopt;
+    }
+    auto stubIt = collIt->second.find(id);
+    if (stubIt == collIt->second.end()) {
+        return std::nullopt;
+    }
+    return stubIt->second;
+}
+
 } // namespace smartbotic::database

+ 84 - 2
service/src/memory_store.hpp

@@ -48,13 +48,34 @@ public:
         uint32_t expirationCheckIntervalMs;
         std::string nodeId;
 
+        // LRU eviction settings
+        uint32_t evictionThresholdPercent;   // Start evicting when memory exceeds this %
+        uint32_t evictionTargetPercent;      // Evict down to this % of maxMemoryBytes
+        uint32_t evictionCheckIntervalMs;    // How often to check memory usage
+
         Config()
-            : maxMemoryBytes(1024ULL * 1024 * 1024)  // 1GB default
-            , expirationCheckIntervalMs(1000)         // Check every second
+            : maxMemoryBytes(800ULL * 1024 * 1024)   // 800 MB default
+            , expirationCheckIntervalMs(1000)         // Check TTL every second
             , nodeId("storage-1")
+            , evictionThresholdPercent(80)            // Start evicting at 80%
+            , evictionTargetPercent(60)               // Evict down to 60%
+            , evictionCheckIntervalMs(5000)           // Check every 5 seconds
         {}
     };
 
+    /**
+     * Lightweight stub for evicted documents.
+     * Keeps minimal info needed to recover from WAL.
+     */
+    struct EvictedDocumentStub {
+        std::string id;
+        std::string collection;
+        uint64_t version;
+        uint64_t walSequence;      // WAL sequence for recovery
+        uint64_t estimatedSize;    // Original size (for stats)
+        uint64_t evictedAt;        // When document was evicted
+    };
+
     explicit MemoryStore(Config config = Config{});
     ~MemoryStore();
 
@@ -97,6 +118,13 @@ public:
      */
     [[nodiscard]] std::optional<CollectionInfo> getCollectionInfo(const std::string& name) const;
 
+    /**
+     * Alter collection options.
+     * Allows updating options like 'pinned' on existing collections.
+     * @return true if updated, false if collection not found
+     */
+    bool alterCollection(const std::string& name, const CollectionOptions& options);
+
     /**
      * Check if collection exists.
      */
@@ -221,10 +249,46 @@ public:
         uint64_t updateCount = 0;
         uint64_t deleteCount = 0;
         uint64_t queryCount = 0;
+        uint64_t evictedCount = 0;         // Documents currently evicted
+        uint64_t evictionCount = 0;        // Total evictions performed
+        uint64_t recoveryCount = 0;        // Documents recovered from eviction
     };
 
     [[nodiscard]] Stats getStats() const;
 
+    // ===== LRU Eviction =====
+
+    /**
+     * Callback for loading evicted documents from WAL.
+     * Returns the recovered document, or nullopt if not found.
+     */
+    using DocumentLoadCallback = std::function<std::optional<Document>(
+        const std::string& collection, const std::string& id, uint64_t walSequence)>;
+
+    /**
+     * Set callback for loading evicted documents.
+     * Must be set before eviction can recover documents.
+     */
+    void setDocumentLoadCallback(DocumentLoadCallback callback);
+
+    /**
+     * Trigger eviction if memory exceeds threshold.
+     * Called automatically by background thread.
+     * @return Number of documents evicted
+     */
+    uint64_t evictDocuments();
+
+    /**
+     * Check if a document is evicted (stub exists but data not in memory).
+     */
+    [[nodiscard]] bool isDocumentEvicted(const std::string& collection, const std::string& id) const;
+
+    /**
+     * Get evicted document stub.
+     */
+    [[nodiscard]] std::optional<EvictedDocumentStub> getEvictedStub(
+        const std::string& collection, const std::string& id) const;
+
     // ===== Version History =====
 
     struct VersionHistoryResult {
@@ -382,6 +446,24 @@ private:
     // Statistics
     mutable std::mutex statsMutex_;
     Stats stats_;
+
+    // ===== LRU Eviction =====
+
+    // Evicted documents: collection -> docId -> stub
+    std::unordered_map<std::string, std::unordered_map<std::string, EvictedDocumentStub>> evictedDocs_;
+    mutable std::shared_mutex evictionMutex_;
+
+    // Background eviction thread
+    std::thread evictionThread_;
+    void evictionLoop();
+
+    // Callback for loading evicted documents from WAL
+    DocumentLoadCallback documentLoadCallback_;
+
+    // Eviction helpers
+    std::vector<std::pair<std::string, std::string>> selectDocumentsForEviction(uint64_t bytesToFree);
+    bool evictDocument(const std::string& collection, const std::string& id, uint64_t walSequence);
+    std::optional<Document> loadEvictedDocument(const std::string& collection, const std::string& id);
 };
 
 } // namespace smartbotic::database

+ 44 - 0
service/src/migrations/migration_runner.cpp

@@ -335,6 +335,50 @@ bool MigrationRunner::applyOperation(const nlohmann::json& operation) {
         return true;
     }
 
+    if (type == "alter_collection") {
+        std::string collection = operation.value("collection", "");
+        if (collection.empty()) {
+            spdlog::error("alter_collection: missing collection name");
+            return false;
+        }
+
+        // Get current collection info to preserve existing options
+        auto currentInfo = store_.getCollectionInfo(collection);
+        if (!currentInfo) {
+            spdlog::warn("alter_collection '{}': collection not found, skipping", collection);
+            return true;  // Not an error - collection may not exist yet
+        }
+
+        CollectionOptions options = currentInfo->options;
+
+        // Apply option updates
+        if (operation.contains("options")) {
+            auto& opts = operation["options"];
+            if (opts.contains("encrypted")) {
+                options.encrypted = opts["encrypted"].get<bool>();
+            }
+            if (opts.contains("pinned")) {
+                options.pinned = opts["pinned"].get<bool>();
+            }
+            if (opts.contains("sensitive_fields")) {
+                options.sensitiveFields.clear();
+                for (const auto& field : opts["sensitive_fields"]) {
+                    options.sensitiveFields.push_back(field.get<std::string>());
+                }
+            }
+            if (opts.contains("default_ttl_seconds")) {
+                options.defaultTtlSeconds = opts["default_ttl_seconds"].get<uint32_t>();
+            }
+            if (opts.contains("max_versions")) {
+                options.maxVersions = opts["max_versions"].get<uint32_t>();
+            }
+        }
+
+        bool altered = store_.alterCollection(collection, options);
+        spdlog::debug("alter_collection '{}': {}", collection, altered ? "altered" : "failed");
+        return true;  // Success even if collection didn't exist
+    }
+
     spdlog::error("Unknown operation type: {}", type);
     return false;
 }

+ 56 - 0
service/src/persistence/persistence_manager.cpp

@@ -372,4 +372,60 @@ std::unordered_map<std::string, uint64_t> PersistenceManager::getCollectionSeque
     return collectionSequences_;
 }
 
+std::optional<Document> PersistenceManager::loadDocument(
+    const std::string& collection, const std::string& id,
+    uint64_t fromSequence) const {
+
+    if (!wal_) {
+        spdlog::warn("Cannot load document: WAL not available");
+        return std::nullopt;
+    }
+
+    std::optional<Document> result;
+    bool deleted = false;
+
+    // Replay WAL to find the document's latest state
+    // Note: We replay from the beginning (or fromSequence hint) to ensure we have
+    // the complete document state including all updates
+    wal_->replay(fromSequence, [&](const WalEntry& entry) {
+        // Only process entries for our target document
+        if (entry.collection != collection || entry.documentId != id) {
+            return;
+        }
+
+        switch (entry.opType) {
+            case WalOpType::INSERT:
+            case WalOpType::UPDATE:
+            case WalOpType::UPSERT:
+                if (entry.data) {
+                    result = Document::fromJson(*entry.data);
+                    deleted = false;
+                }
+                break;
+
+            case WalOpType::DELETE:
+                result = std::nullopt;
+                deleted = true;
+                break;
+
+            default:
+                // Ignore other operation types
+                break;
+        }
+    });
+
+    if (deleted) {
+        spdlog::debug("Document {}/{} was deleted, cannot recover", collection, id);
+        return std::nullopt;
+    }
+
+    if (result) {
+        spdlog::debug("Recovered document {}/{} from WAL", collection, id);
+    } else {
+        spdlog::debug("Document {}/{} not found in WAL", collection, id);
+    }
+
+    return result;
+}
+
 } // namespace smartbotic::database

+ 12 - 0
service/src/persistence/persistence_manager.hpp

@@ -147,6 +147,18 @@ public:
      */
     [[nodiscard]] std::unordered_map<std::string, uint64_t> getCollectionSequences() const;
 
+    /**
+     * Load a single document from WAL (for eviction recovery).
+     * Replays WAL entries to reconstruct the document's current state.
+     * @param collection Collection name
+     * @param id Document ID
+     * @param fromSequence Hint: start searching from this sequence (0 = search all)
+     * @return Document if found and not deleted, nullopt otherwise
+     */
+    [[nodiscard]] std::optional<Document> loadDocument(
+        const std::string& collection, const std::string& id,
+        uint64_t fromSequence = 0) const;
+
 private:
     /**
      * Update collection sequence tracking.