|
@@ -837,134 +837,27 @@ grpc::Status DatabaseGrpcImpl::Delete(
|
|
|
// v2.11.0 T6a/T12 — referential integrity. After the access gate
|
|
// v2.11.0 T6a/T12 — referential integrity. After the access gate
|
|
|
// (authorisation before integrity - a caller must not learn about
|
|
// (authorisation before integrity - a caller must not learn about
|
|
|
// child counts on a collection they cannot read), before store_.remove()
|
|
// child counts on a collection they cannot read), before store_.remove()
|
|
|
- // so a blocked delete never mutates anything. Only meaningful on the
|
|
|
|
|
- // LMDB substrate, where the reverse index lives; if this project has no
|
|
|
|
|
- // LMDB store for some reason, there is nothing to enforce against and
|
|
|
|
|
- // the delete proceeds as before.
|
|
|
|
|
- if (!request->collection().empty() && request->collection()[0] != '_') {
|
|
|
|
|
- try {
|
|
|
|
|
- const auto rc = smartbotic::database::resolveCollection(request->collection());
|
|
|
|
|
- if (auto* ds = service_.docStore(rc.project)) {
|
|
|
|
|
- if (auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds)) {
|
|
|
|
|
- // Named local: CollectionCfg is returned by value, and
|
|
|
|
|
- // RelationEnforcer binds relationsEnforced by reference —
|
|
|
|
|
- // binding straight to the temporary's subobject would dangle.
|
|
|
|
|
- const CollectionCfg cfg = config_manager_.configFor(request->collection());
|
|
|
|
|
- RelationEnforcer enforcer(relation_manager_, *lmdb, cfg.relationsEnforced);
|
|
|
|
|
- std::string err;
|
|
|
|
|
- if (!enforcer.canDelete(request->collection(), request->id(), err)) {
|
|
|
|
|
- return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, err);
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // v2.11.0 T12 — cascade/set_null. restrict has already had
|
|
|
|
|
- // its say (canDelete, above); this is reached only when the
|
|
|
|
|
- // delete is otherwise permitted. relationsEnforced gates
|
|
|
|
|
- // this exactly like it gates restrict: disabled means every
|
|
|
|
|
- // on_delete policy is skipped, not just restrict, so a
|
|
|
|
|
- // collection with enforcement off keeps today's permissive
|
|
|
|
|
- // (dangling-reference) behaviour rather than half-enforcing.
|
|
|
|
|
- if (cfg.relationsEnforced) {
|
|
|
|
|
- bool hasDestructive = false;
|
|
|
|
|
- for (const auto& r : relation_manager_.relationsWithParent(request->collection())) {
|
|
|
|
|
- if (r.onDelete == smartbotic::database::OnDelete::Cascade ||
|
|
|
|
|
- r.onDelete == smartbotic::database::OnDelete::SetNull) {
|
|
|
|
|
- hasDestructive = true;
|
|
|
|
|
- break;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (hasDestructive) {
|
|
|
|
|
- bool parentExisted = false;
|
|
|
|
|
- // v2.11.0 T12 review (C2) — drive replication +
|
|
|
|
|
- // Subscribe events explicitly, once per mutation,
|
|
|
|
|
- // right after that mutation's MemoryStore apply.
|
|
|
|
|
- // See relation_cascade.hpp's file header for why
|
|
|
|
|
- // executeCascade() cannot go back through the
|
|
|
|
|
- // ordinary persistCallback_ path itself.
|
|
|
|
|
- auto notify = [this](const std::string& coll, const std::string& id,
|
|
|
|
|
- const std::optional<smartbotic::database::Document>& doc,
|
|
|
|
|
- smartbotic::database::EventType eventType) {
|
|
|
|
|
- service_.notifyReplicationAndEvents(coll, id, doc, eventType);
|
|
|
|
|
- };
|
|
|
|
|
- try {
|
|
|
|
|
- parentExisted = smartbotic::database::executeCascade(
|
|
|
|
|
- relation_manager_, *lmdb, persistence_, store_, config_manager_,
|
|
|
|
|
- request->collection(), request->id(), notify);
|
|
|
|
|
- } catch (const smartbotic::database::CascadeBlocked& e) {
|
|
|
|
|
- // v2.11.0 T12 review (I2) — a grandchild is
|
|
|
|
|
- // protected by its own restrict relation.
|
|
|
|
|
- // Nothing was written anywhere (this is thrown
|
|
|
|
|
- // from planCascade(), before writeCascadeWal()
|
|
|
|
|
- // ever runs) — same refusal shape restrict
|
|
|
|
|
- // itself uses.
|
|
|
|
|
- return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, e.what());
|
|
|
|
|
- } catch (const std::exception& e) {
|
|
|
|
|
- // v2.11.0 T12 review (I4, round 2 correction)
|
|
|
|
|
- // — WAL-first means a failure here can occur
|
|
|
|
|
- // AFTER the WAL entries for this cascade were
|
|
|
|
|
- // already durably fsynced (writeCascadeWal()
|
|
|
|
|
- // ran inside executeCascade() before
|
|
|
|
|
- // commitCascadeLmdb()/applyCascadeToMemory(),
|
|
|
|
|
- // either of which could be what actually
|
|
|
|
|
- // threw). This is NOT necessarily a no-op
|
|
|
|
|
- // failure. What the caller can and cannot
|
|
|
|
|
- // assume differs by WHICH of those two threw,
|
|
|
|
|
- // and neither is distinguishable from out
|
|
|
|
|
- // here:
|
|
|
|
|
- // - commitCascadeLmdb() threw: LMDB was
|
|
|
|
|
- // never committed (the WriteTxn aborts
|
|
|
|
|
- // unwritten), so LMDB still reflects the
|
|
|
|
|
- // pre-cascade state; MemoryStore is
|
|
|
|
|
- // unchanged too (step 5 never ran). Reads
|
|
|
|
|
- // see the pre-cascade state everywhere,
|
|
|
|
|
- // for now — but the WAL entries are
|
|
|
|
|
- // already durable, so the NEXT RESTART's
|
|
|
|
|
- // replay applies the whole cascade
|
|
|
|
|
- // regardless of this failure.
|
|
|
|
|
- // - applyCascadeToMemory() threw partway:
|
|
|
|
|
- // LMDB already committed (step 4
|
|
|
|
|
- // succeeded) — reads being LMDB-first,
|
|
|
|
|
- // the cascade IS ALREADY VISIBLE for
|
|
|
|
|
- // collections read through LMDB, even
|
|
|
|
|
- // though this call is reporting failure.
|
|
|
|
|
- // MemoryStore may be only PARTIALLY
|
|
|
|
|
- // applied (some mutations done, some
|
|
|
|
|
- // not), and `notify` may have already
|
|
|
|
|
- // fired for the mutations that did
|
|
|
|
|
- // apply.
|
|
|
|
|
- // There is no compensating "un-write" of the
|
|
|
|
|
- // WAL entry in either case — replaying it
|
|
|
|
|
- // again is safe (idempotent), erasing it is
|
|
|
|
|
- // not. Check server logs and current
|
|
|
|
|
- // document state (LMDB, not just MemoryStore)
|
|
|
|
|
- // before retrying or assuming nothing
|
|
|
|
|
- // happened.
|
|
|
|
|
- spdlog::error(
|
|
|
|
|
- "relations: cascade delete of '{}/{}' failed - the WAL entries for "
|
|
|
|
|
- "this cascade may already be durable (will apply on next restart "
|
|
|
|
|
- "regardless) and, if the failure was in the post-LMDB-commit step, "
|
|
|
|
|
- "the mutation may ALREADY be visible via LMDB-first reads even "
|
|
|
|
|
- "though this call is reporting failure: {}",
|
|
|
|
|
- request->collection(), request->id(), e.what());
|
|
|
|
|
- return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
|
|
|
- "cascade delete failed - its WAL entries may already be durable "
|
|
|
|
|
- "(will apply on the next restart regardless of this failure), and "
|
|
|
|
|
- "if the failure occurred after the LMDB commit, the mutation may "
|
|
|
|
|
- "ALREADY be visible via LMDB-first reads even though this call "
|
|
|
|
|
- "failed; check server logs and current document state (not just "
|
|
|
|
|
- "this response) before retrying: " + std::string(e.what()));
|
|
|
|
|
- }
|
|
|
|
|
- // executeCascade already deleted the parent (and its
|
|
|
|
|
- // vector, if any) atomically with every child
|
|
|
|
|
- // mutation — do not fall through to the plain
|
|
|
|
|
- // store_.remove() path below.
|
|
|
|
|
- response->set_deleted(parentExisted);
|
|
|
|
|
- return grpc::Status::OK;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } catch (const std::invalid_argument& e) {
|
|
|
|
|
- return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
|
|
+ // so a blocked delete never mutates anything.
|
|
|
|
|
+ //
|
|
|
|
|
+ // v2.11.0 final review (finding 1) — the body of this lives in
|
|
|
|
|
+ // checkRelationDeleteAllowed()/performRelationCascade() so that
|
|
|
|
|
+ // BatchDelete enforces identically instead of bypassing all of it.
|
|
|
|
|
+ if (auto st = checkRelationDeleteAllowed(request->collection(), request->id()); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ {
|
|
|
|
|
+ bool handled = false;
|
|
|
|
|
+ bool parentExisted = false;
|
|
|
|
|
+ if (auto st = performRelationCascade(request->collection(), request->id(),
|
|
|
|
|
+ handled, parentExisted); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (handled) {
|
|
|
|
|
+ // performRelationCascade already deleted the parent (and its
|
|
|
|
|
+ // vector, if any) atomically with every child mutation - do not
|
|
|
|
|
+ // fall through to the plain store_.remove() path below.
|
|
|
|
|
+ response->set_deleted(parentExisted);
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1286,7 +1179,73 @@ grpc::Status DatabaseGrpcImpl::BatchDelete(
|
|
|
"cannot write to view '" + request->collection() + "': views are read-only");
|
|
"cannot write to view '" + request->collection() + "': views are read-only");
|
|
|
}
|
|
}
|
|
|
std::vector<std::string> ids(request->ids().begin(), request->ids().end());
|
|
std::vector<std::string> ids(request->ids().begin(), request->ids().end());
|
|
|
- uint64_t deleted = store_.bulkDelete(request->collection(), ids);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // v2.11.0 final review (finding 1) — REFERENTIAL INTEGRITY. This handler
|
|
|
|
|
+ // used to call store_.bulkDelete() straight through: no canDelete, no
|
|
|
|
|
+ // cascade, no set_null. Any caller with ordinary write access could delete
|
|
|
|
|
+ // a restrict-protected parent just by putting its id in a batch, defeating
|
|
|
|
|
+ // the feature through a sibling RPC. Same enforcement Delete uses, via the
|
|
|
|
|
+ // same two helpers, so the two cannot drift.
|
|
|
|
|
+ //
|
|
|
|
|
+ // ALL-OR-NOTHING on the restrict check, and deliberately so: BatchDelete's
|
|
|
|
|
+ // response carries only deleted_count, with no per-id error channel, so a
|
|
|
|
|
+ // partial refusal would be indistinguishable from ids that simply did not
|
|
|
|
|
+ // exist. Checking every id BEFORE deleting anything also preserves Delete's
|
|
|
|
|
+ // property that a blocked delete never mutates anything. The refusal names
|
|
|
|
|
+ // the blocking relation for each blocked id, since that is what the caller
|
|
|
|
|
+ // needs in order to act.
|
|
|
|
|
+ {
|
|
|
|
|
+ std::string blocked;
|
|
|
|
|
+ size_t blockedCount = 0;
|
|
|
|
|
+ for (const auto& id : ids) {
|
|
|
|
|
+ auto st = checkRelationDeleteAllowed(request->collection(), id);
|
|
|
|
|
+ if (st.ok()) continue;
|
|
|
|
|
+ if (st.error_code() != grpc::StatusCode::FAILED_PRECONDITION) {
|
|
|
|
|
+ return st; // INVALID_ARGUMENT / INTERNAL - surface as-is
|
|
|
|
|
+ }
|
|
|
|
|
+ ++blockedCount;
|
|
|
|
|
+ // Cap the message: a batch of 10k blocked ids must not produce a
|
|
|
|
|
+ // 10k-entry error string. The count is exact regardless.
|
|
|
|
|
+ if (blockedCount <= 5) {
|
|
|
|
|
+ if (!blocked.empty()) blocked += "; ";
|
|
|
|
|
+ blocked += st.error_message();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (blockedCount > 0) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION,
|
|
|
|
|
+ "batch delete refused: " + std::to_string(blockedCount) + " of " +
|
|
|
|
|
+ std::to_string(ids.size()) + " document(s) are protected by a relation, "
|
|
|
|
|
+ "and nothing was deleted (BatchDelete has no per-id error channel, so it "
|
|
|
|
|
+ "is all-or-nothing). " +
|
|
|
|
|
+ (blockedCount > 5 ? "First five: " : "") + blocked);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uint64_t deleted = 0;
|
|
|
|
|
+ std::vector<std::string> plain; // ids with no destructive relation
|
|
|
|
|
+ plain.reserve(ids.size());
|
|
|
|
|
+ for (const auto& id : ids) {
|
|
|
|
|
+ bool handled = false;
|
|
|
|
|
+ bool parentExisted = false;
|
|
|
|
|
+ // Per id, because a cascade is one atomic LMDB transaction per parent
|
|
|
|
|
+ // (see relation_cascade.hpp) - there is no batched form of it. Any
|
|
|
|
|
+ // failure aborts the rest of the batch rather than continuing: after a
|
|
|
|
|
+ // cascade fault the WAL may already hold durable entries (see
|
|
|
|
|
+ // performRelationCascade's INTERNAL branch), and pressing on would make
|
|
|
|
|
+ // the reported count meaningless.
|
|
|
|
|
+ if (auto st = performRelationCascade(request->collection(), id,
|
|
|
|
|
+ handled, parentExisted); !st.ok()) {
|
|
|
|
|
+ return st;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (handled) {
|
|
|
|
|
+ if (parentExisted) ++deleted;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ plain.push_back(id);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!plain.empty()) {
|
|
|
|
|
+ deleted += store_.bulkDelete(request->collection(), plain);
|
|
|
|
|
+ }
|
|
|
response->set_deleted_count(deleted);
|
|
response->set_deleted_count(deleted);
|
|
|
return grpc::Status::OK;
|
|
return grpc::Status::OK;
|
|
|
}
|
|
}
|
|
@@ -1828,6 +1787,55 @@ grpc::Status DatabaseGrpcImpl::DropCollection(
|
|
|
if (store_.pressure() == MemoryPressure::Emergency) {
|
|
if (store_.pressure() == MemoryPressure::Emergency) {
|
|
|
return memoryEmergencyStatus("DropCollection", store_);
|
|
return memoryEmergencyStatus("DropCollection", store_);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // v2.11.0 final review (finding 1, from the "audit every other RPC that can
|
|
|
|
|
+ // delete a document" sweep) — DROPPING A COLLECTION IS A DELETE OF EVERY
|
|
|
|
|
+ // DOCUMENT IN IT, and this handler enforced nothing. Both directions were
|
|
|
|
|
+ // broken, and neither logged anything:
|
|
|
|
|
+ //
|
|
|
|
|
+ // - Dropping the PARENT collection destroyed every restrict-protected
|
|
|
|
|
+ // parent at once, leaving every child pointing at nothing - the exact
|
|
|
|
|
+ // outcome the feature exists to prevent, reachable with one RPC.
|
|
|
|
|
+ // - Dropping the CHILD collection is worse in a quieter way:
|
|
|
|
|
+ // LmdbDocumentStore::drop_collection() drops the docs and vectors
|
|
|
|
|
+ // sub-dbs ONLY, so every reverse-index posting survives. `restrict`
|
|
|
|
|
+ // then counts children that no longer exist and blocks the parent's
|
|
|
|
|
+ // delete forever, with `relations check` unable to explain it because
|
|
|
|
|
+ // the child collection is gone.
|
|
|
|
|
+ //
|
|
|
|
|
+ // Refused rather than cascaded: this is what SQL's `DROP TABLE` does with
|
|
|
|
|
+ // a foreign key present (RESTRICT is the default), the remedy is one
|
|
|
|
|
+ // command (`DropRelation`), and silently dropping the relation on the
|
|
|
|
|
+ // operator's behalf would destroy a declaration they may want back.
|
|
|
|
|
+ //
|
|
|
|
|
+ // ⚠ Deliberately NOT gated on relationsEnforced. That flag is the escape
|
|
|
|
|
+ // hatch for "let this delete leave a dangling reference"; the child-side
|
|
|
|
|
+ // consequence here is a stale index that corrupts future enforcement for a
|
|
|
|
|
+ // DIFFERENT collection, which is a storage-consistency problem rather than
|
|
|
|
|
+ // an enforcement policy one.
|
|
|
|
|
+ {
|
|
|
|
|
+ std::vector<std::string> involved;
|
|
|
|
|
+ for (const auto& r : relation_manager_.relationsWithParent(request->name())) {
|
|
|
|
|
+ involved.push_back(r.name + " (as parent, child " + r.child + "." + r.childField + ")");
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const auto& r : relation_manager_.relationsWithChild(request->name())) {
|
|
|
|
|
+ involved.push_back(r.name + " (as child, parent " + r.parent + ")");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!involved.empty()) {
|
|
|
|
|
+ std::string list;
|
|
|
|
|
+ for (const auto& s : involved) {
|
|
|
|
|
+ if (!list.empty()) list += "; ";
|
|
|
|
|
+ list += s;
|
|
|
|
|
+ }
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION,
|
|
|
|
|
+ "cannot drop collection '" + request->name() + "': it participates in " +
|
|
|
|
|
+ std::to_string(involved.size()) + " declared relation(s) - " + list +
|
|
|
|
|
+ ". Dropping it would either orphan every child row or leave reverse-index "
|
|
|
|
|
+ "postings for rows that no longer exist (which would block the parent's "
|
|
|
|
|
+ "deletes permanently). Drop the relation(s) first.");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
bool dropped = store_.dropCollection(request->name());
|
|
bool dropped = store_.dropCollection(request->name());
|
|
|
response->set_dropped(dropped);
|
|
response->set_dropped(dropped);
|
|
|
|
|
|
|
@@ -2812,22 +2820,14 @@ grpc::Status DatabaseGrpcImpl::GetViewInfo(
|
|
|
// (or open, when nothing anywhere is secured — the pre-2.7.0 world).
|
|
// (or open, when nothing anywhere is secured — the pre-2.7.0 world).
|
|
|
|
|
|
|
|
namespace {
|
|
namespace {
|
|
|
-std::string relationOnDeleteToString(OnDelete v) {
|
|
|
|
|
- switch (v) {
|
|
|
|
|
- case OnDelete::Restrict: return "restrict";
|
|
|
|
|
- case OnDelete::Cascade: return "cascade";
|
|
|
|
|
- case OnDelete::SetNull: return "set_null";
|
|
|
|
|
- case OnDelete::NoAction: return "no_action";
|
|
|
|
|
- }
|
|
|
|
|
- return "restrict";
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-OnDelete relationOnDeleteFromString(const std::string& s) {
|
|
|
|
|
- if (s == "cascade") return OnDelete::Cascade;
|
|
|
|
|
- if (s == "set_null") return OnDelete::SetNull;
|
|
|
|
|
- if (s == "no_action") return OnDelete::NoAction;
|
|
|
|
|
- return OnDelete::Restrict;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+// v2.11.0 final review (finding 9) — the local relationOnDeleteToString /
|
|
|
|
|
+// relationOnDeleteFromString pair is GONE. Both were duplicates of
|
|
|
|
|
+// relation_manager's own, and the FromString half silently coerced any
|
|
|
|
|
+// unrecognised value to Restrict. There is now exactly one parser
|
|
|
|
|
+// (smartbotic::database::parseOnDelete, which returns nullopt instead of
|
|
|
|
|
+// guessing) and one renderer (onDeleteToString).
|
|
|
|
|
+using smartbotic::database::onDeleteToString;
|
|
|
|
|
+using smartbotic::database::parseOnDelete;
|
|
|
|
|
|
|
|
pb::RelationDefinition relationToProto(const RelationInfo& r) {
|
|
pb::RelationDefinition relationToProto(const RelationInfo& r) {
|
|
|
pb::RelationDefinition out;
|
|
pb::RelationDefinition out;
|
|
@@ -2835,7 +2835,7 @@ pb::RelationDefinition relationToProto(const RelationInfo& r) {
|
|
|
out.set_child(r.child);
|
|
out.set_child(r.child);
|
|
|
out.set_child_field(r.childField);
|
|
out.set_child_field(r.childField);
|
|
|
out.set_parent(r.parent);
|
|
out.set_parent(r.parent);
|
|
|
- out.set_on_delete(relationOnDeleteToString(r.onDelete));
|
|
|
|
|
|
|
+ out.set_on_delete(onDeleteToString(r.onDelete));
|
|
|
out.set_validate_on_write(r.validateOnWrite);
|
|
out.set_validate_on_write(r.validateOnWrite);
|
|
|
out.set_created_at(r.createdAt);
|
|
out.set_created_at(r.createdAt);
|
|
|
out.set_updated_at(r.updatedAt);
|
|
out.set_updated_at(r.updatedAt);
|
|
@@ -2843,6 +2843,166 @@ pb::RelationDefinition relationToProto(const RelationInfo& r) {
|
|
|
}
|
|
}
|
|
|
} // namespace
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
+// -------------------------------------------------------------------------
|
|
|
|
|
+// v2.11.0 final review (finding 1) — shared delete-side relation enforcement.
|
|
|
|
|
+// See the declarations in database_grpc_impl.hpp for why this is shared rather
|
|
|
|
|
+// than living inline in Delete().
|
|
|
|
|
+// -------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::checkRelationDeleteAllowed(const std::string& collection,
|
|
|
|
|
+ const std::string& id) {
|
|
|
|
|
+ // Only meaningful on the LMDB substrate, where the reverse index lives; if
|
|
|
|
|
+ // this project has no LMDB store for some reason there is nothing to
|
|
|
|
|
+ // enforce against and the delete proceeds as before.
|
|
|
|
|
+ if (collection.empty() || collection[0] == '_') return grpc::Status::OK;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const auto rc = smartbotic::database::resolveCollection(collection);
|
|
|
|
|
+ auto* ds = service_.docStore(rc.project);
|
|
|
|
|
+ if (ds == nullptr) return grpc::Status::OK;
|
|
|
|
|
+ auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
|
|
|
|
|
+ if (lmdb == nullptr) return grpc::Status::OK;
|
|
|
|
|
+
|
|
|
|
|
+ // Named local: CollectionCfg is returned by value, and RelationEnforcer
|
|
|
|
|
+ // binds relationsEnforced by reference — binding straight to the
|
|
|
|
|
+ // temporary's subobject would dangle.
|
|
|
|
|
+ const CollectionCfg cfg = config_manager_.configFor(collection);
|
|
|
|
|
+ RelationEnforcer enforcer(relation_manager_, *lmdb, cfg.relationsEnforced);
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ if (!enforcer.canDelete(collection, id, err)) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, err);
|
|
|
|
|
+ }
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ } catch (const std::invalid_argument& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ // v2.11.0 final review (finding 2) — MANDATORY, not defensive. This
|
|
|
|
|
+ // path opens an LMDB ReadTxn and a cursor (canDelete ->
|
|
|
|
|
+ // findRelationBlocks -> lookupRelationCounts ->
|
|
|
|
|
+ // relation_index_child_count), and mdb_check/throw_mdb raise
|
|
|
|
|
+ // std::runtime_error - MDB_READERS_FULL and the EINVAL this project has
|
|
|
|
|
+ // already had in production both land here. Delete() previously caught
|
|
|
|
|
+ // only std::invalid_argument, and an exception escaping a synchronous
|
|
|
|
|
+ // gRPC handler TERMINATES THE PROCESS: a full reader table would have
|
|
|
|
|
+ // taken the service down rather than failing one call. DescribeDelete,
|
|
|
|
|
+ // CheckRelation, CreateIndex and Exists all catch std::exception on the
|
|
|
|
|
+ // same read path; this is the clause that was missing.
|
|
|
|
|
+ spdlog::error("relations: delete enforcement failed for '{}/{}': {}",
|
|
|
|
|
+ collection, id, e.what());
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+grpc::Status DatabaseGrpcImpl::performRelationCascade(const std::string& collection,
|
|
|
|
|
+ const std::string& id,
|
|
|
|
|
+ bool& handled,
|
|
|
|
|
+ bool& parentExisted) {
|
|
|
|
|
+ handled = false;
|
|
|
|
|
+ parentExisted = false;
|
|
|
|
|
+ if (collection.empty() || collection[0] == '_') return grpc::Status::OK;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const auto rc = smartbotic::database::resolveCollection(collection);
|
|
|
|
|
+ auto* ds = service_.docStore(rc.project);
|
|
|
|
|
+ if (ds == nullptr) return grpc::Status::OK;
|
|
|
|
|
+ auto* lmdb = dynamic_cast<smartbotic::db::storage::LmdbDocumentStore*>(ds);
|
|
|
|
|
+ if (lmdb == nullptr) return grpc::Status::OK;
|
|
|
|
|
+
|
|
|
|
|
+ // v2.11.0 T12 — cascade/set_null. restrict has already had its say
|
|
|
|
|
+ // (checkRelationDeleteAllowed, called first by every caller); this is
|
|
|
|
|
+ // reached only when the delete is otherwise permitted.
|
|
|
|
|
+ // relationsEnforced gates this exactly like it gates restrict:
|
|
|
|
|
+ // disabled means every on_delete policy is skipped, not just restrict,
|
|
|
|
|
+ // so a collection with enforcement off keeps today's permissive
|
|
|
|
|
+ // (dangling-reference) behaviour rather than half-enforcing.
|
|
|
|
|
+ const CollectionCfg cfg = config_manager_.configFor(collection);
|
|
|
|
|
+ if (!cfg.relationsEnforced) return grpc::Status::OK;
|
|
|
|
|
+
|
|
|
|
|
+ bool hasDestructive = false;
|
|
|
|
|
+ for (const auto& r : relation_manager_.relationsWithParent(collection)) {
|
|
|
|
|
+ if (r.onDelete == smartbotic::database::OnDelete::Cascade ||
|
|
|
|
|
+ r.onDelete == smartbotic::database::OnDelete::SetNull) {
|
|
|
|
|
+ hasDestructive = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!hasDestructive) return grpc::Status::OK;
|
|
|
|
|
+
|
|
|
|
|
+ // v2.11.0 T12 review (C2) — drive replication + Subscribe events
|
|
|
|
|
+ // explicitly, once per mutation, right after that mutation's
|
|
|
|
|
+ // MemoryStore apply. See relation_cascade.hpp's file header for why
|
|
|
|
|
+ // executeCascade() cannot go back through the ordinary
|
|
|
|
|
+ // persistCallback_ path itself.
|
|
|
|
|
+ auto notify = [this](const std::string& coll, const std::string& docId,
|
|
|
|
|
+ const std::optional<smartbotic::database::Document>& doc,
|
|
|
|
|
+ smartbotic::database::EventType eventType) {
|
|
|
|
|
+ service_.notifyReplicationAndEvents(coll, docId, doc, eventType);
|
|
|
|
|
+ };
|
|
|
|
|
+ try {
|
|
|
|
|
+ parentExisted = smartbotic::database::executeCascade(
|
|
|
|
|
+ relation_manager_, *lmdb, persistence_, store_, config_manager_,
|
|
|
|
|
+ collection, id, notify);
|
|
|
|
|
+ handled = true;
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ } catch (const smartbotic::database::CascadeBlocked& e) {
|
|
|
|
|
+ // v2.11.0 T12 review (I2) — a grandchild is protected by its own
|
|
|
|
|
+ // restrict relation. Also (final review, finding 6) the
|
|
|
|
|
+ // mirror-unhealthy/drifted refusal. Nothing was written anywhere in
|
|
|
|
|
+ // either case: both are thrown before writeCascadeWal() ever runs —
|
|
|
|
|
+ // same refusal shape restrict itself uses.
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ // v2.11.0 T12 review (I4, round 2 correction) — WAL-first means a
|
|
|
|
|
+ // failure here can occur AFTER the WAL entries for this cascade
|
|
|
|
|
+ // were already durably fsynced (writeCascadeWal() ran inside
|
|
|
|
|
+ // executeCascade() before commitCascadeLmdb()/
|
|
|
|
|
+ // applyCascadeToMemory(), either of which could be what actually
|
|
|
|
|
+ // threw). This is NOT necessarily a no-op failure. What the caller
|
|
|
|
|
+ // can and cannot assume differs by WHICH of those two threw, and
|
|
|
|
|
+ // neither is distinguishable from out here:
|
|
|
|
|
+ // - commitCascadeLmdb() threw: LMDB was never committed (the
|
|
|
|
|
+ // WriteTxn aborts unwritten), so LMDB still reflects the
|
|
|
|
|
+ // pre-cascade state; MemoryStore is unchanged too (step 5 never
|
|
|
|
|
+ // ran). Reads see the pre-cascade state everywhere, for now —
|
|
|
|
|
+ // but the WAL entries are already durable, so the NEXT
|
|
|
|
|
+ // RESTART's replay applies the whole cascade regardless of this
|
|
|
|
|
+ // failure.
|
|
|
|
|
+ // - applyCascadeToMemory() threw partway: LMDB already committed
|
|
|
|
|
+ // (step 4 succeeded) — reads being LMDB-first, the cascade IS
|
|
|
|
|
+ // ALREADY VISIBLE for collections read through LMDB, even
|
|
|
|
|
+ // though this call is reporting failure. MemoryStore may be
|
|
|
|
|
+ // only PARTIALLY applied (some mutations done, some not), and
|
|
|
|
|
+ // `notify` may have already fired for the mutations that did
|
|
|
|
|
+ // apply.
|
|
|
|
|
+ // There is no compensating "un-write" of the WAL entry in either
|
|
|
|
|
+ // case — replaying it again is safe (idempotent), erasing it is
|
|
|
|
|
+ // not. Check server logs and current document state (LMDB, not
|
|
|
|
|
+ // just MemoryStore) before retrying or assuming nothing happened.
|
|
|
|
|
+ spdlog::error(
|
|
|
|
|
+ "relations: cascade delete of '{}/{}' failed - the WAL entries for "
|
|
|
|
|
+ "this cascade may already be durable (will apply on next restart "
|
|
|
|
|
+ "regardless) and, if the failure was in the post-LMDB-commit step, "
|
|
|
|
|
+ "the mutation may ALREADY be visible via LMDB-first reads even "
|
|
|
|
|
+ "though this call is reporting failure: {}",
|
|
|
|
|
+ collection, id, e.what());
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL,
|
|
|
|
|
+ "cascade delete failed - its WAL entries may already be durable "
|
|
|
|
|
+ "(will apply on the next restart regardless of this failure), and "
|
|
|
|
|
+ "if the failure occurred after the LMDB commit, the mutation may "
|
|
|
|
|
+ "ALREADY be visible via LMDB-first reads even though this call "
|
|
|
|
|
+ "failed; check server logs and current document state (not just "
|
|
|
|
|
+ "this response) before retrying: " + std::string(e.what()));
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (const std::invalid_argument& e) {
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what());
|
|
|
|
|
+ } catch (const std::exception& e) {
|
|
|
|
|
+ // finding 2 — same reasoning as checkRelationDeleteAllowed's clause:
|
|
|
|
|
+ // relationsWithParent()/configFor()/resolveCollection() must not be
|
|
|
|
|
+ // able to terminate the process.
|
|
|
|
|
+ spdlog::error("relations: cascade planning failed for '{}/{}': {}",
|
|
|
|
|
+ collection, id, e.what());
|
|
|
|
|
+ return grpc::Status(grpc::StatusCode::INTERNAL, e.what());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
void DatabaseGrpcImpl::armRelationsForChild(const std::string& childQualified) {
|
|
void DatabaseGrpcImpl::armRelationsForChild(const std::string& childQualified) {
|
|
|
try {
|
|
try {
|
|
|
const auto rc = smartbotic::database::resolveCollection(childQualified);
|
|
const auto rc = smartbotic::database::resolveCollection(childQualified);
|
|
@@ -2867,10 +3027,24 @@ void DatabaseGrpcImpl::armRelationsForChild(const std::string& childQualified) {
|
|
|
// itself (see RelationRef's comment). ConfigureCollection re-arms
|
|
// itself (see RelationRef's comment). ConfigureCollection re-arms
|
|
|
// after flipping the flag, so this only ever goes stale between that
|
|
// after flipping the flag, so this only ever goes stale between that
|
|
|
// RPC's write and its own re-arm call - never observably.
|
|
// RPC's write and its own re-arm call - never observably.
|
|
|
- const bool enforced = config_manager_.configFor(childQualified).relationsEnforced;
|
|
|
|
|
|
|
+ //
|
|
|
|
|
+ // v2.11.0 final review (finding 8) — rc.qualified, NOT the caller's
|
|
|
|
|
+ // raw string. Boot arming (applyRelationDeclarations) uses the
|
|
|
|
|
+ // canonical `project + ":" + collection`, and set_relations() stores
|
|
|
|
|
+ // under the BARE name either way, so a raw-gRPC caller naming
|
|
|
|
|
+ // "executions" instead of "default:executions" used to look up zero
|
|
|
|
|
+ // relations here and then set_relations(bare, {}) on the SAME map
|
|
|
|
|
+ // entry the canonical arming had filled - disarming both the reverse
|
|
|
|
|
+ // index and validate_on_write for the life of the process, logged only
|
|
|
|
|
+ // as "re-armed 0 relation(s)", and silently repaired by a restart.
|
|
|
|
|
+ // RelationManager now canonicalises internally too (belt and braces:
|
|
|
|
|
+ // the lookup is correct even if a future caller reaches it raw), and
|
|
|
|
|
+ // CollectionConfigManager keys its cache canonically for the same
|
|
|
|
|
+ // reason.
|
|
|
|
|
+ const bool enforced = config_manager_.configFor(rc.qualified).relationsEnforced;
|
|
|
|
|
|
|
|
std::vector<smartbotic::db::storage::RelationRef> refs;
|
|
std::vector<smartbotic::db::storage::RelationRef> refs;
|
|
|
- for (const auto& rel : relation_manager_.relationsWithChild(childQualified)) {
|
|
|
|
|
|
|
+ for (const auto& rel : relation_manager_.relationsWithChild(rc.qualified)) {
|
|
|
const auto rn = smartbotic::database::resolveCollection(rel.name);
|
|
const auto rn = smartbotic::database::resolveCollection(rel.name);
|
|
|
// v2.11.0 T13 — parent is resolved to its BARE collection name,
|
|
// v2.11.0 T13 — parent is resolved to its BARE collection name,
|
|
|
// same as name/childField above: relations never cross projects
|
|
// same as name/childField above: relations never cross projects
|
|
@@ -2909,6 +3083,28 @@ grpc::Status DatabaseGrpcImpl::CreateRelation(
|
|
|
std::to_string(store_.pressurePercent()) + "%); retry after backoff");
|
|
std::to_string(store_.pressurePercent()) + "%); retry after backoff");
|
|
|
return grpc::Status::OK;
|
|
return grpc::Status::OK;
|
|
|
}
|
|
}
|
|
|
|
|
+ // v2.11.0 final review (finding 7) — the same gate CreateIndex(unique)
|
|
|
|
|
+ // carries, for the same reason. The bootstrap scan below
|
|
|
|
|
+ // (build_relation_index) builds the reverse index by walking LMDB, and
|
|
|
|
|
+ // while the mirror is unhealthy or has drifted MemoryStore is
|
|
|
|
|
+ // legitimately AHEAD of LMDB - that is what the fallback is for. Indexing
|
|
|
|
|
+ // off LMDB in that state produces a silently incomplete index, and
|
|
|
|
|
+ // `restrict` then reports zero children for every row LMDB never
|
|
|
|
|
+ // received, permitting exactly the parent deletes this relation is being
|
|
|
|
|
+ // declared to prevent. Refused up front rather than half-built: the
|
|
|
|
|
+ // declaration would look successful and `rows_indexed` would even report
|
|
|
|
|
+ // a plausible number.
|
|
|
|
|
+ if (!service_.mirrorHealthy() || service_.mirrorDriftCount() != 0) {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("cannot declare a relation while the LMDB mirror is unhealthy "
|
|
|
|
|
+ "or has drifted: the bootstrap scan that indexes existing rows "
|
|
|
|
|
+ "reads LMDB only, and MemoryStore may be ahead of it right now, "
|
|
|
|
|
+ "so the reverse index would come out incomplete and `restrict` "
|
|
|
|
|
+ "would permit deletes it should refuse. Check the mirror drift "
|
|
|
|
|
+ "count in the service log; a restart clears drift once the "
|
|
|
|
|
+ "underlying cause is fixed.");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
RelationInfo r;
|
|
RelationInfo r;
|
|
@@ -2916,7 +3112,24 @@ grpc::Status DatabaseGrpcImpl::CreateRelation(
|
|
|
r.child = request->child();
|
|
r.child = request->child();
|
|
|
r.childField = request->child_field();
|
|
r.childField = request->child_field();
|
|
|
r.parent = request->parent();
|
|
r.parent = request->parent();
|
|
|
- r.onDelete = relationOnDeleteFromString(request->on_delete());
|
|
|
|
|
|
|
+ // v2.11.0 final review (finding 9) — VALIDATE, do not coerce. An
|
|
|
|
|
+ // unrecognised on_delete used to become Restrict silently, which
|
|
|
|
|
+ // stopped being a safe default the moment T12 made cascade/set_null
|
|
|
|
|
+ // genuinely destructive: an operator who typed "Cascade" was told the
|
|
|
|
|
+ // relation was created and believed cascade was armed while restrict
|
|
|
|
|
+ // was, and only discovered it when the deletes they expected to
|
|
|
|
|
+ // cascade started refusing. Empty still means the documented default.
|
|
|
|
|
+ if (request->on_delete().empty()) {
|
|
|
|
|
+ r.onDelete = OnDelete::Restrict;
|
|
|
|
|
+ } else if (auto parsed = parseOnDelete(request->on_delete())) {
|
|
|
|
|
+ r.onDelete = *parsed;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ response->set_success(false);
|
|
|
|
|
+ response->set_error("on_delete must be one of " +
|
|
|
|
|
+ std::string(smartbotic::database::kOnDeleteValues) +
|
|
|
|
|
+ " (got '" + request->on_delete() + "')");
|
|
|
|
|
+ return grpc::Status::OK;
|
|
|
|
|
+ }
|
|
|
r.validateOnWrite = request->validate_on_write();
|
|
r.validateOnWrite = request->validate_on_write();
|
|
|
|
|
|
|
|
// Cross-project / malformed-name validation (no LMDB transaction
|
|
// Cross-project / malformed-name validation (no LMDB transaction
|
|
@@ -3112,7 +3325,7 @@ grpc::Status DatabaseGrpcImpl::DescribeDelete(
|
|
|
pbImpact->set_relation(imp.relation);
|
|
pbImpact->set_relation(imp.relation);
|
|
|
pbImpact->set_child_collection(imp.childCollection);
|
|
pbImpact->set_child_collection(imp.childCollection);
|
|
|
pbImpact->set_child_field(imp.childField);
|
|
pbImpact->set_child_field(imp.childField);
|
|
|
- pbImpact->set_on_delete(relationOnDeleteToString(imp.onDelete));
|
|
|
|
|
|
|
+ pbImpact->set_on_delete(onDeleteToString(imp.onDelete));
|
|
|
pbImpact->set_child_count(imp.childCount);
|
|
pbImpact->set_child_count(imp.childCount);
|
|
|
for (const auto& id : imp.sampleChildIds) {
|
|
for (const auto& id : imp.sampleChildIds) {
|
|
|
pbImpact->add_sample_child_ids(id);
|
|
pbImpact->add_sample_child_ids(id);
|