diff --git a/.github/WORKFLOW_GUIDELINES.md b/.github/WORKFLOW_GUIDELINES.md index 1f71ba16a7..c66d328e19 100644 --- a/.github/WORKFLOW_GUIDELINES.md +++ b/.github/WORKFLOW_GUIDELINES.md @@ -4,7 +4,7 @@ Diese Richtlinie gilt fuer den schlanken, release-zentrierten Workflow-Kern. Die kanonische Liste aktiver Workflows steht in `.github/WORKFLOW_REGISTRY.md`. -## Aktive Workflows (43) +## Aktive Workflows (44) Die aktuelle kanonische Liste steht in `.github/WORKFLOW_REGISTRY.md`; der alte 21er-Stand war veraltet und wird hier durch den aktuellen, im Repository geltenden Zustand ersetzt. Kernliste der aktiven Workflows: @@ -28,6 +28,7 @@ Kernliste der aktiven Workflows: - `.github/workflows/compliance-supply-chain.yml` - `.github/workflows/build-ollama-router.yml` - `.github/workflows/gate-copilot-regression.yml` +- `.github/workflows/copilot-code-review.yml` - `.github/workflows/publish-wiki.yml` - `.github/workflows/release-docker-image.yml` - `.github/workflows/edition-hyperscaler-ci.yml` @@ -90,6 +91,9 @@ Kernliste der aktiven Workflows: - `concurrency` mit `cancel-in-progress` auf Push/PR-Workflows setzen. - Berechtigungen minimal halten (`permissions` least privilege). - Schwere Benchmark-, GPU- und Sweep-Jobs standardmaessig ueber `schedule` oder `workflow_dispatch` isolieren. +- Copilot-Review-Runner-Konfigurationen muessen als selbstbegrenzte Workflows + mit dem Jobnamen `copilot-setup-steps` und einem expliziten Ubuntu + `runs-on` deklariert werden. ## Security Guidelines - Keine Secrets im YAML oder in Shell-Skripten hardcoden. diff --git a/.github/WORKFLOW_REGISTRY.md b/.github/WORKFLOW_REGISTRY.md index 122df9e4b6..2aebae04dd 100644 --- a/.github/WORKFLOW_REGISTRY.md +++ b/.github/WORKFLOW_REGISTRY.md @@ -64,6 +64,8 @@ Signalqualität und Release-Stabilitaet zu verbessern. — Scoped CI fuer `tools/copilot-ollama-router/**` - `.github/workflows/gate-copilot-regression.yml` — Copilot/CMake-Regression Guard +- `.github/workflows/copilot-code-review.yml` + — Self-scoped Copilot review runner declaration (`copilot-setup-steps`) so agentic reviews have an assigned Ubuntu runner - `.github/workflows/publish-wiki.yml` — Publishes docs/architecture, docs/governance, src/*/ROADMAP.md and developer wiki to GitHub Wiki on push to develop or manual dispatch; community guardrail blocks private plugin paths @@ -117,7 +119,7 @@ Geplante Dateinamen-Harmonisierung (Soll-Format aus Workflow-Design): - `.github/docs/WORKFLOW_FILENAME_RENAME_MATRIX.md` ## Stand -- Aktive Workflows im Verzeichnis `.github/workflows/`: 43 +- Aktive Workflows im Verzeichnis `.github/workflows/`: 44 - Deaktivierte Workflows in `.github/no_workflows/`: 30 - Strategie: Lean + harte Triggergrenzen + Quarantaene fuer uebertriggernde CI - Der 21er-Zähler war im vorherigen Dokumentationsstand veraltet; der aktuelle Stand wird durch die kanonische Liste in diesem Registry-Dokument und die zugehörigen Workflow-Dateien definiert. diff --git a/.github/workflows/build-clang-fast.yml b/.github/workflows/build-clang-fast.yml index 0a967c1679..e5b85e02ed 100644 --- a/.github/workflows/build-clang-fast.yml +++ b/.github/workflows/build-clang-fast.yml @@ -42,7 +42,7 @@ jobs: submodules: false working_directory: . setup_command: >- - set -euo pipefail; sudo apt-get update -qq; sudo apt-get install -y --no-install-recommends clang lld cmake ninja-build pkg-config librocksdb-dev libgtest-dev libssl-dev zlib1g-dev libzstd-dev libfmt-dev libspdlog-dev nlohmann-json3-dev libtbb-dev libyaml-cpp-dev libmimalloc-dev libcurl4-openssl-dev libvulkan-dev glslc libcpp-httplib-dev libboost-system-dev libboost-filesystem-dev libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc libpugixml-dev + set -euo pipefail; sudo apt-get update -qq; sudo apt-get install -y --no-install-recommends clang lld cmake ninja-build pkg-config sccache librocksdb-dev libgtest-dev libssl-dev zlib1g-dev libzstd-dev libfmt-dev libspdlog-dev nlohmann-json3-dev libtbb-dev libyaml-cpp-dev libmimalloc-dev libcurl4-openssl-dev libvulkan-dev glslc libcpp-httplib-dev libboost-system-dev libboost-filesystem-dev libgrpc++-dev libprotobuf-dev protobuf-compiler-grpc libpugixml-dev configure_command: >- cmake -S . -B build-clang-fast -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_CXX_FLAGS="-Wall -Wextra -Wpedantic" -DTHEMIS_BUILD_BENCHMARKS=OFF -DTHEMIS_BUILD_TESTS=ON build_command: >- diff --git a/.github/workflows/copilot-code-review.yml b/.github/workflows/copilot-code-review.yml new file mode 100644 index 0000000000..77872cf858 --- /dev/null +++ b/.github/workflows/copilot-code-review.yml @@ -0,0 +1,32 @@ +name: Copilot code review setup + +on: + workflow_dispatch: + push: + branches: [develop, community] + paths: + - '.github/workflows/copilot-code-review.yml' + pull_request: + branches: [develop, community] + paths: + - '.github/workflows/copilot-code-review.yml' + +permissions: + contents: read + +concurrency: + group: copilot-code-review-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The job name must remain `copilot-setup-steps` so Copilot can detect it. + copilot-setup-steps: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Confirm runner availability + run: echo "Copilot code review runner is configured." diff --git a/.gitmodules b/.gitmodules index ec5862616a..26bd4e0249 100644 --- a/.gitmodules +++ b/.gitmodules @@ -48,3 +48,9 @@ [submodule "projects/Themis.AdminTools.Shared"] path = projects/Themis.AdminTools.Shared url = https://github.com/makr-code/themisdb_admin_tools.git + +[submodule "plugins/themisdb_ethic_ai"] + path = plugins/themisdb_ethic_ai + url = https://github.com/makr-code/themisdb_ethic_ai.git + branch = develop + commit = ce401ad9d604012a2c02655e79f5c17f57a9f82d diff --git a/ROADMAP.md b/ROADMAP.md index 4d200ffd90..2c43872006 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,14 +36,15 @@ ThemisDB is a high-performance multi-model database with native AI/LLM integrati |---|---|---|---| | core | 9 listed | Mostly DOC / evidence gaps | Runtime adapter registry and plugin loading are delivered; remaining items are Wave D operability and refreshed evidence | | base | 8 listed | Mostly historical scanner noise | `src/base/MODULE_GAPS.md` re-scan shows 0 actionable current gaps; remaining items are documented false positives or follow-up docs | -| server | 4 residual source gaps | REAL IMPL gaps | gRPC-Web proxy is still UNIMPLEMENTED-only in fallback builds; time-series aggregate/retention providers and RoPE metrics still rely on degraded fallback paths; non-Linux MCP stdio transport remains unsupported | +| server | 1 residual source gap | MOSTLY REMEDIATED | gRPC-Web fallback builds now advertise an explicit fail-closed capability contract, RoPE DELETE now disables runtime config, and unsupported MCP stdio self-disables; remaining work is first-class time-series provider DI beyond degraded metadata signaling | | query | residual perf / validation follow-up | Mostly verification / perf gaps | Process-mining trace/pattern/ideal query paths and the three `ETHICS_*` runtime gaps were closed on 2026-08-31; remaining work is optimizer/federation hardening and benchmark evidence | | transaction | 19 listed | Mostly verification / benchmark evidence | Wave 4C code gaps are closed; build/run, chaos, and representative-hardware evidence remain open | | auth | 8+ listed | Mostly verification / perf follow-up | Wave 4B source gaps are closed; remaining work is Wave 8 tests, representative-hardware baselines, and protocol-matrix regressions | | LLM | 13 listed | MIXED | Major Wave 5 closures landed; remaining real gaps center on distributed collectives, multi-tenant isolation, and final cross-module speculative/TARG wiring | | RAG / LLM Wiki | 57 listed | Mostly perf / integration follow-up | BM25+, RRF, persistent cache, and real `LLMJudgeIntegration` path are implemented; remaining work is performance gates, Recall@k sign-off, Wikipedia ABI wiring, and entropy-bridge integration | -| GPU/CUDA | 21+53 listed | REAL IMPL gaps | CUDA/HIP kernel parity and representative-hardware validation remain open release blockers for acceleration-heavy paths | -| access_model | 21 listed | REAL IMPL gaps | Benchmarks and GATE-ACM-01..06 not yet implemented | +| GPU/CUDA | 21+53 listed | REAL IMPL gaps | Break-even routing now uses production build wiring plus explicit CPU/GPU profiling contracts, but CUDA/HIP kernel parity, unchecked-kernel-call closure, and representative-hardware validation remain open release blockers | +| storage | 11 real gaps after 2026-08-31 revalidation | MIXED | Backup restore fail-closed hardening, ggml bridge runtime wiring, `SecuritySignatureManager` null-backend fail-closed behavior, and remote S3/GCS/Azure manifest transport are in place; biggest remaining gaps are long-run validation evidence and cloud-backend hardening follow-up | +| access_model | roadmap contradiction | Mostly DOC drift | Source and module evidence show Phase 5-6 observability, e2e/concurrency tests, and GATE-ACM-01..06 are complete; stale checklist/known-issues text must stay synchronized | ## Release Hardening Program (current canonical version: v2.4.0-alpha) @@ -71,7 +72,7 @@ ThemisDB is a high-performance multi-model database with native AI/LLM integrati - [x] Wave 8, chaos/fault-injection, sanitizer/recovery, penetration-test, and 99.99% SLA sign-off artefacts are closed: sanitizer evidence bundle at `docs/security/GA_SANITIZER_EVIDENCE_BUNDLE.md`; pentest evidence bundle at `security/pentest/GA_PENTEST_EVIDENCE_BUNDLE.md`; Wave 9 SLA/chaos gates PASS; final governance sign-off pending human approval at `docs/governance/GA_PROMOTION_SIGN_OFF.md`. - [x] Phase 1-6 execution contract complete: all technical gates PASS; human sign-off (Section 9 of `docs/governance/GA_PROMOTION_SIGN_OFF.md`) is the only remaining GA blocker. - [x] Tools build-option transition complete: canonical flag for desktop tools is `THEMIS_BUILD_TOOLS` (default `ON`); legacy alias removed. -- [~] Core-first residual source-gap queue revalidated: finish server runtime fallback gaps and query feature gaps first, then close LLM/RAG integration and GPU representative-hardware gates (Target: Q4 2026). +- [~] Core-first residual source-gap queue revalidated: finish the remaining server time-series provider DI gap and query feature gaps first, then close LLM/RAG integration and the remaining GPU parity / representative-hardware gates after the 2026-08-31 acceleration break-even hardening batch. (Target: Q4 2026). ## Program Execution Model (Wave A → B → C → D) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 58ea1ec0c7..4df4681c27 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -2909,6 +2909,7 @@ set(THEMIS_CORE_SOURCES ../src/acceleration/geo_acceleration_bridge.cpp ../src/acceleration/device_manager.cpp ../src/acceleration/vllm_resource_manager.cpp + ../src/acceleration/break_even_validator.cc # PERF-D3: Parallel batch insertion + SIMD distance pipeline ../src/acceleration/vec_knn.cpp # shader_integrity.cpp uses only OpenSSL (SHA-256) — no GPU/graphics dependency. diff --git a/cmake/ModularBuild.cmake b/cmake/ModularBuild.cmake index 0d5576eae2..e19ce06202 100644 --- a/cmake/ModularBuild.cmake +++ b/cmake/ModularBuild.cmake @@ -309,6 +309,7 @@ set(THEMIS_BASE_SOURCES ../src/acceleration/plugin_security.cpp ../src/acceleration/device_manager.cpp ../src/acceleration/vllm_resource_manager.cpp + ../src/acceleration/break_even_validator.cc ../src/acceleration/shader_integrity.cpp # PERF-D3: Parallel batch insertion + SIMD distance pipeline ../src/acceleration/vec_knn.cpp diff --git a/include/acceleration/break_even_validator.h b/include/acceleration/break_even_validator.h index 864002fc27..d061c8bd88 100644 --- a/include/acceleration/break_even_validator.h +++ b/include/acceleration/break_even_validator.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -124,10 +125,10 @@ struct BreakEvenDecision { float speedup_ratio = 0.0f; /// CPU path execution time (milliseconds) - std::chrono::milliseconds cpu_time_ms; + std::chrono::milliseconds cpu_time_ms{0}; /// GPU path execution time including transfer (milliseconds) - std::chrono::milliseconds gpu_time_ms; + std::chrono::milliseconds gpu_time_ms{0}; /// Human-readable reason: "break_even_met", "gpu_unavailable", etc. std::string reason; @@ -170,6 +171,11 @@ struct BreakEvenDecision { */ class BreakEvenValidator { public: + using ProfileFn = std::function( + const WorkloadProfile&)>; + using MetricsSinkFn = std::function; + /** * @brief Construct a new BreakEvenValidator instance. * @@ -197,7 +203,7 @@ class BreakEvenValidator { * @param profile Workload profile defining input characteristics * @return BreakEvenDecision with recommendation and metrics * - * @thread Fully thread-safe; protected by internal mutex + * @note Thread safety: Fully thread-safe; protected by internal mutex */ BreakEvenDecision ShouldUseGPU(const WorkloadProfile& profile); @@ -213,7 +219,7 @@ class BreakEvenValidator { * @param profile Workload profile to profile * @return BreakEvenDecision with fresh profiling results * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ BreakEvenDecision Profile(const WorkloadProfile& profile); @@ -226,7 +232,7 @@ class BreakEvenValidator { * @param kernel Kernel type to set threshold for * @param threshold Minimum speedup ratio (must be >= 1.0) * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ void SetSpeedupThreshold(KernelType kernel, float threshold); @@ -236,7 +242,7 @@ class BreakEvenValidator { * @param kernel Kernel type * @return Current threshold for this kernel (or default 1.5 if not set) * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ float GetSpeedupThreshold(KernelType kernel) const; @@ -245,7 +251,7 @@ class BreakEvenValidator { * * Next call to ShouldUseGPU() will trigger profiling (cache miss). * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ void ClearCache(); @@ -257,10 +263,51 @@ class BreakEvenValidator { * * @param duration Cache validity duration * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ void SetCacheValidityDuration(std::chrono::hours duration); + /** + * @brief Override CPU workload profiling with a caller-provided implementation. + * + * When set, ProfileCPU() delegates to @p fn instead of the built-in + * deterministic cost model. Passing an empty function restores the default + * CPU estimator. Any cached decisions are cleared so subsequent calls are + * re-profiled with the new behavior. + * + * @param fn CPU profiling callback, or empty to restore defaults + * + * @note Thread safety: Fully thread-safe + */ + void SetCPUProfileFn(ProfileFn fn); + + /** + * @brief Override GPU workload profiling with a caller-provided implementation. + * + * When set, ProfileGPU() delegates to @p fn instead of the built-in + * deterministic GPU estimate. Passing an empty function restores the default + * GPU estimator. Any cached decisions are cleared so subsequent calls are + * re-profiled with the new behavior. + * + * @param fn GPU profiling callback, or empty to restore defaults + * + * @note Thread safety: Fully thread-safe + */ + void SetGPUProfileFn(ProfileFn fn); + + /** + * @brief Register a metrics sink that receives every fresh profile decision. + * + * The sink is called after a non-cached profile completes successfully or + * degrades to CPU because GPU profiling is unavailable. Exceptions thrown by + * the sink are swallowed to preserve fail-closed decision behavior. + * + * @param fn Metrics callback, or empty to disable metrics emission + * + * @note Thread safety: Fully thread-safe + */ + void SetMetricsSink(MetricsSinkFn fn); + /** * @brief Get the latest break-even speedup ratio for a kernel type. * @@ -270,7 +317,7 @@ class BreakEvenValidator { * @param kernel Kernel type to query * @return Latest observed speedup ratio, or 0.0 if no data * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ float GetLatestBreakEvenRatio(KernelType kernel) const; @@ -279,7 +326,7 @@ class BreakEvenValidator { * * @return Number of ShouldUseGPU() calls that hit the cache * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ size_t GetCacheHitCount() const; @@ -288,7 +335,7 @@ class BreakEvenValidator { * * @return Number of ShouldUseGPU() calls that missed the cache * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ size_t GetCacheMissCount() const; @@ -297,7 +344,7 @@ class BreakEvenValidator { * * @return Number of cached decision entries * - * @thread Fully thread-safe + * @note Thread safety: Fully thread-safe */ size_t GetCacheSize() const; @@ -321,8 +368,8 @@ class BreakEvenValidator { /** * @brief Profile CPU execution time for the given workload. * - * Delegates to CPU reference kernel implementations to measure - * end-to-end execution time (no GPU transfer overhead). + * Uses a caller-provided profiling hook when configured; otherwise falls + * back to the built-in deterministic CPU cost model. * * @param profile Workload to profile on CPU * @return CPU execution time, or nullopt if profiling failed @@ -333,8 +380,10 @@ class BreakEvenValidator { /** * @brief Profile GPU execution time for the given workload. * - * Includes GPU allocation, data transfer, kernel execution, and sync time. - * Returns nullopt if GPU is unavailable or profiling fails. + * Uses a caller-provided profiling hook when configured; otherwise falls + * back to the built-in deterministic GPU estimate including transfer and + * launch overhead. Returns nullopt if the selected device cannot run GPU + * work or profiling fails. * * @param profile Workload to profile on GPU * @return GPU execution time (transfer + compute), or nullopt if GPU unavailable @@ -342,7 +391,11 @@ class BreakEvenValidator { std::optional ProfileGPU( const WorkloadProfile& profile); - + static bool RequiresVectorDimension(KernelType kernel); + static bool IsGpuCapableDevice(DeviceType device); + static double EstimateWorkUnits(const WorkloadProfile& profile); + static std::optional MillisecondsFromEstimate( + double estimated_ms); /** * @brief Parse KernelType from string. @@ -394,6 +447,11 @@ class BreakEvenValidator { // Cache configuration std::chrono::hours cache_validity_duration_; + + // Optional profiling/metrics hooks + ProfileFn cpu_profile_fn_; + ProfileFn gpu_profile_fn_; + MetricsSinkFn metrics_sink_; }; } // namespace acceleration diff --git a/include/index/vector_index.h b/include/index/vector_index.h index c55030ad76..56b42586ff 100644 --- a/include/index/vector_index.h +++ b/include/index/vector_index.h @@ -39,7 +39,9 @@ namespace utils { class AuditLogger; } -/// VectorIndexManager +/// @brief Manages a vector index namespace backed by RocksDB with optional HNSW ANN acceleration. +/// +/// VectorIndexManager supports: /// - Optional HNSWlib-Unterstützung (compile-time) /// - Fallback: Brute-Force (L2/Cosine) über in-memory Cache oder RocksDB-Scan /// - Persistenz: Vektoren liegen in RocksDB unter Namespace objectName:pk als BaseEntity @@ -58,36 +60,50 @@ class VectorIndexManager { public: enum class Metric { L2, COSINE, DOT }; + /// @brief Result of a vector index operation; carries ok/error state and message. struct Status { bool ok = true; std::string message; + /// @brief Returns a successful Status. static Status OK() { return {}; } + /// @brief Returns an error Status with the given message. + /// @param msg Human-readable error description. + /// @return Status with ok=false and the provided message. static Status Error(std::string msg) { return Status{false, std::move(msg)}; } }; + /// @brief A single KNN result entry holding the primary key and distance to the query vector. struct Result { std::string pk; float distance = 0.0f; // kleiner = besser (für COSINE: 1 - cosine) }; + /// @brief Constructs a VectorIndexManager bound to the given RocksDB wrapper. + /// @param db Reference to the RocksDB wrapper used for persistence. explicit VectorIndexManager(RocksDBWrapper& db); + /// @brief Destructor; saves the index if auto-save is enabled. ~VectorIndexManager() noexcept; - // Phase 1: Set optional audit logger for tracking vector operations + /// @brief Sets the optional audit logger for tracking vector operations. + /// @param logger Shared audit logger instance; pass nullptr to disable. + /// @param user_context User identifier attached to audit log entries. void setAuditLogger(std::shared_ptr logger, std::string user_context = "system"); - // Set user context for audit logging + /// @brief Sets the user context used in audit log entries. + /// @param user_id Identifier of the acting user. void setUserContext(std::string user_id); - // Phase 4: Set optional expression evaluator for advanced filtering + /// @brief Sets the optional expression evaluator used for advanced candidate filtering. + /// @param evaluator Shared evaluator instance; pass nullptr to disable. void setExpressionEvaluator(std::shared_ptr evaluator); - // Get expression evaluator + /// @brief Returns the currently configured expression evaluator, or nullptr if none is set. std::shared_ptr getExpressionEvaluator() const; - // Advanced Vector Index Integration (v1.5.0+) - // Enable FAISS-based advanced indexing (IVF+PQ/HNSW) for large-scale datasets - // Note: Requires THEMIS_GPU_ENABLED for FAISS/DiskANN support + /// @brief Configuration for the optional advanced (FAISS/DiskANN/ScaNN) index backend. + /// + /// Enable FAISS-based advanced indexing (IVF+PQ/HNSW) for large-scale datasets. + /// @note Requires THEMIS_GPU_ENABLED for FAISS/DiskANN support. struct AdvancedIndexConfig { bool enabled = false; // Enable advanced indexing size_t nlist = 1024; // Number of IVF clusters @@ -117,33 +133,45 @@ class VectorIndexManager { size_t diskann_cache_mb = 1024; // RAM cache budget in MiB }; - // Enable advanced indexing with specified configuration - // Must be called before init() to take effect + /// @brief Applies an advanced index configuration. + /// @param config Configuration to activate; must be called before init() to take effect. Status setAdvancedIndexConfig(const AdvancedIndexConfig& config); - // Get current advanced index configuration + /// @brief Returns the current advanced index configuration. AdvancedIndexConfig getAdvancedIndexConfig() const { return advanced_config_; } - // Check if advanced indexing is enabled and available + /// @brief Returns true when the advanced ANN backend is enabled and initialised. bool isAdvancedIndexEnabled() const { return (advanced_config_.enabled && advanced_index_ != nullptr) || ann_backend_ != nullptr; } - // Initialisierung eines Index-Namespace (z. B. "documents"): Dimension, M/ef, Metrik + /// @brief Initialises an index namespace with the given parameters. + /// @param objectName Namespace prefix used as the RocksDB key prefix. + /// @param dim Vector dimension. + /// @param metric Distance metric (L2, COSINE, DOT). + /// @param M HNSW M parameter (graph connections per node). + /// @param efConstruction HNSW efConstruction parameter. + /// @param efSearch HNSW efSearch parameter. + /// @param savePath Optional on-disk directory for auto-save. Status init(std::string_view objectName, int dim, Metric metric = Metric::COSINE, int M = 16, int efConstruction = 200, int efSearch = 64, const std::string& savePath = ""); - // Lifecycle-Management + /// @brief Sets the auto-save path and enables or disables automatic saving on shutdown. + /// @param savePath Directory path for index persistence. + /// @param autoSave When true, the index is saved automatically during shutdown(). void setAutoSavePath(const std::string& savePath, bool autoSave = true); + /// @brief Shuts down the index, saving it if auto-save is enabled. Status shutdown(); // Speichert Index wenn auto_save aktiviert - // HNSW Parameter zur Laufzeit anpassen (nur efSearch; M/efConstruction erfordern Rebuild) + /// @brief Adjusts the efSearch parameter at runtime (without rebuilding the index). + /// @param efSearch New efSearch value; larger values improve recall at the cost of speed. Status setEfSearch(int efSearch); - // Index aus Storage aufbauen (scannt Prefix objectName:) — optional + /// @brief Rebuilds the HNSW index from storage by scanning the objectName: key prefix. + /// @return Status indicating success or failure of the rebuild. Status rebuildFromStorage(); // ===== Incremental Re-indexing ===== @@ -158,7 +186,7 @@ class VectorIndexManager { bool full_rebuild_triggered = false; ///< True when auto full-rebuild ran }; - /// Incremental re-index: sync the HNSW index with current storage state + /// @brief Incrementally re-indexes the HNSW index by syncing with current storage state /// without performing a full rebuild. /// /// Compares in-memory index state against storage and: @@ -179,30 +207,68 @@ class VectorIndexManager { float rebuild_threshold = 0.20f, std::string_view vectorField = "embedding"); - // Persistenz (optional, nur wenn HNSW aktiv): speichert Index + Mapping + Metadaten im Verzeichnis + /// @brief Persists the HNSW index, mapping, and metadata to the given directory. + /// @param directory Target directory; created if it does not exist. + /// @return Status indicating success or failure of the save operation. Status saveIndex(const std::string& directory) const; + /// @brief Loads an HNSW index from the given directory. + /// @param directory Source directory containing index files written by saveIndex(). + /// @return Status indicating success or failure of the load operation. Status loadIndex(const std::string& directory); - // CRUD (Standard: direktes Commit) + /// @brief Adds an entity to the index using a direct commit. + /// @param e Entity whose vector field is indexed. + /// @param vectorField Name of the vector field within the entity (default: "embedding"). Status addEntity(const BaseEntity& e, std::string_view vectorField = "embedding"); + /// @brief Updates an existing entity in the index using a direct commit. + /// @param e Entity with updated vector data. + /// @param vectorField Name of the vector field within the entity. Status updateEntity(const BaseEntity& e, std::string_view vectorField = "embedding"); + /// @brief Removes an entity from the index by primary key using a direct commit. + /// @param pk Primary key of the entity to remove. + /// @return Status indicating success or failure of the removal. Status removeByPk(std::string_view pk); - // CRUD für Transaktionen: nutzen bestehende WriteBatch + /// @brief Adds an entity to the index within an existing WriteBatch transaction. + /// @param e Entity whose vector field is indexed. + /// @param batch WriteBatch to accumulate the write into. + /// @param vectorField Name of the vector field within the entity. Status addEntity(const BaseEntity& e, RocksDBWrapper::WriteBatchWrapper& batch, std::string_view vectorField = "embedding"); + /// @brief Updates an entity in the index within an existing WriteBatch transaction. + /// @param e Entity with updated vector data. + /// @param batch WriteBatch to accumulate the write into. + /// @param vectorField Name of the vector field within the entity. Status updateEntity(const BaseEntity& e, RocksDBWrapper::WriteBatchWrapper& batch, std::string_view vectorField = "embedding"); + /// @brief Removes an entity from the index within an existing WriteBatch transaction. + /// @param pk Primary key of the entity to remove. + /// @param batch WriteBatch to accumulate the delete into. + /// @return Status indicating success or failure of the removal. Status removeByPk(std::string_view pk, RocksDBWrapper::WriteBatchWrapper& batch); - // MVCC Transaction Varianten + /// @brief Adds an entity to the index within an MVCC TransactionWrapper. + /// @param e Entity whose vector field is indexed. + /// @param txn Active MVCC transaction. + /// @param vectorField Name of the vector field within the entity. Status addEntity(const BaseEntity& e, RocksDBWrapper::TransactionWrapper& txn, std::string_view vectorField = "embedding"); + /// @brief Updates an entity in the index within an MVCC TransactionWrapper. + /// @param e Entity with updated vector data. + /// @param txn Active MVCC transaction. + /// @param vectorField Name of the vector field within the entity. Status updateEntity(const BaseEntity& e, RocksDBWrapper::TransactionWrapper& txn, std::string_view vectorField = "embedding"); + /// @brief Removes an entity from the index within an MVCC TransactionWrapper. + /// @param pk Primary key of the entity to remove. + /// @param txn Active MVCC transaction. + /// @return Status indicating success or failure of the removal. Status removeByPk(std::string_view pk, RocksDBWrapper::TransactionWrapper& txn); - // KNN-Suche; optional Whitelist von PKs für hybrides Pre-Filtering + /// @brief Finds the k nearest neighbours of the query vector. + /// @param query Query vector; must match the index dimension. + /// @param k Number of results to return. + /// @param whitelistPks Optional set of PKs to restrict the search to (pre-filtering). std::pair> searchKnn( const std::vector& query, size_t k, @@ -223,8 +289,7 @@ class VectorIndexManager { const std::vector* whitelistPks = nullptr ) const; - // KNN-Suche mit Attribut-Filter (Post-Filtering) - // Filtert Ergebnisse basierend auf Entity-Attributen nach HNSW-Suche + /// @brief Simple post-filter applied to KNN results based on entity attribute equality. struct AttributeFilter { std::string field; std::string value; @@ -236,6 +301,7 @@ class VectorIndexManager { #ifdef IN #undef IN #endif + /// @brief Extended attribute filter with range, set, and comparison operators for pre-filtering via SecondaryIndex. struct AttributeFilterV2 { std::string field; enum class Op { @@ -255,18 +321,35 @@ class VectorIndexManager { std::string value_min; // For RANGE operator std::string value_max; // For RANGE operator - // Convenience constructors + /// @brief Creates an equality filter matching @p field == @p value. + /// @param field Attribute field name to match against. + /// @param value Expected value for the equality check. + /// @return AttributeFilterV2 configured for equality matching. static AttributeFilterV2 Equals(std::string field, std::string value) { return {std::move(field), Op::EQUALS, std::move(value), {}, "", ""}; } + /// @brief Creates a range filter matching @p min <= @p field <= @p max. + /// @param field Attribute field name to apply the range to. + /// @param min Lower bound of the range (inclusive). + /// @param max Upper bound of the range (inclusive). + /// @return AttributeFilterV2 configured for range matching. static AttributeFilterV2 Range(std::string field, std::string min, std::string max) { return {std::move(field), Op::RANGE, "", {}, std::move(min), std::move(max)}; } + /// @brief Creates a set-membership filter matching @p field in @p vals. + /// @param field Attribute field name to check membership for. + /// @param vals Set of accepted values. + /// @return AttributeFilterV2 configured for set-membership matching. static AttributeFilterV2 In(std::string field, std::vector vals) { return {std::move(field), Op::IN, "", std::move(vals), "", ""}; } }; + /// @brief KNN search with post-filtering based on entity attributes. + /// @param query Query vector. + /// @param k Number of results to return. + /// @param filters Attribute filters applied after HNSW search. + /// @param candidateMultiplier Fetch k*multiplier candidates from HNSW before filtering. std::pair> searchKnnFiltered( const std::vector& query, size_t k, @@ -274,9 +357,11 @@ class VectorIndexManager { size_t candidateMultiplier = 3 // Fetch k*multiplier from HNSW, then filter ) const; - // KNN-Suche mit Pre-Filtering via SecondaryIndexManager - // Generiert Whitelist aus SecondaryIndex-Scans, dann HNSW mit Whitelist - // Benötigt SecondaryIndexManager-Pointer (optional dependency) + /// @brief KNN search with pre-filtering via SecondaryIndexManager. + /// @param query Query vector. + /// @param k Number of results to return. + /// @param filters Attribute filters used to generate a PK whitelist. + /// @param secondaryIdx Optional SecondaryIndexManager for whitelist generation. std::pair> searchKnnPreFiltered( const std::vector& query, size_t k, @@ -326,11 +411,14 @@ class VectorIndexManager { /// Update multiple entities in single batch Status updateBatch(const std::vector& entities, std::string_view vectorField = "embedding"); - /// Remove multiple entities by PKs in single batch + /// @brief Removes multiple entities by primary key in a single batch. + /// @param pks List of primary keys to remove. + /// @return Status indicating success or failure of the batch removal. Status removeBatch(const std::vector& pks); // ===== Vector Statistics & Aggregation ===== - + + /// @brief Distance distribution and count statistics for the index. struct Statistics { size_t vector_count = 0; int dimension = 0; @@ -344,10 +432,12 @@ class VectorIndexManager { /// Get index statistics (distance distribution, vector count, etc.) std::pair getStatistics() const; - /// Compute centroid (mean vector) of all vectors in index + /// @brief Computes the centroid (mean vector) of all vectors in the index. + /// @return Pair of Status and the centroid vector; Status is error if the index is empty. std::pair> computeCentroid() const; - /// Compute variance per dimension + /// @brief Computes per-dimension variance across all vectors in the index. + /// @return Pair of Status and the per-dimension variance vector. std::pair> computeVariance() const; /// Find outlier vectors (those far from centroid) @@ -368,7 +458,7 @@ class VectorIndexManager { bool isQuantizationEnabled() const { return quantization_enabled_; } bool isQuantizerTrained() const; - /// Get quantization statistics + /// @brief Product quantization state and compression statistics. struct QuantizationStats { bool enabled = false; bool trained = false; @@ -378,48 +468,72 @@ class VectorIndexManager { }; QuantizationStats getQuantizationStats() const; - // Getter für Konfiguration & Statistiken + /// @brief Returns the index namespace (object name). const std::string& getObjectName() const { return objectName_; } + /// @brief Returns the vector dimension. int getDimension() const { return dim_; } + /// @brief Returns the configured distance metric. Metric getMetric() const { return metric_; } + /// @brief Returns the current efSearch parameter. int getEfSearch() const { return efSearch_; } + /// @brief Returns the HNSW M parameter. int getM() const { return m_; } + /// @brief Returns the HNSW efConstruction parameter. int getEfConstruction() const { return efConstruction_; } + /// @brief Returns the number of indexed vectors. size_t getVectorCount() const { if (useHnsw_ || ann_backend_ != nullptr) { return pkToId_.size(); } return cache_.size(); } + /// @brief Returns true when the HNSW index is active. bool isHnswEnabled() const { return useHnsw_; } + /// @brief Returns the configured on-disk save path. const std::string& getSavePath() const { return savePath_; } /// Get vector by primary key (for searchById support) /// Returns nullopt if vector doesn't exist std::optional> getVectorByPk(std::string_view pk) const; - // Encryption configuration (Phase 1) + /// @brief Returns true when per-vector encryption is enabled. bool isVectorEncryptionEnabled() const; + /// @brief Enables or disables per-vector encryption. void setVectorEncryptionEnabled(bool enabled); + /// @brief Returns the key ID used for vector encryption. const std::string& getVectorKeyId() const { return vectorKeyId_; } + /// @brief Sets the key ID used for vector encryption. void setVectorKeyId(const std::string& keyId) { vectorKeyId_ = keyId; } - // Phase 2: HNSW index encryption + /// @brief Returns true when HNSW index encryption is enabled. bool isHnswEncryptionEnabled() const; + /// @brief Enables or disables HNSW index encryption. void setHnswEncryptionEnabled(bool enabled); + /// @brief Returns the key ID used for HNSW index encryption. const std::string& getHnswKeyId() const { return hnswKeyId_; } + /// @brief Sets the key ID used for HNSW index encryption. void setHnswKeyId(const std::string& keyId) { hnswKeyId_ = keyId; } - // Phase 4: HNSW Layer Optimizer access + /// @brief Returns the HNSW layer optimizer, or nullptr if not configured. HnswLayerOptimizer* getHnswOptimizer() const { return hnsw_optimizer_.get(); } - // Flush pending encrypted writes (Phase 1 batching) + /// @brief Flushes any pending encrypted writes from the internal batch buffer. void flushEncryptedWrites() const; // ===== Rotary Embeddings Support ===== /// Enable/disable rotary embeddings with configuration Status setRotaryEmbeddingConfig(const struct RotationConfig& config); + + /** + * @brief Disable rotary embeddings for subsequent vector operations. + * + * Clears the active RoPE configuration and resets runtime counters. Calls + * that require rotary embeddings will fail closed after this method returns. + * + * @return OK when RoPE was disabled, or an error when RoPE is already off. + */ + Status disableRotaryEmbedding(); /// Check if rotary embeddings are enabled bool isRotaryEmbeddingEnabled() const { return rotary_enabled_; } @@ -437,16 +551,22 @@ class VectorIndexManager { /// Get runtime RoPE stats. Returns nullopt when RoPE is disabled. std::optional getRotaryEmbeddingStats() const; - /// Add entity with automatic positional rotation - /// The embedding is rotated based on the position parameter before storage + /// @brief Adds an entity with automatic positional rotation applied to its embedding. + /// @param e Entity whose vector field is indexed. + /// @param vectorField Name of the vector field within the entity. + /// @param position Position index used to compute the rotation angle. + /// @return Status indicating success or failure. Status addEntityWithRotation( const BaseEntity& e, std::string_view vectorField, size_t position ); - /// Add entity with relational rotation (for Knowledge Graph edges) - /// The embedding is rotated based on the relation type + /// @brief Adds an entity with relational rotation for Knowledge Graph edges. + /// @param e Entity whose vector field is indexed. + /// @param vectorField Name of the vector field within the entity. + /// @param relation_type Relation type identifier used to compute the rotation. + /// @return Status indicating success or failure. Status addEntityWithRelationalRotation( const BaseEntity& e, std::string_view vectorField, diff --git a/include/server/mcp_server.h b/include/server/mcp_server.h index 17fc58079b..bb2463d2aa 100644 --- a/include/server/mcp_server.h +++ b/include/server/mcp_server.h @@ -66,16 +66,17 @@ class PromptManager; } /** - * @brief MCP (Model Context Protocol) Server Implementation - * + * @class McpServer + * @brief MCP (Model Context Protocol) Server Implementation. + * * Provides LLM integration for ThemisDB through the Model Context Protocol. * Supports multiple transports: stdio, SSE (Server-Sent Events), and WebSocket. - * + * * Architecture: * - Tools: Database operations exposed as callable LLM tools * - Resources: Read-only context (schema, stats, metadata) * - Prompts: Query templates for common operations - * + * * @see MCP_PROTOCOL_SUPPORT.md (path relative to project root: docs/apis/MCP_PROTOCOL_SUPPORT.md) */ class McpServer : public std::enable_shared_from_this { @@ -116,32 +117,81 @@ class McpServer : public std::enable_shared_from_this { int websocket_ping_interval_ms = 30000; }; + /** + * @brief Construct an MCP server with default configuration. + * @param io_context Asio I/O context for async operations. + */ explicit McpServer(asio::io_context& io_context); + /** + * @brief Construct an MCP server with explicit configuration. + * @param io_context Asio I/O context for async operations. + * @param config Server configuration controlling transports and buffers. + */ explicit McpServer(asio::io_context& io_context, const Config& config); + /** @brief Destroy the server and release all transport resources. */ ~McpServer(); - // Lifecycle + /** @brief Start all enabled transports and begin accepting requests. */ void start(); + /** @brief Stop all transports and release associated resources. */ void stop(); + /** @brief Return true if the server is currently running. */ bool isRunning() const { return is_running_.load(std::memory_order_acquire); } - // Tool registration + /** + * @brief Register a tool that can be invoked by an LLM client. + * @param name Unique tool name exposed via MCP tools/list. + * @param description Human-readable description of the tool's purpose. + * @param input_schema JSON Schema object describing accepted arguments. + * @param handler Callable invoked when the tool is called. + */ void registerTool(const std::string& name, const std::string& description, const json& input_schema, ToolHandler handler); + /** + * @brief Unregister a previously registered tool by name. + * @param name Tool name to remove. + */ void unregisterTool(const std::string& name); - // Resource registration + /** + * @brief Register a read-only resource accessible to LLM clients. + * @param uri Resource URI used to address the resource. + * @param description Human-readable description of the resource. + * @param mime_type MIME type of the returned content. + * @param handler Callable that returns the resource content for a given URI. + */ void registerResource(const std::string& uri, const std::string& description, const std::string& mime_type, ResourceHandler handler); + /** + * @brief Unregister a previously registered resource by URI. + * @param uri Resource URI to remove. + */ void unregisterResource(const std::string& uri); - // Prompt registration + /** + * @brief Register a prompt template for LLM clients. + * @param name Unique prompt name exposed via MCP prompts/list. + * @param description Human-readable description of the prompt. + * @param arguments_schema JSON Schema describing the prompt's arguments. + * @param handler Callable that produces prompt messages for the given name and args. + */ void registerPrompt(const std::string& name, const std::string& description, const json& arguments_schema, PromptHandler handler); + /** + * @brief Unregister a previously registered prompt by name. + * @param name Prompt name to remove. + */ void unregisterPrompt(const std::string& name); - // Transport management + /** + * @brief Attach an HTTP server for SSE and WebSocket transports. + * @param http_server Shared pointer to the HTTP server instance. + */ void attachHttpServer(std::shared_ptr http_server); + /** + * @brief Attach the primary database backend for default tool handlers. + * @param db Shared pointer to the RocksDB wrapper. + */ void attachDatabase(std::shared_ptr db); /** @@ -173,11 +223,18 @@ class McpServer : public std::enable_shared_from_this { #ifdef THEMIS_ENABLE_LLM void attachOrchestrator(std::shared_ptr orchestrator); #endif + /** @brief Get the stdio transport instance (may be null if stdio is disabled). */ std::shared_ptr getStdioTransport() const { return stdio_transport_; } + /** @brief Get the SSE transport instance (may be null if SSE is disabled). */ std::shared_ptr getSseTransport() const { return sse_transport_; } + /** @brief Get the WebSocket transport instance (may be null if WebSocket is disabled). */ std::shared_ptr getWebSocketTransport() const { return ws_transport_; } - // Request handling + /** + * @brief Dispatch an incoming MCP JSON-RPC request to the appropriate handler. + * @param request JSON-RPC request object. + * @return JSON-RPC response object. + */ json handleRequest(const json& request); private: @@ -420,6 +477,7 @@ class StdioTransport : public McpTransport, public std::enable_shared_from_this< void start() override; void stop() override; void send(const json& message) override; + [[nodiscard]] bool isRunning() const noexcept { return is_running_.load(std::memory_order_acquire); } // Bridge callback for exotic/embedded platforms that lack _WIN32, __unix__, // and __APPLE__ (STUB #65). When set, the injected function is called from diff --git a/include/storage/backup_manager.h b/include/storage/backup_manager.h index 3ca17630c3..96c223d7c3 100644 --- a/include/storage/backup_manager.h +++ b/include/storage/backup_manager.h @@ -28,7 +28,6 @@ namespace themis { class RocksDBWrapper; /** - * @enum RAIDMode * @brief Supported redundancy layouts for backup coordination. * @ingroup storage */ @@ -66,7 +65,6 @@ struct RAIDConfig { }; /** - * @enum CompressionType * @brief Compression modes supported by backup creation and restore flows. * @ingroup storage */ @@ -94,7 +92,6 @@ struct FileIntegrityInfo { }; /** - * @enum StorageBackend * @brief Transport backends available to backup upload and restore operations. * @ingroup storage */ @@ -561,9 +558,18 @@ class BackupManager { * - `StorageBackend::LOCAL`: local filesystem mirror via `file:///absolute/path` * or an absolute path. * - `StorageBackend::S3`: `s3://bucket/path` (requires provider integration). - * - `StorageBackend::AZURE`: `azure://account/container/path` (requires provider integration). + * - `StorageBackend::AZURE`: `azure://account/container/path` or + * `azure://container/path`. When the account segment is omitted, the + * runtime derives the service endpoint from the configured Azure + * connection string / environment. * - `StorageBackend::GCS`: `gs://bucket/path` (requires provider integration). * + * Remote transfers serialize the backup as a manifest plus per-file payload + * objects beneath the requested provider URI so that restore can reconstruct + * the directory tree without relying on provider-side object listing. + * Individual remote payload objects currently transfer in-memory and are + * rejected when they exceed 256 MiB. + * * @param local_backup_path Existing local backup directory or archive. * @param cloud_uri Provider URI or local mirror path, depending on @p options.storage. * @param options Transfer options, backend selection, and provider-specific configuration. @@ -579,8 +585,10 @@ class BackupManager { * * For `StorageBackend::LOCAL`, the method copies the source tree from a * `file:///absolute/path` URI or absolute path into @p local_restore_path. - * Remote backends validate the URI format and then delegate to the matching - * provider integration when available. + * Remote backends fetch a manifest object plus the referenced payload + * objects from the provider URI and reconstruct the original backup tree + * locally. Individual remote payload objects currently transfer in-memory + * and are rejected when they exceed 256 MiB. * * @param cloud_uri Provider URI or local mirror path, depending on @p options.storage. * @param local_restore_path Local destination directory for the restored payload. @@ -898,13 +906,12 @@ class BackupManager { * @param cloud_path Provider URI or local mirror path. * @param backend Transport backend to use. * @param config Provider-specific configuration values. - * @param ec Receives the failure reason when the operation returns `false`. - * @return `true` on success, or `false` when validation or transfer fails. + * @return `Result` on success, or an error when validation, manifest + * generation, upload, or cleanup fails. */ - bool uploadToCloud(const std::string& local_path, const std::string& cloud_path, - StorageBackend backend, - const std::map& config, - std::error_code& ec); + Result uploadToCloud(const std::string& local_path, const std::string& cloud_path, + StorageBackend backend, + const std::map& config); /** * @brief Download or mirror a backup payload from the selected backend. @@ -913,13 +920,12 @@ class BackupManager { * @param local_path Local destination directory or archive path. * @param backend Transport backend to use. * @param config Provider-specific configuration values. - * @param ec Receives the failure reason when the operation returns `false`. - * @return `true` on success, or `false` when validation or transfer fails. + * @return `Result` on success, or an error when validation, manifest + * retrieval, payload download, or local reconstruction fails. */ - bool downloadFromCloud(const std::string& cloud_path, const std::string& local_path, - StorageBackend backend, - const std::map& config, - std::error_code& ec); + Result downloadFromCloud(const std::string& cloud_path, const std::string& local_path, + StorageBackend backend, + const std::map& config); /** * @brief Find the most recent full backup beneath a base directory. diff --git a/include/storage/security_signature_manager.h b/include/storage/security_signature_manager.h index a93279ba3f..1765ada3f7 100644 --- a/include/storage/security_signature_manager.h +++ b/include/storage/security_signature_manager.h @@ -27,7 +27,36 @@ namespace storage { /// Provides CRUD operations and file verification capabilities class SecuritySignatureManager { public: + /** + * @brief Construction-time options for storage-backed signature verification. + */ + struct Options { + /** + * @brief Allow an in-memory fallback store when RocksDB is unavailable. + * + * This is intended for focused tests or explicitly ephemeral workflows. + * Production callers should keep the default fail-closed behavior. + */ + bool allow_in_memory_fallback = false; + }; + + /** + * @brief Create a signature manager backed by RocksDB. + * + * @param db Persistent RocksDB wrapper. When null, the manager stays + * unavailable and all mutating operations fail closed. + */ explicit SecuritySignatureManager(std::shared_ptr db); + /** + * @brief Create a signature manager backed by RocksDB with explicit fallback policy. + * + * @param db Persistent RocksDB wrapper. When null and + * `options.allow_in_memory_fallback` is `false`, the manager stays + * unavailable and all mutating operations fail closed. + * @param options Construction-time fallback policy. + */ + explicit SecuritySignatureManager(std::shared_ptr db, + Options options); // CRUD Operations @@ -54,8 +83,13 @@ class SecuritySignatureManager { int total = 0; ///< Total signatures scanned int verified = 0; ///< Signatures whose files matched their stored hashes int failed = 0; ///< Signatures with hash mismatches or missing files + bool backend_available = true; ///< False when no persistent backend exists and fallback was not enabled + bool used_fallback_memory_store = false; ///< True when verification ran against the explicit in-memory fallback + std::string error_message; ///< Operator-facing reason when verification cannot execute std::vector failed_resource_ids; ///< resource_ids that failed verification - bool success() const { return failed == 0; } + bool success() const { + return backend_available && error_message.empty() && failed == 0; + } }; /// Verify all stored signatures by iterating over all document keys and @@ -68,6 +102,16 @@ class SecuritySignatureManager { /// Normalize resource identifier (resolve relative paths, symlinks) static std::string normalizeResourceId(const std::string& path); + + /// Return whether the manager is currently using the explicit in-memory fallback store. + [[nodiscard]] bool isUsingFallbackMemoryStore() const noexcept { + return use_fallback_memory_store_; + } + + /// Return whether a persistent RocksDB backend is available. + [[nodiscard]] bool hasPersistentBackend() const noexcept { + return static_cast(db_); + } private: std::shared_ptr db_; diff --git a/include/themis/base/interfaces/storage_interface.h b/include/themis/base/interfaces/storage_interface.h index 26d174fe10..cbdc369a70 100644 --- a/include/themis/base/interfaces/storage_interface.h +++ b/include/themis/base/interfaces/storage_interface.h @@ -94,7 +94,7 @@ class IStorageEngine { [[nodiscard]] virtual Result scanRange( [[maybe_unused]] std::string_view start_key, [[maybe_unused]] std::string_view end_key, - [[maybe_unused]] std::function callback) + [[maybe_unused]] std::function callback) { return ErrVoid(errors::ErrorCode::ERR_STORAGE_TRANSACTION_FAILED, "scanRange not implemented"); @@ -113,7 +113,7 @@ class IStorageEngine { */ [[nodiscard]] virtual Result scanPrefix( [[maybe_unused]] std::string_view prefix, - [[maybe_unused]] std::function callback) + [[maybe_unused]] std::function callback) { return ErrVoid(errors::ErrorCode::ERR_STORAGE_TRANSACTION_FAILED, "scanPrefix not implemented"); diff --git a/include/updates/parallel_downloader.h b/include/updates/parallel_downloader.h index ca3d85d765..19c58cced2 100644 --- a/include/updates/parallel_downloader.h +++ b/include/updates/parallel_downloader.h @@ -224,14 +224,14 @@ class ParallelDownloader { * std::string* out_error); */ using FetchFn = std::function; + const std::string& url, + const std::string& dest, + uint64_t resume_offset, + long connect_timeout_s, + long transfer_timeout_s, + uint64_t* out_bytes, + uint64_t* out_total, + std::string* out_error)>; void setFetchFunction(FetchFn fn); diff --git a/plugins/themisdb_importer b/plugins/themisdb_importer deleted file mode 160000 index 317add1f72..0000000000 --- a/plugins/themisdb_importer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 317add1f724d5f6b979883b85b3c530f6191804d diff --git a/plugins/themisdb_plugin_signer b/plugins/themisdb_plugin_signer deleted file mode 160000 index d333b666bd..0000000000 --- a/plugins/themisdb_plugin_signer +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d333b666bdebb01940b19f76ea986abbb0f42968 diff --git a/src/acceleration/CHANGELOG.md b/src/acceleration/CHANGELOG.md index e3d4d073e5..9141259192 100644 --- a/src/acceleration/CHANGELOG.md +++ b/src/acceleration/CHANGELOG.md @@ -31,6 +31,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Added explicit delete/delete copy/move constructors; `#include ` added. ### Changed +- `include/acceleration/break_even_validator.h`, `src/acceleration/break_even_validator.cc`, `cmake/CMakeLists.txt`, `cmake/ModularBuild.cmake`, and `tests/gpu/CMakeLists.txt`: promoted `BreakEvenValidator` from an uncompiled placeholder path to the production acceleration build, replaced hardcoded CPU/GPU timings with explicit profiling hooks plus deterministic fallback estimates, and retired fallback-only focused GPU test linkage. +- `tests/gpu/test_break_even_validation.cpp`: aligned regression coverage with production break-even behavior, including injected profiler hooks, metrics sink emission, and fail-closed invalid-profile handling. +- `src/acceleration/oneapi_backend.cpp`: fail-closed USM allocation path now throws `std::bad_alloc` before any `memcpy` when device allocations return `nullptr`. - Documentation governance sync: roadmap/future/audit/readme/architecture/security/performance docs aligned to source-verifiable statements; planning remains in roadmap/future and history remains in changelog. ### Documentation diff --git a/src/acceleration/MODULE_GAPS.md b/src/acceleration/MODULE_GAPS.md index a9af0ecb28..79f45b06f1 100644 --- a/src/acceleration/MODULE_GAPS.md +++ b/src/acceleration/MODULE_GAPS.md @@ -9,7 +9,7 @@ This file documents all documentation and code quality gaps in the **acceleratio - Quelle: `audit/MARKER_LOCATIONS_2026-08-31.md` - Ergebnis: **16 reale Gaps**, **26 Doku-Leaks** - Klassifikation: Doku-Leaks kommen aus auto-generierten `@note Gap Summary`-Headerzeilen und sind keine fehlende Produktionslogik. -- Real-Beispiel: `GAP-0200` → `src/acceleration/break_even_validator.cc:184` (// Export metrics (TODO: integrate with Prometheus)) +- Delta seit der 2026-08-31-Baseline: der reale `BreakEvenValidator`-Profiling-/Metrics-TODO in `src/acceleration/break_even_validator.cc` wurde am 2026-08-31 durch produktive Build-Wiring-, Hook- und Fail-Closed-Logik ersetzt; die undatierte Modul-Gap-Lage ist damit mindestens um diesen Einzelgap reduziert, auch wenn noch kein neuer Vollscan committed ist. - Doku-Leak-Beispiel: `GAP-0198` → `src/acceleration/ai_hardware_dispatcher.cpp:7` (* @note Gap Summary: total=7; TODO=1, Stub=4, Unimpl=0, Mock=1, Sim=1, Debt=0, C=28, H=49, M=2, L=0) - Korrespondierende Gesamtliste: `audit/MARKER_GAP_CLASSIFICATION_2026-08-31.md` diff --git a/src/acceleration/ROADMAP.md b/src/acceleration/ROADMAP.md index 58a76fa127..9e53a61da9 100644 --- a/src/acceleration/ROADMAP.md +++ b/src/acceleration/ROADMAP.md @@ -1,7 +1,7 @@ # Acceleration Module Roadmap - + @@ -65,6 +65,8 @@ All major GPU acceleration backends are now fully implemented and integrated: - [~] Build system fixes for orphaned test declarations (pre-existing CMakeLists.txt cleanup) - [~] Runtime capability hardening for fail-closed behavior under partial backend availability (Target: Q3 2026) - [x] 2026-08-19: `DeviceManager` now supports deterministic injected capability snapshots for focused validation while preserving CPU-fallback synthesis and fail-closed runtime selection semantics. (Target: Q3 2026) +- [x] 2026-08-31: `BreakEvenValidator` is now wired into the production acceleration build, uses hookable CPU/GPU profiling contracts plus deterministic fallback estimates instead of hardcoded placeholder timings, and focused GPU tests link the production implementation instead of the fallback-only shim. (Target: Q3 2026) +- [x] 2026-08-31: `oneapi_backend.cpp` now fail-closes on USM allocation failure before `memcpy`, turning the prior undefined OOM path into explicit `std::bad_alloc` handling. (Target: Q3 2026) - [~] Multi-device and resource-management reliability tuning under sustained load (Target: Q3 2026) - [~] **B-01 · CUDA vector similarity search** — replacing CPU HNSW fallback in `ai_hardware_dispatcher.cpp` and `vllm_resource_manager.cpp` with GPU kernel dispatch (Target: Q3 2026) @@ -167,19 +169,23 @@ All major GPU acceleration backends are now fully implemented and integrated: ### Phase 1: Design / API Contract - [~] Freeze backend capability/selection contract and fallback semantics for active major lines (Target: Q3 2026) - 2026-08-19: public `DeviceManager::setEnumerateFn()` test bridge added so capability negotiation can be verified without changing production discovery logic. -- [ ] Define explicit failure contracts for unavailable backend, invalid input, and integrity-check failure states (Target: Q3 2026) +- [~] Define explicit failure contracts for unavailable backend, invalid input, and integrity-check failure states (Target: Q3 2026) + - 2026-08-31: `BreakEvenValidator` now exposes explicit hook contracts for CPU/GPU profiling and fails closed on invalid distance/top-k profiles or non-GPU device selection. ### Phase 2: Core Implementation -- [ ] Complete hardening for capability-driven dispatch and deterministic fallback selection paths (Target: Q4 2026) +- [~] Complete hardening for capability-driven dispatch and deterministic fallback selection paths (Target: Q4 2026) + - 2026-08-31: break-even routing no longer depends on hardcoded CPU/GPU placeholder timings; the production implementation is compiled into both monolithic and modular acceleration builds. - [ ] Align multi-device and resource manager behavior to shared bounded execution contracts (Target: Q4 2026) ### Phase 3: Error Handling and Edge Cases -- [ ] Enforce fail-closed behavior for malformed workload input, plugin/signature failure, and partial device outages (Target: Q4 2026) +- [~] Enforce fail-closed behavior for malformed workload input, plugin/signature failure, and partial device outages (Target: Q4 2026) + - 2026-08-31: oneAPI USM allocation failures now fail closed before device copies, and break-even routing rejects malformed distance/top-k profiles instead of silently using placeholder timings. - [ ] Standardize fallback semantics when optional acceleration features are unavailable (Target: Q4 2026) ### Phase 4: Tests - [~] Expand focused regressions for backend matrix, plugin security, and fallback correctness (Target: Q4 2026) - 2026-08-19: `tests/test_device_manager.cpp` extended with injected-enumeration coverage for cache reuse, refresh re-probe, CPU-fallback synthesis, best-device selection, and log observability; focused test registration fixed in `tests/CMakeLists.txt`. + - 2026-08-31: `tests/gpu/test_break_even_validation.cpp` now covers production profiling hooks, metrics emission, and fail-closed invalid-profile handling. - [ ] Extend multi-device failure-injection regressions for merge and resource paths (Target: Q4 2026) ### Phase 5: Performance and Hardening diff --git a/src/acceleration/break_even_validator.cc b/src/acceleration/break_even_validator.cc index 8cdabdf19e..67978a104e 100644 --- a/src/acceleration/break_even_validator.cc +++ b/src/acceleration/break_even_validator.cc @@ -8,15 +8,149 @@ #include "acceleration/break_even_validator.h" #include +#include +#include #include -#include #include +#include +#include #include "fmt/format.h" namespace themis { namespace acceleration { +namespace { + +constexpr double kMinimumProfiledMs = 1.0; + +double clampSelectivity(float selectivity) { + return std::clamp(static_cast(selectivity), 0.001, 1.0); +} + +double normalizedDimension(size_t dimension) { + if (dimension == 0) { + return 1.0; + } + return std::max(1.0, static_cast(dimension) / 128.0); +} + +double log2Scaled(size_t value) { + return value > 1 ? std::log2(static_cast(value)) : 1.0; +} + +double cpuThroughputUnitsPerMs(KernelType kernel) { + switch (kernel) { + case KernelType::kDistance: + return 4'500.0; + case KernelType::kTopK: + return 20'000.0; + case KernelType::kBFS: + return 12'000.0; + case KernelType::kDijkstra: + return 8'000.0; + case KernelType::kGeoDistance: + return 16'000.0; + case KernelType::kGeoContainment: + return 9'500.0; + default: + return 0.0; + } +} + +double gpuThroughputUnitsPerMs(KernelType kernel, DeviceType device) { + const double device_factor = [&]() { + switch (device) { + case DeviceType::kNVIDIA_RTX: + return 1.0; + case DeviceType::kAMD_MI210: + return 0.9; + case DeviceType::kNVIDIA_T4: + return 0.6; + case DeviceType::kIntel_Arc: + return 0.45; + default: + return 0.0; + } + }(); + + const double base = [&]() { + switch (kernel) { + case KernelType::kDistance: + return 36'000.0; + case KernelType::kTopK: + return 70'000.0; + case KernelType::kBFS: + return 34'000.0; + case KernelType::kDijkstra: + return 23'000.0; + case KernelType::kGeoDistance: + return 42'000.0; + case KernelType::kGeoContainment: + return 27'000.0; + default: + return 0.0; + } + }(); + + return base * device_factor; +} + +double gpuLaunchOverheadMs(DeviceType device) { + switch (device) { + case DeviceType::kNVIDIA_RTX: + return 2.8; + case DeviceType::kAMD_MI210: + return 3.4; + case DeviceType::kNVIDIA_T4: + return 4.2; + case DeviceType::kIntel_Arc: + return 5.3; + default: + return std::numeric_limits::infinity(); + } +} + +double estimateTransferBytes(const WorkloadProfile& profile) { + const double input_size = static_cast(profile.input_size); + const double dimension = static_cast(std::max(profile.vector_dimension, 1)); + const double selectivity = clampSelectivity(profile.output_selectivity); + + switch (profile.kernel_type) { + case KernelType::kDistance: + return (input_size * dimension + dimension) * sizeof(float); + case KernelType::kTopK: + return (input_size * dimension + input_size * selectivity * 2.0) * sizeof(float); + case KernelType::kBFS: + return input_size * 6.0 * sizeof(std::uint32_t); + case KernelType::kDijkstra: + return input_size * 8.0 * sizeof(std::uint32_t); + case KernelType::kGeoDistance: + return input_size * 4.0 * sizeof(double); + case KernelType::kGeoContainment: + return input_size * 10.0 * sizeof(double); + default: + return 0.0; + } +} + +double effectiveBandwidthBytesPerMs(DeviceType device) { + switch (device) { + case DeviceType::kNVIDIA_RTX: + return 18'000'000.0; + case DeviceType::kAMD_MI210: + return 16'000'000.0; + case DeviceType::kNVIDIA_T4: + return 11'000'000.0; + case DeviceType::kIntel_Arc: + return 8'500'000.0; + default: + return 0.0; + } +} + +} // namespace + // ============================================================================ // Helper: WorkloadProfile // ============================================================================ @@ -31,11 +165,11 @@ std::string WorkloadProfile::ToCacheKey() const { std::string WorkloadProfile::ToString() const { return fmt::format( - "WorkloadProfile{{kernel={}, input_size={}, dim={}, selectivity={:.2%}, device={}}}", + "WorkloadProfile{{kernel={}, input_size={}, dim={}, selectivity={:.2f}%, device={}}}", BreakEvenValidator::KernelTypeToString(kernel_type), input_size, vector_dimension, - output_selectivity, + output_selectivity * 100.0f, BreakEvenValidator::DeviceTypeToString(device)); } @@ -168,6 +302,8 @@ BreakEvenDecision BreakEvenValidator::Profile( decision.reason = decision.use_gpu ? "break_even_met" : "break_even_not_met"; decision.from_cache = false; + MetricsSinkFn metrics_sink; + // Record latest speedup ratio for metrics { std::lock_guard lock(mutex_); @@ -179,56 +315,98 @@ BreakEvenDecision BreakEvenValidator::Profile( .decision = decision, .timestamp = std::chrono::steady_clock::now(), }; + metrics_sink = metrics_sink_; } - // Export metrics (TODO: integrate with Prometheus) - // metrics::prometheus::RecordHistogram("gpu_acceleration_break_even_ratio", - // speedup_ratio, - // {{"kernel", KernelTypeToString(profile.kernel_type)}}); + if (metrics_sink) { + try { + metrics_sink(profile, decision); + } catch (const std::exception&) { + } catch (...) { + } + } return decision; } std::optional BreakEvenValidator::ProfileCPU( const WorkloadProfile& profile) { - // TODO: Delegate to CPU reference kernel implementations - // For now, return a placeholder timing - // In production, this would: - // 1. Call CPU reference kernel (distance, topk, bfs, dijkstra, geo*) - // 2. Measure actual execution time with high-resolution timer - // 3. Repeat multiple times and take median - - // Placeholder: CPU kernels typically take 10-100ms depending on input size - if (profile.input_size < 1000) { - return std::chrono::milliseconds(5); - } else if (profile.input_size < 100000) { - return std::chrono::milliseconds(50); - } else { - return std::chrono::milliseconds(500); + ProfileFn profile_fn; + { + std::lock_guard lock(mutex_); + profile_fn = cpu_profile_fn_; + } + + if (profile_fn) { + try { + return profile_fn(profile); + } catch (const std::exception&) { + return std::nullopt; + } catch (...) { + return std::nullopt; + } } + + if (profile.input_size == 0 || profile.kernel_type == KernelType::kUnknown) { + return std::nullopt; + } + if (RequiresVectorDimension(profile.kernel_type) && profile.vector_dimension == 0) { + return std::nullopt; + } + + const double throughput = cpuThroughputUnitsPerMs(profile.kernel_type); + if (throughput <= 0.0) { + return std::nullopt; + } + + const double fixed_overhead_ms = profile.kernel_type == KernelType::kDijkstra ? 0.65 : 0.25; + const double estimated_ms = fixed_overhead_ms + EstimateWorkUnits(profile) / throughput; + return MillisecondsFromEstimate(estimated_ms); } std::optional BreakEvenValidator::ProfileGPU( const WorkloadProfile& profile) { - // TODO: Delegate to GPU kernel implementation - // For now, return a placeholder timing - // In production, this would: - // 1. Check GPU availability and select best device - // 2. Allocate GPU memory for input/output - // 3. Transfer input data to GPU - // 4. Launch kernel and measure execution time - // 5. Transfer results back to host - // 6. Return total time (alloc + transfer + compute + sync) - - // Placeholder: GPU kernels have fixed overhead (~20ms) + execution - if (profile.input_size < 10000) { - // Too small: overhead dominates - return std::nullopt; // Not worth it - } else if (profile.input_size < 1000000) { - return std::chrono::milliseconds(35); - } else { - return std::chrono::milliseconds(300); + ProfileFn profile_fn; + { + std::lock_guard lock(mutex_); + profile_fn = gpu_profile_fn_; + } + + if (profile_fn) { + try { + return profile_fn(profile); + } catch (const std::exception&) { + return std::nullopt; + } catch (...) { + return std::nullopt; + } + } + + if (profile.input_size == 0 || profile.kernel_type == KernelType::kUnknown) { + return std::nullopt; + } + if (RequiresVectorDimension(profile.kernel_type) && profile.vector_dimension == 0) { + return std::nullopt; } + if (!IsGpuCapableDevice(profile.device)) { + return std::nullopt; + } + + const double throughput = gpuThroughputUnitsPerMs(profile.kernel_type, profile.device); + const double bandwidth = effectiveBandwidthBytesPerMs(profile.device); + if (throughput <= 0.0 || bandwidth <= 0.0) { + return std::nullopt; + } + + double gpu_work_units = EstimateWorkUnits(profile); + if (profile.kernel_type == KernelType::kTopK) { + gpu_work_units *= 1.5 / (0.20 + clampSelectivity(profile.output_selectivity)); + } + + const double compute_ms = gpu_work_units / throughput; + const double transfer_ms = estimateTransferBytes(profile) / bandwidth; + const double estimated_ms = gpuLaunchOverheadMs(profile.device) + transfer_ms + compute_ms; + return MillisecondsFromEstimate(estimated_ms); } void BreakEvenValidator::SetSpeedupThreshold(KernelType kernel, float threshold) { @@ -252,6 +430,25 @@ void BreakEvenValidator::SetCacheValidityDuration(std::chrono::hours duration) { cache_validity_duration_ = duration; } +void BreakEvenValidator::SetCPUProfileFn(ProfileFn fn) { + std::lock_guard lock(mutex_); + cpu_profile_fn_ = std::move(fn); + decision_cache_.clear(); + latest_speedup_ratios_.clear(); +} + +void BreakEvenValidator::SetGPUProfileFn(ProfileFn fn) { + std::lock_guard lock(mutex_); + gpu_profile_fn_ = std::move(fn); + decision_cache_.clear(); + latest_speedup_ratios_.clear(); +} + +void BreakEvenValidator::SetMetricsSink(MetricsSinkFn fn) { + std::lock_guard lock(mutex_); + metrics_sink_ = std::move(fn); +} + float BreakEvenValidator::GetLatestBreakEvenRatio(KernelType kernel) const { std::lock_guard lock(mutex_); auto it = latest_speedup_ratios_.find(static_cast(kernel)); @@ -273,6 +470,47 @@ size_t BreakEvenValidator::GetCacheSize() const { return decision_cache_.size(); } +bool BreakEvenValidator::RequiresVectorDimension(KernelType kernel) { + return kernel == KernelType::kDistance || kernel == KernelType::kTopK; +} + +bool BreakEvenValidator::IsGpuCapableDevice(DeviceType device) { + return device != DeviceType::kCPU && device != DeviceType::kUnknown; +} + +double BreakEvenValidator::EstimateWorkUnits(const WorkloadProfile& profile) { + const double input_size = static_cast(profile.input_size); + const double dimension_factor = normalizedDimension(profile.vector_dimension); + const double selectivity = clampSelectivity(profile.output_selectivity); + + switch (profile.kernel_type) { + case KernelType::kDistance: + return input_size * dimension_factor * 128.0; + case KernelType::kTopK: + return input_size * std::max(1.0, log2Scaled(profile.input_size)) + * (0.35 + selectivity); + case KernelType::kBFS: + return input_size * (4.0 + selectivity * 6.0); + case KernelType::kDijkstra: + return input_size * std::max(1.0, log2Scaled(profile.input_size)) * 1.6; + case KernelType::kGeoDistance: + return input_size * 14.0; + case KernelType::kGeoContainment: + return input_size * (22.0 + selectivity * 10.0); + default: + return 0.0; + } +} + +std::optional BreakEvenValidator::MillisecondsFromEstimate( + double estimated_ms) { + if (!std::isfinite(estimated_ms) || estimated_ms <= 0.0) { + return std::nullopt; + } + const auto rounded = static_cast(std::ceil(std::max(estimated_ms, kMinimumProfiledMs))); + return std::chrono::milliseconds(rounded); +} + std::string BreakEvenValidator::KernelTypeToString(KernelType kernel) { switch (kernel) { case KernelType::kDistance: @@ -309,7 +547,7 @@ std::string BreakEvenValidator::DeviceTypeToString(DeviceType device) { } } -BreakEvenValidator::KernelType BreakEvenValidator::StringToKernelType( +KernelType BreakEvenValidator::StringToKernelType( const std::string& s) { if (s == "distance") return KernelType::kDistance; if (s == "topk") return KernelType::kTopK; @@ -320,7 +558,7 @@ BreakEvenValidator::KernelType BreakEvenValidator::StringToKernelType( return KernelType::kUnknown; } -BreakEvenValidator::DeviceType BreakEvenValidator::StringToDeviceType( +DeviceType BreakEvenValidator::StringToDeviceType( const std::string& s) { if (s == "nvidia_rtx") return DeviceType::kNVIDIA_RTX; if (s == "nvidia_t4") return DeviceType::kNVIDIA_T4; diff --git a/src/acceleration/oneapi_backend.cpp b/src/acceleration/oneapi_backend.cpp index a41e2bf8c7..4e5878deb1 100644 --- a/src/acceleration/oneapi_backend.cpp +++ b/src/acceleration/oneapi_backend.cpp @@ -144,6 +144,11 @@ class OneAPIVectorBackend : public IVectorBackend { sycl::free(d_distances, q); }; + if (d_queries == nullptr || d_vectors == nullptr || d_distances == nullptr) { + freeUSM(); + throw std::bad_alloc{}; + } + try { // Copy data to device; wait_and_throw() propagates SYCL async errors. q.memcpy(d_queries, queries, numQueries * dimension * sizeof(float)) diff --git a/src/access_model/ROADMAP.md b/src/access_model/ROADMAP.md index d6ddd3e795..1a507f96d6 100644 --- a/src/access_model/ROADMAP.md +++ b/src/access_model/ROADMAP.md @@ -1,7 +1,7 @@ # Access Model Module - Roadmap **Version:** 1.0.0 -**Status:** ACTIVE (Phase 2-4 complete; Phase 5-6 implementation launched) +**Status:** ACTIVE (Phase 2-6 complete; Wave B GA-ready) **Last Validated:** 2026-08-17 **Phase 5-6 Plan:** `ai_working/ACCESS_MODEL_PHASE_56_IMPLEMENTATION_PLAN.md` @@ -133,18 +133,16 @@ - [x] Unit tests ACM-01..ACM-08 (`tests/access_model/test_access_coordinator_focused.cpp`) ✅ **Done 2026-08-09** - [x] Cache integration (CAI-01..CAI-10 in `test_cache_storage_integration.cpp`) ✅ **Done 2026-08-09** - [x] Storage integration (CAI-07..10 via TieredStorageManager + PromotionListener) ✅ **Done 2026-08-09** -- [ ] Observability (pending Phase 5) -- [ ] Integration tests: full-stack e2e (pending Phase 6) -- [ ] Release benchmarks: GATE-ACM-01..06 (pending Phase 6) -- [ ] Operator runbooks (pending Phase 6) +- [x] Observability (Phase 5 complete 2026-08-17) +- [x] Integration tests: full-stack e2e (Phase 6 complete 2026-08-17) +- [x] Release benchmarks: GATE-ACM-01..06 (Phase 6 complete 2026-08-17) +- [x] Operator runbooks (Phase 5/6 complete 2026-08-17) --- ## Known Issues & Limitations -- Phase 5 observability not yet wired (structured logging, trace correlation, dashboard panels — Target: Q1 2027) -- Phase 6 e2e integration tests pending (`test_access_model_e2e.cpp`, `test_coordination_concurrency.cpp` — Target: Q1 2027) -- Benchmark gates GATE-ACM-01..06 and promotion latency benchmarks not yet established (Target: Q1 2027) +- Phase 5-6 observability, e2e/concurrency testing, and benchmark gates were completed and source-validated on 2026-08-17; keep derivative checklists synchronized with the validated source state - Phases 2-4 core implementation and unit/integration tests complete as of 2026-08-09 --- diff --git a/src/index/vector_index.cpp b/src/index/vector_index.cpp index da4051e13a..3021a4efcc 100644 --- a/src/index/vector_index.cpp +++ b/src/index/vector_index.cpp @@ -3090,6 +3090,24 @@ VectorIndexManager::Status VectorIndexManager::setRotaryEmbeddingConfig(const Ro } } +VectorIndexManager::Status VectorIndexManager::disableRotaryEmbedding() { + std::lock_guard stateLock(index_state_mutex_); + if (!rotary_enabled_ && !rotary_embedding_) { + return Status::Error("Rotary embeddings not enabled"); + } + + rotary_enabled_ = false; + rotary_embedding_.reset(); + rotary_positional_rotations_.store(0, std::memory_order_relaxed); + rotary_relational_rotations_.store(0, std::memory_order_relaxed); + rotary_query_rotations_.store(0, std::memory_order_relaxed); + rotary_total_rotation_time_us_.store(0, std::memory_order_relaxed); + + THEMIS_INFO("VectorIndexManager::disableRotaryEmbedding - Rotary embeddings disabled"); + logAuditEvent_("config", "rotary_embeddings", "disable", 0); + return Status::OK(); +} + std::optional VectorIndexManager::getRotaryEmbeddingConfig() const { std::lock_guard stateLock(index_state_mutex_); if (!rotary_enabled_ || !rotary_embedding_) { @@ -3267,4 +3285,3 @@ std::optional> VectorIndexManager::getVectorByPk(std::string_ } } // namespace themis - diff --git a/src/server/ROADMAP.md b/src/server/ROADMAP.md index 4da1cb2efd..3e3e3bc7ac 100644 --- a/src/server/ROADMAP.md +++ b/src/server/ROADMAP.md @@ -14,6 +14,11 @@ Production-ready server stack with HTTP/1.1, HTTP/2, HTTP/3, WebSocket, MQTT, Po - **Tier 1 Criticality:** Runtime-critical path; thread-safety and fail-closed guarantees are mandatory ## Recently Completed +- [x] Wave 4-A residual server runtime contract hardening batch (Completed 2026-08-31) + - gRPC-Web status now exposes an explicit availability/operator contract via `requests_supported`, `backend_mode`, and fail-closed reason reporting for non-gRPC builds + - RoPE `DELETE /api/v1/vector-index/{index}/rope/config` now performs real runtime disablement via `VectorIndexManager::disableRotaryEmbedding()` + - Unsupported exotic-platform MCP stdio transport now self-disables unless a `StdioReadFn` is injected, instead of advertising a started-but-deaf transport + - Time-series metadata endpoints now return explicit `source` and `degraded_mode` fields so builtin/storage fallback is visible to operators and tests - [x] Phase 5 Server Hardening — P5-S01 Wire-Protocol Retry + P5-S02 HTTP Timeout/Shutdown — Completed Q3 2026 (Validated 2026-07-20) - P5-S01: Exponential-backoff retry gate with configurable max_retries, base_delay, budget cap, and optional jitter - P5-S01: Retry eligibility gating (kTransient only; kFatal/kInvalidArg fail-fast) @@ -81,12 +86,12 @@ Production-ready server stack with HTTP/1.1, HTTP/2, HTTP/3, WebSocket, MQTT, Po - [~] Missing audit log: ~12 handler files — tracked in Wave 4-A (Target: Q4 2026) ### Short-term (3-6 months) -- [ ] Residual runtime gap: replace the build-without-gRPC fallback in `grpc_web_proxy_handler.cpp` so gRPC-Web requests are either proxied for real or rejected behind an explicit feature/operator contract instead of an always-UNIMPLEMENTED path (Target: Q4 2026) -- [ ] Residual runtime gap: make aggregate and retention providers first-class production dependencies for the time-series metadata endpoints in `timeseries_api_handler.cpp`, while keeping the builtin/storage fallback documented as degraded mode only (Target: Q4 2026) -- [ ] Residual runtime gap: replace mock RoPE rotation metrics in `rope_api_handler.cpp` with a real metrics source or an explicit disabled-state contract (Target: Q4 2026) +- [x] Residual runtime gap: replace the build-without-gRPC fallback in `grpc_web_proxy_handler.cpp` with an explicit feature/operator contract so non-gRPC builds advertise fail-closed availability via the status endpoint instead of a silent always-UNIMPLEMENTED path (Target: Q4 2026 → Completed 2026-08-31) +- [~] Residual runtime gap: make aggregate and retention providers first-class production dependencies for the time-series metadata endpoints in `timeseries_api_handler.cpp`; explicit `source`/`degraded_mode` signaling is now delivered, but full DI wiring remains open (Target: Q4 2026) +- [x] Residual runtime gap: `DELETE /api/v1/vector-index/{index}/rope/config` now disables rotary embeddings at runtime, and the stats path already uses real rotation metrics from `VectorIndexManager` (Target: Q4 2026 → Completed 2026-08-31) - [x] Residual request-validation gap: replace `validateJsonStub()` in `http_server.cpp` for content-import, PKI-sign, and PKI-verify routes with explicit schema validation entrypoints (Target: Q4 2026) - [~] Residual SQL-wire safety gap: tighten prepared-statement parameter handling in `postgres_session.cpp` with typed validation and placeholder-safe binding while deeper protocol-level bind execution remains open (Target: Q4 2026) -- [ ] Residual deployment gap: keep unsupported non-Linux MCP stdio transport in `mcp_server.cpp` explicitly gated and documented until a real implementation exists (Target: Q4 2026) +- [x] Residual deployment gap: unsupported non-Linux MCP stdio transport in `mcp_server.cpp` is now explicitly self-disabled and documented unless a `StdioReadFn` is injected; native platform implementation still remains future work (Target: Q4 2026 → Completed 2026-08-31) - [ ] Plugin-based server adapter loading with signature validation and rollback guardrails (Target: Q4 2026) - [ ] Cluster-wide distributed rate-limit state hardening for mixed-node latency profiles (Target: Q4 2026) - [ ] GraphQL federation and schema governance hardening for multi-service deployments (Target: Q4 2026) diff --git a/src/server/grpc_web_proxy_handler.cpp b/src/server/grpc_web_proxy_handler.cpp index 4a2de86319..286b7750ff 100644 --- a/src/server/grpc_web_proxy_handler.cpp +++ b/src/server/grpc_web_proxy_handler.cpp @@ -54,6 +54,24 @@ GrpcWebProxyHandler::BackendInvokeFn getBackendInvokeFn() return backendInvokeFnStorage(); } +bool grpcBackendAvailableInCurrentBuild() +{ +#ifdef THEMIS_ENABLE_GRPC + return true; +#else + return static_cast(getBackendInvokeFn()); +#endif +} + +std::string grpcBackendMode() +{ +#ifdef THEMIS_ENABLE_GRPC + return "grpc"; +#else + return getBackendInvokeFn() ? "override" : "unavailable"; +#endif +} + } // namespace void GrpcWebProxyHandler::setBackendInvokeFn(BackendInvokeFn fn) @@ -256,8 +274,15 @@ http::response GrpcWebProxyHandler::handleStatus( {"backend_address", config_.backend_address}, {"backend_tls", config_.backend_tls}, {"deadline_ms", config_.deadline_ms}, - {"cors_allow_origin", config_.cors_allow_origin} + {"cors_allow_origin", config_.cors_allow_origin}, + {"requests_supported", grpcBackendAvailableInCurrentBuild()}, + {"backend_mode", grpcBackendMode()} }; +#ifndef THEMIS_ENABLE_GRPC + if (!status["requests_supported"].get()) { + status["reason"] = "gRPC backend not available in this build"; + } +#endif return makeResponse(http::status::ok, status.dump(), "application/json", req); } @@ -404,4 +429,3 @@ http::response GrpcWebProxyHandler::handlePost( } // namespace server } // namespace themis - diff --git a/src/server/mcp_server.cpp b/src/server/mcp_server.cpp index fff3175e1e..73f05e384c 100644 --- a/src/server/mcp_server.cpp +++ b/src/server/mcp_server.cpp @@ -266,7 +266,11 @@ void McpServer::start() { if (stdio_transport_) { stdio_transport_->setMessageHandler([this](const json& req) { return handleRequest(req); }); stdio_transport_->start(); - spdlog::info("MCP stdio transport started"); + if (stdio_transport_->isRunning()) { + spdlog::info("MCP stdio transport started"); + } else { + spdlog::warn("MCP stdio transport disabled: no supported stdio reader is available on this platform"); + } } else { spdlog::error("MCP: stdio transport allocation failed — stdio disabled"); } @@ -2931,10 +2935,10 @@ StdioTransport::~StdioTransport() { void StdioTransport::start() { bool expected = false; if (!is_running_.compare_exchange_strong(expected, true, std::memory_order_acq_rel)) return; - spdlog::info("MCP stdio transport started"); #if defined(_WIN32) || defined(__unix__) || defined(__APPLE__) // Start async stdin reading + spdlog::info("MCP stdio transport started"); readStdin(); #else // PERMANENT HARDWARE FALLBACK NOTE (MCP StdioTransport — exotic platform): @@ -2956,14 +2960,11 @@ void StdioTransport::start() { fn = stdioReadFnStorage(); } if (fn) { + spdlog::info("MCP stdio transport started with injected platform reader"); try { fn(); } catch (...) {} } else { - // STUB/SIMULATION NOTE: - // Purpose: Non-Linux platform compatibility — Unix socket path uses Linux-specific abstract namespace - // Activation: Compile-time: non-Linux platforms (macOS, Windows) — at runtime the path fallback is active - // Production Delta: Abstract namespace sockets (Linux) replaced by filesystem socket at /tmp/themisdb_mcp.sock - // Removal Plan: Q2 2027 — add native Windows named pipe + macOS launchd socket support - spdlog::warn("MCP stdio transport: Unsupported platform, stdin reading not implemented"); + is_running_.store(false, std::memory_order_release); + spdlog::warn("MCP stdio transport disabled: unsupported platform and no StdioReadFn injected"); } } #endif @@ -3972,4 +3973,3 @@ json McpServer::toolExplainQuery(const json& args) { } // namespace themis #endif // THEMIS_ENABLE_MCP - diff --git a/src/server/rope_api_handler.cpp b/src/server/rope_api_handler.cpp index 07ce2e218a..3c338d7b52 100644 --- a/src/server/rope_api_handler.cpp +++ b/src/server/rope_api_handler.cpp @@ -256,15 +256,17 @@ http::response RopeApiHandler::handleConfigDelete( "RoPE is not enabled for index '" + index_name + "'", req); } - // Note: VectorIndexManager does not currently provide a disable method. - // This endpoint returns success to indicate intent, but RoPE remains configured. - // Future enhancement: Add disableRotaryEmbedding() method to VectorIndexManager - // to fully support disabling RoPE at runtime. + auto disable_status = vector_index_->disableRotaryEmbedding(); + if (!disable_status.ok) { + span.setStatus(false, disable_status.message); + return makeErrorResponse(http::status::internal_server_error, + "Failed to disable RoPE: " + disable_status.message, req); + } json response = { {"status", "success"}, - {"message", "RoPE disable requested for index '" + index_name + "'"}, - {"note", "RoPE configuration persists until server restart"} + {"message", "RoPE disabled for index '" + index_name + "'"}, + {"enabled", false} }; span.setStatus(true); @@ -981,4 +983,3 @@ std::optional RopeApiHandler::extractIndexName(const std::string& p } // namespace server } // namespace themis - diff --git a/src/server/timeseries_api_handler.cpp b/src/server/timeseries_api_handler.cpp index a0baf536c4..54fe127002 100644 --- a/src/server/timeseries_api_handler.cpp +++ b/src/server/timeseries_api_handler.cpp @@ -411,16 +411,20 @@ http::response TimeSeriesApiHandler::handleAggregatesGet( auto span = Tracer::startSpan("handleTimeSeriesAggregatesGet"); try { std::set aggregate_names; - + std::string aggregate_source = "builtin"; + bool degraded_mode = false; + // STUB #301 REMEDIATION: Use real aggregates provider if available if (aggregates_fn_) { auto real_aggregates = aggregates_fn_(); aggregate_names.insert(real_aggregates.begin(), real_aggregates.end()); + aggregate_source = "provider"; span.setAttribute("aggregates.source", "real_provider"); } else if (agg_engine_) { // Fall back to ContinuousAggMaterializationEngine for real registered aggregates auto real_aggregates = agg_engine_->listAggregates(); aggregate_names.insert(real_aggregates.begin(), real_aggregates.end()); + aggregate_source = "agg_engine"; span.setAttribute("aggregates.source", "agg_engine"); } else { // STUB/SIMULATION NOTE: @@ -440,6 +444,7 @@ http::response TimeSeriesApiHandler::handleAggregatesGet( // injected via TimeSeriesApiHandler::setAggregatesFn() // or the relevant DI path, targeting v1.7.0 / Q4 2026. aggregate_names = {"min", "max", "avg", "sum", "count"}; + degraded_mode = true; span.setAttribute("aggregates.source", "builtin"); } @@ -470,8 +475,14 @@ http::response TimeSeriesApiHandler::handleAggregatesGet( nlohmann::json response = { {"aggregates", functions}, {"materialized_aggregates", materialized}, - {"materialized_count", materialized.size()} + {"materialized_count", materialized.size()}, + {"source", aggregate_source}, + {"degraded_mode", degraded_mode} }; + if (degraded_mode) { + response["degraded_reason"] = + "No aggregate provider is wired; returning the builtin aggregate list only"; + } span.setStatus(true); return makeResponse(http::status::ok, response.dump(), req); } catch (const std::exception& e) { @@ -486,6 +497,8 @@ http::response TimeSeriesApiHandler::handleRetentionGet( auto span = Tracer::startSpan("handleTimeSeriesRetentionGet"); try { nlohmann::json policies = nlohmann::json::array(); + std::string policy_source = "storage_config"; + bool degraded_mode = false; // STUB #301 REMEDIATION: Use real retention policies provider if available if (retentions_fn_) { @@ -497,14 +510,17 @@ http::response TimeSeriesApiHandler::handleRetentionGet( {"source", "retention_provider"} }); } + policy_source = "provider"; span.setAttribute("policies.source", "retention_provider"); } else if (retentionPoliciesFn_) { // Also check legacy provider auto legacy_policies = retentionPoliciesFn_(); policies = nlohmann::json(legacy_policies); + policy_source = "legacy_provider"; span.setAttribute("policies.source", "legacy_provider"); } else { // Fall back to storage-based config + degraded_mode = true; if (storage_) { auto stored = storage_->get("config:timeseries"); if (stored) { @@ -537,8 +553,14 @@ http::response TimeSeriesApiHandler::handleRetentionGet( nlohmann::json response = { {"policies", policies}, - {"policy_count", policies.size()} + {"policy_count", policies.size()}, + {"source", policy_source}, + {"degraded_mode", degraded_mode} }; + if (degraded_mode) { + response["degraded_reason"] = + "No retention provider is wired; returning storage-derived retention metadata only"; + } span.setStatus(true); return makeResponse(http::status::ok, response.dump(), req); } catch (const std::exception& e) { diff --git a/src/storage/CHANGELOG.md b/src/storage/CHANGELOG.md index 38096789f3..5592276cfa 100644 --- a/src/storage/CHANGELOG.md +++ b/src/storage/CHANGELOG.md @@ -13,6 +13,8 @@ The format is based on Keep a Changelog. ### Changed - Documentation governance sync: README, ARCHITECTURE, SECURITY, ROADMAP, FUTURE_ENHANCEMENTS, AUDIT, and PERFORMANCE_EXPECTATIONS aligned to source-verifiable module behavior. - Performance expectations updated to explicit verified benchmark symbols from storage-performance and user-storage-mount benchmark suites. +- `SecuritySignatureManager` now fails closed when constructed without RocksDB unless the caller explicitly opts into the in-memory fallback for tests/ephemeral workflows. +- `BackupManager` now uses real manifest-driven S3/GCS/Azure transport for cloud backup upload/restore instead of the previous local-mirror-only remote stub path. ## [2.1.x] - 2026 diff --git a/src/storage/PRODUCTION_REQUIREMENTS.md b/src/storage/PRODUCTION_REQUIREMENTS.md index d21df668fa..e30acf1daa 100644 --- a/src/storage/PRODUCTION_REQUIREMENTS.md +++ b/src/storage/PRODUCTION_REQUIREMENTS.md @@ -27,12 +27,15 @@ Es definiert verbindliche Anforderungen für Durability, WAL/Replay-Verhalten, B ### 1) Integritätssicherung - **MUST:** `security_signature.cpp` aktiv für kritische Daten-Schreibpfade; Signatur-Prüfung bei Lese-Operationen aktiv. +- **MUST:** `SecuritySignatureManager` mit einem persistenten RocksDB-Backend initialisieren; In-Memory-Fallback ist nur mit explizitem Opt-in für Tests oder klar deklarierte ephemere Läufe zulässig. - **MUST:** Storage-Audit-Logging für Write-/Delete-Operationen aktiv. - **MUST NOT:** Integritätsprüfungen für Produktions-Schreibpfade deaktivieren. +- **MUST NOT:** `SecuritySignatureManager(nullptr)` als stillschweigenden Produktions-Downgrade verwenden. ### 2) Blob-Backend-Absicherung - **MUST:** Blob-Backend (S3/GCS/Azure/Filesystem) mit explizit gesetzten Credentials konfiguriert; leere/Default-Credentials werden nicht akzeptiert. +- **MUST:** Remote S3/GCS/Azure-Backups über den manifestbasierten Transport in `backup_manager.cpp` betreiben; Restore darf nicht von Provider-Listing oder lokalen Mirror-Pfaden abhängen. - **MUST:** Blob-Backend-Verbindungsfehler werden als expliziter Fehler propagiert; kein Silent-Fallback auf lokalen Speicher ohne Konfiguration. - **MUST NOT:** Unverschlüsselte Blob-Übertragung in Produktionsdeployments verwenden. @@ -53,6 +56,7 @@ Es definiert verbindliche Anforderungen für Durability, WAL/Replay-Verhalten, B - [ ] Backup-Manager mit explizitem Backup-Ziel und Zeitplan konfiguriert - [ ] PITR konfiguriert wenn RPO-Anforderungen dies verlangen - [ ] Security-Signature-Pfad aktiv +- [ ] `SecuritySignatureManager` läuft gegen RocksDB oder ein ausdrücklich dokumentiertes test-only Fallback - [ ] Blob-Backend-Credentials explizit gesetzt - [ ] Storage-Audit-Logging aktiv - [ ] Produktionsmodus via `THEMIS_PRODUCTION_MODE` oder `THEMIS_ENVIRONMENT` gesetzt diff --git a/src/storage/README.md b/src/storage/README.md index 053ba3a5ef..cf8f4c394a 100644 --- a/src/storage/README.md +++ b/src/storage/README.md @@ -52,9 +52,11 @@ The storage module provides persistence, versioned data handling, blob and tiere - **WAL durability:** Every durable write is preceded by WAL entry durability; replay is deterministic and idempotent. - **Crash recovery:** Unclean shutdown recovery is deterministic; no data loss (MVCC-enabled) and no state corruption. - **Backup guarantee:** Backups are atomic point-in-time copies with full consistency across all layers. +- **Remote backup transport:** S3/GCS/Azure uploads persist a manifest plus payload blobs so restore can rebuild the backup tree without provider-side listing. - **PITR accuracy:** Recovery to a specific timestamp ±100ms; no log gaps, no partial transactions. - **Tiered migration:** Age-based and access-frequency-based tier migration is transparent to readers; no observable latency spike on promotion. - **Configuration scope:** Behavior is bounded by storage configuration; all limits are enforced with explicit error on overage. +- **Integrity persistence:** `SecuritySignatureManager` requires a persistent RocksDB backend in production and only uses an in-memory store when the caller explicitly opts into the test-only fallback. ## Production Readiness Status diff --git a/src/storage/ROADMAP.md b/src/storage/ROADMAP.md index 0d51a0f89e..ebe3aa01c4 100644 --- a/src/storage/ROADMAP.md +++ b/src/storage/ROADMAP.md @@ -6,13 +6,14 @@ ## Current Status -Production-capable storage runtime exists for durable persistence, MVCC/WAL lifecycle behavior, backup/PITR flows, blob/tiering behavior, and storage audit/integrity surfaces. Source revalidation on 2026-08-31 found two degraded restore paths in `backup_manager.cpp`: when compression or OpenSSL dependencies are absent, restore currently copies bytes verbatim instead of performing real decompression/decryption. Follow-up hardening now fail-closes those restore paths, `ggml_tensor_bridge.cpp` now honors the runtime `ggml_context*` so real ggml allocation/copy can occur without an injected allocator when ggml is linked, and EmbeddedLLM startup now registers `GGML_TYPE_TT` once so TT-backed ggml mappings do not rely on an uninitialized type-registration path. +Production-capable storage runtime exists for durable persistence, MVCC/WAL lifecycle behavior, backup/PITR flows, blob/tiering behavior, and storage audit/integrity surfaces. Source revalidation on 2026-08-31 found two degraded restore paths in `backup_manager.cpp`: when compression or OpenSSL dependencies are absent, restore currently copies bytes verbatim instead of performing real decompression/decryption. Follow-up hardening now fail-closes those restore paths, `ggml_tensor_bridge.cpp` now honors the runtime `ggml_context*` so real ggml allocation/copy can occur without an injected allocator when ggml is linked, EmbeddedLLM startup now registers `GGML_TYPE_TT` once so TT-backed ggml mappings do not rely on an uninitialized type-registration path, `SecuritySignatureManager` no longer enables an implicit in-memory store when RocksDB is absent unless the caller explicitly opts into that test-only fallback, and remote S3/GCS/Azure backup transport now uploads a manifest plus payload blobs instead of failing closed with local-mirror-only behavior. ## In Progress - [~] hardening failure-path behavior under sustained write/load and maintenance overlap (Target: Q3 2026) - [~] improving diagnostics consistency across storage, replay, and recovery stages (Target: Q3 2026) - [~] stabilizing benchmark-backed release guardrails for storage hot paths (Target: Q3 2026) +- [x] remove implicit `SecuritySignatureManager(nullptr)` memory-store fallback from production paths; null-backend construction now fails closed unless the caller explicitly enables the test-only fallback option (Target: Q3 2026) - [x] BLOCK 3: Storage Module Integration with AccessCoordinator (Target: Q4 2026) ✅ COMPLETE - [x] Added PromotionListener support to TieredStorageManager - [x] Added `setPromotionListener()` method in header and implementation @@ -39,6 +40,7 @@ Production-capable storage runtime exists for durable persistence, MVCC/WAL life - [ ] tighten deterministic behavior under heavy WAL replay and compaction pressure (Target: Q4 2026) - [ ] expand stress coverage for blob/tiering and PITR edge scenarios (Target: Q4 2026) - [ ] improve operator-facing diagnostics for recovery and maintenance incidents (Target: Q4 2026) +- [x] finish remote cloud backup transport wiring in `backup_manager.cpp` for S3/GCS/Azure so cloud restore now reconstructs backups from a manifest plus payload blobs instead of depending on local-mirror-only behavior (Target: Q4 2026) ### Mid-term (6-12 months) - [ ] re-baseline p95/p99 envelopes for write/replay/recovery-sensitive paths (Target: Q1 2027) @@ -70,7 +72,9 @@ These items are part of the next-phase **Track 2: Distributed Systems Maturity - [x] Define explicit StorageErrorCode taxonomy (WAL_WRITE_FAILED, CHECKPOINT_FAILED, RECOVERY_INCOMPLETE, PITR_INVALID_TIMESTAMP, COMPACTION_ABORTED, STORAGE_EXHAUSTED, …) (Target: Q3 2026) ### Phase 2: Core Implementation -- [ ] complete hardening for WAL/MVCC and backup/PITR internals (Target: Q4 2026) +- [~] complete hardening for WAL/MVCC and backup/PITR internals (Target: Q4 2026) + - [x] `SecuritySignatureManager` now rejects null-backend production use instead of silently downgrading integrity persistence to an in-memory map + - [x] remote cloud backup archive transport now uses provider blob backends plus a manifest-driven restore contract for S3/GCS/Azure - [ ] align tiered/blob/redundancy behavior to bounded runtime contracts (Target: Q4 2026) ### Phase 3: Error Handling and Edge Cases @@ -122,6 +126,7 @@ These items are part of the next-phase **Track 2: Distributed Systems Maturity - runtime behavior depends on storage configuration, backend profile, and workload shape. - selected replay/recovery/tiering edge scenarios need continued hardening. - benchmark depth should continue expanding for advanced storage workloads. +- provider-native remote transport now depends on the linked blob backend plus credentials/runtime environment; multipart/retry soak hardening remains follow-up work. ## Breaking Changes diff --git a/src/storage/backup_manager.cpp b/src/storage/backup_manager.cpp index b0830de44e..7e9ab38158 100644 --- a/src/storage/backup_manager.cpp +++ b/src/storage/backup_manager.cpp @@ -11,6 +11,10 @@ #include "storage/backup_manager.h" +#include "storage/blob_backend_azure.h" +#include "storage/blob_backend_gcs.h" +#include "storage/blob_backend_s3.h" +#include "storage/blob_storage_backend.h" #include "storage/rocksdb_wrapper.h" #include "utils/logger.h" #include "utils/expected.h" @@ -59,6 +63,33 @@ namespace { namespace fs = std::filesystem; constexpr std::string_view kLocalBackupUriScheme{"file://"}; +constexpr std::string_view kRemoteBackupManifestBlobId{"__themis_backup_manifest__"}; +constexpr std::string_view kRemoteBackupFormatVersion{"1"}; +constexpr std::uintmax_t kMaxRemoteBackupPayloadBytes{256ull * 1024ull * 1024ull}; + +struct RemoteBackupLocation { + std::string authority; + std::string container; + std::string prefix; +}; + +#if defined(THEMIS_HAS_AWS_SDK) && THEMIS_HAS_AWS_SDK +constexpr bool kRemoteBackupS3Linked = true; +#else +constexpr bool kRemoteBackupS3Linked = false; +#endif + +#if defined(THEMIS_HAS_AZURE_STORAGE) && THEMIS_HAS_AZURE_STORAGE +constexpr bool kRemoteBackupAzureLinked = true; +#else +constexpr bool kRemoteBackupAzureLinked = false; +#endif + +#if defined(THEMIS_HAS_GCS_SDK) && THEMIS_HAS_GCS_SDK +constexpr bool kRemoteBackupGcsLinked = true; +#else +constexpr bool kRemoteBackupGcsLinked = false; +#endif /// Return whether @p value begins with the provider prefix @p prefix. bool hasUriPrefix(const std::string& value, std::string_view prefix) { @@ -233,6 +264,286 @@ bool isValidRemoteCloudUri(const std::string& uri) { }); } +std::string trimSlashes(std::string value) { + while (!value.empty() && value.front() == '/') { + value.erase(value.begin()); + } + while (!value.empty() && value.back() == '/') { + value.pop_back(); + } + return value; +} + +std::vector splitPathSegments(std::string_view value) { + std::vector segments; + std::size_t start = 0; + while (start < value.size()) { + const auto next = value.find('/', start); + const auto len = next == std::string_view::npos ? value.size() - start : next - start; + if (len > 0) { + segments.emplace_back(value.substr(start, len)); + } + if (next == std::string_view::npos) { + break; + } + start = next + 1; + } + return segments; +} + +std::string joinPathSegments(const std::vector& segments, std::size_t start_index) { + std::string joined; + for (std::size_t i = start_index; i < segments.size(); ++i) { + if (!joined.empty()) { + joined.push_back('/'); + } + joined.append(segments[i]); + } + return joined; +} +bool isRemoteBackupProviderLinked(StorageBackend backend) { + switch (backend) { + case StorageBackend::S3: + return kRemoteBackupS3Linked; + case StorageBackend::AZURE: + return kRemoteBackupAzureLinked; + case StorageBackend::GCS: + return kRemoteBackupGcsLinked; + case StorageBackend::LOCAL: + return false; + } + + return false; +} + +Result validateRemotePayloadSize(std::uintmax_t size_bytes, const std::string& label) { + if (size_bytes <= kMaxRemoteBackupPayloadBytes) { + return OkVoid(); + } + + return ErrVoid(errors::ErrorCode::ERR_UTIL_ALLOCATION_FAILED, + "Remote backup payload exceeds in-memory transfer limit (" + + std::to_string(kMaxRemoteBackupPayloadBytes) + " bytes): " + label); +} + +Result validateRemoteUploadSourceSize(const fs::path& source_path) { + std::error_code ec; + const bool source_exists = fs::exists(source_path, ec); + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to inspect backup path '" + source_path.string() + + "': " + ec.message()); + } + if (!source_exists) { + return ErrVoid(errors::ErrorCode::ERR_STORAGE_FILE_NOT_FOUND, + "Local backup path does not exist: " + source_path.string()); + } + + if (fs::is_regular_file(source_path, ec)) { + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to inspect backup file '" + source_path.string() + + "': " + ec.message()); + } + const auto size_bytes = fs::file_size(source_path, ec); + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to read backup file size '" + source_path.string() + + "': " + ec.message()); + } + return validateRemotePayloadSize(size_bytes, source_path.filename().generic_string()); + } + + if (!fs::is_directory(source_path, ec)) { + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to inspect backup path type '" + source_path.string() + + "': " + ec.message()); + } + return OkVoid(); + } + + for (const auto& entry : fs::recursive_directory_iterator(source_path, ec)) { + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to enumerate backup path '" + source_path.string() + + "': " + ec.message()); + } + if (!entry.is_regular_file()) { + continue; + } + + const auto size_bytes = entry.file_size(ec); + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to read backup file size '" + entry.path().string() + + "': " + ec.message()); + } + + const auto relative = fs::relative(entry.path(), source_path, ec); + const std::string label = ec ? entry.path().filename().generic_string() + : relative.generic_string(); + ec.clear(); + + auto size_check = validateRemotePayloadSize(size_bytes, label); + if (!size_check.has_value()) { + return size_check; + } + } + + return OkVoid(); +} + +std::optional parseRemoteBackupLocation(StorageBackend backend, + const std::string& uri) { + const auto scheme_end = uri.find("://"); + if (scheme_end == std::string::npos) { + return std::nullopt; + } + const auto payload = uri.substr(scheme_end + 3); + switch (backend) { + case StorageBackend::S3: + case StorageBackend::GCS: { + const auto slash = payload.find('/'); + RemoteBackupLocation location; + location.authority = slash == std::string::npos ? payload : payload.substr(0, slash); + location.prefix = slash == std::string::npos ? std::string() : trimSlashes(payload.substr(slash + 1)); + if (location.authority.empty()) { + return std::nullopt; + } + return location; + } + case StorageBackend::AZURE: { + const auto segments = splitPathSegments(payload); + if (segments.size() < 2) { + return std::nullopt; + } + + RemoteBackupLocation location; + if (segments.size() >= 3) { + location.authority = segments[0]; + location.container = segments[1]; + location.prefix = trimSlashes(joinPathSegments(segments, 2)); + } else { + location.container = segments[0]; + location.prefix = trimSlashes(joinPathSegments(segments, 1)); + } + + if (location.container.empty()) { + return std::nullopt; + } + return location; + } + case StorageBackend::LOCAL: + return std::nullopt; + } + + return std::nullopt; +} + +bool isSafeRelativeBackupPath(const fs::path& relative_path) { + if (relative_path.empty() || relative_path.is_absolute() || relative_path.has_root_name()) { + return false; + } + + for (const auto& component : relative_path) { + if (component == "..") { + return false; + } + } + + return true; +} + +Result> readBinaryFileBytes(const fs::path& file_path) { + std::ifstream input(file_path, std::ios::binary); + if (!input) { + return Err>( + errors::ErrorCode::ERR_STORAGE_FILE_NOT_FOUND, + "Failed to open file: " + file_path.string()); + } + + std::vector data((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + return Ok(std::move(data)); +} + +Result writeBinaryFileBytes(const fs::path& file_path, const std::vector& data) { + std::error_code ec; + fs::create_directories(file_path.parent_path(), ec); + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to create directory '" + file_path.parent_path().string() + + "': " + ec.message()); + } + + std::ofstream output(file_path, std::ios::binary | std::ios::trunc); + if (!output) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to open file for write: " + file_path.string()); + } + + output.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!output) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to write file: " + file_path.string()); + } + + return OkVoid(); +} + +std::shared_ptr createRemoteBlobBackend( + StorageBackend backend, + const RemoteBackupLocation& location, + const std::map& config) { + switch (backend) { + case StorageBackend::S3: +#if defined(THEMIS_HAS_AWS_SDK) && THEMIS_HAS_AWS_SDK + return std::make_shared( + location.authority, + [&config]() { + const auto it = config.find("region"); + return it == config.end() || it->second.empty() ? std::string("us-east-1") + : it->second; + }(), + location.prefix); +#else + return {}; +#endif + case StorageBackend::AZURE: +#if defined(THEMIS_HAS_AZURE_STORAGE) && THEMIS_HAS_AZURE_STORAGE + { + std::string connection_string; + if (const auto it = config.find("connection_string"); + it != config.end() && !it->second.empty()) { + connection_string = it->second; + } else if (const char* env = std::getenv("AZURE_STORAGE_CONNECTION_STRING"); + env != nullptr && *env != '\0') { + connection_string = env; + } + + return std::make_shared( + connection_string, + location.container, + location.prefix); + } +#else + return {}; +#endif + case StorageBackend::GCS: +#if defined(THEMIS_HAS_GCS_SDK) && THEMIS_HAS_GCS_SDK + return std::make_shared(location.authority, location.prefix); +#else + return {}; +#endif + case StorageBackend::LOCAL: + return {}; + } + + return {}; +} + } // namespace #ifdef _WIN32 @@ -764,9 +1075,11 @@ bool BackupManager::createDifferentialBackup(const std::string& dest_dir, std::e // Upload to cloud if configured if (options.storage != StorageBackend::LOCAL) { - if (!uploadToCloud(backup_dir.string(), options.storage_path, - options.storage, options.cloud_config, ec)) { - THEMIS_WARN("Failed to upload to cloud storage: {}", ec.message()); + auto upload_result = uploadToCloud(backup_dir.string(), options.storage_path, + options.storage, options.cloud_config); + if (!upload_result.has_value()) { + THEMIS_WARN("Failed to upload to cloud storage: {}", + upload_result.error().message()); } } @@ -1805,70 +2118,300 @@ bool BackupManager::decryptFile([[maybe_unused]] const std::string& src_path, #endif } -bool BackupManager::uploadToCloud(const std::string& local_path, [[maybe_unused]] const std::string& cloud_path, - StorageBackend backend, - const std::map& /*config*/, - [[maybe_unused]] std::error_code& ec) { +Result BackupManager::uploadToCloud(const std::string& local_path, + const std::string& cloud_path, + StorageBackend backend, + const std::map& config) { if (backend == StorageBackend::LOCAL || isLocalBackupUri(cloud_path)) { // Local transport is a real implementation, not a placeholder cloud shim: // the backup tree is mirrored byte-for-byte into another absolute path so // operators can stage backup handoffs without a remote SDK dependency. const auto destination = resolveLocalBackupPath(cloud_path); if (destination.empty()) { - ec = std::make_error_code(std::errc::invalid_argument); - return false; + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Local backup destination is empty"); } THEMIS_INFO("Mirroring backup {} to local destination {}", local_path, destination.string()); - return copyPathRecursively(fs::path(local_path), destination, ec); + std::error_code ec; + if (!copyPathRecursively(fs::path(local_path), destination, ec)) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_CREATION_FAILED, + "Local backup mirror failed: " + ec.message()); + } + return OkVoid(); } - static std::once_flag s_upload_warn; - std::call_once(s_upload_warn, [] { - THEMIS_WARN("BackupManager::uploadToCloud: remote provider transport not linked. " - "Build with a concrete cloud provider integration."); - }); - try { - THEMIS_INFO("Uploading {} to cloud backend {}", local_path, static_cast(backend)); - ec = std::make_error_code(std::errc::not_supported); - return false; + const auto parsed_location = parseRemoteBackupLocation(backend, cloud_path); + if (!parsed_location.has_value()) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Unsupported cloud URI: " + cloud_path); + } + + auto backend_impl = createRemoteBlobBackend(backend, *parsed_location, config); + if (!backend_impl) { + return ErrVoid(errors::ErrorCode::ERR_UNKNOWN, + "Cloud provider transport is not linked for URI: " + cloud_path); + } + if (!backend_impl->isAvailable()) { + return ErrVoid(errors::ErrorCode::ERR_UNKNOWN, + "Cloud provider backend is unavailable for URI: " + cloud_path); + } + + const fs::path source_path(local_path); + const bool source_is_directory = fs::is_directory(source_path); + nlohmann::json manifest; + manifest["format_version"] = kRemoteBackupFormatVersion; + manifest["source_type"] = source_is_directory ? "directory" : "file"; + manifest["entries"] = nlohmann::json::array(); + manifest["uploaded_at"] = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + + std::vector uploaded_refs; + const auto cleanup_uploaded_refs = [&backend_impl, &uploaded_refs]() { + for (const auto& ref : uploaded_refs) { + const auto cleanup_result = backend_impl->remove(ref); + if (!cleanup_result.has_value()) { + THEMIS_WARN("BackupManager::uploadToCloud cleanup failed for {}: {}", + ref.id, cleanup_result.error().message()); + } + } + }; + + if (source_is_directory) { + std::vector directories; + std::vector files; + for (const auto& entry : fs::recursive_directory_iterator(source_path)) { + const auto relative = fs::relative(entry.path(), source_path); + if (!isSafeRelativeBackupPath(relative)) { + cleanup_uploaded_refs(); + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Backup path contains unsafe relative entry: " + + entry.path().string()); + } + + if (entry.is_directory()) { + directories.push_back(relative); + } else if (entry.is_regular_file()) { + files.push_back(relative); + } + } + + std::sort(directories.begin(), directories.end(), + [](const auto& lhs, const auto& rhs) { + return lhs.generic_string() < rhs.generic_string(); + }); + std::sort(files.begin(), files.end(), + [](const auto& lhs, const auto& rhs) { + return lhs.generic_string() < rhs.generic_string(); + }); + + for (const auto& directory : directories) { + manifest["entries"].push_back({ + {"kind", "directory"}, + {"relative_path", directory.generic_string()} + }); + } + + for (const auto& relative_file : files) { + const auto file_path = source_path / relative_file; + auto data_result = readBinaryFileBytes(file_path); + if (!data_result.has_value()) { + cleanup_uploaded_refs(); + return ErrVoid(data_result.error().code(), data_result.error().message()); + } + + const std::string blob_id = "payload/" + relative_file.generic_string(); + auto put_result = backend_impl->put(blob_id, data_result.value()); + if (!put_result.has_value()) { + cleanup_uploaded_refs(); + return ErrVoid(put_result.error().code(), put_result.error().message()); + } + + uploaded_refs.push_back(put_result.value()); + manifest["entries"].push_back({ + {"kind", "file"}, + {"relative_path", relative_file.generic_string()}, + {"blob_id", put_result->id}, + {"size_bytes", put_result->size_bytes}, + {"hash_sha256", put_result->hash_sha256} + }); + } + } else { + auto data_result = readBinaryFileBytes(source_path); + if (!data_result.has_value()) { + return ErrVoid(data_result.error().code(), data_result.error().message()); + } + + const fs::path relative_name = source_path.filename(); + if (!isSafeRelativeBackupPath(relative_name)) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Backup file name is unsafe for remote transport: " + + relative_name.generic_string()); + } + + const std::string blob_id = "payload/" + relative_name.generic_string(); + auto put_result = backend_impl->put(blob_id, data_result.value()); + if (!put_result.has_value()) { + return ErrVoid(put_result.error().code(), put_result.error().message()); + } + + uploaded_refs.push_back(put_result.value()); + manifest["entries"].push_back({ + {"kind", "file"}, + {"relative_path", relative_name.generic_string()}, + {"blob_id", put_result->id}, + {"size_bytes", put_result->size_bytes}, + {"hash_sha256", put_result->hash_sha256} + }); + } + + const auto manifest_dump = manifest.dump(2); + std::vector manifest_bytes(manifest_dump.begin(), manifest_dump.end()); + auto manifest_result = backend_impl->put(std::string(kRemoteBackupManifestBlobId), manifest_bytes); + if (!manifest_result.has_value()) { + cleanup_uploaded_refs(); + return ErrVoid(manifest_result.error().code(), manifest_result.error().message()); + } + + THEMIS_INFO("Uploaded backup {} to remote destination {}", local_path, cloud_path); + return OkVoid(); } catch (const std::exception& e) { - ec = std::make_error_code(std::errc::io_error); THEMIS_ERROR("Exception during cloud upload: {}", e.what()); - return false; + return ErrVoid(errors::ErrorCode::ERR_BACKUP_CREATION_FAILED, + "Exception during cloud upload: " + std::string(e.what())); } } -bool BackupManager::downloadFromCloud(const std::string& cloud_path, - [[maybe_unused]] const std::string& local_path, - StorageBackend backend, - const std::map& /*config*/, - std::error_code& ec) { +Result BackupManager::downloadFromCloud(const std::string& cloud_path, + const std::string& local_path, + StorageBackend backend, + const std::map& config) { if (backend == StorageBackend::LOCAL || isLocalBackupUri(cloud_path)) { // Local restore reuses the same mirrored payload rules as uploadToCloud(): // a file:// URI or absolute path is treated as an operator-managed backup // source and copied into the requested restore directory. const auto source = resolveLocalBackupPath(cloud_path); if (source.empty()) { - ec = std::make_error_code(std::errc::invalid_argument); - return false; + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Local backup source is empty"); } THEMIS_INFO("Restoring local backup mirror {} into {}", source.string(), local_path); - return copyPathRecursively(source, fs::path(local_path), ec); + std::error_code ec; + if (!copyPathRecursively(source, fs::path(local_path), ec)) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_RESTORATION_FAILED, + "Local backup restore failed: " + ec.message()); + } + return OkVoid(); } - static std::once_flag s_download_warn; - std::call_once(s_download_warn, [] { - THEMIS_WARN("BackupManager::downloadFromCloud: remote provider transport not linked. " - "Build with a concrete cloud provider integration."); - }); - THEMIS_ERROR("downloadFromCloud: cannot download {} (cloud backend {}) — " - "no cloud provider transport linked. Restore aborted.", - cloud_path, static_cast(backend)); - ec = std::make_error_code(std::errc::not_supported); - return false; + const auto parsed_location = parseRemoteBackupLocation(backend, cloud_path); + if (!parsed_location.has_value()) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Unsupported cloud URI: " + cloud_path); + } + + auto backend_impl = createRemoteBlobBackend(backend, *parsed_location, config); + if (!backend_impl) { + return ErrVoid(errors::ErrorCode::ERR_UNKNOWN, + "Cloud provider transport is not linked for URI: " + cloud_path); + } + if (!backend_impl->isAvailable()) { + return ErrVoid(errors::ErrorCode::ERR_UNKNOWN, + "Cloud provider backend is unavailable for URI: " + cloud_path); + } + + storage::BlobRef manifest_ref; + manifest_ref.id = std::string(kRemoteBackupManifestBlobId); + manifest_ref.type = storage::BlobStorageType::CUSTOM; + manifest_ref.uri = cloud_path; + auto manifest_bytes_result = backend_impl->get(manifest_ref); + if (!manifest_bytes_result.has_value()) { + return ErrVoid(manifest_bytes_result.error().code(), manifest_bytes_result.error().message()); + } + + nlohmann::json manifest_json; + try { + manifest_json = nlohmann::json::parse(manifest_bytes_result.value()); + } catch (const std::exception& e) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest is invalid JSON: " + std::string(e.what())); + } + + try { + if (!manifest_json.contains("format_version") || + manifest_json["format_version"].get() != kRemoteBackupFormatVersion) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest has unsupported format version"); + } + if (!manifest_json.contains("entries") || !manifest_json["entries"].is_array()) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest is missing entries"); + } + + const fs::path restore_root = fs::path(local_path); + for (const auto& entry : manifest_json["entries"]) { + if (!entry.contains("kind") || !entry.contains("relative_path")) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest entry is incomplete"); + } + + const std::string kind = entry["kind"].get(); + const fs::path relative_path(entry["relative_path"].get()); + if (!isSafeRelativeBackupPath(relative_path)) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_INVALID_TYPE, + "Remote backup manifest contains unsafe path: " + + relative_path.generic_string()); + } + + const fs::path target_path = restore_root / relative_path; + if (kind == "directory") { + std::error_code ec; + fs::create_directories(target_path, ec); + if (ec) { + return ErrVoid(errors::ErrorCode::ERR_UTIL_FILE_OPERATION_FAILED, + "Failed to create restore directory '" + target_path.string() + + "': " + ec.message()); + } + continue; + } + + if (kind != "file" || !entry.contains("blob_id")) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest file entry is incomplete"); + } + + const auto size_bytes = entry.value("size_bytes", 0); + auto size_check = validateRemotePayloadSize( + size_bytes, entry["relative_path"].get()); + if (!size_check.has_value()) { + return size_check; + } + + storage::BlobRef payload_ref; + payload_ref.id = entry["blob_id"].get(); + payload_ref.type = storage::BlobStorageType::CUSTOM; + payload_ref.uri = cloud_path; + payload_ref.size_bytes = static_cast(size_bytes); + payload_ref.hash_sha256 = entry.value("hash_sha256", std::string{}); + auto payload_result = backend_impl->get(payload_ref); + if (!payload_result.has_value()) { + return ErrVoid(payload_result.error().code(), payload_result.error().message()); + } + + auto write_result = writeBinaryFileBytes(target_path, payload_result.value()); + if (!write_result.has_value()) { + return write_result; + } + } + } catch (const std::exception& e) { + return ErrVoid(errors::ErrorCode::ERR_BACKUP_MANIFEST_CORRUPT, + "Remote backup manifest is invalid: " + std::string(e.what())); + } + + THEMIS_INFO("Restored remote backup {} into {}", cloud_path, local_path); + return OkVoid(); } std::string BackupManager::findLastFullBackup(const std::string& backup_dir) { @@ -2599,43 +3142,48 @@ Result BackupManager::uploadBackupToCloud( ? "Invalid local backup destination: '" + cloud_uri + "'. Use file:///absolute/path or an absolute filesystem path." : "Invalid cloud URI: '" + cloud_uri + - "'. Supported schemes: s3:///path, azure:///container/path," + "'. Supported schemes: s3:///path, azure:///container/path" + " or azure:///path," " gs:///path" )); } if (is_local_backend) { - if (!uploadToCloud(local_backup_path, cloud_uri, options.storage, options.cloud_config, ec)) { + auto upload_result = uploadToCloud(local_backup_path, cloud_uri, + options.storage, options.cloud_config); + if (!upload_result.has_value()) { return tl::unexpected(Error( - errors::ErrorCode::ERR_BACKUP_CREATION_FAILED, - "Local backup mirror failed for '" + cloud_uri + "': " + ec.message() + upload_result.error().code(), + "Local backup mirror failed for '" + cloud_uri + "': " + + upload_result.error().message() )); } THEMIS_INFO("Backup mirrored to local destination: {}", cloud_uri); return cloud_uri; } - // Compile-time SDK flags control the active cloud path: - // THEMIS_ENABLE_S3 → AWS S3 SDK - // THEMIS_ENABLE_AZURE → Azure Storage SDK - // THEMIS_ENABLE_GCS → Google Cloud Storage SDK -#if defined(THEMIS_ENABLE_S3) || defined(THEMIS_ENABLE_AZURE) || defined(THEMIS_ENABLE_GCS) - if (!uploadToCloud(local_backup_path, cloud_uri, options.storage, - options.cloud_config, ec)) { + auto size_check = validateRemoteUploadSourceSize(fs::path(local_backup_path)); + if (!size_check.has_value()) { + return tl::unexpected(Error(size_check.error().code(), size_check.error().message())); + } + if (!isRemoteBackupProviderLinked(options.storage)) { return tl::unexpected(Error( - errors::ErrorCode::ERR_BACKUP_CREATION_FAILED, - "Cloud upload failed for '" + cloud_uri + "': " + ec.message() + errors::ErrorCode::ERR_UNKNOWN, + "Cloud backup upload not available for the requested provider in this build." + )); + } + + auto upload_result = uploadToCloud(local_backup_path, cloud_uri, options.storage, + options.cloud_config); + if (!upload_result.has_value()) { + return tl::unexpected(Error( + upload_result.error().code(), + "Cloud upload failed for '" + cloud_uri + "': " + + upload_result.error().message() )); } THEMIS_INFO("Backup uploaded to cloud: {}", cloud_uri); return cloud_uri; -#else - return tl::unexpected(Error( - errors::ErrorCode::ERR_UNKNOWN, - "Cloud backup upload not available. " - "Build with THEMIS_ENABLE_S3, THEMIS_ENABLE_AZURE, or THEMIS_ENABLE_GCS." - )); -#endif } Result BackupManager::restoreFromCloud( @@ -2679,22 +3227,26 @@ Result BackupManager::restoreFromCloud( )); } - if (!downloadFromCloud(cloud_uri, local_restore_path, options.storage, - options.cloud_config, ec)) { + auto download_result = downloadFromCloud(cloud_uri, local_restore_path, + options.storage, options.cloud_config); + if (!download_result.has_value()) { return tl::unexpected(Error( - errors::ErrorCode::ERR_BACKUP_RESTORATION_FAILED, - "Local backup restore failed for '" + cloud_uri + "': " + ec.message() + download_result.error().code(), + "Local backup restore failed for '" + cloud_uri + "': " + + download_result.error().message() )); } THEMIS_INFO("Backup restored from local mirror: {} → {}", cloud_uri, local_restore_path); return OkVoid(); } - // Compile-time SDK flags control the active cloud path: - // THEMIS_ENABLE_S3 → AWS S3 SDK - // THEMIS_ENABLE_AZURE → Azure Storage SDK - // THEMIS_ENABLE_GCS → Google Cloud Storage SDK -#if defined(THEMIS_ENABLE_S3) || defined(THEMIS_ENABLE_AZURE) || defined(THEMIS_ENABLE_GCS) + if (!isRemoteBackupProviderLinked(options.storage)) { + return tl::unexpected(Error( + errors::ErrorCode::ERR_UNKNOWN, + "Cloud backup restore not available for the requested provider in this build." + )); + } + namespace fs = std::filesystem; std::error_code ec; fs::create_directories(local_restore_path, ec); @@ -2706,22 +3258,17 @@ Result BackupManager::restoreFromCloud( )); } - if (!downloadFromCloud(cloud_uri, local_restore_path, options.storage, - options.cloud_config, ec)) { + auto download_result = downloadFromCloud(cloud_uri, local_restore_path, + options.storage, options.cloud_config); + if (!download_result.has_value()) { return tl::unexpected(Error( - errors::ErrorCode::ERR_BACKUP_RESTORATION_FAILED, - "Cloud download failed for '" + cloud_uri + "': " + ec.message() + download_result.error().code(), + "Cloud download failed for '" + cloud_uri + "': " + + download_result.error().message() )); } THEMIS_INFO("Backup restored from cloud: {} → {}", cloud_uri, local_restore_path); return OkVoid(); -#else - return tl::unexpected(Error( - errors::ErrorCode::ERR_UNKNOWN, - "Cloud backup restore not available. " - "Build with THEMIS_ENABLE_S3, THEMIS_ENABLE_AZURE, or THEMIS_ENABLE_GCS." - )); -#endif } Result BackupManager::createSnapshot( @@ -3344,4 +3891,3 @@ Result BackupManager::decompressBackup(const std::string& /* compre #endif // THEMIS_ROCKSDB_AVAILABLE } // namespace themis - diff --git a/src/storage/blob_backend_azure.cpp b/src/storage/blob_backend_azure.cpp index 73b1d41bbe..592c47d13b 100644 --- a/src/storage/blob_backend_azure.cpp +++ b/src/storage/blob_backend_azure.cpp @@ -184,15 +184,17 @@ class AzureBlobBackend : public IBlobStorageBackend { data.insert(data.end(), buffer.begin(), buffer.begin() + bytes_read); } - // Verify hash - std::string actual_hash = computeSHA256(data); - if (actual_hash != ref.hash_sha256) { - THEMIS_ERROR("Hash mismatch for blob {}: expected={}, actual={}", - ref.id, ref.hash_sha256, actual_hash); - return Err>( - errors::ErrorCode::ERR_STORAGE_CORRUPTION, - "Hash mismatch for blob: " + ref.id - ); + // Verify hash when the caller supplied an expected digest. + if (!ref.hash_sha256.empty()) { + std::string actual_hash = computeSHA256(data); + if (actual_hash != ref.hash_sha256) { + THEMIS_ERROR("Hash mismatch for blob {}: expected={}, actual={}", + ref.id, ref.hash_sha256, actual_hash); + return Err>( + errors::ErrorCode::ERR_STORAGE_CORRUPTION, + "Hash mismatch for blob: " + ref.id + ); + } } THEMIS_DEBUG("Blob retrieved from Azure: id={}, size={} bytes", ref.id, data.size()); diff --git a/src/storage/blob_backend_s3.cpp b/src/storage/blob_backend_s3.cpp index e15a0999af..6ec022dbf5 100644 --- a/src/storage/blob_backend_s3.cpp +++ b/src/storage/blob_backend_s3.cpp @@ -202,15 +202,19 @@ class S3BlobBackend : public IBlobStorageBackend { data.insert(data.end(), buffer, buffer + body.gcount()); } - // Verify hash - std::string actual_hash = computeSHA256(data); - if (actual_hash != ref.hash_sha256) { - THEMIS_ERROR("Hash mismatch for blob {}: expected={}, actual={}", - ref.id, ref.hash_sha256, actual_hash); - return Err>( - errors::ErrorCode::ERR_STORAGE_CORRUPTION, - "Hash mismatch for blob: " + ref.id - ); + // Verify hash when the caller supplied an expected digest. Manifest + // bootstrap fetches intentionally omit the hash so they do not pay the + // checksum cost twice. + if (!ref.hash_sha256.empty()) { + std::string actual_hash = computeSHA256(data); + if (actual_hash != ref.hash_sha256) { + THEMIS_ERROR("Hash mismatch for blob {}: expected={}, actual={}", + ref.id, ref.hash_sha256, actual_hash); + return Err>( + errors::ErrorCode::ERR_STORAGE_CORRUPTION, + "Hash mismatch for blob: " + ref.id + ); + } } THEMIS_DEBUG("Blob retrieved from S3: id={}, size={} bytes", ref.id, data.size()); @@ -285,4 +289,3 @@ std::mutex S3BlobBackend::init_mutex_; } // namespace themis #endif - diff --git a/src/storage/security_signature_manager.cpp b/src/storage/security_signature_manager.cpp index 1a537847fe..faafbc1bba 100644 --- a/src/storage/security_signature_manager.cpp +++ b/src/storage/security_signature_manager.cpp @@ -23,10 +23,18 @@ namespace storage { namespace fs = std::filesystem; SecuritySignatureManager::SecuritySignatureManager(std::shared_ptr db) - : db_(db) { + : SecuritySignatureManager(std::move(db), Options{}) {} + +SecuritySignatureManager::SecuritySignatureManager(std::shared_ptr db, + Options options) + : db_(std::move(db)) { if (!db_) { - // Allow in-memory fallback for test environments where RocksDB is not wired - use_fallback_memory_store_ = true; + if (options.allow_in_memory_fallback) { + use_fallback_memory_store_ = true; + THEMIS_WARN("SecuritySignatureManager: explicit in-memory fallback enabled"); + } else { + THEMIS_ERROR("SecuritySignatureManager: RocksDB backend unavailable; integrity operations will fail closed"); + } } } @@ -51,6 +59,11 @@ bool SecuritySignatureManager::storeSignature(const SecuritySignature& sig) { return true; } + if (!db_) { + THEMIS_ERROR("SecuritySignatureManager::storeSignature rejected because no backend is available"); + return false; + } + return db_->put(key, value); } catch (...) { THEMIS_WARN("security_signature_manager::db_: unhandled exception caught"); @@ -70,6 +83,10 @@ std::optional SecuritySignatureManager::getSignature(const st } value = it->second; } else { + if (!db_) { + THEMIS_ERROR("SecuritySignatureManager::getSignature rejected because no backend is available"); + return std::nullopt; + } if (!db_->get(key, value)) { return std::nullopt; } @@ -88,6 +105,10 @@ bool SecuritySignatureManager::deleteSignature(const std::string& resource_id) { if (use_fallback_memory_store_) { return mem_store_.erase(key) > 0; } + if (!db_) { + THEMIS_ERROR("SecuritySignatureManager::deleteSignature rejected because no backend is available"); + return false; + } return db_->del(key); } catch (...) { THEMIS_WARN("security_signature_manager: unhandled exception caught"); @@ -107,6 +128,11 @@ std::vector SecuritySignatureManager::listAllSignatures() { } return signatures; } + + if (!db_) { + THEMIS_ERROR("SecuritySignatureManager::listAllSignatures rejected because no backend is available"); + return signatures; + } // Compute the end key for the prefix range: increment the last byte of KEY_PREFIX // e.g. "security_sig:" -> "security_sig;" (';' == ':' + 1) @@ -212,6 +238,7 @@ SecuritySignatureManager::VerifyAllResult SecuritySignatureManager::verifyAll() VerifyAllResult result; if (use_fallback_memory_store_) { + result.used_fallback_memory_store = true; for (const auto& [key, value] : mem_store_) { auto sig = SecuritySignature::deserialize(value); if (!sig.has_value()) { @@ -230,6 +257,14 @@ SecuritySignatureManager::VerifyAllResult SecuritySignatureManager::verifyAll() return result; } + if (!db_) { + result.backend_available = false; + result.error_message = + "SecuritySignatureManager has no RocksDB backend; enable explicit in-memory fallback only for tests"; + THEMIS_ERROR("SecuritySignatureManager::verifyAll rejected because no backend is available"); + return result; + } + // Use iterateRange to scan all signature keys from RocksDB auto [start_key, end_key] = makePrefixRange(); @@ -255,4 +290,3 @@ SecuritySignatureManager::VerifyAllResult SecuritySignatureManager::verifyAll() } // namespace storage } // namespace themis - diff --git a/tests/gpu/CMakeLists.txt b/tests/gpu/CMakeLists.txt index acd343e7d5..3b200437aa 100644 --- a/tests/gpu/CMakeLists.txt +++ b/tests/gpu/CMakeLists.txt @@ -76,15 +76,6 @@ foreach(_src IN LISTS GPU_MODULE_TEST_SOURCES) target_sources(${_target} PRIVATE ${THEMIS_ROOT_DIR}/src/acceleration/kernel_registry.cpp) endif() - # If the acceleration implementation is absent in a modular split, compile - # a lightweight fallback implementation into focused tests so unit tests - # link and run. This file provides synthetic but deterministic behavior - # suitable for unit tests and CI. - if(EXISTS "${THEMIS_ROOT_DIR}/src/acceleration/break_even_validator_impl_fallback.cpp" - AND NOT _stem STREQUAL "test_gpu_adversarial") - target_sources(${_target} PRIVATE ${THEMIS_ROOT_DIR}/src/acceleration/break_even_validator_impl_fallback.cpp) - endif() - # Determine labels based on test file set(_labels "gpu") if(_stem MATCHES "test_gpu_error_handling_comprehensive|test_gpu_phase_c_integration") diff --git a/tests/gpu/test_break_even_validation.cpp b/tests/gpu/test_break_even_validation.cpp index 927835cbe6..f1029fd7c1 100644 --- a/tests/gpu/test_break_even_validation.cpp +++ b/tests/gpu/test_break_even_validation.cpp @@ -63,7 +63,7 @@ TEST_F(BreakEvenValidatorTest, L2Distance_SmallInput_CPUPreferred) { auto decision = validator_.ShouldUseGPU(profile); EXPECT_FALSE(decision.use_gpu) << "Small input (100 vectors) should prefer CPU (GPU overhead too high)"; - EXPECT_EQ(decision.reason, "gpu_unavailable"); // Placeholder: GPU too small + EXPECT_EQ(decision.reason, "break_even_not_met"); } TEST_F(BreakEvenValidatorTest, L2Distance_MediumInput_MaybeGPU) { @@ -361,6 +361,7 @@ TEST_F(BreakEvenValidatorTest, DeviceType_CPU) { auto decision = validator_.ShouldUseGPU(profile); // CPU device should not recommend GPU EXPECT_FALSE(decision.use_gpu); + EXPECT_EQ(decision.reason, "gpu_unavailable"); } // ============================================================================ @@ -381,6 +382,44 @@ TEST_F(BreakEvenValidatorTest, Metrics_LatestSpeedupRatio) { EXPECT_GT(ratio, 0.0f); } +TEST_F(BreakEvenValidatorTest, Hooks_InjectedProfilersDriveDecisionAndMetrics) { + auto profile = MakeProfile(KernelType::kDistance, 4'096); + bool metrics_called = false; + BreakEvenDecision emitted_decision; + + validator_.SetCPUProfileFn([](const WorkloadProfile&) { + return std::chrono::milliseconds(42); + }); + validator_.SetGPUProfileFn([](const WorkloadProfile&) { + return std::chrono::milliseconds(14); + }); + validator_.SetMetricsSink([&](const WorkloadProfile& observed_profile, + const BreakEvenDecision& decision) { + metrics_called = true; + emitted_decision = decision; + EXPECT_EQ(observed_profile.input_size, profile.input_size); + EXPECT_EQ(observed_profile.kernel_type, profile.kernel_type); + }); + + const auto decision = validator_.ShouldUseGPU(profile); + + EXPECT_TRUE(metrics_called); + EXPECT_TRUE(decision.use_gpu); + EXPECT_EQ(decision.reason, "break_even_met"); + EXPECT_FLOAT_EQ(decision.speedup_ratio, 3.0f); + EXPECT_EQ(emitted_decision.speedup_ratio, decision.speedup_ratio); +} + +TEST_F(BreakEvenValidatorTest, InvalidDistanceProfileFailsClosed) { + auto profile = MakeProfile(KernelType::kDistance, 1'024); + profile.vector_dimension = 0; + + const auto decision = validator_.ShouldUseGPU(profile); + + EXPECT_FALSE(decision.use_gpu); + EXPECT_EQ(decision.reason, "cpu_profile_failed"); +} + // ============================================================================ // String Conversion Tests // ============================================================================ diff --git a/tests/legacy/cloud/test_cloud_storage_backup_comprehensive.cpp b/tests/legacy/cloud/test_cloud_storage_backup_comprehensive.cpp index 7900dbf095..0b36830b80 100644 --- a/tests/legacy/cloud/test_cloud_storage_backup_comprehensive.cpp +++ b/tests/legacy/cloud/test_cloud_storage_backup_comprehensive.cpp @@ -135,6 +135,16 @@ class CloudStorageBackupTest : public ::testing::Test { #endif return false; } + + bool isExpectedCloudTransportError(const std::string& error_msg) { + return error_msg.find("credentials") != std::string::npos || + error_msg.find("authentication") != std::string::npos || + error_msg.find("not available") != std::string::npos || + error_msg.find("unavailable") != std::string::npos || + error_msg.find("not linked") != std::string::npos || + error_msg.find("not found") != std::string::npos || + error_msg.find("unsupported") != std::string::npos; + } fs::path test_dir_; fs::path db_path_; @@ -177,8 +187,8 @@ TEST_F(CloudStorageBackupTest, UploadToCloudInterfaceExists) { if (!isCloudProviderAvailable("aws")) { EXPECT_FALSE(s3_result.has_value()) << "Should fail when AWS SDK not available"; EXPECT_TRUE(s3_result.error().message().find("not available") != std::string::npos || - s3_result.error().message().find("not yet implemented") != std::string::npos || - s3_result.error().message().find("not implemented") != std::string::npos); + s3_result.error().message().find("not linked") != std::string::npos || + s3_result.error().message().find("unavailable") != std::string::npos); } // If AWS SDK is available but no credentials, should return auth error } @@ -276,14 +286,8 @@ TEST_F(CloudStorageBackupTest, UploadBackupToS3) { // Should return cloud URI or credential error if (!result.has_value()) { - // Check for expected error messages (current implementation is a stub) std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not yet implemented") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ) << "Unexpected error: " << error_msg; + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)) << "Unexpected error: " << error_msg; } } @@ -387,11 +391,7 @@ TEST_F(CloudStorageBackupTest, UploadBackupToAzureBlob) { // Should fail without valid credentials if (!result.has_value()) { std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ); + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)); } } @@ -497,11 +497,7 @@ TEST_F(CloudStorageBackupTest, UploadBackupToGCS) { // Should fail without valid credentials if (!result.has_value()) { std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ); + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)); } } @@ -664,8 +660,8 @@ TEST_F(CloudStorageBackupTest, InvalidCloudURIHandling) { error_msg.find("Invalid cloud URI") != std::string::npos || error_msg.find("invalid") != std::string::npos || error_msg.find("URI") != std::string::npos || - error_msg.find("not yet implemented") != std::string::npos || - error_msg.find("not implemented") != std::string::npos || + error_msg.find("not linked") != std::string::npos || + error_msg.find("unsupported") != std::string::npos || error_msg.find("not found") != std::string::npos ) << "Expected error message for invalid URI: " << uri << ", got: " << error_msg; } diff --git a/tests/legacy/grpc/test_grpc_web_proxy_bridge.cpp b/tests/legacy/grpc/test_grpc_web_proxy_bridge.cpp index c0fd8411a3..69e7b5bc10 100644 --- a/tests/legacy/grpc/test_grpc_web_proxy_bridge.cpp +++ b/tests/legacy/grpc/test_grpc_web_proxy_bridge.cpp @@ -8,6 +8,7 @@ #include "server/grpc_web_proxy_handler.h" #include +#include #include #include #include @@ -118,6 +119,15 @@ TEST_F(GrpcWebProxyBridgeTest, InjectedFnIsCalled) EXPECT_EQ(res.result(), http::status::ok); EXPECT_TRUE(fn_called); EXPECT_EQ(extractGrpcStatus(res.body()), 0); + +#ifndef THEMIS_ENABLE_GRPC + http::request status_req{http::verb::get, "/api/v1/grpc-web/status", 11}; + auto status_res = handler.handleStatus(status_req); + EXPECT_EQ(status_res.result(), http::status::ok); + auto status_body = nlohmann::json::parse(status_res.body()); + EXPECT_TRUE(status_body["requests_supported"].get()); + EXPECT_EQ(status_body["backend_mode"].get(), "override"); +#endif } TEST_F(GrpcWebProxyBridgeTest, ThrowingFnPropagatesException) diff --git a/tests/legacy/grpc/test_grpc_web_proxy_handler.cpp b/tests/legacy/grpc/test_grpc_web_proxy_handler.cpp index be494ea535..a19fa64e0b 100644 --- a/tests/legacy/grpc/test_grpc_web_proxy_handler.cpp +++ b/tests/legacy/grpc/test_grpc_web_proxy_handler.cpp @@ -248,6 +248,8 @@ TEST_F(GrpcWebProxyHandlerTest, Status_Returns200WithJson) { EXPECT_TRUE(body.contains("backend_tls")); EXPECT_TRUE(body.contains("deadline_ms")); EXPECT_TRUE(body.contains("cors_allow_origin")); + EXPECT_TRUE(body.contains("requests_supported")); + EXPECT_TRUE(body.contains("backend_mode")); } TEST_F(GrpcWebProxyHandlerTest, Status_ReflectsConfig) { @@ -266,6 +268,14 @@ TEST_F(GrpcWebProxyHandlerTest, Status_ReflectsConfig) { EXPECT_EQ(body["backend_tls"].get(), true); EXPECT_EQ(body["deadline_ms"].get(), 5000u); EXPECT_EQ(body["cors_allow_origin"].get(), "https://ui.example.com"); +#ifdef THEMIS_ENABLE_GRPC + EXPECT_TRUE(body["requests_supported"].get()); + EXPECT_EQ(body["backend_mode"].get(), "grpc"); +#else + EXPECT_FALSE(body["requests_supported"].get()); + EXPECT_EQ(body["backend_mode"].get(), "unavailable"); + EXPECT_EQ(body["reason"].get(), "gRPC backend not available in this build"); +#endif } // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/network/test_http_rope.cpp b/tests/network/test_http_rope.cpp index ea49190894..1528e7eb7b 100644 --- a/tests/network/test_http_rope.cpp +++ b/tests/network/test_http_rope.cpp @@ -386,6 +386,11 @@ TEST_F(HttpRopeApiTest, DisableRoPE) { ASSERT_TRUE(response.contains("status")); EXPECT_EQ(response["status"], "success"); + ASSERT_TRUE(response.contains("enabled")); + EXPECT_FALSE(response["enabled"]); + + auto get_response = httpGet("/api/v1/vector-index/test_rope/rope/config"); + ASSERT_TRUE(get_response.contains("error")); } // Test 9: Invalid Configuration (hidden_dim not even) diff --git a/tests/network/test_http_timeseries.cpp b/tests/network/test_http_timeseries.cpp index 72c5e14444..1e04d4986d 100644 --- a/tests/network/test_http_timeseries.cpp +++ b/tests/network/test_http_timeseries.cpp @@ -381,6 +381,8 @@ TEST_F(HttpTimeSeriesTest, GetAggregates_ReturnsList) { ASSERT_TRUE(response.contains("materialized_aggregates")); EXPECT_TRUE(response["materialized_aggregates"].is_array()); ASSERT_TRUE(response.contains("materialized_count")); + EXPECT_EQ(response["source"].get(), "builtin"); + EXPECT_TRUE(response["degraded_mode"].get()); } // Test: Get retention policies @@ -393,6 +395,8 @@ TEST_F(HttpTimeSeriesTest, GetRetention_ReturnsPolicies) { EXPECT_TRUE(response["policies"].is_array()); ASSERT_TRUE(response.contains("policy_count")); EXPECT_EQ(response["policy_count"].get(), response["policies"].size()); + EXPECT_EQ(response["source"].get(), "storage_config"); + EXPECT_TRUE(response["degraded_mode"].get()); } // Test: Multiple metrics with label filtering diff --git a/tests/security/test_security_signature_rocksdb_iteration.cpp b/tests/security/test_security_signature_rocksdb_iteration.cpp index 63442f584e..6185c97f8d 100644 --- a/tests/security/test_security_signature_rocksdb_iteration.cpp +++ b/tests/security/test_security_signature_rocksdb_iteration.cpp @@ -359,15 +359,31 @@ TEST_F(SecuritySignatureRocksDBIterationTests, VerifyAll_MixedResults) { // In-memory fallback path (no RocksDB) // --------------------------------------------------------------------------- -TEST(SecuritySignatureVerifyAllFallbackTests, FallbackVerifyAll_EmptyStore) { - SecuritySignatureManager mgr(nullptr); // triggers fallback +TEST(SecuritySignatureVerifyAllFallbackTests, NullBackendFailsClosedWithoutExplicitFallback) { + SecuritySignatureManager mgr(nullptr); + SecuritySignature sig; + sig.resource_id = "fb_file"; + sig.hash = std::string(64, 'e'); + sig.algorithm = "sha256"; + sig.created_at = 42; + + EXPECT_FALSE(mgr.isUsingFallbackMemoryStore()); + EXPECT_FALSE(mgr.hasPersistentBackend()); + EXPECT_FALSE(mgr.storeSignature(sig)); + EXPECT_TRUE(mgr.listAllSignatures().empty()); + auto result = mgr.verifyAll(); EXPECT_EQ(result.total, 0); - EXPECT_TRUE(result.success()); + EXPECT_FALSE(result.backend_available); + EXPECT_FALSE(result.used_fallback_memory_store); + EXPECT_FALSE(result.success()); + EXPECT_FALSE(result.error_message.empty()); } -TEST(SecuritySignatureVerifyAllFallbackTests, FallbackListAllSignatures_ReturnsStoredSigs) { - SecuritySignatureManager mgr(nullptr); +TEST(SecuritySignatureVerifyAllFallbackTests, ExplicitFallbackListAllSignatures_ReturnsStoredSigs) { + SecuritySignatureManager mgr( + nullptr, + SecuritySignatureManager::Options{.allow_in_memory_fallback = true}); SecuritySignature sig; sig.resource_id = "fb_file"; @@ -379,4 +395,17 @@ TEST(SecuritySignatureVerifyAllFallbackTests, FallbackListAllSignatures_ReturnsS auto sigs = mgr.listAllSignatures(); ASSERT_EQ(sigs.size(), 1u); EXPECT_EQ(sigs[0].resource_id, "fb_file"); + EXPECT_TRUE(mgr.isUsingFallbackMemoryStore()); + EXPECT_FALSE(mgr.hasPersistentBackend()); +} + +TEST(SecuritySignatureVerifyAllFallbackTests, ExplicitFallbackVerifyAll_EmptyStore) { + SecuritySignatureManager mgr( + nullptr, + SecuritySignatureManager::Options{.allow_in_memory_fallback = true}); + auto result = mgr.verifyAll(); + EXPECT_EQ(result.total, 0); + EXPECT_TRUE(result.backend_available); + EXPECT_TRUE(result.used_fallback_memory_store); + EXPECT_TRUE(result.success()); } diff --git a/tests/stub_remediation_test.cpp b/tests/stub_remediation_test.cpp index b6e4c2b1cf..9898400785 100644 --- a/tests/stub_remediation_test.cpp +++ b/tests/stub_remediation_test.cpp @@ -83,6 +83,8 @@ TEST_F(StubRemediationTest, TimeSeriesAggregatesWithProvider) { auto& aggregates = response_body["aggregates"]; ASSERT_TRUE(aggregates.is_array()); ASSERT_GE(aggregates.size(), 8); // At least the provided aggregates + EXPECT_EQ(response_body["source"].get(), "provider"); + EXPECT_FALSE(response_body["degraded_mode"].get()); // Verify all provided aggregates are present std::set agg_set; @@ -128,6 +130,8 @@ TEST_F(StubRemediationTest, TimeSeriesRetentionWithProvider) { auto& policies = response_body["policies"]; ASSERT_TRUE(policies.is_array()); ASSERT_GE(policies.size(), 3); // At least the provided policies + EXPECT_EQ(response_body["source"].get(), "provider"); + EXPECT_FALSE(response_body["degraded_mode"].get()); // Verify policies contain expected metrics std::set metric_set; @@ -159,6 +163,9 @@ TEST_F(StubRemediationTest, TimeSeriesAggregatesDefaultFallback) { json response_body = json::parse(response.body()); auto& aggregates = response_body["aggregates"]; + EXPECT_EQ(response_body["source"].get(), "builtin"); + EXPECT_TRUE(response_body["degraded_mode"].get()); + EXPECT_TRUE(response_body.contains("degraded_reason")); // Should still have the built-in defaults ASSERT_GE(aggregates.size(), 5); diff --git a/tests/test_cloud_storage_backup_comprehensive.cpp b/tests/test_cloud_storage_backup_comprehensive.cpp index 3ad250b318..b97d5d4bb1 100644 --- a/tests/test_cloud_storage_backup_comprehensive.cpp +++ b/tests/test_cloud_storage_backup_comprehensive.cpp @@ -134,6 +134,16 @@ class CloudStorageBackupTest : public ::testing::Test { #endif return false; } + + bool isExpectedCloudTransportError(const std::string& error_msg) { + return error_msg.find("credentials") != std::string::npos || + error_msg.find("authentication") != std::string::npos || + error_msg.find("not available") != std::string::npos || + error_msg.find("unavailable") != std::string::npos || + error_msg.find("not linked") != std::string::npos || + error_msg.find("not found") != std::string::npos || + error_msg.find("unsupported") != std::string::npos; + } fs::path test_dir_; fs::path db_path_; @@ -176,8 +186,8 @@ TEST_F(CloudStorageBackupTest, UploadToCloudInterfaceExists) { if (!isCloudProviderAvailable("aws")) { EXPECT_FALSE(s3_result.has_value()) << "Should fail when AWS SDK not available"; EXPECT_TRUE(s3_result.error().message().find("not available") != std::string::npos || - s3_result.error().message().find("not yet implemented") != std::string::npos || - s3_result.error().message().find("not implemented") != std::string::npos); + s3_result.error().message().find("not linked") != std::string::npos || + s3_result.error().message().find("unavailable") != std::string::npos); } // If AWS SDK is available but no credentials, should return auth error } @@ -275,14 +285,8 @@ TEST_F(CloudStorageBackupTest, UploadBackupToS3) { // Should return cloud URI or credential error if (!result.has_value()) { - // Check for expected error messages (current implementation is a stub) std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not yet implemented") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ) << "Unexpected error: " << error_msg; + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)) << "Unexpected error: " << error_msg; } } @@ -386,11 +390,7 @@ TEST_F(CloudStorageBackupTest, UploadBackupToAzureBlob) { // Should fail without valid credentials if (!result.has_value()) { std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ); + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)); } } @@ -496,11 +496,7 @@ TEST_F(CloudStorageBackupTest, UploadBackupToGCS) { // Should fail without valid credentials if (!result.has_value()) { std::string error_msg = result.error().message(); - EXPECT_TRUE( - error_msg.find("credentials") != std::string::npos || - error_msg.find("authentication") != std::string::npos || - error_msg.find("not implemented") != std::string::npos - ); + EXPECT_TRUE(isExpectedCloudTransportError(error_msg)); } } @@ -663,8 +659,8 @@ TEST_F(CloudStorageBackupTest, InvalidCloudURIHandling) { error_msg.find("Invalid cloud URI") != std::string::npos || error_msg.find("invalid") != std::string::npos || error_msg.find("URI") != std::string::npos || - error_msg.find("not yet implemented") != std::string::npos || - error_msg.find("not implemented") != std::string::npos || + error_msg.find("not linked") != std::string::npos || + error_msg.find("unsupported") != std::string::npos || error_msg.find("not found") != std::string::npos ) << "Expected error message for invalid URI: " << uri << ", got: " << error_msg; } @@ -701,6 +697,32 @@ TEST_F(CloudStorageBackupTest, MissingLocalBackupHandling) { } } +TEST_F(CloudStorageBackupTest, UploadBackupRejectsOversizedRemotePayloadBeforeTransfer) { + const auto oversized_file = backup_path_ / "oversized_payload.bin"; + { + std::ofstream output(oversized_file, std::ios::binary); + ASSERT_TRUE(output.is_open()); + output.seekp(static_cast(256ull * 1024ull * 1024ull)); + output.put('\0'); + } + + BackupOptions options; + options.storage = StorageBackend::S3; + + auto result = backup_mgr_->uploadBackupToCloud( + oversized_file.string(), + "s3://test-bucket/oversized", + options + ); + + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error().code(), errors::ErrorCode::ERR_UTIL_ALLOCATION_FAILED); + EXPECT_NE(result.error().message().find("in-memory transfer limit"), std::string::npos); + + std::error_code ec; + std::filesystem::remove(oversized_file, ec); +} + /** * Intent: Test handling of authentication failures * diff --git a/tests/test_grpc_web_proxy_bridge.cpp b/tests/test_grpc_web_proxy_bridge.cpp index c0fd8411a3..69e7b5bc10 100644 --- a/tests/test_grpc_web_proxy_bridge.cpp +++ b/tests/test_grpc_web_proxy_bridge.cpp @@ -8,6 +8,7 @@ #include "server/grpc_web_proxy_handler.h" #include +#include #include #include #include @@ -118,6 +119,15 @@ TEST_F(GrpcWebProxyBridgeTest, InjectedFnIsCalled) EXPECT_EQ(res.result(), http::status::ok); EXPECT_TRUE(fn_called); EXPECT_EQ(extractGrpcStatus(res.body()), 0); + +#ifndef THEMIS_ENABLE_GRPC + http::request status_req{http::verb::get, "/api/v1/grpc-web/status", 11}; + auto status_res = handler.handleStatus(status_req); + EXPECT_EQ(status_res.result(), http::status::ok); + auto status_body = nlohmann::json::parse(status_res.body()); + EXPECT_TRUE(status_body["requests_supported"].get()); + EXPECT_EQ(status_body["backend_mode"].get(), "override"); +#endif } TEST_F(GrpcWebProxyBridgeTest, ThrowingFnPropagatesException) diff --git a/tests/test_grpc_web_proxy_handler.cpp b/tests/test_grpc_web_proxy_handler.cpp index be494ea535..a19fa64e0b 100644 --- a/tests/test_grpc_web_proxy_handler.cpp +++ b/tests/test_grpc_web_proxy_handler.cpp @@ -248,6 +248,8 @@ TEST_F(GrpcWebProxyHandlerTest, Status_Returns200WithJson) { EXPECT_TRUE(body.contains("backend_tls")); EXPECT_TRUE(body.contains("deadline_ms")); EXPECT_TRUE(body.contains("cors_allow_origin")); + EXPECT_TRUE(body.contains("requests_supported")); + EXPECT_TRUE(body.contains("backend_mode")); } TEST_F(GrpcWebProxyHandlerTest, Status_ReflectsConfig) { @@ -266,6 +268,14 @@ TEST_F(GrpcWebProxyHandlerTest, Status_ReflectsConfig) { EXPECT_EQ(body["backend_tls"].get(), true); EXPECT_EQ(body["deadline_ms"].get(), 5000u); EXPECT_EQ(body["cors_allow_origin"].get(), "https://ui.example.com"); +#ifdef THEMIS_ENABLE_GRPC + EXPECT_TRUE(body["requests_supported"].get()); + EXPECT_EQ(body["backend_mode"].get(), "grpc"); +#else + EXPECT_FALSE(body["requests_supported"].get()); + EXPECT_EQ(body["backend_mode"].get(), "unavailable"); + EXPECT_EQ(body["reason"].get(), "gRPC backend not available in this build"); +#endif } // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_http_rope.cpp b/tests/test_http_rope.cpp index ea49190894..1528e7eb7b 100644 --- a/tests/test_http_rope.cpp +++ b/tests/test_http_rope.cpp @@ -386,6 +386,11 @@ TEST_F(HttpRopeApiTest, DisableRoPE) { ASSERT_TRUE(response.contains("status")); EXPECT_EQ(response["status"], "success"); + ASSERT_TRUE(response.contains("enabled")); + EXPECT_FALSE(response["enabled"]); + + auto get_response = httpGet("/api/v1/vector-index/test_rope/rope/config"); + ASSERT_TRUE(get_response.contains("error")); } // Test 9: Invalid Configuration (hidden_dim not even) diff --git a/tests/test_http_timeseries.cpp b/tests/test_http_timeseries.cpp index 72c5e14444..1e04d4986d 100644 --- a/tests/test_http_timeseries.cpp +++ b/tests/test_http_timeseries.cpp @@ -381,6 +381,8 @@ TEST_F(HttpTimeSeriesTest, GetAggregates_ReturnsList) { ASSERT_TRUE(response.contains("materialized_aggregates")); EXPECT_TRUE(response["materialized_aggregates"].is_array()); ASSERT_TRUE(response.contains("materialized_count")); + EXPECT_EQ(response["source"].get(), "builtin"); + EXPECT_TRUE(response["degraded_mode"].get()); } // Test: Get retention policies @@ -393,6 +395,8 @@ TEST_F(HttpTimeSeriesTest, GetRetention_ReturnsPolicies) { EXPECT_TRUE(response["policies"].is_array()); ASSERT_TRUE(response.contains("policy_count")); EXPECT_EQ(response["policy_count"].get(), response["policies"].size()); + EXPECT_EQ(response["source"].get(), "storage_config"); + EXPECT_TRUE(response["degraded_mode"].get()); } // Test: Multiple metrics with label filtering diff --git a/tests/test_security_signature_rocksdb_iteration.cpp b/tests/test_security_signature_rocksdb_iteration.cpp index 9f7fe780c7..a44f6bd718 100644 --- a/tests/test_security_signature_rocksdb_iteration.cpp +++ b/tests/test_security_signature_rocksdb_iteration.cpp @@ -323,15 +323,31 @@ TEST_F(SecuritySignatureRocksDBIterationTests, VerifyAll_MixedResults) { // In-memory fallback path (no RocksDB) // --------------------------------------------------------------------------- -TEST(SecuritySignatureVerifyAllFallbackTests, FallbackVerifyAll_EmptyStore) { - SecuritySignatureManager mgr(nullptr); // triggers fallback +TEST(SecuritySignatureVerifyAllFallbackTests, NullBackendFailsClosedWithoutExplicitFallback) { + SecuritySignatureManager mgr(nullptr); + SecuritySignature sig; + sig.resource_id = "fb_file"; + sig.hash = std::string(64, 'e'); + sig.algorithm = "sha256"; + sig.created_at = 42; + + EXPECT_FALSE(mgr.isUsingFallbackMemoryStore()); + EXPECT_FALSE(mgr.hasPersistentBackend()); + EXPECT_FALSE(mgr.storeSignature(sig)); + EXPECT_TRUE(mgr.listAllSignatures().empty()); + auto result = mgr.verifyAll(); EXPECT_EQ(result.total, 0); - EXPECT_TRUE(result.success()); + EXPECT_FALSE(result.backend_available); + EXPECT_FALSE(result.used_fallback_memory_store); + EXPECT_FALSE(result.success()); + EXPECT_FALSE(result.error_message.empty()); } -TEST(SecuritySignatureVerifyAllFallbackTests, FallbackListAllSignatures_ReturnsStoredSigs) { - SecuritySignatureManager mgr(nullptr); +TEST(SecuritySignatureVerifyAllFallbackTests, ExplicitFallbackListAllSignatures_ReturnsStoredSigs) { + SecuritySignatureManager mgr( + nullptr, + SecuritySignatureManager::Options{.allow_in_memory_fallback = true}); SecuritySignature sig; sig.resource_id = "fb_file"; @@ -343,4 +359,17 @@ TEST(SecuritySignatureVerifyAllFallbackTests, FallbackListAllSignatures_ReturnsS auto sigs = mgr.listAllSignatures(); ASSERT_EQ(sigs.size(), 1u); EXPECT_EQ(sigs[0].resource_id, "fb_file"); + EXPECT_TRUE(mgr.isUsingFallbackMemoryStore()); + EXPECT_FALSE(mgr.hasPersistentBackend()); +} + +TEST(SecuritySignatureVerifyAllFallbackTests, ExplicitFallbackVerifyAll_EmptyStore) { + SecuritySignatureManager mgr( + nullptr, + SecuritySignatureManager::Options{.allow_in_memory_fallback = true}); + auto result = mgr.verifyAll(); + EXPECT_EQ(result.total, 0); + EXPECT_TRUE(result.backend_available); + EXPECT_TRUE(result.used_fallback_memory_store); + EXPECT_TRUE(result.success()); }