소스 검색

feat(config): CollectionConfigManager + per-collection timestamp precision

- New CollectionConfigManager caches configs from _collection_meta system collection
- MemoryStore::currentTimeFor(collection) consults manager for ns vs ms precision
- All document createdAt/updatedAt stamps route through currentTimeFor
- Collection metadata timestamps (coll->createdAt/updatedAt) stay in ms
- ConfigureCollection and GetCollectionConfig RPCs implemented
- MigrateCollectionTimestamps stubbed (implementation in next task)
- Default precision: ms — existing collections unchanged
fszontagh 3 달 전
부모
커밋
517139b681

+ 1 - 0
service/CMakeLists.txt

@@ -28,6 +28,7 @@ set(DATABASE_SERVICE_SOURCES
     src/migrations/migration_runner.cpp
     src/views/projection.cpp
     src/views/view_manager.cpp
+    src/config/collection_config_manager.cpp
 )
 
 # Create executable

+ 93 - 0
service/src/config/collection_config_manager.cpp

@@ -0,0 +1,93 @@
+#include "collection_config_manager.hpp"
+
+#include "../memory_store.hpp"
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::database {
+
+namespace {
+
+nlohmann::json toJson(const CollectionCfg& cfg) {
+    return {
+        {"timestamp_precision", cfg.timestampPrecision}
+    };
+}
+
+CollectionCfg fromJson(const nlohmann::json& j) {
+    CollectionCfg c;
+    c.timestampPrecision = j.value("timestamp_precision", std::string("ms"));
+    return c;
+}
+
+} // anonymous namespace
+
+CollectionConfigManager::CollectionConfigManager(MemoryStore& store) : store_(store) {}
+
+void CollectionConfigManager::loadFromStore() {
+    std::unique_lock<std::shared_mutex> lock(cacheMutex_);
+    cache_.clear();
+
+    // Ensure _collection_meta system collection exists
+    CollectionOptions opts;
+    store_.createCollection(SYSTEM_COLLECTION, opts);
+
+    // Load all config documents (docId == collection name)
+    Query q;
+    auto result = store_.find(SYSTEM_COLLECTION, q);
+    for (const auto& doc : result.documents) {
+        if (doc.id.empty()) continue;
+        cache_[doc.id] = fromJson(doc.data);
+    }
+    spdlog::info("CollectionConfigManager: loaded {} configs from {}",
+                 cache_.size(), SYSTEM_COLLECTION);
+}
+
+bool CollectionConfigManager::setConfig(const std::string& collection,
+                                         const CollectionCfg& cfg,
+                                         std::string& errorOut) {
+    if (collection.empty()) {
+        errorOut = "collection name is required";
+        return false;
+    }
+    if (cfg.timestampPrecision != "ms" && cfg.timestampPrecision != "ns") {
+        errorOut = "timestamp_precision must be 'ms' or 'ns'";
+        return false;
+    }
+
+    // Persist via upsert (document ID = collection name)
+    Document doc;
+    doc.id = collection;
+    doc.data = toJson(cfg);
+    try {
+        store_.upsert(SYSTEM_COLLECTION, doc);
+    } catch (const std::exception& e) {
+        errorOut = std::string("failed to persist config: ") + e.what();
+        return false;
+    }
+
+    {
+        std::unique_lock<std::shared_mutex> wlock(cacheMutex_);
+        cache_[collection] = cfg;
+    }
+    spdlog::info("CollectionConfigManager: set '{}' timestamp_precision={}",
+                 collection, cfg.timestampPrecision);
+    return true;
+}
+
+CollectionCfg CollectionConfigManager::configFor(const std::string& collection) const {
+    std::shared_lock<std::shared_mutex> lock(cacheMutex_);
+    auto it = cache_.find(collection);
+    if (it == cache_.end()) {
+        return CollectionCfg{};  // default (ms)
+    }
+    return it->second;
+}
+
+bool CollectionConfigManager::hasExplicitConfig(const std::string& collection) const {
+    std::shared_lock<std::shared_mutex> lock(cacheMutex_);
+    return cache_.contains(collection);
+}
+
+} // namespace smartbotic::database

