Ver código fonte

feat: US-006 - Database Core - Snapshot Persistence

Implement snapshot persistence system for data durability across restarts:

- Binary snapshot format with magic number validation (SNAP header)
- Configurable snapshot interval (default 5 minutes)
- Snapshot on shutdown (graceful) with final snapshot creation
- Snapshot on significant events (configurable N document change threshold)
- Async write using background thread (non-blocking)
- Load snapshot on startup functionality
- Snapshot includes all collections, documents, and metadata
- Thread-safe snapshot manager with atomic status tracking
- Comprehensive test suite with 14 passing tests

Key components:
- SnapshotManager: Manages async snapshot lifecycle
- SnapshotConfig: Configurable interval, threshold, and directory
- ICollectionProvider: Interface for collection data access
- Binary format: Header + collection headers + documents as JSON
Fszontagh 6 meses atrás
pai
commit
2dffc1fdac

+ 4 - 0
CMakeLists.txt

@@ -144,6 +144,10 @@ add_subdirectory(database)
 add_subdirectory(webserver)
 add_subdirectory(webui)
 
+if(BUILD_TESTING)
+    add_subdirectory(tests)
+endif()
+
 # ============================================================================
 # Summary
 # ============================================================================

+ 1 - 0
database/CMakeLists.txt

