Prechádzať zdrojové kódy

feat(observability): GetMemoryStats RPC + client API (v1.7.0 T9)

Returns:
  - total_memory_bytes, max_memory_bytes, pressure_percent, pressure_level
  - per-collection: document_count, estimated_bytes, evicted_stub_count, priority
  - last-eviction: timestamp, docs, bytes_freed

Evicted-stub counts come from the store-level evictedDocs_ map (stubs are
not stored per-collection).

evictOneChunk() now records the most recent real eviction (evictedCount > 0)
into stats_.lastEviction{Timestamp,Docs,BytesFreed} so GetMemoryStats can
report progress to operators.

Read-only RPC — not gated by read-only mode or emergency admission, since
operators need to see pressure/eviction stats especially when writes are
being refused.

Client-side smartbotic-db-cli integration lands in a separate task if
anyone needs it; the client method is enough for shadowman-cpp's
metrics-rollup to consume.
fszontagh 3 mesiacov pred
rodič
commit
886fdf99eb

+ 29 - 0
client/include/smartbotic/database/client.hpp

@@ -462,6 +462,35 @@ public:
      */
     [[nodiscard]] std::optional<StatsInfo> getStats();
 
+    // ===== Memory Stats (v1.7.0 T9 observability) =====
+
+    struct MemoryCollectionStats {
+        std::string collection;
+        uint64_t documentCount = 0;
+        uint64_t estimatedBytes = 0;
+        uint64_t evictedStubCount = 0;
+        std::string priority;   // "low" | "normal" | "high"
+    };
+
+    struct MemoryStats {
+        uint64_t totalMemoryBytes = 0;
+        uint64_t maxMemoryBytes = 0;
+        uint32_t pressurePercent = 0;
+        std::string pressureLevel;   // "normal" | "soft" | "hard" | "emergency"
+        std::vector<MemoryCollectionStats> collections;
+        uint64_t lastEvictionTimestamp = 0;
+        uint64_t lastEvictionDocs = 0;
+        uint64_t lastEvictionBytesFreed = 0;
+    };
+
+    /**
+     * Get per-collection memory usage, pressure level, and last-eviction info.
+     * Read-only RPC — safe to call even when the server is in emergency
+     * admission state. Intended for shadowman-cpp's metrics rollup and for
+     * operator dashboards.
+     */
+    [[nodiscard]] MemoryStats getMemoryStats();
+
     // ===== Read-Only Control =====
 
     struct ReadOnlyStatus {

+ 41 - 0
client/src/client.cpp

@@ -1135,6 +1135,43 @@ public:
         return info;
     }
 