+ 67 - 0
service/src/config/collection_config_manager.hpp

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <memory>
+#include <optional>
+#include <shared_mutex>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+namespace smartbotic::database {
+
+class MemoryStore;
+
+/**
+ * In-memory representation of per-collection configuration.
+ * Mirrors the CollectionConfig proto message.
+ */
+struct CollectionCfg {
+    // "ms" (default) or "ns"
+    std::string timestampPrecision = "ms";
+};
+
+/**
+ * CollectionConfigManager — loads, caches, and manages per-collection configuration
+ * (currently just timestamp precision).
+ *
+ * Configs are persisted as documents in the `_collection_meta` system collection.
+ * Document ID = collection name, document data = the config fields.
+ *
+ * Hot-path API: configFor(name) returns the cached config with O(1) lookup.
+ * When no explicit config exists, returns the default (ms precision).
+ */
+class CollectionConfigManager {
+public:
+    static constexpr const char* SYSTEM_COLLECTION = "_collection_meta";
+
+    explicit CollectionConfigManager(MemoryStore& store);
+
+    /**
+     * Load all configs from the store. Call once at startup, after persistence recovery.
+     */
+    void loadFromStore();
+
+    /**
+     * Set or replace a config for the given collection. Persists to store and updates cache.
+     * @return true on success. On failure errorOut is populated.
+     */
+    bool setConfig(const std::string& collection, const CollectionCfg& cfg, std::string& errorOut);
+
+    /**
+     * Hot-path lookup used by the write paths in MemoryStore.
+     * Returns the configured config, or the default if not explicitly set.
+     */
+    CollectionCfg configFor(const std::string& collection) const;
+
+    /**
+     * Returns true if the collection has an explicit config (vs. default).
+     */
+    bool hasExplicitConfig(const std::string& collection) const;
+
+private:
+    MemoryStore& store_;
+    mutable std::shared_mutex cacheMutex_;
+    std::unordered_map<std::string, CollectionCfg> cache_;
+};
+
+} // namespace smartbotic::database

+ 54 - 1
service/src/database_grpc_impl.cpp

@@ -18,13 +18,15 @@ DatabaseGrpcImpl::DatabaseGrpcImpl(
     EventManager& events,
     FileManager& files,
     EncryptionManager& encryption,
-    ViewManager& view_manager
+    ViewManager& view_manager,
+    CollectionConfigManager& config_manager
 ) : store_(store)
   , persistence_(persistence)
   , events_(events)
   , files_(files)
   , encryption_(encryption)
   , view_manager_(view_manager)
+  , config_manager_(config_manager)
 {
 }
 
@@ -1378,4 +1380,55 @@ grpc::Status DatabaseReplicationGrpcImpl::GetNodeState(
     return grpc::Status::OK;
 }
 
+// ===== Collection Config Operations =====
+
+grpc::Status DatabaseGrpcImpl::ConfigureCollection(
+    grpc::ServerContext* /*context*/,
+    const pb::ConfigureCollectionRequest* request,
+    pb::ConfigureCollectionResponse* response
+) {
+    try {
+        CollectionCfg cfg;
+        cfg.timestampPrecision = request->config().timestamp_precision();
+        if (cfg.timestampPrecision.empty()) {
+            cfg.timestampPrecision = "ms";  // default
+        }
+
+        // Idempotent: setConfig handles create-or-update
+        std::string err;
+        bool ok = config_manager_.setConfig(request->collection(), cfg, err);
+        response->set_success(ok);
+        if (!ok) {
+            response->set_error(err);
+        }
+        return grpc::Status::OK;
+    } catch (const std::exception& e) {
+        return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
+    }
+}
+
+grpc::Status DatabaseGrpcImpl::GetCollectionConfig(
+    grpc::ServerContext* /*context*/,
+    const pb::GetCollectionConfigRequest* request,
+    pb::GetCollectionConfigResponse* response
+) {
+    auto cfg = config_manager_.configFor(request->collection());
+    response->mutable_config()->set_timestamp_precision(cfg.timestampPrecision);
+    response->set_found(config_manager_.hasExplicitConfig(request->collection()));
+    return grpc::Status::OK;
+}
+
+grpc::Status DatabaseGrpcImpl::MigrateCollectionTimestamps(
+    grpc::ServerContext* /*context*/,
+    const pb::MigrateCollectionTimestampsRequest* /*request*/,
+    pb::MigrateCollectionTimestampsResponse* response
+) {
+    // Implemented in v1.6.0 T5 (separate commit)
+    response->set_success(false);
+    response->set_error("not yet implemented — coming in next task");
+    response->set_rows_migrated(0);
+    response->set_rows_skipped(0);
+    return grpc::Status::OK;
+}
+
 } // namespace smartbotic::database