@@ -5,6 +5,7 @@ add_library(smartbotic_database STATIC
     src/document.cpp
     src/collection.cpp
     src/collection_meta.cpp
+    src/snapshot.cpp
 )
 
 target_include_directories(smartbotic_database

+ 199 - 0
database/include/smartbotic/database/snapshot.hpp

@@ -0,0 +1,199 @@
+#pragma once
+
+#include <atomic>
+#include <chrono>
+#include <condition_variable>
+#include <cstdint>
+#include <fstream>
+#include <memory>
+#include <mutex>
+#include <shared_mutex>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <unordered_map>
+#include <vector>
+
+#include "smartbotic/database/collection.hpp"
+#include "smartbotic/database/collection_meta.hpp"
+#include "smartbotic/database/document.hpp"
+
+namespace smartbotic::database {
+
+/// Configuration for snapshot persistence
+struct SnapshotConfig {
+    std::string snapshot_dir = "./data/snapshots";  // Directory to store snapshots
+    std::chrono::seconds interval{300};              // Default 5 minutes
+    uint64_t change_threshold = 100;                 // Snapshot after N document changes
+    bool enabled = true;                             // Enable/disable automatic snapshots
+
+    /// Create default configuration
+    static auto Default() -> SnapshotConfig { return SnapshotConfig{}; }
+
+    /// Create configuration with custom interval (in seconds)
+    static auto WithInterval(int64_t seconds) -> SnapshotConfig {
+        SnapshotConfig config;
+        config.interval = std::chrono::seconds(seconds);
+        return config;
+    }
+};
+
+/// Binary format version for snapshot file
+constexpr uint32_t kSnapshotVersion = 1;
+
+/// Magic number for snapshot file identification ("SNAP" in little-endian)
+constexpr uint32_t kSnapshotMagic = 0x50414E53;
+
+/// Header structure for snapshot binary file
+struct SnapshotFileHeader {
+    uint32_t magic = kSnapshotMagic;        // File identification magic number
+    uint32_t version = kSnapshotVersion;    // Format version
+    uint64_t collection_count = 0;          // Number of collections in snapshot
+    uint64_t total_document_count = 0;      // Total number of documents across all collections
+    int64_t created_seconds = 0;            // Creation timestamp (seconds since epoch)
+    int32_t created_nanos = 0;              // Creation timestamp (nanoseconds)
+    uint32_t reserved = 0;                  // Reserved for future use
+};
+
+/// Header for each collection within the snapshot
+struct SnapshotCollectionHeader {
+    uint32_t name_length = 0;       // Length of collection name string
+    uint64_t document_count = 0;    // Number of documents in this collection
+    uint64_t metadata_length = 0;   // Length of metadata JSON string
+};
+
+/// Statistics about a snapshot operation
+struct SnapshotStats {
+    uint64_t collections = 0;       // Number of collections
+    uint64_t documents = 0;         // Total number of documents
+    uint64_t bytes_written = 0;     // Total bytes written
+    Timestamp created_at;           // When snapshot was created
+    std::chrono::milliseconds duration{0};  // How long the snapshot took
+};
+
+/// Status of the snapshot manager
+enum class SnapshotManagerStatus : uint8_t {
+    kStopped,        // Manager is not running
+    kRunning,        // Manager is running and accepting changes
+    kSnapshotting,   // Currently creating a snapshot
+    kShuttingDown    // Graceful shutdown in progress
+};
+
+/// Interface for collection data provider
+/// The snapshot manager uses this to get current state of all collections
+class ICollectionProvider {
+public:
+    virtual ~ICollectionProvider() = default;
+
+    /// Get names of all collections
+    [[nodiscard]] virtual auto GetCollectionNames() const -> std::vector<std::string> = 0;
+
+    /// Get a collection by name (returns nullptr if not found)
+    [[nodiscard]] virtual auto GetCollection(std::string_view name) const -> const Collection* = 0;
+
+    /// Get all collections' metadata
+    [[nodiscard]] virtual auto GetAllMetadata() const -> std::unordered_map<std::string, CollectionMeta> = 0;
+};
+
+/// Manages snapshot persistence with async writes and configurable triggers
+class SnapshotManager {
+public:
+    /// Create a snapshot manager with the given configuration
+    explicit SnapshotManager(SnapshotConfig config = SnapshotConfig::Default());
+
+    /// Destructor - ensures graceful shutdown
+    ~SnapshotManager();
+
+    /// Non-copyable and non-movable (owns background thread)
+    SnapshotManager(const SnapshotManager&) = delete;
+    auto operator=(const SnapshotManager&) -> SnapshotManager& = delete;
+    SnapshotManager(SnapshotManager&&) = delete;
+    auto operator=(SnapshotManager&&) -> SnapshotManager& = delete;
+
+    /// Start the snapshot manager with a collection provider
+    /// Returns false if already running or provider is null
+    [[nodiscard]] auto Start(std::shared_ptr<ICollectionProvider> provider) -> bool;
+
+    /// Stop the snapshot manager gracefully (creates final snapshot)
+    /// Blocks until shutdown is complete
+    void Stop();
+
+    /// Notify the manager that a document change occurred
+    /// This increments the change counter for threshold-based snapshots
+    void NotifyChange();
+
+    /// Notify the manager of multiple changes at once
+    void NotifyChanges(uint64_t count);
+
+    /// Request an immediate snapshot (async)
+    /// Returns immediately; snapshot happens in background
+    void RequestSnapshot();
+
+    /// Create a snapshot synchronously (blocks until complete)
+    /// Returns stats about the snapshot or error message
+    [[nodiscard]] auto CreateSnapshotSync() -> Result<SnapshotStats>;
+
+    /// Load a snapshot from disk
+    /// Returns a map of collection name -> (metadata, documents) or error
+    [[nodiscard]] auto LoadSnapshot() -> Result<
+        std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>;
+
+    /// Check if a snapshot file exists
+    [[nodiscard]] auto SnapshotExists() const -> bool;
+
+    /// Get the current status
+    [[nodiscard]] auto GetStatus() const -> SnapshotManagerStatus;
+
+    /// Get the configuration
+    [[nodiscard]] auto GetConfig() const -> const SnapshotConfig& { return config_; }
+
+    /// Get the number of pending changes since last snapshot
+    [[nodiscard]] auto GetPendingChanges() const -> uint64_t;
+
+    /// Get the last snapshot stats (if any)
+    [[nodiscard]] auto GetLastSnapshotStats() const -> std::optional<SnapshotStats>;
+
+    /// Get the snapshot file path
+    [[nodiscard]] auto GetSnapshotPath() const -> std::string;
+
+private:
+    SnapshotConfig config_;
+    std::shared_ptr<ICollectionProvider> provider_;
+
+    // Background thread management
+    std::thread background_thread_;
+    std::atomic<SnapshotManagerStatus> status_{SnapshotManagerStatus::kStopped};
+    std::atomic<bool> shutdown_requested_{false};
+    std::atomic<bool> snapshot_requested_{false};
+
+    // Change tracking
+    std::atomic<uint64_t> change_count_{0};
+
+    // Synchronization
+    mutable std::mutex mutex_;
+    std::condition_variable cv_;
+
+    // Last snapshot statistics
+    std::optional<SnapshotStats> last_stats_;
+    mutable std::shared_mutex stats_mutex_;
+
+    /// Background thread main loop
+    void BackgroundLoop();
+
+    /// Perform the actual snapshot write
+    [[nodiscard]] auto WriteSnapshot() -> Result<SnapshotStats>;
+
+    /// Write a length-prefixed string to the stream
+    static void WriteString(std::ofstream& stream, const std::string& str);
+
+    /// Read a length-prefixed string from the stream
+    [[nodiscard]] static auto ReadString(std::ifstream& stream) -> std::string;
+
+    /// Write a JSON value as a length-prefixed string
+    static void WriteJson(std::ofstream& stream, const nlohmann::json& json);
+
+    /// Read a JSON value from a length-prefixed string
+    [[nodiscard]] static auto ReadJson(std::ifstream& stream) -> nlohmann::json;
+};
+
+}  // namespace smartbotic::database

+ 434 - 0
database/src/snapshot.cpp

@@ -0,0 +1,434 @@
+#include "smartbotic/database/snapshot.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <filesystem>
+
+namespace smartbotic::database {
+
+// ============================================================================
+// SnapshotManager Implementation
+// ============================================================================
+
+SnapshotManager::SnapshotManager(SnapshotConfig config) : config_(std::move(config)) {}
+
+SnapshotManager::~SnapshotManager() {
+    Stop();
+}
+
+auto SnapshotManager::Start(std::shared_ptr<ICollectionProvider> provider) -> bool {
+    if (!provider) {
+        spdlog::error("Cannot start SnapshotManager with null provider");
+        return false;
+    }
+
+    SnapshotManagerStatus expected = SnapshotManagerStatus::kStopped;
+    if (!status_.compare_exchange_strong(expected, SnapshotManagerStatus::kRunning)) {
+        spdlog::warn("SnapshotManager already running");
+        return false;
+    }
+
+    provider_ = std::move(provider);
+    shutdown_requested_ = false;
+    snapshot_requested_ = false;
+    change_count_ = 0;
+
+    // Create snapshot directory if it doesn't exist
+    std::error_code ec;
+    std::filesystem::create_directories(config_.snapshot_dir, ec);
+    if (ec) {
+        spdlog::error("Failed to create snapshot directory '{}': {}", config_.snapshot_dir, ec.message());
+        status_ = SnapshotManagerStatus::kStopped;
+        return false;
+    }
+
+    // Start background thread
+    background_thread_ = std::thread(&SnapshotManager::BackgroundLoop, this);
+
+    spdlog::info("SnapshotManager started with interval={}s, threshold={} changes", config_.interval.count(),
+                 config_.change_threshold);
+    return true;
+}
+
+void SnapshotManager::Stop() {
+    SnapshotManagerStatus expected = SnapshotManagerStatus::kRunning;
+    if (!status_.compare_exchange_strong(expected, SnapshotManagerStatus::kShuttingDown)) {
+        // Not running, check if it's snapshotting and wait
+        expected = SnapshotManagerStatus::kSnapshotting;
+        if (!status_.compare_exchange_strong(expected, SnapshotManagerStatus::kShuttingDown)) {
+            // Already stopped or shutting down
+            return;
+        }
+    }
+
+    spdlog::info("SnapshotManager shutting down...");
+
+    // Signal shutdown
+    {
+        std::lock_guard lock(mutex_);
+        shutdown_requested_ = true;
+    }
+    cv_.notify_all();
+
+    // Wait for background thread
+    if (background_thread_.joinable()) {
+        background_thread_.join();
+    }
+
+    status_ = SnapshotManagerStatus::kStopped;
+    provider_.reset();
+
+    spdlog::info("SnapshotManager stopped");
+}
+
+void SnapshotManager::NotifyChange() {
+    NotifyChanges(1);
+}
+
+void SnapshotManager::NotifyChanges(uint64_t count) {
+    uint64_t new_count = change_count_.fetch_add(count) + count;
+
+    // Check if we've hit the threshold
+    if (config_.enabled && config_.change_threshold > 0 && new_count >= config_.change_threshold) {
+        spdlog::debug("Change threshold reached ({} changes), triggering snapshot", new_count);
+        RequestSnapshot();
+    }
+}
+
+void SnapshotManager::RequestSnapshot() {
+    {
+        std::lock_guard lock(mutex_);
+        snapshot_requested_ = true;
+    }
+    cv_.notify_one();
+}
+
+auto SnapshotManager::CreateSnapshotSync() -> Result<SnapshotStats> {
+    if (!provider_) {
+        return Result<SnapshotStats>::Error("No collection provider available");
+    }
+
+    return WriteSnapshot();
+}
+
+auto SnapshotManager::LoadSnapshot()
+    -> Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>> {
+    std::string path = GetSnapshotPath();
+
+    if (!std::filesystem::exists(path)) {
+        spdlog::debug("Snapshot file '{}' does not exist", path);
+        return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Ok({});
+    }
+
+    std::ifstream file(path, std::ios::binary);
+    if (!file.is_open()) {
+        return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Error(
+            "Failed to open snapshot file: " + path);
+    }
+
+    // Read and validate header
+    SnapshotFileHeader header{};
+    file.read(reinterpret_cast<char*>(&header), sizeof(header));
+
+    if (file.gcount() != sizeof(header)) {
+        return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Error(
+            "Failed to read snapshot header");
+    }
+
+    if (header.magic != kSnapshotMagic) {
+        return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Error(
+            "Invalid snapshot magic number");
+    }
+
+    if (header.version != kSnapshotVersion) {
+        return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Error(
+            "Unsupported snapshot version: " + std::to_string(header.version));
+    }
+
+    spdlog::info("Loading snapshot with {} collections, {} total documents", header.collection_count,
+                 header.total_document_count);
+
+    std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>> result;
+
+    // Read each collection
+    for (uint64_t i = 0; i < header.collection_count; ++i) {
+        // Read collection header
+        SnapshotCollectionHeader col_header{};
+        file.read(reinterpret_cast<char*>(&col_header), sizeof(col_header));
+
+        if (!file.good()) {
+            return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Error(
+                "Failed to read collection header at index " + std::to_string(i));
+        }
+
+        // Read collection name
+        std::string name(col_header.name_length, '\0');
+        file.read(name.data(), static_cast<std::streamsize>(col_header.name_length));
+
+        // Read collection metadata
+        nlohmann::json meta_json = ReadJson(file);
+        CollectionMeta meta = CollectionMeta::FromJson(meta_json);
+
+        // Read documents
+        std::vector<Document> documents;
+        documents.reserve(col_header.document_count);
+
+        for (uint64_t j = 0; j < col_header.document_count; ++j) {
+            nlohmann::json doc_json = ReadJson(file);
+            documents.push_back(Document::FromJson(doc_json));
+        }
+
+        spdlog::debug("Loaded collection '{}' with {} documents", name, documents.size());
+        result.emplace(name, std::make_pair(std::move(meta), std::move(documents)));
+    }
+
+    spdlog::info("Successfully loaded {} collections from snapshot", result.size());
+    return Result<std::unordered_map<std::string, std::pair<CollectionMeta, std::vector<Document>>>>::Ok(
+        std::move(result));
+}
+
+auto SnapshotManager::SnapshotExists() const -> bool {
+    return std::filesystem::exists(GetSnapshotPath());
+}
+
+auto SnapshotManager::GetStatus() const -> SnapshotManagerStatus {
+    return status_.load();
+}
+
+auto SnapshotManager::GetPendingChanges() const -> uint64_t {
+    return change_count_.load();
+}
+
+auto SnapshotManager::GetLastSnapshotStats() const -> std::optional<SnapshotStats> {
+    std::shared_lock lock(stats_mutex_);
+    return last_stats_;
+}
+
+auto SnapshotManager::GetSnapshotPath() const -> std::string {
+    return config_.snapshot_dir + "/snapshot.bin";
+}
+
+void SnapshotManager::BackgroundLoop() {
+    spdlog::debug("Snapshot background thread started");
+
+    while (!shutdown_requested_.load()) {
+        std::unique_lock lock(mutex_);
+
+        // Wait for either:
+        // 1. Snapshot interval elapsed
+        // 2. Snapshot explicitly requested
+        // 3. Shutdown requested
+        bool triggered = cv_.wait_for(lock, config_.interval, [this] {
+            return shutdown_requested_.load() || snapshot_requested_.load();
+        });
+
+        // Check for shutdown
+        if (shutdown_requested_.load()) {
+            break;
+        }
+
+        // Determine if we should snapshot
+        bool should_snapshot = false;
+        if (snapshot_requested_.load()) {
+            snapshot_requested_ = false;
+            should_snapshot = true;
+            spdlog::debug("Snapshot triggered by explicit request");
+        } else if (!triggered && config_.enabled) {
+            // Interval elapsed
+            should_snapshot = true;
+            spdlog::debug("Snapshot triggered by interval timer");
+        }
+
+        if (should_snapshot) {
+            lock.unlock();  // Release lock during snapshot
+
+            // Update status
+            SnapshotManagerStatus expected = SnapshotManagerStatus::kRunning;
+            status_.compare_exchange_strong(expected, SnapshotManagerStatus::kSnapshotting);
+
+            auto result = WriteSnapshot();
+
+            // Restore status
+            expected = SnapshotManagerStatus::kSnapshotting;
+            status_.compare_exchange_strong(expected, SnapshotManagerStatus::kRunning);
+
+            if (result.IsError()) {
+                spdlog::error("Snapshot failed: {}", result.error);
+            }
+        }
+    }
+
+    // Final snapshot on shutdown
+    spdlog::info("Creating final snapshot before shutdown...");
+    auto result = WriteSnapshot();
+    if (result.IsError()) {
+        spdlog::error("Final snapshot failed: {}", result.error);
+    } else {
+        spdlog::info("Final snapshot completed: {} collections, {} documents", result.value->collections,
+                     result.value->documents);
+    }
+
+    spdlog::debug("Snapshot background thread exiting");
+}
+
+auto SnapshotManager::WriteSnapshot() -> Result<SnapshotStats> {
+    if (!provider_) {
+        return Result<SnapshotStats>::Error("No collection provider available");
+    }
+
+    auto start_time = std::chrono::steady_clock::now();
+    Timestamp created_at = Timestamp::Now();
+
+    // Get all collection names
+    auto collection_names = provider_->GetCollectionNames();
+
+    // Create temporary file path
+    std::string temp_path = GetSnapshotPath() + ".tmp";
+    std::string final_path = GetSnapshotPath();
+
+    // Ensure parent directory exists
+    std::filesystem::path path(final_path);
+    if (path.has_parent_path()) {
+        std::error_code ec;
+        std::filesystem::create_directories(path.parent_path(), ec);
+        if (ec) {
+            return Result<SnapshotStats>::Error("Failed to create snapshot directory: " + ec.message());
+        }
+    }
+
+    std::ofstream file(temp_path, std::ios::binary | std::ios::trunc);
+    if (!file.is_open()) {
+        return Result<SnapshotStats>::Error("Failed to open snapshot file for writing: " + temp_path);
+    }
+
+    // Prepare header (will update counts after writing)
+    SnapshotFileHeader header{};
+    header.magic = kSnapshotMagic;
+    header.version = kSnapshotVersion;
+    header.collection_count = collection_names.size();
+    header.total_document_count = 0;
+    header.created_seconds = created_at.seconds;
+    header.created_nanos = created_at.nanos;
+
+    // Write header placeholder
+    auto header_pos = file.tellp();
+    file.write(reinterpret_cast<const char*>(&header), sizeof(header));
+
+    SnapshotStats stats;
+    stats.created_at = created_at;
+    stats.collections = collection_names.size();
+
+    // Write each collection
+    for (const auto& name : collection_names) {
+        const Collection* collection = provider_->GetCollection(name);
+        if (collection == nullptr) {
+            spdlog::warn("Collection '{}' disappeared during snapshot", name);
+            continue;
+        }
+
+        // Get metadata and documents
+        CollectionMeta meta = collection->GetMetadata();
+        std::vector<Document> documents = collection->GetAll();
+
+        // Write collection header
+        SnapshotCollectionHeader col_header{};
+        col_header.name_length = static_cast<uint32_t>(name.size());
+        col_header.document_count = documents.size();
+
+        nlohmann::json meta_json = meta.ToJson();
+        std::string meta_str = meta_json.dump();
+        col_header.metadata_length = meta_str.size();
+
+        file.write(reinterpret_cast<const char*>(&col_header), sizeof(col_header));
+
+        // Write collection name
+        file.write(name.data(), static_cast<std::streamsize>(name.size()));
+
+        // Write metadata JSON
+        WriteJson(file, meta_json);
+
+        // Write documents
+        for (const auto& doc : documents) {
+            WriteJson(file, doc.ToJson());
+        }
+
+        stats.documents += documents.size();
+        header.total_document_count += documents.size();
+
+        spdlog::debug("Snapshot: wrote collection '{}' with {} documents", name, documents.size());
+    }
+
+    // Update header with final counts
+    file.seekp(header_pos);
+    file.write(reinterpret_cast<const char*>(&header), sizeof(header));
+
+    file.flush();
+    if (!file.good()) {
+        return Result<SnapshotStats>::Error("Failed to write snapshot file");
+    }
+
+    stats.bytes_written = static_cast<uint64_t>(file.tellp());
+    file.close();
+
+    // Atomically rename temp file to final file
+    std::error_code ec;
+    std::filesystem::rename(temp_path, final_path, ec);
+    if (ec) {
+        // Try to remove temp file
+        std::filesystem::remove(temp_path, ec);
+        return Result<SnapshotStats>::Error("Failed to finalize snapshot file: " + ec.message());
+    }
+
+    // Reset change counter
+    change_count_ = 0;
+
+    auto end_time = std::chrono::steady_clock::now();
+    stats.duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time);
+
+    // Store last stats
+    {
+        std::unique_lock lock(stats_mutex_);
+        last_stats_ = stats;
+    }
+
+    spdlog::info("Snapshot completed: {} collections, {} documents, {} bytes in {}ms", stats.collections,
+                 stats.documents, stats.bytes_written, stats.duration.count());
+
+    return Result<SnapshotStats>::Ok(stats);
+}
+
+void SnapshotManager::WriteString(std::ofstream& stream, const std::string& str) {
+    auto length = static_cast<uint32_t>(str.size());
+    stream.write(reinterpret_cast<const char*>(&length), sizeof(length));
+    if (!str.empty()) {
+        stream.write(str.data(), static_cast<std::streamsize>(str.size()));
+    }
+}
+
+auto SnapshotManager::ReadString(std::ifstream& stream) -> std::string {
+    uint32_t length = 0;
+    stream.read(reinterpret_cast<char*>(&length), sizeof(length));
+
+    if (length == 0) {
+        return "";
+    }
+
+    std::string result(length, '\0');
+    stream.read(result.data(), length);
+    return result;
+}
+
+void SnapshotManager::WriteJson(std::ofstream& stream, const nlohmann::json& json) {
+    std::string serialized = json.dump();
+    WriteString(stream, serialized);
+}
+
+auto SnapshotManager::ReadJson(std::ifstream& stream) -> nlohmann::json {
+    std::string serialized = ReadString(stream);
+    if (serialized.empty()) {
+        return nlohmann::json::object();
+    }
+    return nlohmann::json::parse(serialized);
+}
+
+}  // namespace smartbotic::database

