|
@@ -22,6 +22,7 @@
|
|
|
|
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <nlohmann/json.hpp>
|
|
|
|
|
|
|
|
|
|
+#include "config/collection_config_manager.hpp"
|
|
|
#include "document.hpp"
|
|
#include "document.hpp"
|
|
|
#include "memory_store.hpp"
|
|
#include "memory_store.hpp"
|
|
|
#include "persistence/persistence_manager.hpp"
|
|
#include "persistence/persistence_manager.hpp"
|
|
@@ -33,8 +34,10 @@
|
|
|
|
|
|
|
|
namespace fs = std::filesystem;
|
|
namespace fs = std::filesystem;
|
|
|
|
|
|
|
|
|
|
+using smartbotic::database::CascadeBlocked;
|
|
|
using smartbotic::database::CascadeMutation;
|
|
using smartbotic::database::CascadeMutation;
|
|
|
using smartbotic::database::CascadePlan;
|
|
using smartbotic::database::CascadePlan;
|
|
|
|
|
+using smartbotic::database::CollectionConfigManager;
|
|
|
using smartbotic::database::Document;
|
|
using smartbotic::database::Document;
|
|
|
using smartbotic::database::MemoryStore;
|
|
using smartbotic::database::MemoryStore;
|
|
|
using smartbotic::database::OnDelete;
|
|
using smartbotic::database::OnDelete;
|
|
@@ -528,6 +531,8 @@ void test_cascade_deletes_scalar_children_wal_first() {
|
|
|
mstore.start();
|
|
mstore.start();
|
|
|
RelationManager rm(mstore);
|
|
RelationManager rm(mstore);
|
|
|
rm.loadFromStore();
|
|
rm.loadFromStore();
|
|
|
|
|
+ CollectionConfigManager cfgManager(mstore);
|
|
|
|
|
+ cfgManager.loadFromStore();
|
|
|
|
|
|
|
|
TmpEnv t("rel-cascade-scalar");
|
|
TmpEnv t("rel-cascade-scalar");
|
|
|
LmdbDocumentStore store(t.env);
|
|
LmdbDocumentStore store(t.env);
|
|
@@ -563,9 +568,37 @@ void test_cascade_deletes_scalar_children_wal_first() {
|
|
|
|
|
|
|
|
check(store.relation_index_child_count("exec_wf", "wf-1") == 3, "three children indexed");
|
|
check(store.relation_index_child_count("exec_wf", "wf-1") == 3, "three children indexed");
|
|
|
|
|
|
|
|
- const bool parentExisted = executeCascade(rm, store, p.pm, mstore, "default:workflows", "wf-1");
|
|
|
|
|
|
|
+ // v2.11.0 T12 review (C2) - a notify callback wired the way
|
|
|
|
|
+ // DatabaseGrpcImpl::Delete wires DatabaseService::notifyReplicationAndEvents,
|
|
|
|
|
+ // recorded here instead of actually queuing replication/publishing events
|
|
|
|
|
+ // (this test has no DatabaseService). Confirms executeCascade() actually
|
|
|
|
|
+ // drives it once per mutation plus once for the parent, with the right
|
|
|
|
|
+ // event type each time - the wiring C2 added, not just that it compiles.
|
|
|
|
|
+ struct Notification {
|
|
|
|
|
+ std::string collection, id;
|
|
|
|
|
+ bool hadDoc;
|
|
|
|
|
+ smartbotic::database::EventType eventType;
|
|
|
|
|
+ };
|
|
|
|
|
+ std::vector<Notification> notifications;
|
|
|
|
|
+ auto notify = [&](const std::string& coll, const std::string& id,
|
|
|
|
|
+ const std::optional<Document>& doc,
|
|
|
|
|
+ smartbotic::database::EventType et) {
|
|
|
|
|
+ notifications.push_back({coll, id, doc.has_value(), et});
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const bool parentExisted = executeCascade(rm, store, p.pm, mstore, cfgManager,
|
|
|
|
|
+ "default:workflows", "wf-1", notify);
|
|
|
check(parentExisted, "the parent document was present and removed");
|
|
check(parentExisted, "the parent document was present and removed");
|
|
|
|
|
|
|
|
|
|
+ check(notifications.size() == 4, "notified for e1, e2, e3 and the parent - nothing missed, nothing extra");
|
|
|
|
|
+ int deleteNotifications = 0;
|
|
|
|
|
+ for (const auto& n : notifications) {
|
|
|
|
|
+ check(!n.hadDoc, "every notification here is a delete - no doc payload");
|
|
|
|
|
+ check(n.eventType == smartbotic::database::EventType::DELETE, "every notification is a DELETE");
|
|
|
|
|
+ if (n.eventType == smartbotic::database::EventType::DELETE) ++deleteNotifications;
|
|
|
|
|
+ }
|
|
|
|
|
+ check(deleteNotifications == 4, "all four notifications are DELETE (3 children + parent)");
|
|
|
|
|
+
|
|
|
// LMDB: children gone, index gone.
|
|
// LMDB: children gone, index gone.
|
|
|
check(!store.get("executions", "e1").has_value(), "e1 gone from LMDB");
|
|
check(!store.get("executions", "e1").has_value(), "e1 gone from LMDB");
|
|
|
check(!store.get("executions", "e2").has_value(), "e2 gone from LMDB");
|
|
check(!store.get("executions", "e2").has_value(), "e2 gone from LMDB");
|
|
@@ -606,6 +639,8 @@ void test_array_reference_pulls_id_and_keeps_document() {
|
|
|
mstore.start();
|
|
mstore.start();
|
|
|
RelationManager rm(mstore);
|
|
RelationManager rm(mstore);
|
|
|
rm.loadFromStore();
|
|
rm.loadFromStore();
|
|
|
|
|
+ CollectionConfigManager cfgManager(mstore);
|
|
|
|
|
+ cfgManager.loadFromStore();
|
|
|
|
|
|
|
|
TmpEnv t("rel-cascade-array");
|
|
TmpEnv t("rel-cascade-array");
|
|
|
LmdbDocumentStore store(t.env);
|
|
LmdbDocumentStore store(t.env);
|
|
@@ -638,9 +673,35 @@ void test_array_reference_pulls_id_and_keeps_document() {
|
|
|
std::string mgrErr;
|
|
std::string mgrErr;
|
|
|
check(rm.createRelation(rel, mgrErr), "declared the cascade relation on an array field");
|
|
check(rm.createRelation(rel, mgrErr), "declared the cascade relation on an array field");
|
|
|
|
|
|
|
|
- const bool parentExisted = executeCascade(rm, store, p.pm, mstore, "default:credentials", "c1");
|
|
|
|
|
|
|
+ // v2.11.0 T12 review (C2) - same notify-recording as the scalar test,
|
|
|
|
|
+ // to confirm the UPDATE (not DELETE) path also notifies correctly with
|
|
|
|
|
+ // the post-mutation document attached.
|
|
|
|
|
+ struct Notification {
|
|
|
|
|
+ std::string collection, id;
|
|
|
|
|
+ bool hadDoc;
|
|
|
|
|
+ smartbotic::database::EventType eventType;
|
|
|
|
|
+ };
|
|
|
|
|
+ std::vector<Notification> notifications;
|
|
|
|
|
+ auto notify = [&](const std::string& coll, const std::string& id,
|
|
|
|
|
+ const std::optional<Document>& doc,
|
|
|
|
|
+ smartbotic::database::EventType et) {
|
|
|
|
|
+ notifications.push_back({coll, id, doc.has_value(), et});
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const bool parentExisted = executeCascade(rm, store, p.pm, mstore, cfgManager,
|
|
|
|
|
+ "default:credentials", "c1", notify);
|
|
|
check(parentExisted, "the credential document was present and removed");
|
|
check(parentExisted, "the credential document was present and removed");
|
|
|
|
|
|
|
|
|
|
+ check(notifications.size() == 2, "notified for the node update and the parent delete");
|
|
|
|
|
+ check(notifications[0].collection == "default:nodes" && notifications[0].id == "n1" &&
|
|
|
|
|
+ notifications[0].hadDoc &&
|
|
|
|
|
+ notifications[0].eventType == smartbotic::database::EventType::UPDATE,
|
|
|
|
|
+ "node notification is an UPDATE carrying the mutated doc");
|
|
|
|
|
+ check(notifications[1].collection == "default:credentials" && notifications[1].id == "c1" &&
|
|
|
|
|
+ !notifications[1].hadDoc &&
|
|
|
|
|
+ notifications[1].eventType == smartbotic::database::EventType::DELETE,
|
|
|
|
|
+ "parent notification is a DELETE with no doc payload");
|
|
|
|
|
+
|
|
|
// The node survives, in BOTH stores, with c1 pulled and the other two ids intact.
|
|
// The node survives, in BOTH stores, with c1 pulled and the other two ids intact.
|
|
|
auto lmdbNode = store.get("nodes", "n1");
|
|
auto lmdbNode = store.get("nodes", "n1");
|
|
|
check(lmdbNode.has_value(), "node n1 still exists in LMDB - not deleted");
|
|
check(lmdbNode.has_value(), "node n1 still exists in LMDB - not deleted");
|
|
@@ -708,6 +769,8 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
mstore.start();
|
|
mstore.start();
|
|
|
RelationManager rm(mstore);
|
|
RelationManager rm(mstore);
|
|
|
rm.loadFromStore();
|
|
rm.loadFromStore();
|
|
|
|
|
+ CollectionConfigManager cfgManager(mstore);
|
|
|
|
|
+ cfgManager.loadFromStore();
|
|
|
|
|
|
|
|
TmpEnv t("rel-cascade-crash");
|
|
TmpEnv t("rel-cascade-crash");
|
|
|
LmdbDocumentStore store(t.env);
|
|
LmdbDocumentStore store(t.env);
|
|
@@ -716,10 +779,20 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
TmpPersistence p("rel-cascade-crash-wal");
|
|
TmpPersistence p("rel-cascade-crash-wal");
|
|
|
check(p.pm.start(), "persistence manager started");
|
|
check(p.pm.start(), "persistence manager started");
|
|
|
|
|
|
|
|
|
|
+ // v2.11.0 T12 review (C1) - seed through the REAL WAL via
|
|
|
|
|
+ // p.pm.logInsert(), not just store.put()/mstore.loadDocument() (neither
|
|
|
|
|
+ // of which writes to the WAL). Without this the WAL below would contain
|
|
|
|
|
+ // ONLY the cascade's DELETE entries, freshStore would start from
|
|
|
|
|
+ // nothing, and "!freshStore.get(...).has_value()" would be true whether
|
|
|
|
|
+ // or not writeCascadeWal() wrote anything at all - a vacuous assertion
|
|
|
|
|
+ // (caught in review; confirmed by actually reverting this fix and
|
|
|
|
|
+ // re-running, see the Task 12 report). Seeding via logInsert makes the
|
|
|
|
|
+ // WAL read INSERT-then-DELETE per document, so recovery only ends up
|
|
|
|
|
+ // with nothing there if the DELETE entries genuinely got applied.
|
|
|
auto putBoth = [&](const std::string& id, const nlohmann::json& data) {
|
|
auto putBoth = [&](const std::string& id, const nlohmann::json& data) {
|
|
|
Document d; d.id = id; d.collection = "executions"; d.set_data(data);
|
|
Document d; d.id = id; d.collection = "executions"; d.set_data(data);
|
|
|
store.put("executions", id, d);
|
|
store.put("executions", id, d);
|
|
|
- mstore.loadDocument("default:executions", d);
|
|
|
|
|
|
|
+ p.pm.logInsert("default:executions", d);
|
|
|
};
|
|
};
|
|
|
putBoth("e1", {{"workflowId", "wf-1"}});
|
|
putBoth("e1", {{"workflowId", "wf-1"}});
|
|
|
putBoth("e2", {{"workflowId", "wf-1"}});
|
|
putBoth("e2", {{"workflowId", "wf-1"}});
|
|
@@ -728,7 +801,7 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
Document parent; parent.id = "wf-1"; parent.collection = "workflows";
|
|
Document parent; parent.id = "wf-1"; parent.collection = "workflows";
|
|
|
parent.set_data({{"name", "example"}});
|
|
parent.set_data({{"name", "example"}});
|
|
|
store.put("workflows", "wf-1", parent);
|
|
store.put("workflows", "wf-1", parent);
|
|
|
- mstore.loadDocument("default:workflows", parent);
|
|
|
|
|
|
|
+ p.pm.logInsert("default:workflows", parent);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
RelationInfo rel;
|
|
RelationInfo rel;
|
|
@@ -742,7 +815,7 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
|
|
|
|
|
// Steps 1+3 only - simulating a crash immediately after flushWal()
|
|
// Steps 1+3 only - simulating a crash immediately after flushWal()
|
|
|
// returns, before commitCascadeLmdb()/applyCascadeToMemory() ever run.
|
|
// returns, before commitCascadeLmdb()/applyCascadeToMemory() ever run.
|
|
|
- CascadePlan plan = planCascade(rm, store, "default:workflows", "wf-1");
|
|
|
|
|
|
|
+ CascadePlan plan = planCascade(rm, store, cfgManager, "default:workflows", "wf-1");
|
|
|
check(plan.mutations.size() == 2, "planned both children");
|
|
check(plan.mutations.size() == 2, "planned both children");
|
|
|
writeCascadeWal(p.pm, "default:workflows", "wf-1", plan);
|
|
writeCascadeWal(p.pm, "default:workflows", "wf-1", plan);
|
|
|
p.pm.stop(); // closes the WAL file, like a process exiting
|
|
p.pm.stop(); // closes the WAL file, like a process exiting
|
|
@@ -757,6 +830,15 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
check(store.get("workflows", "wf-1").has_value(),
|
|
check(store.get("workflows", "wf-1").has_value(),
|
|
|
"LMDB was never committed - the parent is still there");
|
|
"LMDB was never committed - the parent is still there");
|
|
|
|
|
|
|
|
|
|
+ // The WAL now genuinely contains 3 INSERTs (e1, e2, parent) followed by
|
|
|
|
|
+ // 3 DELETEs (e1, e2, parent) from writeCascadeWal() - read directly,
|
|
|
|
|
+ // the same way test_cascade_deletes_scalar_children_wal_first does,
|
|
|
|
|
+ // rather than only inferred from the recovered store's absence.
|
|
|
|
|
+ auto rawEntries = replayRawWal(p.path);
|
|
|
|
|
+ check(walHasDelete(rawEntries, "default:executions", "e1"), "WAL has DELETE for e1");
|
|
|
|
|
+ check(walHasDelete(rawEntries, "default:executions", "e2"), "WAL has DELETE for e2");
|
|
|
|
|
+ check(walHasDelete(rawEntries, "default:workflows", "wf-1"), "WAL has DELETE for the parent");
|
|
|
|
|
+
|
|
|
// "Restart": fresh MemoryStore, fresh PersistenceManager over the SAME
|
|
// "Restart": fresh MemoryStore, fresh PersistenceManager over the SAME
|
|
|
// dataDir, recover().
|
|
// dataDir, recover().
|
|
|
MemoryStore freshStore(MemoryStore::Config{});
|
|
MemoryStore freshStore(MemoryStore::Config{});
|
|
@@ -767,9 +849,13 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
auto outcome = pm2.recover(freshStore);
|
|
auto outcome = pm2.recover(freshStore);
|
|
|
check(outcome.kind != smartbotic::database::RecoveryOutcome::Kind::Failed,
|
|
check(outcome.kind != smartbotic::database::RecoveryOutcome::Kind::Failed,
|
|
|
"recovery did not fail");
|
|
"recovery did not fail");
|
|
|
- check(outcome.walEntriesReplayed >= 3,
|
|
|
|
|
- "replayed at least the parent delete + two child deletes");
|
|
|
|
|
|
|
+ check(outcome.walEntriesReplayed >= 6,
|
|
|
|
|
+ "replayed the 3 inserts AND the 3 deletes (parent + two children)");
|
|
|
|
|
|
|
|
|
|
+ // Load-bearing: with the C1 fix, this is only possible because the WAL
|
|
|
|
|
+ // held both the INSERT and the DELETE for each id, and recovery applied
|
|
|
|
|
+ // both in order. Without the DELETE entries (the bug this test exists
|
|
|
|
|
+ // to catch), these would all be PRESENT after recovery instead.
|
|
|
check(!freshStore.get("default:executions", "e1").has_value(),
|
|
check(!freshStore.get("default:executions", "e1").has_value(),
|
|
|
"recovery converges: e1 is gone from the recovered MemoryStore");
|
|
"recovery converges: e1 is gone from the recovered MemoryStore");
|
|
|
check(!freshStore.get("default:executions", "e2").has_value(),
|
|
check(!freshStore.get("default:executions", "e2").has_value(),
|
|
@@ -790,6 +876,89 @@ void test_crash_between_wal_and_commit_recovers() {
|
|
|
mstore.stop();
|
|
mstore.stop();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// v2.11.0 T12 review (I2) - a cascade must refuse rather than silently
|
|
|
|
|
+// destroy a grandchild protected by its own `restrict` relation.
|
|
|
|
|
+// workflows --cascade--> executions --restrict--> logs: deleting the
|
|
|
|
|
+// workflow would, without this check, delete e1 out from under a log entry
|
|
|
|
|
+// that names it, with canDelete() never having evaluated e1 at all (it only
|
|
|
|
|
+// ever evaluates the ORIGINAL parent, wf-1). Would fail if regressed: a
|
|
|
|
|
+// version of planCascade() without the grandchild check would throw
|
|
|
|
|
+// nothing here and instead silently proceed to delete e1.
|
|
|
|
|
+void test_cascade_refuses_when_grandchild_is_restrict_protected() {
|
|
|
|
|
+ MemoryStore mstore(MemoryStore::Config{});
|
|
|
|
|
+ mstore.start();
|
|
|
|
|
+ RelationManager rm(mstore);
|
|
|
|
|
+ rm.loadFromStore();
|
|
|
|
|
+ CollectionConfigManager cfgManager(mstore);
|
|
|
|
|
+ cfgManager.loadFromStore();
|
|
|
|
|
+
|
|
|
|
|
+ TmpEnv t("rel-cascade-grandchild");
|
|
|
|
|
+ LmdbDocumentStore store(t.env);
|
|
|
|
|
+ store.set_relations("executions", {{"exec_wf", "workflowId"}});
|
|
|
|
|
+ store.set_relations("logs", {{"log_exec", "executionId"}});
|
|
|
|
|
+
|
|
|
|
|
+ TmpPersistence p("rel-cascade-grandchild-wal");
|
|
|
|
|
+ check(p.pm.start(), "persistence manager started");
|
|
|
|
|
+
|
|
|
|
|
+ {
|
|
|
|
|
+ Document parent; parent.id = "wf-1"; parent.collection = "workflows";
|
|
|
|
|
+ parent.set_data({{"name", "example"}});
|
|
|
|
|
+ store.put("workflows", "wf-1", parent);
|
|
|
|
|
+ }
|
|
|
|
|
+ {
|
|
|
|
|
+ Document exec; exec.id = "e1"; exec.collection = "executions";
|
|
|
|
|
+ exec.set_data({{"workflowId", "wf-1"}});
|
|
|
|
|
+ store.put("executions", "e1", exec);
|
|
|
|
|
+ }
|
|
|
|
|
+ {
|
|
|
|
|
+ Document log; log.id = "log1"; log.collection = "logs";
|
|
|
|
|
+ log.set_data({{"executionId", "e1"}});
|
|
|
|
|
+ store.put("logs", "log1", log);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::string mgrErr;
|
|
|
|
|
+ RelationInfo cascadeRel;
|
|
|
|
|
+ cascadeRel.name = "default:exec_wf";
|
|
|
|
|
+ cascadeRel.child = "default:executions";
|
|
|
|
|
+ cascadeRel.childField = "workflowId";
|
|
|
|
|
+ cascadeRel.parent = "default:workflows";
|
|
|
|
|
+ cascadeRel.onDelete = OnDelete::Cascade;
|
|
|
|
|
+ check(rm.createRelation(cascadeRel, mgrErr), "declared the cascade relation");
|
|
|
|
|
+
|
|
|
|
|
+ RelationInfo restrictRel;
|
|
|
|
|
+ restrictRel.name = "default:log_exec";
|
|
|
|
|
+ restrictRel.child = "default:logs";
|
|
|
|
|
+ restrictRel.childField = "executionId";
|
|
|
|
|
+ restrictRel.parent = "default:executions";
|
|
|
|
|
+ restrictRel.onDelete = OnDelete::Restrict;
|
|
|
|
|
+ check(rm.createRelation(restrictRel, mgrErr), "declared the grandchild restrict relation");
|
|
|
|
|
+
|
|
|
|
|
+ bool threw = false;
|
|
|
|
|
+ std::string thrownMessage;
|
|
|
|
|
+ try {
|
|
|
|
|
+ executeCascade(rm, store, p.pm, mstore, cfgManager, "default:workflows", "wf-1");
|
|
|
|
|
+ } catch (const CascadeBlocked& e) {
|
|
|
|
|
+ threw = true;
|
|
|
|
|
+ thrownMessage = e.what();
|
|
|
|
|
+ }
|
|
|
|
|
+ check(threw, "cascade refuses rather than silently deleting the restrict-protected grandchild");
|
|
|
|
|
+ check(thrownMessage.find("e1") != std::string::npos, "names the blocked child");
|
|
|
|
|
+ check(thrownMessage.find("log_exec") != std::string::npos || thrownMessage.find("logs") != std::string::npos,
|
|
|
|
|
+ "names the blocking relation or collection");
|
|
|
|
|
+
|
|
|
|
|
+ // Nothing was mutated anywhere - the throw happens inside planCascade(),
|
|
|
|
|
+ // before writeCascadeWal() ever runs.
|
|
|
|
|
+ check(store.get("workflows", "wf-1").has_value(), "parent untouched");
|
|
|
|
|
+ check(store.get("executions", "e1").has_value(), "the protected child untouched");
|
|
|
|
|
+ check(store.get("logs", "log1").has_value(), "the grandchild untouched");
|
|
|
|
|
+
|
|
|
|
|
+ p.pm.stop();
|
|
|
|
|
+ auto entries = replayRawWal(p.path);
|
|
|
|
|
+ check(entries.empty(), "nothing was ever written to the WAL - the refusal happened before step 3");
|
|
|
|
|
+
|
|
|
|
|
+ mstore.stop();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
} // namespace
|
|
} // namespace
|
|
|
|
|
|
|
|
int main() {
|
|
int main() {
|
|
@@ -807,6 +976,7 @@ int main() {
|
|
|
test_cascade_deletes_scalar_children_wal_first();
|
|
test_cascade_deletes_scalar_children_wal_first();
|
|
|
test_array_reference_pulls_id_and_keeps_document();
|
|
test_array_reference_pulls_id_and_keeps_document();
|
|
|
test_crash_between_wal_and_commit_recovers();
|
|
test_crash_between_wal_and_commit_recovers();
|
|
|
|
|
+ test_cascade_refuses_when_grandchild_is_restrict_protected();
|
|
|
|
|
|
|
|
std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
|
|
std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
|
|
|
return g_fail == 0 ? 0 : 1;
|
|
return g_fail == 0 ? 0 : 1;
|