Explorar el Código

feat: US-013 - Webserver - gRPC Client Connection to Database

Implement gRPC client for webserver to connect to the database service:

- Add DatabaseClient class with connection management
- Implement connection pooling with channel reuse
- Add retry logic with exponential backoff for transient failures
- Health check on startup with fail-fast mode (configurable)
- Configurable database address (default localhost:50051)
- Expose all database service stubs (Document, Collection, Query, Admin, Subscription)
- Integrate database client into HttpServer with startup health check
Fszontagh hace 6 meses
padre
commit
4ce4f6fba7

+ 2 - 0
webserver/CMakeLists.txt

@@ -4,6 +4,7 @@
 add_library(smartbotic_webserver STATIC
     src/config.cpp
     src/http_server.cpp
+    src/database_client.cpp
 )
 
 target_include_directories(smartbotic_webserver
@@ -17,6 +18,7 @@ target_compile_options(smartbotic_webserver PRIVATE ${SMARTBOTIC_CXX_WARNINGS})
 target_link_libraries(smartbotic_webserver
     PUBLIC
         smartbotic::common
+        smartbotic::proto
         spdlog::spdlog
         nlohmann_json::nlohmann_json
         yaml-cpp::yaml-cpp

+ 175 - 0
webserver/include/smartbotic/webserver/database_client.hpp

@@ -0,0 +1,175 @@
+#pragma once
+
+#include <grpcpp/grpcpp.h>
+
+#include <atomic>
+#include <chrono>
+#include <memory>
+#include <mutex>
+#include <string>
+#include <thread>
+
+#include "database.grpc.pb.h"
+#include "smartbotic/webserver/config.hpp"
+
+namespace smartbotic::webserver {
+
+/// Configuration for database client connection
+struct DatabaseClientConfig {
+    std::string address = "127.0.0.1:50051";  // Database server address
+
+    // Connection settings
+    std::chrono::milliseconds connect_timeout{5000};       // Initial connection timeout
+    std::chrono::milliseconds request_timeout{30000};      // Default request timeout
+    std::chrono::milliseconds health_check_interval{30000};  // Health check interval
+
+    // Retry settings
+    uint32_t max_retries = 3;                              // Maximum retry attempts
+    std::chrono::milliseconds initial_backoff{100};        // Initial backoff delay
+    std::chrono::milliseconds max_backoff{10000};          // Maximum backoff delay
+    double backoff_multiplier = 2.0;                       // Backoff multiplier
+
+    // Channel pool settings (for future expansion)
+    size_t channel_pool_size = 1;  // Number of channels to maintain
+};
+
+/// Result of a health check
+struct HealthCheckResult {
+    bool healthy = false;
+    std::string message;
+    std::chrono::milliseconds latency{0};
+};
+
+/// gRPC client for connecting to the database service
+/// Provides connection management, retry logic, and health checking
+class DatabaseClient {
+public:
+    /// Create a client with the given configuration
+    explicit DatabaseClient(DatabaseClientConfig config = DatabaseClientConfig{});
+
+    /// Create a client from HttpServerConfig
+    explicit DatabaseClient(const HttpServerConfig& server_config);
+
+    ~DatabaseClient();
+
+    /// Non-copyable and non-movable
+    DatabaseClient(const DatabaseClient&) = delete;
+    auto operator=(const DatabaseClient&) -> DatabaseClient& = delete;
+    DatabaseClient(DatabaseClient&&) = delete;
+    auto operator=(DatabaseClient&&) -> DatabaseClient& = delete;
+
+    /// Connect to the database server
+    /// Returns true if connection was established successfully
+    [[nodiscard]] auto Connect() -> bool;
+
+    /// Disconnect from the database server
+    void Disconnect();
+
+    /// Check if the client is connected
+    [[nodiscard]] auto IsConnected() const -> bool;
+
+    /// Perform a health check on the database connection
+    /// This verifies the connection is working by making a test call
+    [[nodiscard]] auto HealthCheck() -> HealthCheckResult;
+
+    /// Wait for the channel to be ready with timeout
+    /// Useful for startup health checks
+    [[nodiscard]] auto WaitForReady(std::chrono::milliseconds timeout) -> bool;
+
+    /// Get the current channel state
+    [[nodiscard]] auto GetChannelState() const -> grpc_connectivity_state;
+
+    /// Get channel state as string
+    [[nodiscard]] auto GetChannelStateString() const -> std::string;
+
+    /// Get the configuration
+    [[nodiscard]] auto GetConfig() const -> const DatabaseClientConfig& { return config_; }
+
+    // =========================================================================
+    // Service Stubs - Direct access for advanced use cases
+    // =========================================================================
+
+    /// Get the DocumentService stub
+    [[nodiscard]] auto GetDocumentService() -> ::smartbotic::database::DocumentService::Stub*;
+
+    /// Get the CollectionService stub
+    [[nodiscard]] auto GetCollectionService() -> ::smartbotic::database::CollectionService::Stub*;
+
+    /// Get the QueryService stub
+    [[nodiscard]] auto GetQueryService() -> ::smartbotic::database::QueryService::Stub*;
+
+    /// Get the AdminService stub
+    [[nodiscard]] auto GetAdminService() -> ::smartbotic::database::AdminService::Stub*;
+
+    /// Get the SubscriptionService stub
+    [[nodiscard]] auto GetSubscriptionService() -> ::smartbotic::database::SubscriptionService::Stub*;
+
+    // =========================================================================
+    // Retry-Enabled Operations
+    // =========================================================================
+
+    /// Execute a gRPC call with retry logic
+    /// The operation function should return a grpc::Status
+    template <typename Request, typename Response, typename Operation>
+    auto ExecuteWithRetry(const Request& request, Response* response, Operation operation) -> grpc::Status;
+
+private:
+    /// Create the gRPC channel with appropriate options
+    void CreateChannel();
+
+    /// Create all service stubs
+    void CreateStubs();
+
+    /// Calculate backoff duration for a given retry attempt
+    [[nodiscard]] auto CalculateBackoff(uint32_t attempt) const -> std::chrono::milliseconds;
+
+    /// Check if a status code is retryable
+    [[nodiscard]] static auto IsRetryable(grpc::StatusCode code) -> bool;
+
+    DatabaseClientConfig config_;
+    std::shared_ptr<grpc::Channel> channel_;
+
+    // Service stubs
+    std::unique_ptr<::smartbotic::database::DocumentService::Stub> document_stub_;
+    std::unique_ptr<::smartbotic::database::CollectionService::Stub> collection_stub_;
+    std::unique_ptr<::smartbotic::database::QueryService::Stub> query_stub_;
+    std::unique_ptr<::smartbotic::database::AdminService::Stub> admin_stub_;
+    std::unique_ptr<::smartbotic::database::SubscriptionService::Stub> subscription_stub_;
+
+    std::atomic<bool> connected_{false};
+    mutable std::mutex mutex_;
+};
+
+// Template implementation
+template <typename Request, typename Response, typename Operation>
+auto DatabaseClient::ExecuteWithRetry(const Request& request, Response* response, Operation operation) -> grpc::Status {
+    grpc::Status last_status;
+
+    for (uint32_t attempt = 0; attempt <= config_.max_retries; ++attempt) {
+        if (attempt > 0) {
+            // Calculate and apply backoff
+            auto backoff = CalculateBackoff(attempt - 1);
+            std::this_thread::sleep_for(backoff);
+        }
+
+        // Create a new context for each attempt
+        grpc::ClientContext context;
+        context.set_deadline(std::chrono::system_clock::now() + config_.request_timeout);
+
+        // Execute the operation
+        last_status = operation(&context, request, response);
+
+        if (last_status.ok()) {
+            return last_status;
+        }
+
+        // Check if the error is retryable
+        if (!IsRetryable(last_status.error_code())) {
+            break;
+        }
+    }
+
+    return last_status;
+}
+
+}  // namespace smartbotic::webserver

+ 15 - 1
webserver/include/smartbotic/webserver/http_server.hpp

@@ -17,6 +17,7 @@
 #include <vector>
 
 #include "smartbotic/webserver/config.hpp"
+#include "smartbotic/webserver/database_client.hpp"
 
 namespace smartbotic::webserver {
 
@@ -135,8 +136,9 @@ public:
     auto operator=(HttpServer&&) -> HttpServer& = delete;
 
     /// Start the server (begins accepting requests)
+    /// If require_database is true (default), fails fast if database is unavailable
     /// Returns true on success, false on failure
-    [[nodiscard]] auto Start() -> bool;
+    [[nodiscard]] auto Start(bool require_database = true) -> bool;
 
     /// Stop the server gracefully
     void Stop();
@@ -159,12 +161,21 @@ public:
     /// Get the number of connected WebSocket clients
     [[nodiscard]] auto GetWebSocketClientCount() const -> size_t;
 
+    /// Get the database client
+    [[nodiscard]] auto GetDatabaseClient() -> DatabaseClient& { return *db_client_; }
+
+    /// Check if the database is connected
+    [[nodiscard]] auto IsDatabaseConnected() const -> bool;
+
 private:
     void DoAccept();
     void OnAccept(beast::error_code ec, tcp::socket socket);
     void OnWebSocketConnect(const std::shared_ptr<WebSocketSession>& session);
     void OnWebSocketDisconnect(const std::shared_ptr<WebSocketSession>& session);
 
+    /// Connect to the database with health check
+    [[nodiscard]] auto ConnectToDatabase() -> bool;
+
     HttpServerConfig config_;
     std::atomic<bool> running_{false};
 
@@ -172,6 +183,9 @@ private:
     tcp::acceptor acceptor_;
     std::vector<std::thread> threads_;
 
+    // Database client
+    std::unique_ptr<DatabaseClient> db_client_;
+
     // WebSocket session management
     std::unordered_set<std::shared_ptr<WebSocketSession>> ws_sessions_;
     mutable std::mutex ws_sessions_mutex_;

+ 258 - 0
webserver/src/database_client.cpp

@@ -0,0 +1,258 @@
+#include "smartbotic/webserver/database_client.hpp"
+
+#include <spdlog/spdlog.h>
+
+#include <algorithm>
+#include <cmath>
+#include <cstdlib>
+#include <thread>
+
+namespace smartbotic::webserver {
+
+DatabaseClient::DatabaseClient(DatabaseClientConfig config) : config_(std::move(config)) {}
+
+DatabaseClient::DatabaseClient(const HttpServerConfig& server_config) {
+    config_.address = server_config.database_address;
+}
+
+DatabaseClient::~DatabaseClient() {
+    Disconnect();
+}
+
+auto DatabaseClient::Connect() -> bool {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    if (connected_.load()) {
+        spdlog::debug("Database client already connected");
+        return true;
+    }
+
+    spdlog::info("Connecting to database at {}", config_.address);
+
+    try {
+        CreateChannel();
+        CreateStubs();
+
+        // Wait for the channel to become ready
+        if (!WaitForReady(config_.connect_timeout)) {
+            spdlog::error("Failed to connect to database at {}: connection timeout", config_.address);
+            return false;
+        }
+
+        connected_.store(true);
+        spdlog::info("Successfully connected to database at {}", config_.address);
+        return true;
+    } catch (const std::exception& e) {
+        spdlog::error("Failed to connect to database: {}", e.what());
+        return false;
+    }
+}
+
+void DatabaseClient::Disconnect() {
+    std::lock_guard<std::mutex> lock(mutex_);
+
+    if (!connected_.load()) {
+        return;
+    }
+
+    spdlog::info("Disconnecting from database");
+
+    // Clear stubs
+    document_stub_.reset();
+    collection_stub_.reset();
+    query_stub_.reset();
+    admin_stub_.reset();
+    subscription_stub_.reset();
+
+    // Release channel
+    channel_.reset();
+
+    connected_.store(false);
+    spdlog::info("Disconnected from database");
+}
+
+auto DatabaseClient::IsConnected() const -> bool {
+    if (!connected_.load()) {
+        return false;
+    }
+
+    // Also verify channel state
+    if (channel_) {
+        auto state = channel_->GetState(false);
+        return state == GRPC_CHANNEL_READY || state == GRPC_CHANNEL_IDLE;
+    }
+
+    return false;
+}
+
+auto DatabaseClient::HealthCheck() -> HealthCheckResult {
+    HealthCheckResult result;
+
+    if (!connected_.load() || !admin_stub_) {
+        result.healthy = false;
+        result.message = "Not connected to database";
+        return result;
+    }
+
+    auto start = std::chrono::steady_clock::now();
+
+    // Use GetDatabaseStats as a health check - it's lightweight
+    grpc::ClientContext context;
+    context.set_deadline(std::chrono::system_clock::now() + std::chrono::seconds(5));
+
+    ::smartbotic::database::GetDatabaseStatsRequest request;
+    ::smartbotic::database::DatabaseStats response;
+
+    auto status = admin_stub_->GetDatabaseStats(&context, request, &response);
+
+    auto end = std::chrono::steady_clock::now();
+    result.latency = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
+
+    if (status.ok()) {
+        result.healthy = true;
+        result.message = "Database is healthy";
+    } else {
+        result.healthy = false;
+        result.message = "Database health check failed: " + status.error_message();
+        spdlog::warn("Database health check failed: {} (code: {})", status.error_message(),
+                     static_cast<int>(status.error_code()));
+    }
+
+    return result;
+}
+
+auto DatabaseClient::WaitForReady(std::chrono::milliseconds timeout) -> bool {
+    if (!channel_) {
+        return false;
+    }
+
+    auto deadline = std::chrono::system_clock::now() + timeout;
+
+    // Try to establish connection
+    auto state = channel_->GetState(true);  // true = try to connect
+
+    while (state != GRPC_CHANNEL_READY) {
+        if (state == GRPC_CHANNEL_SHUTDOWN) {
+            spdlog::error("Channel is shutdown");
+            return false;
+        }
+
+        if (!channel_->WaitForStateChange(state, deadline)) {
+            // Timeout
+            spdlog::error("Timeout waiting for channel to become ready (current state: {})", GetChannelStateString());
+            return false;
+        }
+
+        state = channel_->GetState(false);
+    }
+
+    return true;
+}
+
+auto DatabaseClient::GetChannelState() const -> grpc_connectivity_state {
+    if (!channel_) {
+        return GRPC_CHANNEL_SHUTDOWN;
+    }
+    return channel_->GetState(false);
+}
+
+auto DatabaseClient::GetChannelStateString() const -> std::string {
+    switch (GetChannelState()) {
+        case GRPC_CHANNEL_IDLE:
+            return "IDLE";
+        case GRPC_CHANNEL_CONNECTING:
+            return "CONNECTING";
+        case GRPC_CHANNEL_READY:
+            return "READY";
+        case GRPC_CHANNEL_TRANSIENT_FAILURE:
+            return "TRANSIENT_FAILURE";
+        case GRPC_CHANNEL_SHUTDOWN:
+            return "SHUTDOWN";
+        default:
+            return "UNKNOWN";
+    }
+}
+
+auto DatabaseClient::GetDocumentService() -> ::smartbotic::database::DocumentService::Stub* {
+    return document_stub_.get();
+}
+
+auto DatabaseClient::GetCollectionService() -> ::smartbotic::database::CollectionService::Stub* {
+    return collection_stub_.get();
+}
+
+auto DatabaseClient::GetQueryService() -> ::smartbotic::database::QueryService::Stub* {
+    return query_stub_.get();
+}
+
+auto DatabaseClient::GetAdminService() -> ::smartbotic::database::AdminService::Stub* {
+    return admin_stub_.get();
+}
+
+auto DatabaseClient::GetSubscriptionService() -> ::smartbotic::database::SubscriptionService::Stub* {
+    return subscription_stub_.get();
+}
+
+void DatabaseClient::CreateChannel() {
+    grpc::ChannelArguments args;
+
+    // Set keepalive parameters for long-lived connections
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIME_MS, 30000);           // 30 seconds
+    args.SetInt(GRPC_ARG_KEEPALIVE_TIMEOUT_MS, 10000);        // 10 seconds
+    args.SetInt(GRPC_ARG_KEEPALIVE_PERMIT_WITHOUT_CALLS, 1);  // Allow keepalive without active calls
+    args.SetInt(GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS, 5000);
+
+    // Set maximum message sizes
+    args.SetMaxReceiveMessageSize(64 * 1024 * 1024);  // 64 MB
+    args.SetMaxSendMessageSize(64 * 1024 * 1024);     // 64 MB
+
+    // Initial reconnect backoff
+    args.SetInt(GRPC_ARG_INITIAL_RECONNECT_BACKOFF_MS, static_cast<int>(config_.initial_backoff.count()));
+    args.SetInt(GRPC_ARG_MAX_RECONNECT_BACKOFF_MS, static_cast<int>(config_.max_backoff.count()));
+
+    // Create channel with insecure credentials (for local development)
+    // In production, this should use TLS credentials
+    channel_ = grpc::CreateCustomChannel(config_.address, grpc::InsecureChannelCredentials(), args);
+
+    spdlog::debug("Created gRPC channel to {}", config_.address);
+}
+
+void DatabaseClient::CreateStubs() {
+    document_stub_ = ::smartbotic::database::DocumentService::NewStub(channel_);
+    collection_stub_ = ::smartbotic::database::CollectionService::NewStub(channel_);
+    query_stub_ = ::smartbotic::database::QueryService::NewStub(channel_);
+    admin_stub_ = ::smartbotic::database::AdminService::NewStub(channel_);
+    subscription_stub_ = ::smartbotic::database::SubscriptionService::NewStub(channel_);
+
+    spdlog::debug("Created all gRPC service stubs");
+}
+
+auto DatabaseClient::CalculateBackoff(uint32_t attempt) const -> std::chrono::milliseconds {
+    // Exponential backoff with jitter
+    auto base_backoff = static_cast<double>(config_.initial_backoff.count());
+    auto multiplier = std::pow(config_.backoff_multiplier, static_cast<double>(attempt));
+    auto backoff_ms = static_cast<int64_t>(base_backoff * multiplier);
+
+    // Cap at max backoff
+    backoff_ms = std::min(backoff_ms, static_cast<int64_t>(config_.max_backoff.count()));
+
+    // Add jitter (±10%)
+    auto jitter = static_cast<int64_t>(backoff_ms * 0.1);
+    backoff_ms += (rand() % (2 * jitter + 1)) - jitter;  // NOLINT(concurrency-mt-unsafe)
+
+    return std::chrono::milliseconds(backoff_ms);
+}
+
+auto DatabaseClient::IsRetryable(grpc::StatusCode code) -> bool {
+    switch (code) {
+        case grpc::StatusCode::UNAVAILABLE:
+        case grpc::StatusCode::DEADLINE_EXCEEDED:
+        case grpc::StatusCode::ABORTED:
+        case grpc::StatusCode::RESOURCE_EXHAUSTED:
+            return true;
+        default:
+            return false;
+    }
+}
+
+}  // namespace smartbotic::webserver

+ 50 - 2
webserver/src/http_server.cpp

@@ -442,7 +442,8 @@ void HttpSession::OnWrite(beast::error_code ec, std::size_t bytes_transferred, b
 // HttpServer Implementation
 // ============================================================================
 
-HttpServer::HttpServer(HttpServerConfig config) : config_(std::move(config)), ioc_(1), acceptor_(ioc_) {}
+HttpServer::HttpServer(HttpServerConfig config)
+    : config_(std::move(config)), ioc_(1), acceptor_(ioc_), db_client_(std::make_unique<DatabaseClient>(config_)) {}
 
 HttpServer::~HttpServer() {
     if (running_.load()) {
@@ -450,7 +451,7 @@ HttpServer::~HttpServer() {
     }
 }
 
-auto HttpServer::Start() -> bool {
+auto HttpServer::Start(bool require_database) -> bool {
     if (running_.load()) {
         spdlog::warn("Server already running");
         return true;
@@ -458,6 +459,21 @@ auto HttpServer::Start() -> bool {
 
     spdlog::info("Starting HTTP server on {}", config_.GetListenAddress());
 
+    // Connect to database first (fail-fast if required)
+    if (require_database) {
+        spdlog::info("Connecting to database at {} (fail-fast mode enabled)", config_.database_address);
+        if (!ConnectToDatabase()) {
+            spdlog::error("Failed to connect to database - server startup aborted");
+            return false;
+        }
+    } else {
+        // Try to connect but don't fail if unavailable
+        spdlog::info("Connecting to database at {} (optional mode)", config_.database_address);
+        if (!ConnectToDatabase()) {
+            spdlog::warn("Database connection failed - server will start without database connectivity");
+        }
+    }
+
     try {
         auto const kAddress = asio::ip::make_address(config_.address);
         tcp::endpoint endpoint{kAddress, config_.port};
@@ -497,6 +513,8 @@ auto HttpServer::Start() -> bool {
         spdlog::info("HTTP server started successfully on {}", config_.GetListenAddress());
         spdlog::info("WebSocket endpoint: ws://{}{}", config_.GetListenAddress(), config_.ws_path);
         spdlog::info("Static files: {}", config_.webui_path);
+        spdlog::info("Database: {} ({})", config_.database_address,
+                     IsDatabaseConnected() ? "connected" : "not connected");
 
         return true;
     } catch (const std::exception& e) {
@@ -514,6 +532,11 @@ void HttpServer::Stop() {
 
     running_.store(false);
 
+    // Disconnect from database
+    if (db_client_) {
+        db_client_->Disconnect();
+    }
+
     // Close all WebSocket sessions
     {
         std::lock_guard<std::mutex> lock(ws_sessions_mutex_);
@@ -613,4 +636,29 @@ void HttpServer::OnWebSocketDisconnect(const std::shared_ptr<WebSocketSession>&
     spdlog::info("WebSocket client disconnected (total: {})", ws_sessions_.size());
 }
 
+auto HttpServer::IsDatabaseConnected() const -> bool {
+    return db_client_ && db_client_->IsConnected();
+}
+
+auto HttpServer::ConnectToDatabase() -> bool {
+    if (!db_client_) {
+        db_client_ = std::make_unique<DatabaseClient>(config_);
+    }
+
+    // Try to connect
+    if (!db_client_->Connect()) {
+        return false;
+    }
+
+    // Perform a health check to verify the connection is working
+    auto health = db_client_->HealthCheck();
+    if (!health.healthy) {
+        spdlog::error("Database health check failed: {}", health.message);
+        return false;
+    }
+
+    spdlog::info("Database health check passed (latency: {}ms)", health.latency.count());
+    return true;
+}
+
 }  // namespace smartbotic::webserver