|
@@ -3,6 +3,7 @@
|
|
|
#include "project_addressing.hpp"
|
|
#include "project_addressing.hpp"
|
|
|
#include "storage/cosine_simd.hpp"
|
|
#include "storage/cosine_simd.hpp"
|
|
|
#include "storage/document_store.hpp"
|
|
#include "storage/document_store.hpp"
|
|
|
|
|
+#include "storage/document_store_lmdb.hpp"
|
|
|
#include "json_parse.hpp"
|
|
#include "json_parse.hpp"
|
|
|
#include "persistence/wal.hpp"
|
|
#include "persistence/wal.hpp"
|
|
|
#include "views/projection.hpp"
|
|
#include "views/projection.hpp"
|
|
@@ -2713,6 +2714,200 @@ grpc::Status DatabaseReplicationGrpcImpl::GetNodeState(
|
|
|
|
|
|
|
|
// ===== Collection Config Operations =====
|
|
// ===== Collection Config Operations =====
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// -------------------------------------------------------------------------
|
|
|
|
|
+// v2.9.0 — secondary index management.
|
|
|
|
|
+//
|
|
|
|
|
+// Declaration is explicit rather than automatic. An index costs write
|
|
|
|
|
+// throughput, and indexing every field would build ones the planner never uses:
|
|
|
|
|
+// on the live `executions` collection status="completed" matches 66% of rows,
|
|
|
|
|
+// where reading an index and then fetching two thirds of the collection by id is
|
|
|
|
|
+// slower than scanning.
|
|
|
|
|
+//
|
|
|
|
|
+// The declaration is persisted in CollectionCfg (`_collection_meta`), which is an
|
|
|
|
|
+// ordinary collection and therefore WAL'd and snapshotted for free. It MUST be
|
|
|
|
|
+// durable: if a restart lost it, writes would stop maintaining an index that
|
|
|
|
|
+// queries still consult, and the index would return stale rows rather than an
|
|
|
|
|
+// error.
|
|
|
|
|
+// -------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::CreateIndex(
|
|
|
|
|
+ grpc::ServerContext* context,
|
|
|
|
|
+ const pb::CreateIndexRequest* request,
|
|
|
|
|
+ pb::CreateIndexResponse* response
|
|
|
|
|
+) {
|
|
|
|
|
+ // An index is derived from stored data and changes write behaviour, so it
|
|
|
|
|
+ // gates as a write - same reasoning as ConfigureCollection.
|
|
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
|
|
+ if (auto st = gate(context, request->collection(),
|
|
|
|
|
+ smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (service_.isReadOnly()) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (request->field().empty()) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("field is required");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ // A masked field must not be indexable: the index would answer questions
|
|
|
|
|
+ // about values the principal is not allowed to see, which is the same
|
|
|
|
|
+ // inference channel that makes filtering on a masked field refused.
|
|
|
|
|
+ if (!dec.mask.empty() &&
|
|
|
|
|
+ std::find(dec.mask.begin(), dec.mask.end(), request->field()) != dec.mask.end()) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("cannot index a masked field");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const auto rc = smartbotic::database::resolveCollection(request->collection());
|
|
|
|
|
+ auto* ds = service_.docStore(rc.project);
|
|
|
|
|
+ if (ds == nullptr) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("no storage for project '" + rc.project + "'");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
|
|
|
|
|
+ if (lmdb == nullptr) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("secondary indexes require the LMDB substrate");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ CollectionCfg cfg = config_manager_.configFor(request->collection());
|
|
|
|
|
+ const bool existed =
|
|
|
|
|
+ std::find(cfg.indexedFields.begin(), cfg.indexedFields.end(),
|
|
|
|
|
+ request->field()) != cfg.indexedFields.end();
|
|
|
|
|
+
|
|
|
|
|
+ // Backfill BEFORE declaring. Between the declaration and the backfill an
|
|
|
|
|
+ // index is incomplete, and the planner would happily serve a query from
|
|
|
|
|
+ // it and omit rows. Building first means it is only ever consulted once
|
|
|
|
|
+ // complete.
|
|
|
|
|
+ const uint64_t rows = lmdb->build_index(rc.collection, request->field());
|
|
|
|
|
+
|
|
|
|
|
+ if (!existed) {
|
|
|
|
|
+ cfg.indexedFields.push_back(request->field());
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ if (!config_manager_.setConfig(request->collection(), cfg, err)) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("index built but could not be recorded: " + err);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lmdb->set_indexed_fields(rc.collection, cfg.indexedFields);
|
|
|
|
|
+
|
|
|
|
|
+ response->set_success(true);
|
|
|
|
|
+ response->set_rows_indexed(rows);
|
|
|
|
|
+ response->set_already_existed(existed);
|
|
|
|
|
+ spdlog::info("v2.9 index created coll={} field={} rows={} (already_existed={})",
|
|
|
|
|
+ request->collection(), request->field(), rows, existed);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ } catch (const std::invalid_argument& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::DropIndex(
|
|
|
|
|
+ grpc::ServerContext* context,
|
|
|
|
|
+ const pb::DropIndexRequest* request,
|
|
|
|
|
+ pb::DropIndexResponse* response
|
|
|
|
|
+) {
|
|
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
|
|
+ if (auto st = gate(context, request->collection(),
|
|
|
|
|
+ smartbotic::database::Access::Write, dec); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (service_.isReadOnly()) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("database is in read-only mode: " + service_.readOnlyReason());
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const auto rc = smartbotic::database::resolveCollection(request->collection());
|
|
|
|
|
+ auto* ds = service_.docStore(rc.project);
|
|
|
|
|
+ auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
|
|
|
|
|
+ if (lmdb == nullptr) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("secondary indexes require the LMDB substrate");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Undeclare FIRST. While the declaration stands, writes maintain the
|
|
|
|
|
+ // index; dropping the sub-db first would leave a window where writes
|
|
|
|
|
+ // recreate entries in a half-dropped index.
|
|
|
|
|
+ CollectionCfg cfg = config_manager_.configFor(request->collection());
|
|
|
|
|
+ auto it = std::find(cfg.indexedFields.begin(), cfg.indexedFields.end(),
|
|
|
|
|
+ request->field());
|
|
|
|
|
+ if (it != cfg.indexedFields.end()) {
|
|
|
|
|
+ cfg.indexedFields.erase(it);
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ if (!config_manager_.setConfig(request->collection(), cfg, err)) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error(err);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ lmdb->set_indexed_fields(rc.collection, cfg.indexedFields);
|
|
|
|
|
+ lmdb->drop_index(rc.collection, request->field());
|
|
|
|
|
+
|
|
|
|
|
+ // Idempotent: dropping an index that is not there is a success, the
|
|
|
|
|
+ // requested end state having been reached either way.
|
|
|
|
|
+ response->set_success(true);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ } catch (const std::invalid_argument& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::ListIndexes(
|
|
|
|
|
+ grpc::ServerContext* context,
|
|
|
|
|
+ const pb::ListIndexesRequest* request,
|
|
|
|
|
+ pb::ListIndexesResponse* response
|
|
|
|
|
+) {
|
|
|
|
|
+ smartbotic::database::Decision dec;
|
|
|
|
|
+ if (auto st = gate(context, request->collection(),
|
|
|
|
|
+ smartbotic::database::Access::Read, dec); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const auto rc = smartbotic::database::resolveCollection(request->collection());
|
|
|
|
|
+ auto* ds = service_.docStore(rc.project);
|
|
|
|
|
+ auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
|
|
|
|
|
+
|
|
|
|
|
+ const CollectionCfg cfg = config_manager_.configFor(request->collection());
|
|
|
|
|
+ for (const auto& f : cfg.indexedFields) {
|
|
|
|
|
+ // A masked field's index is not listed: its existence and cardinality
|
|
|
|
|
+ // are information about values the principal cannot read.
|
|
|
|
|
+ if (!dec.mask.empty() &&
|
|
|
|
|
+ std::find(dec.mask.begin(), dec.mask.end(), f) != dec.mask.end()) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ auto* info = response->add_indexes();
|
|
|
|
|
+ info->set_field(f);
|
|
|
|
|
+ if (lmdb != nullptr) {
|
|
|
|
|
+ if (auto st = lmdb->index_stats(rc.collection, f)) {
|
|
|
|
|
+ info->set_distinct_values(st->distinct_values);
|
|
|
|
|
+ info->set_entries(st->entries);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ response->set_success(true);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ } catch (const std::invalid_argument& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
grpc::Status DatabaseGrpcImpl::ConfigureCollection(
|
|
grpc::Status DatabaseGrpcImpl::ConfigureCollection(
|
|
|
grpc::ServerContext* context,
|
|
grpc::ServerContext* context,
|
|
|
const pb::ConfigureCollectionRequest* request,
|
|
const pb::ConfigureCollectionRequest* request,
|