|
@@ -0,0 +1,268 @@
|
|
|
|
|
+// v2.11.0 T9 — relations end to end, over gRPC, through the real client,
|
|
|
|
|
+// including a real service restart and a manufactured self-heal fixture.
|
|
|
|
|
+//
|
|
|
|
|
+// tests/load_test/test_relations_client_e2e.cpp already covers the basic
|
|
|
|
|
+// client/server-boundary shape (non-default project, createRelation/
|
|
|
|
|
+// listRelations/getRelationInfo qualify-by-name correctly, restrict blocks
|
|
|
|
|
+// a delete, cross-project relation names don't collide). This driver adds
|
|
|
|
|
+// exactly what that suite structurally cannot, because it never restarts
|
|
|
|
|
+// the service:
|
|
|
|
|
+//
|
|
|
|
|
+// 1. A relation declaration SURVIVES a restart, and — the load-bearing
|
|
|
|
|
+// half — a child document written AFTER the restart is indexed. That
|
|
|
|
|
+// proves DatabaseService::applyRelationDeclarations() re-armed the
|
|
|
|
|
+// write-path maintenance hook, not merely that RelationManager
|
|
|
|
|
+// remembered the row. (Mirrors test_indexes.cpp's identical proof for
|
|
|
|
|
+// secondary indexes.)
|
|
|
|
|
+// 2. Boot-time self-heal: with the relation's `_relidx1_*` reverse-index
|
|
|
|
|
+// sub-db manufactured missing (via relidx_drop_tool, run by
|
|
|
|
|
+// test_relations.sh while the service is stopped), a restart must
|
|
|
|
|
+// rebuild the index from the CHILD ROWS THAT ALREADY EXISTED before
|
|
|
|
|
+// the sub-db was dropped — not just index rows written afterward —
|
|
|
|
|
+// and enforcement on the original relationship must work again.
|
|
|
|
|
+// 3. Per-project isolation: two projects each declare a relation under
|
|
|
|
|
+// the identical bare name; a project must not see the other
|
|
|
|
|
+// project's children when evaluating restrict/DescribeDelete. Same
|
|
|
|
|
+// failure shape as the v2.4.2 createView bug.
|
|
|
|
|
+//
|
|
|
|
|
+// Usage: test_relations <address> <projectA> <projectB> <phase>
|
|
|
|
|
+// phase=setup - declare relations in both projects, insert data,
|
|
|
|
|
+// assert restrict/no_action/DescribeDelete/isolation
|
|
|
|
|
+// phase=verify - AFTER restart #1 (plain restart, no sub-db
|
|
|
|
|
+// tampering): declaration survived, restrict still
|
|
|
|
|
+// blocks the pre-restart relationship, and a NEW
|
|
|
|
|
+// child written post-restart is indexed
|
|
|
|
|
+// phase=verify_selfheal - AFTER restart #2 (with projectA's relidx sub-db
|
|
|
|
|
+// dropped between stop and start): restrict still
|
|
|
|
|
+// blocks the ORIGINAL pre-drop relationship
|
|
|
|
|
+// (proving the rebuild recovered existing rows,
|
|
|
|
|
+// not just future writes), and post-restart
|
|
|
|
|
+// writes are still indexed too
|
|
|
|
|
+
|
|
|
|
|
+#include <smartbotic/database/client.hpp>
|
|
|
|
|
+
|
|
|
|
|
+#include <iostream>
|
|
|
|
|
+#include <string>
|
|
|
|
|
+
|
|
|
|
|
+using namespace smartbotic::database;
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+
|
|
|
|
|
+int g_pass = 0;
|
|
|
|
|
+int g_fail = 0;
|
|
|
|
|
+
|
|
|
|
|
+void check(bool cond, const std::string& msg) {
|
|
|
|
|
+ if (cond) {
|
|
|
|
|
+ ++g_pass;
|
|
|
|
|
+ std::cout << " ok " << msg << "\n";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ++g_fail;
|
|
|
|
|
+ std::cout << " FAIL " << msg << "\n";
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+constexpr const char* kRelation = "wf_exec";
|
|
|
|
|
+constexpr const char* kParent = "workflows";
|
|
|
|
|
+constexpr const char* kChild = "executions";
|
|
|
|
|
+constexpr const char* kChildField = "workflowId";
|
|
|
|
|
+
|
|
|
|
|
+// Refusal text is pinned from service/src/relations/relation_enforcement.cpp
|
|
|
|
|
+// (formatRelationBlockError) — NOT transcribed from any report. A prior task
|
|
|
|
|
+// report mis-transcribed "child document(s)" as "document(s)"; assert the
|
|
|
|
|
+// real string so that mistake can't repeat silently here.
|
|
|
|
|
+bool looksLikeRestrictRefusal(const std::string& err, const std::string& qualifiedRelation,
|
|
|
|
|
+ const std::string& qualifiedChildColl, uint64_t count) {
|
|
|
|
|
+ if (err.find("cannot delete '") == std::string::npos) return false;
|
|
|
|
|
+ if (err.find("relation '" + qualifiedRelation + "' has " + std::to_string(count) +
|
|
|
|
|
+ " child document(s) in '" + qualifiedChildColl + "' referencing it") ==
|
|
|
|
|
+ std::string::npos) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (err.find("or change the relation's on_delete to no_action to permit the "
|
|
|
|
|
+ "dangling reference.") == std::string::npos) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void setup(Client& a, Client& b, const std::string& projA, const std::string& projB) {
|
|
|
|
|
+ std::cout << "-- setup --\n";
|
|
|
|
|
+
|
|
|
|
|
+ // Identically-named relation declared independently in two projects.
|
|
|
|
|
+ // RelationManager keys by the project-qualified name, so this must NOT
|
|
|
|
|
+ // collide — same shape the v2.4.2 createView bug got wrong.
|
|
|
|
|
+ check(a.createRelation(kRelation, kChild, kChildField, kParent),
|
|
|
|
|
+ "projectA declares 'wf_exec'");
|
|
|
|
|
+ check(b.createRelation(kRelation, kChild, kChildField, kParent),
|
|
|
|
|
+ "projectB declares the SAME bare name 'wf_exec' without collision");
|
|
|
|
|
+
|
|
|
|
|
+ // A no_action relation in project A, to prove restrict isn't the only
|
|
|
|
|
+ // policy path this suite exercises.
|
|
|
|
|
+ check(a.createRelation("wf_logs_na", "logs", kChildField, kParent, "no_action"),
|
|
|
|
|
+ "projectA declares a no_action relation");
|
|
|
|
|
+
|
|
|
|
|
+ // Parent + child in A only.
|
|
|
|
|
+ a.upsert(kParent, nlohmann::json{{"name", "wf-1"}}, "wf-1");
|
|
|
|
|
+ a.upsert(kChild, nlohmann::json{{kChildField, "wf-1"}}, "ex-a1");
|
|
|
|
|
+
|
|
|
|
|
+ // Same parent id exists in B, but with NO referencing child — this is
|
|
|
|
|
+ // the isolation probe: B's identically-named relation must report zero
|
|
|
|
|
+ // children for a parent id that DOES have a child in A.
|
|
|
|
|
+ b.upsert(kParent, nlohmann::json{{"name", "wf-1"}}, "wf-1");
|
|
|
|
|
+
|
|
|
|
|
+ // ---- DescribeDelete, non-destructive, before any delete attempt ------
|
|
|
|
|
+ auto descA = a.describeDelete(kParent, "wf-1");
|
|
|
|
|
+ check(descA.success, "projectA describeDelete succeeds");
|
|
|
|
|
+ bool foundA = false;
|
|
|
|
|
+ for (const auto& imp : descA.impacts) {
|
|
|
|
|
+ if (imp.relation != kRelation) continue;
|
|
|
|
|
+ foundA = true;
|
|
|
|
|
+ check(imp.childCollection == kChild, "impact reports the bare child collection");
|
|
|
|
|
+ check(imp.childField == kChildField, "impact reports the child field");
|
|
|
|
|
+ check(imp.onDelete == "restrict", "impact reports the on_delete policy");
|
|
|
|
|
+ check(imp.childCount == 1, "projectA sees exactly its own 1 child");
|
|
|
|
|
+ check(imp.blocks, "impact.blocks is true (relations_enforced defaults on)");
|
|
|
|
|
+ }
|
|
|
|
|
+ check(foundA, "projectA describeDelete includes the wf_exec impact");
|
|
|
|
|
+
|
|
|
|
|
+ auto descB = b.describeDelete(kParent, "wf-1");
|
|
|
|
|
+ check(descB.success, "projectB describeDelete succeeds");
|
|
|
|
|
+ bool foundB = false;
|
|
|
|
|
+ for (const auto& imp : descB.impacts) {
|
|
|
|
|
+ if (imp.relation != kRelation) continue;
|
|
|
|
|
+ foundB = true;
|
|
|
|
|
+ check(imp.childCount == 0,
|
|
|
|
|
+ "projectB sees ZERO children for the SAME parent id 'wf-1' - "
|
|
|
|
|
+ "A's child does not leak across the project boundary");
|
|
|
|
|
+ check(!imp.blocks, "projectB's describeDelete is not blocked");
|
|
|
|
|
+ }
|
|
|
|
|
+ check(foundB, "projectB describeDelete includes its own wf_exec impact");
|
|
|
|
|
+
|
|
|
|
|
+ // ---- restrict actually blocks in A, and the message names the relation
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ bool deleted = a.remove(kParent, "wf-1", err);
|
|
|
|
|
+ check(!deleted, "projectA: restrict blocks deleting a referenced parent");
|
|
|
|
|
+ check(looksLikeRestrictRefusal(err, projA + ":" + kRelation, projA + ":" + kChild, 1),
|
|
|
|
|
+ "the refusal message matches formatRelationBlockError's real text "
|
|
|
|
|
+ "('child document(s)', not 'document(s)')");
|
|
|
|
|
+
|
|
|
|
|
+ // ---- restrict does NOT block in B: no children reference wf-1 there --
|
|
|
|
|
+ err.clear();
|
|
|
|
|
+ deleted = b.remove(kParent, "wf-1", err);
|
|
|
|
|
+ check(deleted, "projectB: the identically-named relation does not block - "
|
|
|
|
|
+ "isolation holds for the actual delete, not just DescribeDelete");
|
|
|
|
|
+ check(err.empty(), "no error on projectB's successful delete");
|
|
|
|
|
+
|
|
|
|
|
+ // ---- no_action permits a dangling reference -----------------------
|
|
|
|
|
+ a.upsert(kParent, nlohmann::json{{"name", "wf-2"}}, "wf-2");
|
|
|
|
|
+ a.upsert("logs", nlohmann::json{{kChildField, "wf-2"}}, "log-a1");
|
|
|
|
|
+ err.clear();
|
|
|
|
|
+ deleted = a.remove(kParent, "wf-2", err);
|
|
|
|
|
+ check(deleted, "no_action permits deleting a parent with a dangling child");
|
|
|
|
|
+ check(err.empty(), "no error on the no_action delete");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void verify(Client& a, const std::string& projA) {
|
|
|
|
|
+ std::cout << "-- verify after restart #1 (plain restart) --\n";
|
|
|
|
|
+
|
|
|
|
|
+ auto listed = a.listRelations();
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (const auto& r : listed) if (r.name == kRelation) found = true;
|
|
|
|
|
+ check(found, "the declaration SURVIVED the restart - listRelations still "
|
|
|
|
|
+ "reports 'wf_exec'");
|
|
|
|
|
+
|
|
|
|
|
+ const std::string qualRelation = projA + ":" + kRelation;
|
|
|
|
|
+ const std::string qualChild = projA + ":" + kChild;
|
|
|
|
|
+
|
|
|
|
|
+ // The pre-restart relationship must still block.
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ bool deleted = a.remove(kParent, "wf-1", err);
|
|
|
|
|
+ check(!deleted, "restrict still blocks the pre-restart relationship "
|
|
|
|
|
+ "(wf-1/ex-a1) after the restart");
|
|
|
|
|
+ check(looksLikeRestrictRefusal(err, qualRelation, qualChild, 1),
|
|
|
|
|
+ "the refusal still matches the pinned restrict-block text");
|
|
|
|
|
+
|
|
|
|
|
+ // THE load-bearing check: a child written AFTER the restart must be
|
|
|
|
|
+ // indexed. If applyRelationDeclarations() had merely restored the
|
|
|
|
|
+ // in-memory RelationManager cache without calling set_relations() on
|
|
|
|
|
+ // the LMDB store, writes after this point would go unmaintained and
|
|
|
|
|
+ // this would silently NOT block.
|
|
|
|
|
+ a.upsert(kParent, nlohmann::json{{"name", "wf-3"}}, "wf-3");
|
|
|
|
|
+ a.upsert(kChild, nlohmann::json{{kChildField, "wf-3"}}, "ex-a3");
|
|
|
|
|
+ err.clear();
|
|
|
|
|
+ deleted = a.remove(kParent, "wf-3", err);
|
|
|
|
|
+ check(!deleted, "a child written AFTER the restart IS indexed - the "
|
|
|
|
|
+ "write path is armed, not merely remembered");
|
|
|
|
|
+ check(looksLikeRestrictRefusal(err, qualRelation, qualChild, 1),
|
|
|
|
|
+ "the refusal for the post-restart relationship matches the "
|
|
|
|
|
+ "pinned text too");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void verifySelfHeal(Client& a, const std::string& projA) {
|
|
|
|
|
+ std::cout << "-- verify after restart #2 (relidx sub-db manufactured "
|
|
|
|
|
+ "missing, then restarted) --\n";
|
|
|
|
|
+
|
|
|
|
|
+ auto listed = a.listRelations();
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (const auto& r : listed) if (r.name == kRelation) found = true;
|
|
|
|
|
+ check(found, "the declaration still exists after the self-heal restart "
|
|
|
|
|
+ "(dropping the INDEX sub-db must not touch the declaration "
|
|
|
|
|
+ "in RelationManager's own store)");
|
|
|
|
|
+
|
|
|
|
|
+ const std::string qualRelation = projA + ":" + kRelation;
|
|
|
|
|
+ const std::string qualChild = projA + ":" + kChild;
|
|
|
|
|
+
|
|
|
|
|
+ // THE point of this phase: wf-1/ex-a1 was written and indexed BEFORE
|
|
|
|
|
+ // the sub-db was dropped. If the self-heal rebuild only covered new
|
|
|
|
|
+ // writes (or didn't run at all), this relationship would no longer
|
|
|
|
|
+ // block, and a real delete of wf-1 would silently succeed, leaving
|
|
|
|
|
+ // ex-a1 dangling with nothing to say so.
|
|
|
|
|
+ std::string err;
|
|
|
|
|
+ bool deleted = a.remove(kParent, "wf-1", err);
|
|
|
|
|
+ check(!deleted, "restrict STILL blocks the ORIGINAL relationship after "
|
|
|
|
|
+ "the sub-db was dropped and the service restarted - "
|
|
|
|
|
+ "the boot-time self-heal rebuilt it from the existing "
|
|
|
|
|
+ "child row, not merely from future writes");
|
|
|
|
|
+ check(looksLikeRestrictRefusal(err, qualRelation, qualChild, 1),
|
|
|
|
|
+ "the refusal matches the pinned restrict-block text");
|
|
|
|
|
+
|
|
|
|
|
+ // And maintenance is still armed post-self-heal for brand new writes.
|
|
|
|
|
+ a.upsert(kParent, nlohmann::json{{"name", "wf-4"}}, "wf-4");
|
|
|
|
|
+ a.upsert(kChild, nlohmann::json{{kChildField, "wf-4"}}, "ex-a4");
|
|
|
|
|
+ err.clear();
|
|
|
|
|
+ deleted = a.remove(kParent, "wf-4", err);
|
|
|
|
|
+ check(!deleted, "a child written after the self-heal restart is indexed too");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+int main(int argc, char** argv) {
|
|
|
|
|
+ if (argc < 5) {
|
|
|
|
|
+ std::cerr << "usage: test_relations <address> <projectA> <projectB> "
|
|
|
|
|
+ "<setup|verify|verify_selfheal>\n";
|
|
|
|
|
+ return 2;
|
|
|
|
|
+ }
|
|
|
|
|
+ const std::string address = argv[1];
|
|
|
|
|
+ const std::string projA = argv[2];
|
|
|
|
|
+ const std::string projB = argv[3];
|
|
|
|
|
+ const std::string phase = argv[4];
|
|
|
|
|
+
|
|
|
|
|
+ Client a({.address = address, .project = projA});
|
|
|
|
|
+ a.connect();
|
|
|
|
|
+
|
|
|
|
|
+ if (phase == "setup") {
|
|
|
|
|
+ Client b({.address = address, .project = projB});
|
|
|
|
|
+ b.connect();
|
|
|
|
|
+ setup(a, b, projA, projB);
|
|
|
|
|
+ } else if (phase == "verify") {
|
|
|
|
|
+ verify(a, projA);
|
|
|
|
|
+ } else if (phase == "verify_selfheal") {
|
|
|
|
|
+ verifySelfHeal(a, projA);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ std::cerr << "unknown phase '" << phase << "'\n";
|
|
|
|
|
+ return 2;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::cout << "\npassed=" << g_pass << " failed=" << g_fail << "\n";
|
|
|
|
|
+ return g_fail == 0 ? 0 : 1;
|
|
|
|
|
+}
|