+ 33 - 0
tests/CMakeLists.txt

@@ -0,0 +1,33 @@
+# Tests for SmartBotic CRM
+
+# Fetch GoogleTest
+include(FetchContent)
+FetchContent_Declare(
+    googletest
+    GIT_REPOSITORY https://github.com/google/googletest.git
+    GIT_TAG v1.14.0
+)
+# Prevent overriding the parent project's compiler/linker settings
+set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(googletest)
+
+enable_testing()
+
+# ============================================================================
+# Database Tests
+# ============================================================================
+
+add_executable(database_tests
+    database/snapshot_test.cpp
+)
+
+target_compile_options(database_tests PRIVATE ${SMARTBOTIC_CXX_WARNINGS})
+
+target_link_libraries(database_tests
+    PRIVATE
+        smartbotic::database
+        GTest::gtest_main
+)
+
+include(GoogleTest)
+gtest_discover_tests(database_tests)

+ 474 - 0
tests/database/snapshot_test.cpp

@@ -0,0 +1,474 @@
+#include <gtest/gtest.h>
+
+#include <filesystem>
+#include <thread>
+
+#include "smartbotic/database/snapshot.hpp"
+
+namespace smartbotic::database {
+namespace {
+
+/// Mock collection provider for testing
+class MockCollectionProvider : public ICollectionProvider {
+public:
+    void AddCollection(std::unique_ptr<Collection> collection) {
+        std::string name(collection->GetName());
+        collections_.emplace(name, std::move(collection));
+    }
+
+    [[nodiscard]] auto GetCollectionNames() const -> std::vector<std::string> override {
+        std::vector<std::string> names;
+        names.reserve(collections_.size());
+        for (const auto& [name, _] : collections_) {
+            names.push_back(name);
+        }
+        return names;
+    }
+
+    [[nodiscard]] auto GetCollection(std::string_view name) const -> const Collection* override {
+        auto it = collections_.find(std::string(name));
+        if (it == collections_.end()) {
+            return nullptr;
+        }
+        return it->second.get();
+    }
+
+    [[nodiscard]] auto GetAllMetadata() const -> std::unordered_map<std::string, CollectionMeta> override {
+        std::unordered_map<std::string, CollectionMeta> result;
+        for (const auto& [name, collection] : collections_) {
+            result.emplace(name, collection->GetMetadata());
+        }
+        return result;
+    }
+
+private:
+    std::unordered_map<std::string, std::unique_ptr<Collection>> collections_;
+};
+
+class SnapshotTest : public ::testing::Test {
+protected:
+    void SetUp() override {
+        // Use a unique test directory for each test
+        test_dir_ = std::filesystem::temp_directory_path() / ("snapshot_test_" + std::to_string(std::time(nullptr)));
+        std::filesystem::create_directories(test_dir_);
+    }
+
+    void TearDown() override {
+        // Clean up test directory
+        std::error_code ec;
+        std::filesystem::remove_all(test_dir_, ec);
+    }
+
+    [[nodiscard]] auto CreateConfig() -> SnapshotConfig {
+        SnapshotConfig config;
+        config.snapshot_dir = test_dir_.string();
+        config.interval = std::chrono::seconds(3600);  // Long interval for tests
+        config.change_threshold = 0;                    // Disable threshold for tests
+        config.enabled = true;
+        return config;
+    }
+
+    std::filesystem::path test_dir_;
+};
+
+// ============================================================================
+// Basic Snapshot Tests
+// ============================================================================
+
+TEST_F(SnapshotTest, CreateSnapshotSync_EmptyDatabase) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    ASSERT_TRUE(manager.Start(provider));
+    EXPECT_EQ(manager.GetStatus(), SnapshotManagerStatus::kRunning);
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+    EXPECT_EQ(result.value->collections, 0);
+    EXPECT_EQ(result.value->documents, 0);
+
+    // Snapshot file should exist
+    EXPECT_TRUE(manager.SnapshotExists());
+
+    manager.Stop();
+    EXPECT_EQ(manager.GetStatus(), SnapshotManagerStatus::kStopped);
+}
+
+TEST_F(SnapshotTest, CreateSnapshotSync_WithCollections) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    // Create a collection with some documents
+    auto users = std::make_unique<Collection>("users");
+    (void)users->Create({{"name", "Alice"}, {"email", "alice@example.com"}}, "user-1");
+    (void)users->Create({{"name", "Bob"}, {"email", "bob@example.com"}}, "user-2");
+    provider->AddCollection(std::move(users));
+
+    // Create another collection
+    auto posts = std::make_unique<Collection>("posts");
+    (void)posts->Create({{"title", "Hello World"}, {"content", "First post"}}, "post-1");
+    provider->AddCollection(std::move(posts));
+
+    ASSERT_TRUE(manager.Start(provider));
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+    EXPECT_EQ(result.value->collections, 2);
+    EXPECT_EQ(result.value->documents, 3);
+    EXPECT_GT(result.value->bytes_written, 0);
+
+    manager.Stop();
+}
+
+TEST_F(SnapshotTest, LoadSnapshot_EmptyFile) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    // No snapshot file exists
+    EXPECT_FALSE(manager.SnapshotExists());
+
+    auto result = manager.LoadSnapshot();
+    ASSERT_TRUE(result.IsOk());
+    EXPECT_TRUE(result.value->empty());
+}
+
+TEST_F(SnapshotTest, LoadSnapshot_RestoresData) {
+    auto config = CreateConfig();
+
+    // Create and save a snapshot
+    {
+        SnapshotManager manager(config);
+        auto provider = std::make_shared<MockCollectionProvider>();
+
+        auto users = std::make_unique<Collection>("users");
+        (void)users->Create({{"name", "Alice"}, {"age", 30}}, "user-1");
+        (void)users->Create({{"name", "Bob"}, {"age", 25}}, "user-2");
+        provider->AddCollection(std::move(users));
+
+        ASSERT_TRUE(manager.Start(provider));
+        auto result = manager.CreateSnapshotSync();
+        ASSERT_TRUE(result.IsOk());
+        manager.Stop();
+    }
+
+    // Load the snapshot with a new manager
+    {
+        SnapshotManager manager(config);
+        auto result = manager.LoadSnapshot();
+        ASSERT_TRUE(result.IsOk());
+        EXPECT_EQ(result.value->size(), 1);
+
+        auto it = result.value->find("users");
+        ASSERT_NE(it, result.value->end());
+
+        const auto& [meta, documents] = it->second;
+        EXPECT_EQ(meta.name, "users");
+        EXPECT_EQ(documents.size(), 2);
+
+        // Verify document data
+        bool found_alice = false;
+        bool found_bob = false;
+        for (const auto& doc : documents) {
+            if (doc.GetId() == "user-1") {
+                EXPECT_EQ(doc.GetData()["name"], "Alice");
+                EXPECT_EQ(doc.GetData()["age"], 30);
+                found_alice = true;
+            } else if (doc.GetId() == "user-2") {
+                EXPECT_EQ(doc.GetData()["name"], "Bob");
+                EXPECT_EQ(doc.GetData()["age"], 25);
+                found_bob = true;
+            }
+        }
+        EXPECT_TRUE(found_alice);
+        EXPECT_TRUE(found_bob);
+    }
+}
+
+TEST_F(SnapshotTest, PreservesCollectionMetadata) {
+    auto config = CreateConfig();
+
+    // Create a collection with custom metadata
+    {
+        SnapshotManager manager(config);
+        auto provider = std::make_shared<MockCollectionProvider>();
+
+        auto users = std::make_unique<Collection>("users");
+
+        // Set TTL config
+        TTLConfig ttl;
+        ttl.enabled = true;
+        ttl.ttl_seconds = 3600;
+        ttl.field_name = "_createdAt";
+        users->SetTTLConfig(ttl);
+
+        // Set encryption config
+        EncryptionConfig encryption;
+        encryption.encrypted_fields = {"password", "ssn"};
+        encryption.key_id = "key-001";
+        users->SetEncryptionConfig(encryption);
+
+        (void)users->Create({{"name", "Test"}}, "user-1");
+        provider->AddCollection(std::move(users));
+
+        ASSERT_TRUE(manager.Start(provider));
+        auto result = manager.CreateSnapshotSync();
+        ASSERT_TRUE(result.IsOk());
+        manager.Stop();
+    }
+
+    // Load and verify metadata
+    {
+        SnapshotManager manager(config);
+        auto result = manager.LoadSnapshot();
+        ASSERT_TRUE(result.IsOk());
+
+        auto it = result.value->find("users");
+        ASSERT_NE(it, result.value->end());
+
+        const auto& meta = it->second.first;
+        EXPECT_EQ(meta.name, "users");
+
+        // Check TTL config
+        EXPECT_TRUE(meta.ttl_config.enabled);
+        EXPECT_EQ(meta.ttl_config.ttl_seconds, 3600);
+        EXPECT_EQ(meta.ttl_config.field_name, "_createdAt");
+
+        // Check encryption config
+        EXPECT_EQ(meta.encryption.key_id, "key-001");
+        EXPECT_TRUE(meta.encryption.encrypted_fields.contains("password"));
+        EXPECT_TRUE(meta.encryption.encrypted_fields.contains("ssn"));
+    }
+}
+
+// ============================================================================
+// Async and Background Thread Tests
+// ============================================================================
+
+TEST_F(SnapshotTest, StartWithNullProvider_Fails) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    EXPECT_FALSE(manager.Start(nullptr));
+    EXPECT_EQ(manager.GetStatus(), SnapshotManagerStatus::kStopped);
+}
+
+TEST_F(SnapshotTest, DoubleStart_Fails) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    EXPECT_TRUE(manager.Start(provider));
+    EXPECT_FALSE(manager.Start(provider));
+
+    manager.Stop();
+}
+
+TEST_F(SnapshotTest, RequestSnapshot_TriggersAsync) {
+    auto config = CreateConfig();
+    config.interval = std::chrono::seconds(3600);  // Very long interval
+
+    SnapshotManager manager(config);
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    auto users = std::make_unique<Collection>("users");
+    (void)users->Create({{"name", "Test"}}, "user-1");
+    provider->AddCollection(std::move(users));
+
+    ASSERT_TRUE(manager.Start(provider));
+    EXPECT_FALSE(manager.SnapshotExists());
+
+    // Request an async snapshot
+    manager.RequestSnapshot();
+
+    // Wait for the snapshot to complete (with timeout)
+    int attempts = 0;
+    while (!manager.SnapshotExists() && attempts < 50) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(100));
+        ++attempts;
+    }
+
+    EXPECT_TRUE(manager.SnapshotExists());
+    EXPECT_EQ(manager.GetPendingChanges(), 0);
+
+    manager.Stop();
+}
+
+TEST_F(SnapshotTest, ChangeThreshold_TriggersSnapshot) {
+    auto config = CreateConfig();
+    config.interval = std::chrono::seconds(3600);  // Very long interval
+    config.change_threshold = 5;                    // Trigger after 5 changes
+
+    SnapshotManager manager(config);
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    auto users = std::make_unique<Collection>("users");
+    provider->AddCollection(std::move(users));
+
+    ASSERT_TRUE(manager.Start(provider));
+    EXPECT_FALSE(manager.SnapshotExists());
+
+    // Notify changes below threshold
+    manager.NotifyChanges(3);
+    EXPECT_EQ(manager.GetPendingChanges(), 3);
+
+    // Add more to exceed threshold
+    manager.NotifyChanges(3);  // Now at 6, exceeds 5
+
+    // Wait for the snapshot to complete
+    int attempts = 0;
+    while (!manager.SnapshotExists() && attempts < 50) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(100));
+        ++attempts;
+    }
+
+    EXPECT_TRUE(manager.SnapshotExists());
+    EXPECT_EQ(manager.GetPendingChanges(), 0);
+
+    manager.Stop();
+}
+
+TEST_F(SnapshotTest, GracefulShutdown_CreatesSnapshot) {
+    auto config = CreateConfig();
+    config.interval = std::chrono::seconds(3600);  // Very long interval
+
+    SnapshotManager manager(config);
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    auto users = std::make_unique<Collection>("users");
+    (void)users->Create({{"name", "Final"}}, "user-1");
+    provider->AddCollection(std::move(users));
+
+    ASSERT_TRUE(manager.Start(provider));
+    EXPECT_FALSE(manager.SnapshotExists());
+
+    // Stop should create a final snapshot
+    manager.Stop();
+
+    EXPECT_TRUE(manager.SnapshotExists());
+    EXPECT_EQ(manager.GetStatus(), SnapshotManagerStatus::kStopped);
+
+    // Verify the snapshot contains the data
+    auto result = manager.LoadSnapshot();
+    ASSERT_TRUE(result.IsOk());
+    EXPECT_EQ(result.value->size(), 1);
+}
+
+// ============================================================================
+// Statistics Tests
+// ============================================================================
+
+TEST_F(SnapshotTest, LastSnapshotStats_Updated) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    auto users = std::make_unique<Collection>("users");
+    (void)users->Create({{"name", "Test"}}, "user-1");
+    provider->AddCollection(std::move(users));
+
+    ASSERT_TRUE(manager.Start(provider));
+
+    EXPECT_FALSE(manager.GetLastSnapshotStats().has_value());
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+
+    auto stats = manager.GetLastSnapshotStats();
+    ASSERT_TRUE(stats.has_value());
+    EXPECT_EQ(stats->collections, 1);
+    EXPECT_EQ(stats->documents, 1);
+    EXPECT_GT(stats->bytes_written, 0);
+
+    manager.Stop();
+}
+
+// ============================================================================
+// Edge Cases
+// ============================================================================
+
+TEST_F(SnapshotTest, EmptyCollection) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+    provider->AddCollection(std::make_unique<Collection>("empty"));
+
+    ASSERT_TRUE(manager.Start(provider));
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+    EXPECT_EQ(result.value->collections, 1);
+    EXPECT_EQ(result.value->documents, 0);
+
+    manager.Stop();
+
+    // Reload and verify
+    auto load_result = manager.LoadSnapshot();
+    ASSERT_TRUE(load_result.IsOk());
+    EXPECT_EQ(load_result.value->size(), 1);
+    EXPECT_TRUE(load_result.value->at("empty").second.empty());
+}
+
+TEST_F(SnapshotTest, LargeDocuments) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    auto data = std::make_unique<Collection>("data");
+
+    // Create a document with a large string
+    std::string large_content(100000, 'x');
+    (void)data->Create({{"content", large_content}}, "large-doc");
+    provider->AddCollection(std::move(data));
+
+    ASSERT_TRUE(manager.Start(provider));
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+
+    manager.Stop();
+
+    // Reload and verify
+    auto load_result = manager.LoadSnapshot();
+    ASSERT_TRUE(load_result.IsOk());
+    const auto& docs = load_result.value->at("data").second;
+    EXPECT_EQ(docs.size(), 1);
+    EXPECT_EQ(docs[0].GetData()["content"].get<std::string>().size(), 100000);
+}
+
+TEST_F(SnapshotTest, SystemCollection) {
+    auto config = CreateConfig();
+    SnapshotManager manager(config);
+
+    auto provider = std::make_shared<MockCollectionProvider>();
+
+    // System collections start with underscore
+    auto system_col = std::make_unique<Collection>("_sessions");
+    (void)system_col->Create({{"token", "abc123"}}, "session-1");
+    provider->AddCollection(std::move(system_col));
+
+    ASSERT_TRUE(manager.Start(provider));
+
+    auto result = manager.CreateSnapshotSync();
+    ASSERT_TRUE(result.IsOk());
+
+    manager.Stop();
+
+    // Reload and verify system collection is preserved
+    auto load_result = manager.LoadSnapshot();
+    ASSERT_TRUE(load_result.IsOk());
+    ASSERT_EQ(load_result.value->size(), 1);
+
+    const auto& meta = load_result.value->at("_sessions").first;
+    EXPECT_TRUE(meta.is_system);
+}
+
+}  // namespace
+}  // namespace smartbotic::database