+ 24 - 1
service/src/database_grpc_impl.hpp

@@ -7,6 +7,7 @@
 #include "encryption/encryption_manager.hpp"
 #include "replication/replication_manager.hpp"
 #include "views/view_manager.hpp"
+#include "config/collection_config_manager.hpp"
 
 #include <database.grpc.pb.h>
 
@@ -31,7 +32,8 @@ public:
         EventManager& events,
         FileManager& files,
         EncryptionManager& encryption,
-        ViewManager& view_manager
+        ViewManager& view_manager,
+        CollectionConfigManager& config_manager
     );
 
     ~DatabaseGrpcImpl() override = default;
@@ -224,6 +226,26 @@ public:
         pb::GetViewInfoResponse* response
     ) override;
 
+    // ===== Collection Config Operations =====
+
+    grpc::Status ConfigureCollection(
+        grpc::ServerContext* context,
+        const pb::ConfigureCollectionRequest* request,
+        pb::ConfigureCollectionResponse* response
+    ) override;
+
+    grpc::Status GetCollectionConfig(
+        grpc::ServerContext* context,
+        const pb::GetCollectionConfigRequest* request,
+        pb::GetCollectionConfigResponse* response
+    ) override;
+
+    grpc::Status MigrateCollectionTimestamps(
+        grpc::ServerContext* context,
+        const pb::MigrateCollectionTimestampsRequest* request,
+        pb::MigrateCollectionTimestampsResponse* response
+    ) override;
+
     // ===== File Operations =====
 
     grpc::Status UploadFile(
@@ -293,6 +315,7 @@ private:
     FileManager& files_;
     EncryptionManager& encryption_;
     ViewManager& view_manager_;
+    CollectionConfigManager& config_manager_;
     std::chrono::steady_clock::time_point startTime_ = std::chrono::steady_clock::now();
 };
 

+ 11 - 1
service/src/database_service.cpp