+    Client::MemoryStats getMemoryStats() {
+        smartbotic::databasepb::GetMemoryStatsRequest request;
+        smartbotic::databasepb::GetMemoryStatsResponse response;
+        grpc::ClientContext context;
+        setDeadline(context);
+
+        Client::MemoryStats out;
+        auto status = stub_->GetMemoryStats(&context, request, &response);
+        if (!status.ok()) {
+            spdlog::error("Client::getMemoryStats failed: {}", status.error_message());
+            return out;
+        }
+        out.totalMemoryBytes       = response.total_memory_bytes();
+        out.maxMemoryBytes         = response.max_memory_bytes();
+        out.pressurePercent        = response.pressure_percent();
+        out.pressureLevel          = response.pressure_level();
+        out.lastEvictionTimestamp  = response.last_eviction_timestamp();
+        out.lastEvictionDocs       = response.last_eviction_docs();
+        out.lastEvictionBytesFreed = response.last_eviction_bytes_freed();
+        out.collections.reserve(response.collections_size());
+        for (const auto& pbc : response.collections()) {
+            Client::MemoryCollectionStats c;
+            c.collection      = pbc.collection();
+            c.documentCount   = pbc.document_count();
+            c.estimatedBytes  = pbc.estimated_bytes();
+            c.evictedStubCount = pbc.evicted_stub_count();
+            switch (pbc.priority()) {
+                case smartbotic::databasepb::MEMORY_PRIORITY_LOW:    c.priority = "low"; break;
+                case smartbotic::databasepb::MEMORY_PRIORITY_NORMAL: c.priority = "normal"; break;
+                case smartbotic::databasepb::MEMORY_PRIORITY_HIGH:   c.priority = "high"; break;
+                default:                                              c.priority = "normal"; break;
+            }
+            out.collections.push_back(std::move(c));
+        }
+        return out;
+    }
+
     // ===== Read-Only Control =====
 
     bool setReadOnly(bool readOnly) {
@@ -1548,6 +1585,10 @@ std::optional<Client::StatsInfo> Client::getStats() {
     return impl_->getStats();
 }
 
+Client::MemoryStats Client::getMemoryStats() {
+    return impl_->getMemoryStats();
+}
+
 bool Client::setReadOnly(bool readOnly) {
     return impl_->setReadOnly(readOnly);
 }

+ 37 - 0
service/src/database_grpc_impl.cpp

@@ -1223,6 +1223,43 @@ grpc::Status DatabaseGrpcImpl::GetStats(
     return grpc::Status::OK;
 }
 
+grpc::Status DatabaseGrpcImpl::GetMemoryStats(
+    grpc::ServerContext* /*context*/,
+    const pb::GetMemoryStatsRequest* /*request*/,
+    pb::GetMemoryStatsResponse* response
+) {
+    // Read-only observability RPC — intentionally not gated by read-only
+    // mode or the emergency-admission check. Operators need to see stats
+    // even (especially) when the store is refusing writes.
+    auto snap = store_.getMemoryStatsSnapshot();
+    response->set_total_memory_bytes(snap.totalMemoryBytes);
+    response->set_max_memory_bytes(snap.maxMemoryBytes);
+    response->set_pressure_percent(snap.pressurePercent);
+    response->set_pressure_level(memoryPressureToString(snap.pressureLevel));
+    response->set_last_eviction_timestamp(snap.lastEvictionTimestamp);
+    response->set_last_eviction_docs(snap.lastEvictionDocs);
+    response->set_last_eviction_bytes_freed(snap.lastEvictionBytesFreed);
+
+    auto toProtoPriority = [](MemoryPriority p) -> pb::MemoryPriority {
+        switch (p) {
+            case MemoryPriority::Low:    return pb::MEMORY_PRIORITY_LOW;
+            case MemoryPriority::Normal: return pb::MEMORY_PRIORITY_NORMAL;
+            case MemoryPriority::High:   return pb::MEMORY_PRIORITY_HIGH;
+        }
+        return pb::MEMORY_PRIORITY_NORMAL;
+    };
+
+    for (const auto& c : snap.collections) {
+        auto* pbc = response->add_collections();
+        pbc->set_collection(c.collection);
+        pbc->set_document_count(c.documentCount);
+        pbc->set_estimated_bytes(c.estimatedBytes);
+        pbc->set_evicted_stub_count(c.evictedStubCount);
+        pbc->set_priority(toProtoPriority(c.priority));
+    }
+    return grpc::Status::OK;
+}
+
 // ===== Helper Methods =====
 
 pb::Document DatabaseGrpcImpl::toProto(const Document& doc) {

+ 6 - 0
service/src/database_grpc_impl.hpp

@@ -306,6 +306,12 @@ public:
         pb::GetStatsResponse* response
     ) override;
 
+    grpc::Status GetMemoryStats(
+        grpc::ServerContext* context,
+        const pb::GetMemoryStatsRequest* request,
+        pb::GetMemoryStatsResponse* response
+    ) override;
+
     // ===== Read-Only Control =====
 
     grpc::Status SetReadOnly(

+ 59 - 0
service/src/memory_store.cpp

@@ -1559,6 +1559,55 @@ uint32_t MemoryStore::pressurePercent() const {
     return static_cast<uint32_t>(std::min<uint64_t>(100, used * 100 / max));
 }
 
+MemoryStore::MemoryStatsSnapshot MemoryStore::getMemoryStatsSnapshot() const {
+    MemoryStatsSnapshot s;
+    s.totalMemoryBytes = estimatedMemoryBytes_.load(std::memory_order_relaxed);
+    s.maxMemoryBytes   = config_.maxMemoryBytes;
+    s.pressurePercent  = pressurePercent();
+    s.pressureLevel    = pressure();
+
+    // Snapshot last-eviction event under statsMutex_ — evictOneChunk writes
+    // these fields at the end of each chunk under the same mutex.
+    {
+        std::lock_guard<std::mutex> lock(statsMutex_);
+        s.lastEvictionTimestamp  = stats_.lastEvictionTimestamp;
+        s.lastEvictionDocs       = stats_.lastEvictionDocs;
+        s.lastEvictionBytesFreed = stats_.lastEvictionBytesFreed;
+    }
+
+    // Per-collection: documents + estimated size live under the coll's own
+    // mutex; evicted-stub counts live in the store-level evictedDocs_ map.
+    // Grab a shared lock on each — callers accept the O(docs) scan cost.
+    std::unordered_map<std::string, uint64_t> evictedCounts;
+    {
+        std::shared_lock<std::shared_mutex> elock(evictionMutex_);
+        for (const auto& [collName, stubs] : evictedDocs_) {
+            evictedCounts[collName] = stubs.size();
+        }
+    }
+
+    std::shared_lock<std::shared_mutex> glock(globalMutex_);
+    s.collections.reserve(collections_.size());
+    for (const auto& [name, collPtr] : collections_) {
+        CollectionStats cs;
+        cs.collection = name;
+        cs.priority   = collPtr->options.memoryPriority;
+        {
+            std::shared_lock<std::shared_mutex> clock(collPtr->mutex);
+            cs.documentCount = collPtr->documents.size();
+            for (const auto& [id, doc] : collPtr->documents) {
+                cs.estimatedBytes += estimateDocumentSize(doc);
+            }
+        }
+        auto eIt = evictedCounts.find(name);
+        if (eIt != evictedCounts.end()) {
+            cs.evictedStubCount = eIt->second;
+        }
+        s.collections.push_back(std::move(cs));
+    }
+    return s;
+}
+
 // ===== Version History =====
 
 void MemoryStore::saveToHistory(CollectionData& coll, const Document& currentDoc) {
@@ -2627,6 +2676,16 @@ uint64_t MemoryStore::evictOneChunk() {
     uint64_t actuallyFreed = (bytesFreedBefore > bytesFreedAfter)
         ? (bytesFreedBefore - bytesFreedAfter) : 0;
 
+    // v1.7.0 T9: record last-eviction event for observability (GetMemoryStats).
+    // Only update when we actually evicted something, so "last eviction" means
+    // "most recent real eviction" rather than "most recent attempt".
+    if (evictedCount > 0) {
+        std::lock_guard<std::mutex> statsLock(statsMutex_);
+        stats_.lastEvictionTimestamp  = currentTimeMs();
+        stats_.lastEvictionDocs       = evictedCount;
+        stats_.lastEvictionBytesFreed = actuallyFreed;
+    }
+
     spdlog::info("Eviction chunk: {} docs evicted, {} bytes freed, pressure={}",
                  evictedCount, actuallyFreed, memoryPressureToString(pressure()));
 

+ 39 - 0
service/src/memory_store.hpp

@@ -352,6 +352,12 @@ public:
         uint64_t evictionCount = 0;        // Total evictions performed
         uint64_t recoveryCount = 0;        // Documents recovered from eviction
 
+        // Most recent eviction chunk (0 timestamp = none yet). Updated at the
+        // end of evictOneChunk() so observability callers can see progress.
+        uint64_t lastEvictionTimestamp = 0;
+        uint64_t lastEvictionDocs = 0;
+        uint64_t lastEvictionBytesFreed = 0;
+
         // Operation timing (microseconds) - for performance monitoring
         uint64_t getTotalMicros = 0;       // Total time spent in get operations
         uint64_t getMaxMicros = 0;         // Max single get operation time
@@ -371,6 +377,39 @@ public:
 
     [[nodiscard]] Stats getStats() const;
 
+    // ===== Memory Stats Snapshot (v1.7.0 T9 observability) =====
+
+    struct CollectionStats {
+        std::string collection;
+        uint64_t documentCount = 0;
+        uint64_t estimatedBytes = 0;
+        uint64_t evictedStubCount = 0;
+        MemoryPriority priority = MemoryPriority::Normal;
+    };
+
+    struct MemoryStatsSnapshot {
+        uint64_t totalMemoryBytes = 0;
+        uint64_t maxMemoryBytes = 0;
+        uint32_t pressurePercent = 0;
+        MemoryPressure pressureLevel = MemoryPressure::Normal;
+        std::vector<CollectionStats> collections;
+
+        // Most recent eviction event (0 timestamp = none yet)
+        uint64_t lastEvictionTimestamp = 0;
+        uint64_t lastEvictionDocs = 0;
+        uint64_t lastEvictionBytesFreed = 0;
+    };
+
+    /**
+     * Collect per-collection memory usage and the latest eviction event.
+     * Read-only snapshot intended for the GetMemoryStats RPC and operator
+     * observability. Takes a global shared lock plus a shared lock on each
+     * collection, so it's safe to call while the store is under load but
+     * the cost scales with total document count (it sums estimated sizes
+     * per collection).
+     */
+    [[nodiscard]] MemoryStatsSnapshot getMemoryStatsSnapshot() const;
+
     /**
      * Get the current configuration.
      */