@@ -48,6 +48,11 @@ bool DatabaseService::initialize() {
         // populated by the persistence recovery above).
         view_manager_->loadFromStore();
 
+        // Load per-collection configs from the _collection_meta system collection.
+        // Must happen AFTER persistence recovery and BEFORE migrations so any
+        // migration-created documents are stamped with the correct precision.
+        config_manager_->loadFromStore();
+
         // Run migrations if enabled
         if (config_.migrations.enabled && !config_.migrations.directory.empty()) {
             if (!runMigrations()) {
@@ -307,6 +312,11 @@ void DatabaseService::setupComponents() {
     // Create view manager (cache loaded in initialize() after persistence recovery)
     view_manager_ = std::make_unique<ViewManager>(*store_);
 
+    // Create per-collection config manager and attach it to the store so the
+    // write paths route document timestamp stamps through it.
+    config_manager_ = std::make_unique<CollectionConfigManager>(*store_);
+    store_->setConfigManager(config_manager_.get());
+
     // Create persistence manager
     PersistenceManager::Config persistConfig;
     persistConfig.dataDir = config_.dataDirectory;
@@ -421,7 +431,7 @@ void DatabaseService::setupComponents() {
 
     // Create gRPC implementations
     storageImpl_ = std::make_unique<DatabaseGrpcImpl>(
-        *store_, *persistence_, *events_, *files_, *encryption_, *view_manager_
+        *store_, *persistence_, *events_, *files_, *encryption_, *view_manager_, *config_manager_
     );
     replicationImpl_ = std::make_unique<DatabaseReplicationGrpcImpl>(
         *store_, *replication_, *persistence_

+ 2 - 0
service/src/database_service.hpp

@@ -10,6 +10,7 @@
 #include "replication/replication_manager.hpp"
 #include "migrations/migration_runner.hpp"
 #include "views/view_manager.hpp"
+#include "config/collection_config_manager.hpp"
 
 #include <nlohmann/json.hpp>
 
@@ -148,6 +149,7 @@ private:
     std::unique_ptr<FileManager> files_;
     std::unique_ptr<ReplicationManager> replication_;
     std::unique_ptr<ViewManager> view_manager_;
+    std::unique_ptr<CollectionConfigManager> config_manager_;
 
     // gRPC
     std::unique_ptr<DatabaseGrpcImpl> storageImpl_;

+ 29 - 11
service/src/memory_store.cpp

@@ -1,5 +1,7 @@
 #include "memory_store.hpp"
 
+#include "config/collection_config_manager.hpp"
+
 #include <spdlog/spdlog.h>
 
 #include <algorithm>
@@ -237,7 +239,7 @@ std::string MemoryStore::insert(const std::string& collection, Document doc) {
     // Set metadata
     doc.collection = collection;
     doc.version = 1;
-    doc.createdAt = currentTimeMs();
+    doc.createdAt = currentTimeFor(collection);
     doc.updatedAt = doc.createdAt;
     doc.nodeId = config_.nodeId;
 
@@ -373,7 +375,7 @@ bool MemoryStore::update(const std::string& collection, const std::string& id, c
     updated.version = it->second.version + 1;
     updated.createdAt = it->second.createdAt;
     updated.createdBy = it->second.createdBy;  // Preserve original creator
-    updated.updatedAt = currentTimeMs();
+    updated.updatedAt = currentTimeFor(collection);
     updated.nodeId = config_.nodeId;
 
     // Add to new expiration index
@@ -450,7 +452,7 @@ std::string MemoryStore::upsert(const std::string& collection, Document doc) {
         isInsert = true;
         doc.collection = collection;
         doc.version = 1;
-        doc.createdAt = currentTimeMs();
+        doc.createdAt = currentTimeFor(collection);
         doc.updatedAt = doc.createdAt;
         doc.nodeId = config_.nodeId;
 
@@ -476,7 +478,7 @@ std::string MemoryStore::upsert(const std::string& collection, Document doc) {
         doc.version = it->second.version + 1;
         doc.createdAt = it->second.createdAt;
         doc.createdBy = it->second.createdBy;  // Preserve original creator
-        doc.updatedAt = currentTimeMs();
+        doc.updatedAt = currentTimeFor(collection);
         doc.nodeId = config_.nodeId;
 
         if (doc.expiresAt > 0) {
@@ -620,7 +622,7 @@ bool MemoryStore::updateIfVersion(const std::string& collection, const std::stri
     updated.version = expectedVersion + 1;
     updated.createdAt = it->second.createdAt;
     updated.createdBy = it->second.createdBy;  // Preserve original creator
-    updated.updatedAt = currentTimeMs();
+    updated.updatedAt = currentTimeFor(collection);
     updated.nodeId = config_.nodeId;
 
     // Add to new expiration index
@@ -690,7 +692,7 @@ uint64_t MemoryStore::patchDocument(const std::string& collection, const std::st
 
     // Update metadata
     it->second.version++;
-    it->second.updatedAt = currentTimeMs();
+    it->second.updatedAt = currentTimeFor(collection);
     it->second.nodeId = config_.nodeId;
     if (!actor.empty()) {
         it->second.updatedBy = actor;
@@ -926,7 +928,7 @@ bool MemoryStore::setAdd(const std::string& collection, const std::string& setId
         doc.data = nlohmann::json::object();
         doc.data["members"] = nlohmann::json::array({member});
         doc.version = 1;
-        doc.createdAt = currentTimeMs();
+        doc.createdAt = currentTimeFor(collection);
         doc.updatedAt = doc.createdAt;
         doc.nodeId = config_.nodeId;
 
@@ -967,7 +969,7 @@ bool MemoryStore::setAdd(const std::string& collection, const std::string& setId
     saveToHistory(*coll, it->second);
     members.push_back(member);
     it->second.version++;
-    it->second.updatedAt = currentTimeMs();
+    it->second.updatedAt = currentTimeFor(collection);
     it->second.nodeId = config_.nodeId;
     coll->updatedAt = it->second.updatedAt;
 
@@ -1018,7 +1020,7 @@ bool MemoryStore::setRemove(const std::string& collection, const std::string& se
     saveToHistory(*coll, it->second);
     it->second.data["members"] = newMembers;
     it->second.version++;
-    it->second.updatedAt = currentTimeMs();
+    it->second.updatedAt = currentTimeFor(collection);
     it->second.nodeId = config_.nodeId;
     coll->updatedAt = it->second.updatedAt;
 
@@ -1594,7 +1596,7 @@ uint64_t MemoryStore::restoreToVersion(const std::string& collection, const std:
         newVersion = docIt->second.version + 1;
         docIt->second.data = targetVer->data;
         docIt->second.version = newVersion;
-        docIt->second.updatedAt = currentTimeMs();
+        docIt->second.updatedAt = currentTimeFor(collection);
         docIt->second.updatedBy = actor;
         docIt->second.encrypted = targetVer->encrypted;
         docIt->second.encryptedFields = targetVer->encryptedFields;
@@ -1616,7 +1618,7 @@ uint64_t MemoryStore::restoreToVersion(const std::string& collection, const std:
         restoredDoc.version = newVersion;
         restoredDoc.createdAt = targetVer->createdAt;
         restoredDoc.createdBy = targetVer->createdBy;
-        restoredDoc.updatedAt = currentTimeMs();
+        restoredDoc.updatedAt = currentTimeFor(collection);
         restoredDoc.updatedBy = actor;
         restoredDoc.encrypted = targetVer->encrypted;
         restoredDoc.encryptedFields = targetVer->encryptedFields;
@@ -1871,6 +1873,22 @@ uint64_t MemoryStore::currentTimeMs() {
     );
 }
 
+uint64_t MemoryStore::currentTimeFor(const std::string& collection) const {
+    // Fast path: no manager attached → ms precision (default)
+    std::string precision = "ms";
+    if (configManager_) {
+        precision = configManager_->configFor(collection).timestampPrecision;
+    }
+
+    auto now = std::chrono::system_clock::now().time_since_epoch();
+    if (precision == "ns") {
+        return static_cast<uint64_t>(
+            std::chrono::duration_cast<std::chrono::nanoseconds>(now).count());
+    }
+    return static_cast<uint64_t>(
+        std::chrono::duration_cast<std::chrono::milliseconds>(now).count());
+}
+
 bool MemoryStore::matchesFilters(const Document& doc, const std::vector<Filter>& filters) const {
     for (const auto& filter : filters) {
         auto value = getJsonPath(doc.data, filter.field);

+ 19 - 0
service/src/memory_store.hpp

@@ -18,6 +18,10 @@
 
 namespace smartbotic::database {
 
+// Forward declaration — full include lives in memory_store.cpp to avoid
+// circular dependency (CollectionConfigManager itself holds a MemoryStore&).
+class CollectionConfigManager;
+
 /**
  * Callback for storage events.
  */
@@ -94,6 +98,13 @@ public:
     void setEventCallback(EventCallback callback);
     void setPersistCallback(PersistCallback callback);
 
+    /**
+     * Set the per-collection config manager. Stamps for document createdAt/updatedAt
+     * will consult the manager to pick between ns and ms precision.
+     * Safe to leave unset (tests, bootstrap) — stamping falls back to ms.
+     */
+    void setConfigManager(CollectionConfigManager* mgr) { configManager_ = mgr; }
+
     // ===== Collection Management =====
 
     /**
@@ -483,6 +494,11 @@ private:
     // Get current timestamp in milliseconds
     [[nodiscard]] static uint64_t currentTimeMs();
 
+    // Get current timestamp for a specific collection, respecting the collection's
+    // configured timestamp_precision ("ms" or "ns"). Falls back to ms when no
+    // config manager is attached or the collection has no explicit config.
+    [[nodiscard]] uint64_t currentTimeFor(const std::string& collection) const;
+
     // Get JSON value at a path (e.g., "user.email")
     [[nodiscard]] static std::optional<nlohmann::json> getJsonPath(const nlohmann::json& obj, const std::string& path);
 
@@ -539,6 +555,9 @@ private:
     PersistCallback persistCallback_;
     mutable std::mutex callbackMutex_;  // mutable for const peekEvictedDocument
 
+    // Per-collection config manager (optional; nullptr = default ms precision)
+    CollectionConfigManager* configManager_ = nullptr;
+
     // Background expiration thread
     std::thread expirationThread_;
 

+ 1 - 0
tests/CMakeLists.txt

@@ -5,6 +5,7 @@
 set(TEST_VECTOR_SOURCES
     test_vector_storage.cpp
     ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/memory_store.cpp
+    ${CMAKE_CURRENT_SOURCE_DIR}/../service/src/config/collection_config_manager.cpp
 )
 
 add_executable(test_vector_storage ${TEST_VECTOR_SOURCES})