| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016 |
- // v2.4.4 — sub-db identity sentinel tests.
- //
- // Regression cover for the production incident in which an invalidated
- // MDB_dbi was reused after LMDB reassigned its slot, so writes aimed at
- // `image_hashes` landed in `executions` and succeeded silently. 31 documents
- // across smartbotic-automation ended up in a sub-db other than the one they
- // declared. See storage/subdb_identity.hpp for the full mechanism.
- //
- // The core test is `misbound_handle_is_refused`: it reproduces the misbinding
- // directly by handing the verifier a handle for a different sub-db, which is
- // what a stale cache entry amounts to.
- #include <cassert>
- #include <atomic>
- #include <filesystem>
- #include <iostream>
- #include <cstdio>
- #include <algorithm>
- #include <functional>
- #include <thread>
- #include <set>
- #include <string>
- #include <unistd.h>
- #include <lmdb.h>
- #include <nlohmann/json.hpp>
- #include "document.hpp"
- #include "storage/document_store_lmdb.hpp"
- #include "storage/lmdb_env.hpp"
- #include "storage/lmdb_txn.hpp"
- #include "storage/subdb_identity.hpp"
- namespace fs = std::filesystem;
- using smartbotic::database::Document;
- using smartbotic::db::storage::is_identity_key;
- using smartbotic::db::storage::kSubdbIdentityKey;
- using smartbotic::db::storage::LmdbDocumentStore;
- using smartbotic::db::storage::LmdbEnv;
- using smartbotic::db::storage::LmdbEnvOpts;
- using smartbotic::db::storage::read_subdb_identity;
- using smartbotic::db::storage::ReadTxn;
- using smartbotic::db::storage::RelationRef;
- using smartbotic::db::storage::verify_subdb_identity;
- using smartbotic::db::storage::write_subdb_identity;
- using smartbotic::db::storage::WriteTxn;
- namespace {
- int g_pass = 0;
- int g_fail = 0;
- void check(bool cond, const char* msg) {
- if (cond) {
- ++g_pass;
- } else {
- ++g_fail;
- std::cerr << "FAIL: " << msg << "\n";
- }
- }
- std::string make_tmpdir(const char* tag) {
- static std::atomic<int> counter{0};
- std::string path = "/tmp/subdb-identity-test-" + std::to_string(::getpid()) +
- "-" + std::to_string(counter.fetch_add(1)) + "-" + tag;
- std::error_code ec;
- fs::remove_all(path, ec);
- return path;
- }
- struct TmpEnv {
- std::string path;
- LmdbEnv env;
- explicit TmpEnv(const char* tag)
- : path(make_tmpdir(tag)),
- env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
- ~TmpEnv() {
- std::error_code ec;
- fs::remove_all(path, ec);
- }
- TmpEnv(const TmpEnv&) = delete;
- TmpEnv& operator=(const TmpEnv&) = delete;
- };
- // Open (creating) a named sub-db inside a write txn and return its handle.
- unsigned int open_subdb(WriteTxn& txn, const char* name) {
- MDB_dbi dbi = 0;
- int rc = mdb_dbi_open(txn.raw(), name, MDB_CREATE, &dbi);
- assert(rc == MDB_SUCCESS);
- (void)rc;
- return dbi;
- }
- Document make_doc(const std::string& id, const std::string& collection) {
- Document d;
- d.id = id;
- d.collection = collection;
- d.set_data(nlohmann::json{{"seenCount", 1}, {"who", collection}});
- return d;
- }
- // -------------------------------------------------------------------------
- void test_sentinel_roundtrip() {
- TmpEnv t("roundtrip");
- {
- WriteTxn w(t.env);
- unsigned int dbi = open_subdb(w, "image_hashes");
- write_subdb_identity(w, dbi, "image_hashes");
- w.commit();
- }
- {
- WriteTxn w(t.env);
- unsigned int dbi = open_subdb(w, "image_hashes");
- bool threw = false;
- try {
- verify_subdb_identity(w, dbi, "image_hashes");
- } catch (const std::exception&) {
- threw = true;
- }
- check(!threw, "matching sentinel must verify without throwing");
- w.commit();
- }
- {
- ReadTxn r(t.env);
- MDB_dbi dbi = 0;
- mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
- check(read_subdb_identity(r, dbi) == "image_hashes",
- "read_subdb_identity returns the stamped name");
- }
- }
- // THE regression test. A stale cache entry is, in effect, a handle that
- // addresses someone else's sub-db. Hand the verifier exactly that.
- void test_misbound_handle_is_refused() {
- TmpEnv t("misbound");
- unsigned int executions_dbi = 0;
- {
- WriteTxn w(t.env);
- unsigned int ih = open_subdb(w, "image_hashes");
- write_subdb_identity(w, ih, "image_hashes");
- executions_dbi = open_subdb(w, "executions");
- write_subdb_identity(w, executions_dbi, "executions");
- w.commit();
- }
- WriteTxn w(t.env);
- // Re-open so the handle is valid in this txn, then deliberately verify it
- // under the WRONG name — the production misbinding, reproduced.
- unsigned int exec = open_subdb(w, "executions");
- bool threw = false;
- std::string msg;
- try {
- verify_subdb_identity(w, exec, "image_hashes");
- } catch (const std::exception& e) {
- threw = true;
- msg = e.what();
- }
- check(threw, "handle for 'executions' verified as 'image_hashes' must throw");
- check(msg.find("image_hashes") != std::string::npos &&
- msg.find("executions") != std::string::npos,
- "misbinding error names both the requested and actual sub-db");
- w.abort();
- }
- // Existing deployments have sub-dbs with no sentinel. Those must keep working.
- void test_unstamped_subdb_is_permitted() {
- TmpEnv t("unstamped");
- WriteTxn w(t.env);
- unsigned int dbi = open_subdb(w, "legacy");
- bool threw = false;
- try {
- verify_subdb_identity(w, dbi, "legacy");
- } catch (const std::exception&) {
- threw = true;
- }
- check(!threw, "sub-db without a sentinel must verify (absence is unknown, not wrong)");
- w.commit();
- }
- void test_identity_key_predicate() {
- check(is_identity_key(kSubdbIdentityKey), "sentinel key recognised");
- check(!is_identity_key("__subdb_identity__"),
- "same text without the leading NUL is NOT the sentinel");
- check(!is_identity_key("e63c1b90"), "a document id is not the sentinel");
- check(kSubdbIdentityKey[0] == '\0',
- "sentinel must start with NUL so it cannot collide with a doc id");
- }
- // The sentinel is an implementation detail: it must never surface through the
- // DocumentStore API as a document, nor inflate a count.
- void test_sentinel_invisible_through_store() {
- TmpEnv t("invisible");
- LmdbDocumentStore store(t.env);
- store.put("image_hashes", "aaa", make_doc("aaa", "image_hashes"));
- store.put("image_hashes", "bbb", make_doc("bbb", "image_hashes"));
- check(store.count("image_hashes") == 2,
- "count() must exclude the identity sentinel");
- smartbotic::database::Query q;
- q.limit = 100;
- auto res = store.scan("image_hashes", q);
- check(res.documents.size() == 2, "scan() must exclude the identity sentinel");
- check(res.total_matched == 2, "scan() total_matched must exclude the sentinel");
- for (const auto& d : res.documents) {
- check(d.id == "aaa" || d.id == "bbb",
- "scan() must not surface the sentinel as a document");
- }
- // And it really is on disk.
- ReadTxn r(t.env);
- MDB_dbi dbi = 0;
- int rc = mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
- check(rc == MDB_SUCCESS, "sub-db exists");
- check(read_subdb_identity(r, dbi) == "image_hashes",
- "store.put() stamps the sentinel on first write");
- }
- // A vector sub-db gets stamped with its own (prefixed) name, and scan_vectors
- // must skip the sentinel rather than trying to read it as float32 bytes.
- void test_vector_subdb_sentinel() {
- TmpEnv t("vectors");
- LmdbDocumentStore store(t.env);
- store.put_vector("emb", "v1", {1.0f, 2.0f, 3.0f});
- store.put_vector("emb", "v2", {4.0f, 5.0f, 6.0f});
- int seen = 0;
- bool bad = false;
- store.scan_vectors("emb", [&](std::string_view id, const float*, size_t n) {
- ++seen;
- if (n != 3) bad = true;
- if (is_identity_key(id)) bad = true;
- });
- check(seen == 2, "scan_vectors must skip the sentinel");
- check(!bad, "scan_vectors must not decode the sentinel as float data");
- ReadTxn r(t.env);
- MDB_dbi dbi = 0;
- mdb_dbi_open(r.raw(), "_vectors_emb", 0, &dbi);
- check(read_subdb_identity(r, dbi) == "_vectors_emb",
- "vector sub-db is stamped with its prefixed name");
- }
- // v2.4.4 Count is LMDB-first. Unfiltered it uses count(); filtered it uses
- // scan() with limit=0 and reads total_matched. Pin that contract: total_matched
- // is computed BEFORE pagination, so limit=0 must still report the true total
- // while returning no documents.
- void test_scan_limit_zero_reports_total() {
- TmpEnv t("counting");
- LmdbDocumentStore store(t.env);
- for (int i = 0; i < 5; ++i) {
- Document d;
- d.id = "id" + std::to_string(i);
- d.collection = "things";
- d.set_data(nlohmann::json{{"kind", i < 3 ? "alpha" : "beta"}});
- store.put("things", d.id, d);
- }
- smartbotic::database::Query q;
- q.limit = 0;
- auto all = store.scan("things", q);
- check(all.total_matched == 5, "limit=0 reports the full total");
- check(all.documents.empty(), "limit=0 returns no documents");
- check(store.count("things") == 5, "unfiltered count matches");
- smartbotic::database::Query fq;
- fq.limit = 0;
- smartbotic::database::Filter f;
- f.field = "kind";
- f.op = smartbotic::database::FilterOp::EQ;
- f.value = "alpha";
- fq.filters.push_back(f);
- auto filtered = store.scan("things", fq);
- check(filtered.total_matched == 3,
- "filtered limit=0 reports the matching total, not the collection size");
- }
- // v2.7.1 — the unfiltered/unsorted fast path in scan() must agree with the
- // general path exactly. It exists because the general path decoded every
- // document in the collection to return `limit` of them, so cost tracked total
- // bytes rather than page size (382ms to return one 510-byte document from a
- // 414 MB collection on a live instance). Any divergence here is a paging bug.
- void test_scan_fast_path_matches_general_path() {
- TmpEnv t("fastpath");
- LmdbDocumentStore store(t.env);
- for (int i = 0; i < 25; ++i) {
- Document d;
- char buf[16];
- std::snprintf(buf, sizeof(buf), "id%02d", i);
- d.id = buf;
- d.collection = "things";
- d.set_data(nlohmann::json{{"n", i}, {"kind", i % 2 ? "odd" : "even"}});
- store.put("things", d.id, d);
- }
- // total_matched and has_more must match what a full count says.
- smartbotic::database::Query page;
- page.limit = 10;
- page.offset = 0;
- auto p0 = store.scan("things", page);
- check(p0.documents.size() == 10, "fast path returns exactly `limit` docs");
- check(p0.total_matched == 25, "fast path total_matched excludes the sentinel");
- check(p0.has_more, "has_more true when more remain");
- page.offset = 20;
- auto p2 = store.scan("things", page);
- check(p2.documents.size() == 5, "final page returns the remainder");
- check(p2.total_matched == 25, "total_matched stable across pages");
- check(!p2.has_more, "has_more false on the last page");
- page.offset = 25;
- auto p3 = store.scan("things", page);
- check(p3.documents.empty(), "offset past the end returns nothing");
- check(p3.total_matched == 25, "and still reports the true total");
- // Paging must cover every document exactly once, in a stable order.
- std::set<std::string> seen;
- for (uint32_t off = 0; off < 25; off += 7) {
- smartbotic::database::Query q;
- q.limit = 7;
- q.offset = off;
- for (const auto& d : store.scan("things", q).documents) seen.insert(d.id);
- }
- check(seen.size() == 25, "paging the whole collection yields every document once");
- // A filter forces the general path; it must still be correct.
- smartbotic::database::Query fq;
- fq.limit = 100;
- smartbotic::database::Filter f;
- f.field = "kind";
- f.op = smartbotic::database::FilterOp::EQ;
- f.value = "odd";
- fq.filters.push_back(f);
- auto filtered = store.scan("things", fq);
- check(filtered.total_matched == 12, "filtered path still counts matches, not rows");
- // A sort also forces the general path.
- smartbotic::database::Query sq;
- sq.limit = 3;
- sq.sort = smartbotic::database::Sort{"n", true};
- auto sorted = store.scan("things", sq);
- check(sorted.documents.size() == 3, "sorted path paginates");
- check(sorted.total_matched == 25, "sorted path totals all rows");
- check(sorted.documents[0].data().value("n", -1) == 24,
- "descending sort really sorted (fast path must not swallow sorts)");
- // limit=0 keeps meaning "no documents, but a true total" - the contract
- // Count depends on (see test_scan_limit_zero_reports_total).
- smartbotic::database::Query zq;
- zq.limit = 0;
- auto z = store.scan("things", zq);
- check(z.documents.empty(), "limit=0 returns no documents on the fast path");
- check(z.total_matched == 25, "limit=0 still reports the true total");
- }
- // v2.8.0 — a WRITE that aborts must not poison the collection.
- //
- // This is the v2.4.3 EINVAL bug in a third failure mode, observed live in
- // production on 2.7.1: `find` on smartbotic-automation:workflows failed with
- // "LMDB cursor_open: Invalid argument" on every attempt while every other
- // collection was fine, and a restart was the only cure.
- //
- // Cause: open_for_write() cached the MDB_dbi immediately after mdb_dbi_open,
- // BEFORE the caller committed. LMDB keeps a handle private to the opening
- // transaction until it commits and CLOSES it if that transaction aborts - so any
- // write that threw after the handle was cached (a failed mdb_put, a sentinel
- // mismatch, a WriteTxn destructing uncommitted) left a closed handle in the
- // cache, and every later operation on that collection failed EINVAL for the rest
- // of the process's life.
- //
- // The abort is induced honestly here, with a key past LMDB's 511-byte limit, so
- // the test exercises the same path a real failed write takes.
- void test_aborted_write_does_not_poison_the_collection() {
- TmpEnv t("abortpoison");
- LmdbDocumentStore store(t.env);
- // Force a write that opens the sub-db and then fails: an oversized key makes
- // mdb_put return MDB_BAD_VALSIZE, which throws, so the WriteTxn aborts.
- const std::string huge_id(600, 'k');
- bool threw = false;
- try {
- store.put("poisoned", huge_id, make_doc(huge_id, "poisoned"));
- } catch (const std::exception&) {
- threw = true;
- }
- check(threw, "an oversized key really does fail the write");
- // The collection must still be usable. Before the fix, every one of these
- // failed with EINVAL because the cache held a handle LMDB had closed.
- bool ok_put = true;
- try {
- store.put("poisoned", "good", make_doc("good", "poisoned"));
- } catch (const std::exception&) {
- ok_put = false;
- }
- check(ok_put, "a later WRITE to the same collection still works");
- bool ok_read = true;
- try {
- smartbotic::database::Query q;
- q.limit = 10;
- auto res = store.scan("poisoned", q);
- check(res.documents.size() == 1, "and the document written after the abort is there");
- } catch (const std::exception&) {
- ok_read = false;
- }
- check(ok_read, "a later SCAN of the same collection still works (cursor_open)");
- bool ok_count = true;
- try {
- check(store.count("poisoned") == 1, "count is right after the abort");
- } catch (const std::exception&) {
- ok_count = false;
- }
- check(ok_count, "and count() does not throw");
- check(store.get("poisoned", "good").has_value(), "get() works after the abort");
- }
- // v2.8.0 — the two-pass filtered scan must agree with the old row-at-a-time path
- // on every operator, not just the common ones.
- //
- // scan() now evaluates predicates against a yyjson tree via a field resolver and
- // materialises only the returned page, because building a Document per row was
- // 88% of a filtered query's cost (3168ms vs 369ms for the parse alone over 193 MB
- // of real rows). A resolver that mishandles one operator returns silently wrong
- // data, so this walks the matrix.
- void test_filtered_scan_operator_matrix() {
- TmpEnv t("filtermatrix");
- LmdbDocumentStore store(t.env);
- auto put = [&](const std::string& id, const nlohmann::json& data) {
- Document d;
- d.id = id;
- d.collection = "m";
- d.version = 3;
- d.createdAt = 1000;
- d.updatedAt = 2000;
- d.set_data(data);
- store.put("m", id, d);
- };
- put("a", {{"n", 1}, {"kind", "odd"}, {"tags", {"x", "y"}}, {"nest", {{"deep", "hit"}}}});
- put("b", {{"n", 2}, {"kind", "even"}, {"tags", {"y"}}, {"nest", {{"deep", "miss"}}}});
- put("c", {{"n", 3}, {"kind", "odd"}, {"tags", nlohmann::json::array()}});
- put("d", {{"n", 4}, {"kind", "even"}, {"extra", "present"}});
- auto ids = [&](const smartbotic::database::Query& q) {
- std::vector<std::string> out;
- for (const auto& d : store.scan("m", q).documents) out.push_back(d.id);
- std::sort(out.begin(), out.end());
- return out;
- };
- auto q1 = [&](const char* field, smartbotic::database::FilterOp op,
- const nlohmann::json& val) {
- smartbotic::database::Query q;
- q.limit = 100;
- smartbotic::database::Filter f;
- f.field = field; f.op = op; f.value = val;
- q.filters.push_back(f);
- return q;
- };
- using Op = smartbotic::database::FilterOp;
- check(ids(q1("kind", Op::EQ, "odd")) == (std::vector<std::string>{"a", "c"}),
- "EQ on a data field");
- check(ids(q1("kind", Op::NE, "odd")) == (std::vector<std::string>{"b", "d"}),
- "NE on a data field");
- check(ids(q1("n", Op::GT, 2)) == (std::vector<std::string>{"c", "d"}), "GT numeric");
- check(ids(q1("n", Op::GTE, 3)) == (std::vector<std::string>{"c", "d"}), "GTE numeric");
- check(ids(q1("n", Op::LT, 2)) == (std::vector<std::string>{"a"}), "LT numeric");
- check(ids(q1("n", Op::LTE, 2)) == (std::vector<std::string>{"a", "b"}), "LTE numeric");
- check(ids(q1("n", Op::IN, nlohmann::json::array({1, 4}))) ==
- (std::vector<std::string>{"a", "d"}), "IN");
- check(ids(q1("tags", Op::CONTAINS, "x")) == (std::vector<std::string>{"a"}),
- "CONTAINS descends into an array value");
- check(ids(q1("extra", Op::EXISTS, true)) == (std::vector<std::string>{"d"}),
- "EXISTS true");
- check(ids(q1("extra", Op::EXISTS, false)) ==
- (std::vector<std::string>{"a", "b", "c"}), "EXISTS false");
- check(ids(q1("kind", Op::REGEX, "^od")) == (std::vector<std::string>{"a", "c"}),
- "REGEX");
- check(ids(q1("nest.deep", Op::EQ, "hit")) == (std::vector<std::string>{"a"}),
- "dotted path descends into data");
- check(ids(q1("nest.missing", Op::EXISTS, true)).empty(),
- "a dotted path that does not resolve matches nothing");
- // Document metadata, which lives at the top level of the stored JSON rather
- // than inside "data".
- check(ids(q1("_id", Op::EQ, "b")) == (std::vector<std::string>{"b"}), "_id");
- check(ids(q1("_version", Op::EQ, 3)).size() == 4, "_version");
- check(ids(q1("_created_at", Op::GTE, 1000)).size() == 4, "_created_at");
- check(ids(q1("_updated_at", Op::LT, 2000)).empty(), "_updated_at");
- // SEARCH must still work - it needs the whole document, so it takes the old
- // path.
- check(ids(q1("", Op::SEARCH, "present")) == (std::vector<std::string>{"d"}),
- "SEARCH still matches (routed to the whole-document path)");
- check(ids(q1("", Op::SEARCH, "nothinghere")).empty(), "SEARCH non-match");
- // Sorting, pagination and total_matched over a filtered set.
- {
- smartbotic::database::Query q;
- q.limit = 1;
- smartbotic::database::Filter f;
- f.field = "kind"; f.op = Op::EQ; f.value = "odd";
- q.filters.push_back(f);
- q.sort = smartbotic::database::Sort{"n", true}; // descending
- auto page0 = store.scan("m", q);
- check(page0.total_matched == 2, "total_matched counts matches, not rows");
- check(page0.documents.size() == 1, "limit honoured");
- check(page0.documents[0].id == "c", "descending sort picks the highest first");
- check(page0.has_more, "has_more true mid-set");
- q.offset = 1;
- auto page1 = store.scan("m", q);
- check(page1.documents.size() == 1 && page1.documents[0].id == "a",
- "second page continues the sort order");
- check(!page1.has_more, "has_more false on the last page");
- q.offset = 5;
- check(store.scan("m", q).documents.empty(), "offset past the end is empty");
- }
- // Ascending, and a sort field that is missing from some documents.
- {
- smartbotic::database::Query q;
- q.limit = 10;
- q.sort = smartbotic::database::Sort{"extra", false};
- auto res = store.scan("m", q);
- check(res.total_matched == 4, "no filter plus a sort still totals every row");
- check(res.documents.size() == 4, "and returns them all");
- // sort_documents returns `descending` when the LEFT value is missing, so
- // ascending puts documents that HAVE the field first and the ones missing
- // it last. The two-pass path copies that rule rather than inventing one.
- check(res.documents.front().id == "d",
- "ascending: the document that has the sort field comes first");
- std::vector<std::string> tail;
- for (size_t i = 1; i < res.documents.size(); ++i) tail.push_back(res.documents[i].id);
- check(tail == (std::vector<std::string>{"a", "b", "c"}),
- "and the ones missing it follow, tie-broken by id");
- }
- }
- // v2.8.1 — concurrent reads must not rebind a cached handle.
- //
- // lmdb.h: "This function [mdb_dbi_open] must not be called from multiple
- // concurrent transactions in the same process. A transaction that uses this
- // function must finish (either commit or abort) before any other transaction in
- // the process may use this function."
- //
- // The old read path violated that on every read of a collection this process had
- // not yet written, from every gRPC thread at once. MDB_dbi is an index into the
- // env's shared handle table, so churning it silently REBOUND handles that
- // committed writes had already cached. Live on 2.8.0-3 that produced 41 refusals
- // in 45 minutes, all "caller asked for 'image_hashes' but the handle addresses
- // 'nsfw_images'" - and because each refusal bumped mirror drift, every read in
- // the process fell back to MemoryStore permanently.
- //
- // The fix primes all handles in one committed write txn at construction, so only
- // write txns ever call mdb_dbi_open and LMDB serialises those itself.
- //
- // HONEST LIMITATION: this test does NOT reproduce the race. Reverting the fix
- // leaves it green apart from the primed_count assertion - the torn slot-table
- // update needs an interleaving of concurrent mdb_dbi_open calls that 8 threads
- // over 10 collections does not reliably hit. What the test does pin is the
- // invariant the race violates (no read returns another collection's document, no
- // write is refused by the sentinel) plus the mechanism that removes the race:
- // handles are opened up front, so the read path has no mdb_dbi_open left to call.
- // The deterministic evidence is the documented contract in lmdb.h and the
- // production log.
- void test_concurrent_reads_do_not_rebind_cached_handles() {
- const std::string path = make_tmpdir("dbi-concurrent");
- const LmdbEnvOpts opts{path, 64ULL << 20, 256, 126, false};
- struct Cleanup {
- const std::string& p;
- ~Cleanup() { std::error_code ec; fs::remove_all(p, ec); }
- } cleanup{path};
- // Enough collections that slot churn has somewhere to go.
- const std::vector<std::string> colls = {
- "alpha", "beta", "gamma", "delta", "epsilon",
- "zeta", "eta", "theta", "iota", "kappa"};
- {
- LmdbEnv env(opts);
- LmdbDocumentStore writer(env);
- for (const auto& c : colls) {
- Document d;
- d.id = "seed";
- d.collection = c;
- d.set_data(nlohmann::json{{"who", c}});
- writer.put(c, "seed", d);
- }
- }
- // Restart: fresh env, so the shared handle table starts empty and the store
- // must prime it.
- LmdbEnv env2(opts);
- LmdbDocumentStore store(env2);
- check(store.prime_error().empty(), "priming succeeded on reopen");
- check(store.primed_count() >= colls.size(),
- "priming opened a handle for every existing sub-db");
- // Hammer reads from many threads. Every one of these used to call
- // mdb_dbi_open inside its own read txn.
- std::atomic<int> read_failures{0};
- std::atomic<int> wrong_data{0};
- {
- std::vector<std::thread> threads;
- for (int t = 0; t < 8; ++t) {
- threads.emplace_back([&, t]() {
- for (int i = 0; i < 40; ++i) {
- const auto& c = colls[(t + i) % colls.size()];
- try {
- auto got = store.get(c, "seed");
- if (!got) { ++read_failures; continue; }
- // A rebound handle reads a STRANGER's sub-db, so the
- // document that comes back belongs to another collection.
- if (got->data().value("who", std::string{}) != c) ++wrong_data;
- } catch (const std::exception&) {
- ++read_failures;
- }
- }
- });
- }
- for (auto& th : threads) th.join();
- }
- check(read_failures.load() == 0, "concurrent reads all succeeded");
- check(wrong_data.load() == 0,
- "no read returned another collection's document - a rebound handle "
- "addresses whichever sub-db now occupies its slot");
- // Now write to every collection through the cached handles. This is where
- // the live failure surfaced: the sentinel refused the write.
- int write_failures = 0;
- for (const auto& c : colls) {
- try {
- Document d;
- d.id = "after";
- d.collection = c;
- d.set_data(nlohmann::json{{"who", c}});
- store.put(c, "after", d);
- } catch (const std::exception&) {
- ++write_failures;
- }
- }
- check(write_failures == 0,
- "writes through primed handles are not refused by the identity "
- "sentinel - the refusal is what production saw");
- for (const auto& c : colls) {
- check(store.count(c) == 2, ("both documents readable in " + c).c_str());
- }
- }
- // A collection that exists on disk but has NOT been written by this process must
- // still be readable. The read path no longer opens handles on demand, so if
- // priming missed anything a read would report the collection as EMPTY - a
- // silent-wrong-data failure worse than the bug being fixed.
- void test_existing_collection_readable_without_writing_first() {
- const std::string path = make_tmpdir("dbi-prime-read");
- const LmdbEnvOpts opts{path, 64ULL << 20, 256, 126, false};
- struct Cleanup {
- const std::string& p;
- ~Cleanup() { std::error_code ec; fs::remove_all(p, ec); }
- } cleanup{path};
- {
- LmdbEnv env(opts);
- LmdbDocumentStore writer(env);
- Document d;
- d.id = "only";
- d.collection = "archive";
- d.set_data(nlohmann::json{{"kept", true}});
- writer.put("archive", "only", d);
- }
- LmdbEnv env2(opts);
- LmdbDocumentStore reader(env2);
- // Read-only access, never a write on this collection in this process.
- auto got = reader.get("archive", "only");
- check(got.has_value(), "a never-written-here collection is still readable");
- check(reader.count("archive") == 1, "count sees it");
- smartbotic::database::Query q; q.limit = 10;
- check(reader.scan("archive", q).documents.size() == 1, "scan sees it");
- // And a collection that genuinely does not exist still reads as absent.
- check(!reader.get("nosuch", "x").has_value(),
- "a missing collection is still absent, not an error");
- check(reader.count("nosuch") == 0, "and counts zero");
- }
- // v2.9.0 — a secondary index must stay exactly in step with the documents.
- //
- // The index is a second copy of a fact already stored in the row. Every way the
- // two can diverge is a silent-wrong-data bug: a stale entry returns a row that
- // no longer matches, a missing entry hides a row that does. So this walks the
- // full lifecycle - insert, update the indexed field, update something else,
- // delete, re-insert - and after every step asserts the index agrees with a
- // brute-force scan of the collection.
- void test_index_tracks_documents_through_every_write() {
- TmpEnv t("idx-maint");
- LmdbDocumentStore store(t.env);
- store.set_indexed_fields("execs", {"workflowId"});
- auto put = [&](const std::string& id, const std::string& wf, int n) {
- Document d;
- d.id = id;
- d.collection = "execs";
- d.set_data(nlohmann::json{{"workflowId", wf}, {"n", n}});
- store.put("execs", id, d);
- };
- // What the index SHOULD say, computed by scanning every row - the oracle.
- auto truth = [&](const std::string& wf) {
- smartbotic::database::Query q;
- q.limit = 10000;
- std::vector<std::string> ids;
- for (const auto& d : store.scan("execs", q).documents) {
- if (d.data().value("workflowId", std::string{}) == wf) ids.push_back(d.id);
- }
- std::sort(ids.begin(), ids.end());
- return ids;
- };
- auto indexed = [&](const std::string& wf) {
- auto got = store.index_lookup_eq("execs", "workflowId", nlohmann::json(wf));
- std::vector<std::string> ids = got.value_or(std::vector<std::string>{});
- std::sort(ids.begin(), ids.end());
- return ids;
- };
- auto agree = [&](const std::string& wf, const char* stage) {
- const bool ok = indexed(wf) == truth(wf);
- const std::string msg = "index agrees with a full scan for " + wf +
- " after " + stage;
- check(ok, msg.c_str());
- };
- // Insert
- put("e1", "wf-a", 1);
- put("e2", "wf-a", 2);
- put("e3", "wf-b", 3);
- agree("wf-a", "inserts");
- agree("wf-b", "inserts");
- check(indexed("wf-a").size() == 2, "two rows under wf-a");
- // Update the INDEXED field: the old posting must go, the new one appear.
- put("e2", "wf-b", 2);
- agree("wf-a", "moving e2 to wf-b");
- agree("wf-b", "moving e2 to wf-b");
- check(indexed("wf-a").size() == 1, "wf-a lost e2");
- check(indexed("wf-b").size() == 2, "wf-b gained it");
- // Update an UNindexed field: the index must be untouched, not duplicated.
- put("e1", "wf-a", 99);
- agree("wf-a", "updating an unindexed field");
- check(indexed("wf-a").size() == 1,
- "no duplicate posting from re-writing the same indexed value");
- // Delete
- check(store.del("execs", "e3"), "deleted e3");
- agree("wf-b", "deleting e3");
- check(indexed("wf-b").size() == 1, "e3 is gone from the index");
- // Re-insert the same id
- put("e3", "wf-b", 7);
- agree("wf-b", "re-inserting e3");
- check(indexed("wf-b").size() == 2, "e3 is back exactly once");
- // A value with no rows is an empty result, NOT "no index".
- auto none = store.index_lookup_eq("execs", "workflowId", nlohmann::json("wf-zzz"));
- check(none.has_value() && none->empty(),
- "an indexed field with no matching rows returns empty, not nullopt - "
- "nullopt means 'no index' and would send the caller to a scan");
- // An undeclared field has no index, and must say so rather than say 'none'.
- auto unindexed = store.index_lookup_eq("execs", "n", nlohmann::json(1));
- check(!unindexed.has_value(),
- "an unindexed field returns nullopt so the caller falls back to a scan "
- "instead of concluding there are no matches");
- }
- // A collection with no declared index must behave exactly as before, and pay
- // nothing. Also: declaring an index later must pick up the rows already there.
- void test_build_index_over_existing_rows() {
- TmpEnv t("idx-build");
- LmdbDocumentStore store(t.env);
- // Write BEFORE declaring the index.
- for (int i = 0; i < 20; ++i) {
- Document d;
- d.id = "d" + std::to_string(i);
- d.collection = "c";
- d.set_data(nlohmann::json{{"grp", i % 4 == 0 ? "hot" : "cold"}});
- store.put("c", d.id, d);
- }
- check(!store.index_lookup_eq("c", "grp", nlohmann::json("hot")).has_value(),
- "no index exists before it is declared");
- const uint64_t built = store.build_index("c", "grp");
- check(built == 20, "the backfill indexed every existing row");
- store.set_indexed_fields("c", {"grp"});
- auto hot = store.index_lookup_eq("c", "grp", nlohmann::json("hot"));
- check(hot.has_value() && hot->size() == 5,
- "the backfilled index finds the pre-existing rows (d0,d4,d8,d12,d16)");
- // Idempotent: a second build must not double the postings.
- store.build_index("c", "grp");
- hot = store.index_lookup_eq("c", "grp", nlohmann::json("hot"));
- check(hot.has_value() && hot->size() == 5,
- "re-running the backfill does not duplicate postings");
- // The count guard must see the same number without reading the ids.
- auto n = store.index_count_eq("c", "grp", nlohmann::json("hot"));
- check(n.has_value() && *n == 5, "index_count_eq agrees with the lookup");
- auto cold = store.index_count_eq("c", "grp", nlohmann::json("cold"));
- check(cold.has_value() && *cold == 15, "and counts the larger group");
- check(store.drop_index("c", "grp"), "the index drops");
- check(!store.index_lookup_eq("c", "grp", nlohmann::json("hot")).has_value(),
- "after dropping, lookups report no index rather than no rows");
- }
- // Numbers are where an index most easily disagrees with a scan, because the scan
- // compares across numeric subtypes. A doc stored with 5 must be found by a
- // filter asking for 5.0 through EITHER path.
- void test_index_numeric_equality_matches_scan() {
- TmpEnv t("idx-num");
- LmdbDocumentStore store(t.env);
- store.set_indexed_fields("m", {"code"});
- Document a;
- a.id = "a"; a.collection = "m";
- a.set_data(nlohmann::json{{"code", 5}}); // integer
- store.put("m", "a", a);
- Document b;
- b.id = "b"; b.collection = "m";
- b.set_data(nlohmann::json{{"code", 5.0}}); // integral double
- store.put("m", "b", b);
- auto by_int = store.index_lookup_eq("m", "code", nlohmann::json(5));
- auto by_dbl = store.index_lookup_eq("m", "code", nlohmann::json(5.0));
- check(by_int.has_value() && by_int->size() == 2,
- "asking for 5 finds BOTH the int and the integral-double row");
- check(by_dbl == by_int,
- "and asking for 5.0 returns exactly the same rows - the scan's EQ "
- "compares numbers across subtypes, so the index must too");
- // A big integer must not collide with its neighbour via double precision.
- Document c;
- c.id = "c"; c.collection = "m";
- c.set_data(nlohmann::json{{"code", 1786263002080195076LL}});
- store.put("m", "c", c);
- auto near = store.index_lookup_eq("m", "code",
- nlohmann::json(1786263002080195077LL));
- check(near.has_value() && near->empty(),
- "a neighbouring ns-scale integer does not collide - these two ARE "
- "equal as doubles, so routing through double would false-match");
- }
- // v2.9.0 — an indexed plan must return EXACTLY what the unindexed plan returns.
- //
- // This is the whole safety argument for indexing. The index is an optimisation,
- // so any observable difference is a bug, and the interesting failures are silent:
- // a missing posting drops a row, a stale one adds a row that no longer matches,
- // and a different code path can disagree about ordering or total_matched.
- //
- // The test runs each query twice against the same data - once with the field
- // declared indexed, once not - and compares the complete result: ids in order,
- // total_matched, and has_more.
- void test_indexed_and_unindexed_plans_agree() {
- TmpEnv t("idx-equiv");
- LmdbDocumentStore store(t.env);
- // 300 rows: `grp` is selective enough to use the index (10 groups of 30 =
- // 10%), `bucket` deliberately is NOT (2 values, 50% each) so the guard has
- // something to decline.
- for (int i = 0; i < 300; ++i) {
- Document d;
- d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) +
- std::to_string(i);
- d.collection = "c";
- d.set_data(nlohmann::json{
- {"grp", "g" + std::to_string(i % 10)},
- {"bucket", (i % 2 == 0) ? "even" : "odd"},
- {"n", i},
- {"nest", {{"deep", "d" + std::to_string(i % 10)}}},
- });
- store.put("c", d.id, d);
- }
- using Op = smartbotic::database::FilterOp;
- struct Case {
- const char* name;
- std::vector<smartbotic::database::Filter> filters;
- std::optional<smartbotic::database::Sort> sort;
- uint32_t limit;
- uint32_t offset;
- };
- auto F = [](const char* f, Op op, const nlohmann::json& v) {
- smartbotic::database::Filter x;
- x.field = f; x.op = op; x.value = v;
- return x;
- };
- const std::vector<Case> cases = {
- {"eq indexed field", {F("grp", Op::EQ, "g3")}, std::nullopt, 100, 0},
- {"eq + second predicate", {F("grp", Op::EQ, "g3"), F("bucket", Op::EQ, "even")}, std::nullopt, 100, 0},
- {"eq + range on another field", {F("grp", Op::EQ, "g3"), F("n", Op::GT, 100)}, std::nullopt, 100, 0},
- {"eq with sort asc", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", false}, 100, 0},
- {"eq with sort desc", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", true}, 100, 0},
- {"eq paginated", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", false}, 7, 10},
- {"eq offset past end", {F("grp", Op::EQ, "g3")}, std::nullopt, 10, 999},
- {"eq matching nothing", {F("grp", Op::EQ, "nope")}, std::nullopt, 100, 0},
- {"eq on unselective field", {F("bucket", Op::EQ, "even")}, std::nullopt, 100, 0},
- {"eq plus SEARCH", {F("grp", Op::EQ, "g3"), F("", Op::SEARCH, "g3")}, std::nullopt, 100, 0},
- {"ne on indexed field", {F("grp", Op::NE, "g3")}, std::nullopt, 100, 0},
- {"eq on nested path", {F("nest.deep", Op::EQ, "d4")}, std::nullopt, 100, 0},
- {"limit zero", {F("grp", Op::EQ, "g3")}, std::nullopt, 0, 0},
- };
- auto run = [&](const Case& c) {
- smartbotic::database::Query q;
- q.filters = c.filters;
- q.sort = c.sort;
- q.limit = c.limit;
- q.offset = c.offset;
- auto r = store.scan("c", q);
- std::string sig = "total=" + std::to_string(r.total_matched) +
- " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
- for (const auto& d : r.documents) { sig += d.id; sig += ","; }
- sig += "]";
- return sig;
- };
- for (const auto& c : cases) {
- store.set_indexed_fields("c", {}); // no index
- const std::string without = run(c);
- store.set_indexed_fields("c", {"grp", "bucket", "nest.deep"});
- store.build_index("c", "grp");
- store.build_index("c", "bucket");
- store.build_index("c", "nest.deep");
- const std::string with = run(c);
- const std::string msg = std::string("indexed and unindexed plans agree: ")
- + c.name;
- if (with != without) {
- std::cerr << " without index: " << without << "\n"
- << " with index: " << with << "\n";
- }
- check(with == without, msg.c_str());
- }
- // The agreement above is only meaningful if the index plan was actually
- // TAKEN for the selective cases. Otherwise the planner declined every time
- // and the test compared the scan against itself.
- store.set_indexed_fields("c", {"grp", "bucket", "nest.deep"});
- {
- store.reset_index_plan_stats();
- smartbotic::database::Query q;
- q.limit = 100;
- q.filters.push_back(F("grp", Op::EQ, "g3"));
- auto r = store.scan("c", q);
- auto st = store.index_plan_stats();
- check(r.total_matched == 30, "the selective query matches 30 of 300 rows");
- check(st.counted_scans == 1 && st.full_scans == 0,
- "a single unsorted EQ is answered by the COUNTED plan - total from "
- "the index, only the page's rows read");
- }
- {
- store.reset_index_plan_stats();
- smartbotic::database::Query q;
- q.limit = 100;
- q.filters.push_back(F("bucket", Op::EQ, "even"));
- auto r = store.scan("c", q);
- auto st = store.index_plan_stats();
- check(r.total_matched == 150, "the unselective query matches half the rows");
- check(st.counted_scans == 1 && st.full_scans == 0,
- "an UNSELECTIVE EQ is now cheap too. The old selectivity guard "
- "declined it, but only because counting 150 matches meant visiting "
- "them; with the count read from the index, breadth costs nothing");
- }
- {
- // An index on a field the query does not filter on must not be consulted.
- store.reset_index_plan_stats();
- smartbotic::database::Query q;
- q.limit = 100;
- q.filters.push_back(F("n", Op::GT, 250));
- store.scan("c", q);
- auto st = store.index_plan_stats();
- check(st.full_scans == 1 && st.indexed_scans == 0 && st.counted_scans == 0,
- "a query whose predicates name no indexed field scans");
- }
- {
- // The selectivity guard still governs the shapes the counted plan cannot
- // serve. A SORT rules it out (postings come in id order, not sort order),
- // so an unselective EQ plus a sort must still decline to the scan.
- store.reset_index_plan_stats();
- smartbotic::database::Query q;
- q.limit = 10;
- q.filters.push_back(F("bucket", Op::EQ, "even"));
- q.sort = smartbotic::database::Sort{"n", true};
- store.scan("c", q);
- auto st = store.index_plan_stats();
- check(st.counted_scans == 0 && st.declined_unselective == 1,
- "unselective EQ + a sort still declines - the counted plan cannot "
- "order, and the guard is what stops the index pessimising it");
- }
- auto n = store.index_count_eq("c", "bucket", nlohmann::json("even"));
- check(n.has_value() && *n == 150,
- "the unselective index does exist and holds 150 of 300 rows");
- }
- // An empty document id is a zero-length LMDB key, which mdb_get rejects with
- // MDB_BAD_VALSIZE. That surfaced in production as a gRPC INTERNAL and an ERROR
- // log line every time a consumer asked for one - noise that masks real failures.
- // A key that cannot exist is absent, not an error.
- void test_empty_id_reads_as_absent() {
- TmpEnv t("empty-id");
- LmdbDocumentStore store(t.env);
- Document d;
- d.id = "real";
- d.collection = "c";
- d.set_data(nlohmann::json{{"x", 1}});
- store.put("c", "real", d);
- bool threw = false;
- try {
- check(!store.get("c", "").has_value(), "an empty id reads as absent");
- } catch (const std::exception&) {
- threw = true;
- }
- check(!threw, "and does NOT throw - MDB_BAD_VALSIZE became a gRPC INTERNAL");
- threw = false;
- try {
- check(!store.del("c", ""), "deleting an empty id is a no-op");
- } catch (const std::exception&) {
- threw = true;
- }
- check(!threw, "and does not throw either");
- check(store.get("c", "real").has_value(), "real ids still work");
- check(store.count("c") == 1, "and nothing was disturbed");
- }
- // v2.9.1 — ranges, CONTAINS and intersection must agree with the scan too, and
- // must actually be USED. Each is a new way for the index to disagree with a
- // brute-force answer, and each disagreement would be silent.
- void test_range_contains_and_intersection() {
- TmpEnv t("idx-v291");
- LmdbDocumentStore store(t.env);
- // n mixes integers and reals on purpose: v2.9.0 encoded those under separate
- // type tags, so a range spanning both could not be served at all.
- for (int i = 0; i < 400; ++i) {
- Document d;
- d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
- d.collection = "c";
- nlohmann::json data{
- {"n", (i % 2 == 0) ? nlohmann::json(i) : nlohmann::json(i + 0.5)},
- {"grp", "g" + std::to_string(i % 20)},
- {"other", "o" + std::to_string(i % 20)},
- // Deliberately COARSE: 4 and 5 values, so each matches 25% and 20% of
- // 400 rows - both above the 10% budget alone - while their pair
- // narrows to i%20, i.e. 20 rows (5%). That is the only shape where
- // intersecting two posting lists earns its keep.
- {"q4", i % 4},
- {"q5", i % 5},
- {"tags", nlohmann::json::array({"t" + std::to_string(i % 25), "all"})},
- };
- d.set_data(data);
- store.put("c", d.id, d);
- }
- using Op = smartbotic::database::FilterOp;
- auto F = [](const char* f, Op op, const nlohmann::json& v) {
- smartbotic::database::Filter x;
- x.field = f; x.op = op; x.value = v;
- return x;
- };
- auto sig = [&](const std::vector<smartbotic::database::Filter>& fs,
- std::optional<smartbotic::database::Sort> so = std::nullopt) {
- smartbotic::database::Query q;
- q.filters = fs;
- q.sort = so;
- q.limit = 1000;
- auto r = store.scan("c", q);
- std::string out = "total=" + std::to_string(r.total_matched) + " [";
- std::vector<std::string> ids;
- for (const auto& d : r.documents) ids.push_back(d.id);
- std::sort(ids.begin(), ids.end());
- for (const auto& i : ids) { out += i; out += ","; }
- return out + "]";
- };
- const std::vector<std::pair<const char*, std::vector<smartbotic::database::Filter>>> cases = {
- {"GT on a mixed int/real field", {F("n", Op::GT, 380)}},
- {"GTE on a mixed field", {F("n", Op::GTE, 380)}},
- {"LT on a mixed field", {F("n", Op::LT, 12)}},
- {"LTE on a mixed field", {F("n", Op::LTE, 12)}},
- {"GT with a real bound", {F("n", Op::GT, 380.5)}},
- {"range that matches nothing", {F("n", Op::GT, 100000)}},
- {"range that matches everything", {F("n", Op::GT, -1)}},
- {"CONTAINS an array element", {F("tags", Op::CONTAINS, "t3")}},
- {"CONTAINS a common element", {F("tags", Op::CONTAINS, "all")}},
- {"CONTAINS a missing element", {F("tags", Op::CONTAINS, "nope")}},
- {"two broad EQs, narrow together", {F("q4", Op::EQ, 1), F("q5", Op::EQ, 2)}},
- {"two broad EQs, disjoint result", {F("q4", Op::EQ, 1), F("q5", Op::EQ, 2), F("grp", Op::EQ, "g19")}},
- {"range plus EQ", {F("n", Op::GT, 300), F("grp", Op::EQ, "g3")}},
- {"EQ on an array field (whole)", {F("tags", Op::EQ, nlohmann::json::array({"t3", "all"}))}},
- };
- for (const auto& [name, filters] : cases) {
- store.set_indexed_fields("c", {});
- const std::string without = sig(filters);
- store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
- for (const char* f : {"n", "grp", "other", "tags", "q4", "q5"}) {
- store.build_index("c", f);
- }
- const std::string with = sig(filters);
- if (with != without) {
- std::cerr << " without: " << without.substr(0, 200) << "\n"
- << " with: " << with.substr(0, 200) << "\n";
- }
- const std::string msg = std::string("indexed == unindexed: ") + name;
- check(with == without, msg.c_str());
- }
- // Now prove each new plan is actually taken.
- store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
- {
- store.reset_index_plan_stats();
- (void)sig({F("n", Op::GT, 380)});
- auto st = store.index_plan_stats();
- check(st.range_scans == 1 && st.indexed_scans == 1,
- "a selective range WALKS the index - impossible before the numeric "
- "encoding was unified");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("n", Op::GT, -1)}); // matches everything
- auto st = store.index_plan_stats();
- check(st.range_scans == 0 && st.full_scans == 1,
- "an unselective range gives up and scans - and must return no-plan "
- "rather than a truncated candidate list");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("tags", Op::CONTAINS, "t3")});
- auto st = store.index_plan_stats();
- check(st.indexed_scans == 1,
- "CONTAINS is served from the per-element postings an array writes");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("q4", Op::EQ, 1), F("q5", Op::EQ, 2)});
- auto st = store.index_plan_stats();
- check(st.intersected_scans == 1 && st.indexed_scans == 1,
- "q4 matches 25% and q5 20% - each too broad alone - so their posting "
- "lists are INTERSECTED down to 5% instead of scanning");
- }
- // An array-valued field: EQ on a scalar may over-return from the index, which
- // is safe only because every candidate is re-filtered. Pin that.
- {
- const std::string indexed = sig({F("tags", Op::EQ, "t3")});
- store.set_indexed_fields("c", {});
- const std::string scanned = sig({F("tags", Op::EQ, "t3")});
- store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
- check(indexed == scanned,
- "EQ for a scalar on an array field agrees - the index may name rows "
- "whose array merely CONTAINS it, and re-filtering drops them");
- }
- }
- // v2.9.2 — the index supplies the ORDER, not just filter candidates.
- //
- // "newest N, unfiltered" walked the whole collection to discover what to sort by:
- // 341ms to return one row from 592 MB of real data, with the index declared and
- // unused, because a Sort is not a filter. Walking the sort field's index reads the
- // page and nothing else.
- //
- // The dangerous part is not speed, it is that sort_documents places rows MISSING
- // the sort field FIRST when descending, while an index holds no posting for them.
- // A walk would silently omit them from the first page - wrong rows, not slow ones.
- // So every case here compares the ordered plan against the plain scan.
- void test_index_supplies_ordering() {
- using Op = smartbotic::database::FilterOp;
- auto make = [](LmdbDocumentStore& store, int n,
- const std::function<nlohmann::json(int)>& body) {
- for (int i = 0; i < n; ++i) {
- Document d;
- d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) +
- std::to_string(i);
- d.collection = "c";
- d.set_data(body(i));
- store.put("c", d.id, d);
- }
- };
- auto sig = [](LmdbDocumentStore& store, const smartbotic::database::Query& q) {
- auto r = store.scan("c", q);
- std::string out = "total=" + std::to_string(r.total_matched) +
- " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
- for (const auto& d : r.documents) { out += d.id; out += ","; }
- return out + "]";
- };
- auto Q = [](const char* field, bool desc, uint32_t limit, uint32_t offset) {
- smartbotic::database::Query q;
- q.sort = smartbotic::database::Sort{field, desc};
- q.limit = limit;
- q.offset = offset;
- return q;
- };
- // ---- every row carries the sort field: the ordered plan applies ----
- {
- TmpEnv t("ord-total");
- LmdbDocumentStore store(t.env);
- // Deliberate duplicate keys (i/3) so tie-breaking by id is exercised in
- // both directions.
- make(store, 300, [](int i) {
- return nlohmann::json{{"seq", i}, {"dup", i / 3}};
- });
- const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
- {"desc, first page", Q("seq", true, 10, 0)},
- {"asc, first page", Q("seq", false, 10, 0)},
- {"desc, deep offset", Q("seq", true, 10, 250)},
- {"asc, deep offset", Q("seq", false, 7, 33)},
- {"desc, last partial page", Q("seq", true, 10, 295)},
- {"offset past the end", Q("seq", true, 10, 999)},
- {"limit=0", Q("seq", true, 0, 0)},
- {"whole collection", Q("seq", false, 1000, 0)},
- {"desc with tied keys", Q("dup", true, 12, 0)},
- {"asc with tied keys", Q("dup", false, 12, 0)},
- {"tied keys, deep offset", Q("dup", true, 5, 40)},
- };
- uint64_t ordered_used = 0;
- for (const auto& [name, q] : cases) {
- store.set_indexed_fields("c", {});
- const std::string without = sig(store, q);
- store.set_indexed_fields("c", {"seq", "dup"});
- store.build_index("c", "seq");
- store.build_index("c", "dup");
- store.reset_index_plan_stats();
- const std::string with = sig(store, q);
- ordered_used += store.index_plan_stats().ordered_scans;
- if (with != without) {
- std::cerr << " scan: " << without.substr(0, 180) << "\n"
- << " ordered: " << with.substr(0, 180) << "\n";
- }
- const std::string msg = std::string("ordered plan == scan: ") + name;
- check(with == without, msg.c_str());
- }
- // EVERY case above must have used the ordered plan, not just one. The
- // first version of this test asserted only a single query and so passed
- // while the plan was silently never taken (the collection's row count
- // included the identity sentinel, so entries never equalled rows).
- check(ordered_used == cases.size(),
- ("the index SUPPLIED THE ORDER in all " + std::to_string(cases.size()) +
- " cases (got " + std::to_string(ordered_used) + ") - otherwise the "
- "comparisons above are scan against scan").c_str());
- }
- // ---- THE trap: one row lacks the sort field ----
- {
- TmpEnv t("ord-missing");
- LmdbDocumentStore store(t.env);
- make(store, 50, [](int i) {
- nlohmann::json j{{"other", i}};
- if (i != 7) j["seq"] = i; // r007 has no seq
- return j;
- });
- store.set_indexed_fields("c", {});
- const std::string without = sig(store, Q("seq", true, 5, 0));
- store.set_indexed_fields("c", {"seq"});
- store.build_index("c", "seq");
- const std::string with = sig(store, Q("seq", true, 5, 0));
- check(with == without,
- "one row missing the sort field: DESCENDING puts it FIRST, and the "
- "index has no posting for it - the ordered plan must decline");
- store.reset_index_plan_stats();
- (void)sig(store, Q("seq", true, 5, 0));
- check(store.index_plan_stats().ordered_scans == 0,
- "and it does decline - entries != rows is the guard");
- }
- // ---- an array-valued sort field must also decline ----
- {
- TmpEnv t("ord-array");
- LmdbDocumentStore store(t.env);
- make(store, 40, [](int i) {
- return nlohmann::json{{"seq", nlohmann::json::array({i})}};
- });
- store.set_indexed_fields("c", {});
- const std::string without = sig(store, Q("seq", true, 5, 0));
- store.set_indexed_fields("c", {"seq"});
- store.build_index("c", "seq");
- const std::string with = sig(store, Q("seq", true, 5, 0));
- check(with == without,
- "a one-element-array sort field agrees - it satisfies entries==rows, "
- "so the fetched-page array check is what catches it");
- }
- }
- // v2.9.2 — IN as a union of posting lists, EXISTS as all postings.
- void test_index_in_and_exists() {
- using Op = smartbotic::database::FilterOp;
- TmpEnv t("in-exists");
- LmdbDocumentStore store(t.env);
- for (int i = 0; i < 400; ++i) {
- Document d;
- d.id = "r" + std::to_string(1000 + i);
- d.collection = "c";
- nlohmann::json j{{"grp", "g" + std::to_string(i % 40)}};
- // `rare` exists on 8 of 400 rows, so EXISTS=true is highly selective.
- if (i % 50 == 0) j["rare"] = i;
- d.set_data(j);
- store.put("c", d.id, d);
- }
- auto F = [](const char* f, Op op, const nlohmann::json& v) {
- smartbotic::database::Filter x;
- x.field = f; x.op = op; x.value = v;
- return x;
- };
- auto sig = [&](const std::vector<smartbotic::database::Filter>& fs) {
- smartbotic::database::Query q;
- q.filters = fs;
- q.limit = 1000;
- auto r = store.scan("c", q);
- std::vector<std::string> ids;
- for (const auto& d : r.documents) ids.push_back(d.id);
- std::sort(ids.begin(), ids.end());
- std::string out = "total=" + std::to_string(r.total_matched) + " [";
- for (const auto& i : ids) { out += i; out += ","; }
- return out + "]";
- };
- const std::vector<std::pair<const char*, std::vector<smartbotic::database::Filter>>> cases = {
- {"IN over three values", {F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"}))}},
- {"IN with a missing value", {F("grp", Op::IN, nlohmann::json::array({"g1","nope"}))}},
- {"IN over one value", {F("grp", Op::IN, nlohmann::json::array({"g5"}))}},
- {"IN matching nothing", {F("grp", Op::IN, nlohmann::json::array({"x","y"}))}},
- {"IN too broad to help", {F("grp", Op::IN, nlohmann::json::array(
- {"g0","g1","g2","g3","g4","g5","g6","g7","g8","g9","g10","g11"}))}},
- {"EXISTS true on a sparse field", {F("rare", Op::EXISTS, true)}},
- {"EXISTS false on a sparse field", {F("rare", Op::EXISTS, false)}},
- {"EXISTS true on a dense field", {F("grp", Op::EXISTS, true)}},
- };
- for (const auto& [name, filters] : cases) {
- store.set_indexed_fields("c", {});
- const std::string without = sig(filters);
- store.set_indexed_fields("c", {"grp", "rare"});
- store.build_index("c", "grp");
- store.build_index("c", "rare");
- const std::string with = sig(filters);
- if (with != without) {
- std::cerr << " scan: " << without.substr(0, 160) << "\n"
- << " indexed: " << with.substr(0, 160) << "\n";
- }
- const std::string msg = std::string("indexed == scan: ") + name;
- check(with == without, msg.c_str());
- }
- store.set_indexed_fields("c", {"grp", "rare"});
- {
- store.reset_index_plan_stats();
- (void)sig({F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"}))});
- auto st = store.index_plan_stats();
- check(st.counted_scans == 1,
- "a single unsorted IN is answered by the COUNTED plan: the per-value "
- "counts are disjoint on a non-multivalued field, so their sum is the "
- "exact total");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("grp", Op::IN, nlohmann::json::array(
- {"g0","g1","g2","g3","g4","g5","g6","g7","g8","g9","g10","g11"}))});
- auto st = store.index_plan_stats();
- check(st.counted_scans == 1 && st.full_scans == 0,
- "even a BROAD IN is served now - the total is a sum of counts and "
- "only ids are merged, so no document outside the page is touched. "
- "The union plan remains for the sorted case");
- }
- {
- // The union plan still exists for the shape the counted plan declines.
- store.reset_index_plan_stats();
- smartbotic::database::Query q;
- q.limit = 1000;
- q.filters.push_back(F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"})));
- q.sort = smartbotic::database::Sort{"grp", false};
- store.scan("c", q);
- auto st = store.index_plan_stats();
- check(st.union_scans == 1,
- "a SORTED IN still unions posting lists as filter candidates");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("rare", Op::EXISTS, true)});
- check(store.index_plan_stats().exists_scans == 1,
- "EXISTS=true on a sparse field is served from all its postings");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("rare", Op::EXISTS, false)});
- auto st = store.index_plan_stats();
- check(st.exists_scans == 0 && st.full_scans == 1,
- "EXISTS=false cannot be served - rows WITHOUT a posting are not "
- "enumerable from the index, so it must scan");
- }
- {
- store.reset_index_plan_stats();
- (void)sig({F("grp", Op::EXISTS, true)});
- auto st = store.index_plan_stats();
- check(st.exists_scans == 0 && st.full_scans == 1,
- "EXISTS=true on a field every row has is not selective, so it scans");
- }
- }
- // v2.10.0 — the total and the page come from the index, so nothing outside the
- // page is read. The risk moves from "are the rows right" to "is the TOTAL right",
- // and a wrong total silently breaks paging rather than erroring.
- void test_counted_plan() {
- using Op = smartbotic::database::FilterOp;
- TmpEnv t("counted");
- LmdbDocumentStore store(t.env);
- // 200 rows, 100 of them state="done" - deliberately unselective, the case the
- // old guard declined.
- for (int i = 0; i < 200; ++i) {
- Document d;
- d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
- d.collection = "c";
- d.set_data(nlohmann::json{{"state", (i % 2 == 0) ? "done" : "todo"},
- {"grp", "g" + std::to_string(i % 5)}});
- store.put("c", d.id, d);
- }
- auto F = [](const char* f, Op op, const nlohmann::json& v) {
- smartbotic::database::Filter x;
- x.field = f; x.op = op; x.value = v;
- return x;
- };
- auto sig = [&](const smartbotic::database::Query& q) {
- auto r = store.scan("c", q);
- std::string out = "total=" + std::to_string(r.total_matched) +
- " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
- for (const auto& d : r.documents) { out += d.id; out += ","; }
- return out + "]";
- };
- auto Q = [&](std::vector<smartbotic::database::Filter> fs, uint32_t limit,
- uint32_t offset) {
- smartbotic::database::Query q;
- q.filters = std::move(fs);
- q.limit = limit;
- q.offset = offset;
- return q;
- };
- const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
- {"EQ first page", Q({F("state", Op::EQ, "done")}, 10, 0)},
- {"EQ deep offset", Q({F("state", Op::EQ, "done")}, 10, 80)},
- {"EQ last partial page", Q({F("state", Op::EQ, "done")}, 10, 95)},
- {"EQ offset past the end", Q({F("state", Op::EQ, "done")}, 10, 500)},
- {"EQ limit=0", Q({F("state", Op::EQ, "done")}, 0, 0)},
- {"EQ whole match set", Q({F("state", Op::EQ, "done")}, 1000, 0)},
- {"EQ matching nothing", Q({F("state", Op::EQ, "nope")}, 10, 0)},
- {"IN two values", Q({F("grp", Op::IN, nlohmann::json::array({"g1","g3"}))}, 10, 0)},
- {"IN paged", Q({F("grp", Op::IN, nlohmann::json::array({"g1","g3"}))}, 7, 55)},
- {"IN one missing value", Q({F("grp", Op::IN, nlohmann::json::array({"g1","zz"}))}, 10, 0)},
- };
- uint64_t counted_used = 0;
- for (const auto& [name, q] : cases) {
- store.set_indexed_fields("c", {});
- const std::string without = sig(q);
- store.set_indexed_fields("c", {"state", "grp"});
- store.build_index("c", "state");
- store.build_index("c", "grp");
- store.reset_index_plan_stats();
- const std::string with = sig(q);
- counted_used += store.index_plan_stats().counted_scans;
- if (with != without) {
- std::cerr << " scan: " << without.substr(0, 200) << "\n"
- << " counted: " << with.substr(0, 200) << "\n";
- }
- const std::string msg = std::string("counted plan == scan: ") + name;
- check(with == without, msg.c_str());
- }
- check(counted_used == cases.size(),
- ("the counted plan ran in all " + std::to_string(cases.size()) +
- " cases (got " + std::to_string(counted_used) + ")").c_str());
- // ---- an ARRAY-valued field must NOT be counted from the index ----
- //
- // EQ compares the whole array, but the index holds one posting per element, so
- // a key's duplicate count is the number of rows CONTAINING that element - not
- // the number whose value equals it. Counting from the index there would report
- // a total for rows that do not match.
- {
- TmpEnv t2("counted-multi");
- LmdbDocumentStore s2(t2.env);
- for (int i = 0; i < 60; ++i) {
- Document d;
- d.id = "m" + std::to_string(i);
- d.collection = "c";
- d.set_data(nlohmann::json{{"tags", nlohmann::json::array(
- {"t" + std::to_string(i % 3), "all"})}});
- s2.put("c", d.id, d);
- }
- auto sig2 = [&](const smartbotic::database::Query& q) {
- auto r = s2.scan("c", q);
- return "total=" + std::to_string(r.total_matched) +
- " rows=" + std::to_string(r.documents.size());
- };
- smartbotic::database::Query q;
- q.limit = 100;
- q.filters.push_back(F("tags", Op::EQ, "t1"));
- s2.set_indexed_fields("c", {});
- const std::string without = sig2(q);
- s2.set_indexed_fields("c", {"tags"});
- s2.build_index("c", "tags");
- s2.reset_index_plan_stats();
- const std::string with = sig2(q);
- check(with == without,
- "EQ on an array-valued field agrees with the scan (both find 0 - EQ "
- "compares the WHOLE array)");
- check(s2.index_plan_stats().counted_scans == 0,
- "and the counted plan DECLINES, because the index is marked "
- "multivalued: a key's duplicate count counts rows CONTAINING the "
- "element, which is not the EQ match count");
- // CONTAINS on the same data still works, via candidates.
- smartbotic::database::Query qc;
- qc.limit = 100;
- qc.filters.push_back(F("tags", Op::CONTAINS, "t1"));
- s2.set_indexed_fields("c", {});
- const std::string cwithout = sig2(qc);
- s2.set_indexed_fields("c", {"tags"});
- const std::string cwith = sig2(qc);
- check(cwith == cwithout, "CONTAINS on the same array field still agrees");
- }
- }
- // v2.10.0 — a range on the field being sorted. The walk's order already IS the
- // sort order, so no sorting pass runs and only the page is decoded. The subtle
- // part is descending ties: reversing an ascending walk must reproduce
- // sort_documents' reverse-id tie-break exactly.
- void test_range_ordered_plan() {
- using Op = smartbotic::database::FilterOp;
- TmpEnv t("range-ordered");
- LmdbDocumentStore store(t.env);
- for (int i = 0; i < 250; ++i) {
- Document d;
- d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
- d.collection = "c";
- // `dup` deliberately ties three rows per value so the tie-break matters.
- store.put("c", d.id, [&]{
- d.set_data(nlohmann::json{{"n", i}, {"dup", i / 3}});
- return d;
- }());
- }
- auto F = [](const char* f, Op op, const nlohmann::json& v) {
- smartbotic::database::Filter x;
- x.field = f; x.op = op; x.value = v;
- return x;
- };
- auto sig = [&](const smartbotic::database::Query& q) {
- auto r = store.scan("c", q);
- std::string out = "total=" + std::to_string(r.total_matched) +
- " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
- for (const auto& d : r.documents) { out += d.id; out += ","; }
- return out + "]";
- };
- auto Q = [&](smartbotic::database::Filter f, const char* sortField, bool desc,
- uint32_t limit, uint32_t offset) {
- smartbotic::database::Query q;
- q.filters.push_back(std::move(f));
- q.sort = smartbotic::database::Sort{sortField, desc};
- q.limit = limit;
- q.offset = offset;
- return q;
- };
- const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
- {"GT sorted desc on the same field", Q(F("n", Op::GT, 200), "n", true, 10, 0)},
- {"GT sorted asc on the same field", Q(F("n", Op::GT, 200), "n", false, 10, 0)},
- {"GTE sorted desc", Q(F("n", Op::GTE, 200), "n", true, 10, 0)},
- {"LT sorted asc", Q(F("n", Op::LT, 40), "n", false, 10, 0)},
- {"LTE sorted desc", Q(F("n", Op::LTE, 40), "n", true, 10, 0)},
- {"paged inside the range", Q(F("n", Op::GT, 100), "n", true, 7, 20)},
- {"offset past the range", Q(F("n", Op::GT, 240), "n", true, 10, 99)},
- {"limit=0 over a range", Q(F("n", Op::GT, 100), "n", true, 0, 0)},
- {"range matching nothing", Q(F("n", Op::GT, 9999), "n", true, 10, 0)},
- {"range over the whole collection", Q(F("n", Op::GTE, 0), "n", true, 5, 0)},
- {"TIED keys, desc", Q(F("dup", Op::GT, 40), "dup", true, 12, 0)},
- {"TIED keys, asc", Q(F("dup", Op::GT, 40), "dup", false, 12, 0)},
- {"TIED keys, desc, paged", Q(F("dup", Op::GTE, 20), "dup", true, 5, 13)},
- // A range on one field but sorted by ANOTHER must NOT take this plan.
- {"range sorted by a different field", Q(F("n", Op::GT, 200), "dup", true, 10, 0)},
- };
- uint64_t used = 0;
- for (const auto& [name, q] : cases) {
- store.set_indexed_fields("c", {});
- const std::string without = sig(q);
- store.set_indexed_fields("c", {"n", "dup"});
- store.build_index("c", "n");
- store.build_index("c", "dup");
- store.reset_index_plan_stats();
- const std::string with = sig(q);
- used += store.index_plan_stats().range_ordered_scans;
- if (with != without) {
- std::cerr << " scan: " << without.substr(0, 200) << "\n"
- << " rangeord: " << with.substr(0, 200) << "\n";
- }
- const std::string msg = std::string("range-ordered == scan: ") + name;
- check(with == without, msg.c_str());
- }
- // All but the last case (sorted by a different field) should use the plan.
- check(used == cases.size() - 1,
- ("the range-ordered plan ran in " + std::to_string(used) + " of " +
- std::to_string(cases.size() - 1) + " applicable cases").c_str());
- {
- store.set_indexed_fields("c", {"n", "dup"});
- store.reset_index_plan_stats();
- (void)sig(Q(F("n", Op::GT, 200), "dup", true, 10, 0));
- auto st = store.index_plan_stats();
- check(st.range_ordered_scans == 0,
- "a range on one field sorted by another does NOT take the plan - the "
- "walk's order is not the requested order");
- }
- }
- // v2.10.0 — distinct values and min/max, answered from the index.
- void test_index_values() {
- TmpEnv t("idx-values");
- LmdbDocumentStore store(t.env);
- for (int i = 0; i < 120; ++i) {
- Document d;
- d.id = "r" + std::to_string(100 + i);
- d.collection = "c";
- d.set_data(nlohmann::json{
- {"state", (i % 3 == 0) ? "done" : ((i % 3 == 1) ? "todo" : "wip")},
- {"n", i},
- {"tags", nlohmann::json::array({"t" + std::to_string(i % 2)})},
- });
- store.put("c", d.id, d);
- }
- store.build_index("c", "state");
- store.build_index("c", "n");
- store.build_index("c", "tags");
- auto vals = store.index_values("c", "state", 20, true);
- check(vals.size() == 3, "three distinct states");
- check(!vals.empty() && vals[0].value == "done", "ascending: 'done' sorts first");
- uint64_t sum = 0;
- for (const auto& v : vals) sum += v.count;
- check(sum == 120, "the counts add up to every row - none double-counted");
- auto desc = store.index_values("c", "state", 20, false);
- check(desc.size() == 3 && desc[0].value == "wip",
- "descending walks from the highest value");
- // limit=1 in each direction is min and max.
- auto mn = store.index_values("c", "n", 1, true);
- auto mx = store.index_values("c", "n", 1, false);
- check(mn.size() == 1 && mn[0].value == 0, "ascending limit=1 is the MINIMUM");
- check(mx.size() == 1 && mx[0].value == 119, "descending limit=1 is the MAXIMUM");
- check(store.index_values("c", "n", 5, true).size() == 5, "limit is honoured");
- check(store.index_values("c", "n", 0, true).empty(), "limit=0 returns nothing");
- check(store.index_values("c", "nosuch", 5, true).empty(),
- "an unindexed field returns nothing rather than erroring");
- // An array field's keys are ELEMENTS, so reporting the row's value (the whole
- // array) would be misleading. It reports nothing instead.
- check(store.index_values("c", "tags", 5, true).empty(),
- "an array-valued field reports no values - its keys are elements, not "
- "values, and returning the array would misstate what the key means");
- }
- // v2.11.0 T10 — the whole point of the txn-accepting overloads: one
- // transaction spanning TWO DIFFERENT collections (plus an index sub-db) must
- // be all-or-nothing. Task 12's atomic cascade depends on this - a parent
- // delete and its children's cleanup have to share one commit/abort, and the
- // no-txn put()/del() each open and commit their own WriteTxn, so they cannot
- // compose into one atomic unit at all.
- //
- // Written BEFORE the overloads exist, per the brief: this must fail to
- // compile/link until put(WriteTxn&, ...) and friends are added.
- void test_txn_accepting_writes_are_atomic_across_collections() {
- TmpEnv t("txn-atomic");
- LmdbDocumentStore store(t.env);
- store.set_indexed_fields("parents", {"name"});
- // A relation declared on 'children' as the CHILD side, so put(wtxn, ...)
- // on 'children' also maintains a reverse-index posting - the brief's "no
- // relation entry survives" half needs one declared to be testable at
- // all, and it shares the identical to_cache mechanism as the index path.
- store.set_relations("children", {RelationRef{"par_child", "parentId"}});
- Document pd;
- pd.id = "p1";
- pd.collection = "parents";
- pd.set_data(nlohmann::json{{"name", "alice"}});
- Document cd;
- cd.id = "c1";
- cd.collection = "children";
- cd.set_data(nlohmann::json{{"parentId", "p1"}});
- // --- Abort path: neither document, nor the index entry, nor the
- // relation posting, may survive. ---
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- store.put(wtxn, "parents", "p1", pd, to_cache);
- store.put(wtxn, "children", "c1", cd, to_cache);
- wtxn.abort();
- // to_cache is deliberately NOT applied to the process-wide dbi cache -
- // see the header comment: caching before commit is exactly the v2.8.0
- // bug. Nothing here should call cacheCommittedDbi.
- }
- check(!store.get("parents", "p1").has_value(),
- "aborted txn: the parent document does not exist");
- check(!store.get("children", "c1").has_value(),
- "aborted txn: the child document does not exist");
- // --- The collection must not be poisoned by the aborted transaction: a
- // later write, scan, count and get on EITHER collection must still work.
- // This is precisely the v2.8.0 failure mode - caching a handle before
- // commit leaves a closed handle behind an aborted caller transaction.
- // It also DOUBLES as the only way to make the index/relation-rollback
- // checks below non-vacuous: before either collection has ever committed
- // successfully, index_lookup_eq()/relation_index_children() report
- // nullopt/empty purely because their sub-db was never cached - that
- // would pass whether or not the aborted posting actually rolled back.
- // Writing a NEW row under the SAME key values the aborted write used
- // (name="alice", parentId="p1") warms those caches for real, so a
- // survived posting from the aborted write would show up alongside it. ---
- bool ok_put = true;
- try {
- store.put("parents", "p2", pd); // same name: "alice" as the aborted p1
- } catch (const std::exception&) {
- ok_put = false;
- }
- check(ok_put, "a later write to 'parents' after the abort still works");
- bool ok_put2 = true;
- try {
- store.put("children", "c2", cd); // same parentId: "p1" as the aborted c1
- } catch (const std::exception&) {
- ok_put2 = false;
- }
- check(ok_put2, "a later write to 'children' after the abort still works");
- check(store.get("parents", "p2").has_value(), "get() on 'parents' works after the abort");
- check(store.get("children", "c2").has_value(), "get() on 'children' works after the abort");
- smartbotic::database::Query q;
- q.limit = 10;
- check(store.scan("parents", q).documents.size() == 1,
- "scan() on 'parents' works after the abort and sees only the post-abort write");
- check(store.scan("children", q).documents.size() == 1,
- "scan() on 'children' works after the abort and sees only the post-abort write");
- check(store.count("parents") == 1, "count() on 'parents' is right after the abort");
- check(store.count("children") == 1, "count() on 'children' is right after the abort");
- // Now the real proof: the index for "alice" holds ONLY p2, and the
- // relation's postings for parent "p1" hold ONLY c2. If the aborted
- // write's postings (p1 under "alice", c1 under "p1") had survived, either
- // of these would report two ids instead of one - unlike the vacuous
- // "nullopt/empty" checks a cold cache would also produce.
- auto idx = store.index_lookup_eq("parents", "name", nlohmann::json("alice"));
- check(idx.has_value() && idx->size() == 1 && (*idx)[0] == "p2",
- "aborted txn: the index under 'alice' holds only the post-abort "
- "write (p2) - the aborted p1 posting did not survive");
- auto children_of_p1 = store.relation_index_children("par_child", "p1", 10);
- check(children_of_p1.size() == 1 && children_of_p1[0] == "c2",
- "aborted txn: parent 'p1' has only the post-abort child (c2) in the "
- "relation index - the aborted c1 posting did not survive");
- // --- Commit path: both documents AND the index entry land together,
- // once the caller re-primes to pick up the handles it chose not to
- // cache itself (see the next block for why that step is mandatory in
- // real callers). ---
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- Document pd2;
- pd2.id = "p3";
- pd2.collection = "parents";
- pd2.set_data(nlohmann::json{{"name", "bob"}});
- Document cd2;
- cd2.id = "c3";
- cd2.collection = "children";
- cd2.set_data(nlohmann::json{{"parentId", "p3"}});
- store.put(wtxn, "parents", "p3", pd2, to_cache);
- store.put(wtxn, "children", "c3", cd2, to_cache);
- wtxn.commit();
- check(to_cache.size() >= 2,
- "put(wtxn,...) reports every handle it opened (collection dbis, "
- "plus the index dbi since 'parents' declares one) for the caller "
- "to cache after its own commit");
- }
- store.prime_dbi_cache(); // stand-in for the caching step a real caller does
- check(store.get("parents", "p3").has_value(), "committed txn: parent document exists");
- check(store.get("children", "c3").has_value(), "committed txn: child document exists");
- auto idx2 = store.index_lookup_eq("parents", "name", nlohmann::json("bob"));
- check(idx2.has_value() && idx2->size() == 1 && (*idx2)[0] == "p3",
- "committed txn: the index entry for the new parent is there");
- // --- The contract that makes handle-return necessary: after a commit
- // through the txn-accepting overload, the caller MUST cache the handles
- // it was given, because reads are cache-only (see try_open_for_read's
- // comment: a cache miss reads as "no such collection" by design, so
- // that read transactions never have to call mdb_dbi_open). Skip that
- // step and freshly-committed data becomes unreadable - not corrupted,
- // just invisible - for the life of the process. Demonstrated on a
- // brand-new collection name never touched before this point. ---
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- Document nd;
- nd.id = "n1";
- nd.collection = "brandnew";
- nd.set_data(nlohmann::json{{"x", 1}});
- store.put(wtxn, "brandnew", "n1", nd, to_cache);
- wtxn.commit();
- check(!to_cache.empty(),
- "put(wtxn,...) hands back the handle it opened for 'brandnew'");
- // Deliberately do NOT cache it - that is the point of this block.
- }
- check(!store.get("brandnew", "n1").has_value(),
- "without caching after commit, the freshly-committed row reads as "
- "absent - a cold cache, not lost data (proven next)");
- store.prime_dbi_cache();
- check(store.get("brandnew", "n1").has_value(),
- "after priming (which caches it), the same row is visible - "
- "confirming the earlier miss was purely a cold cache");
- }
- // v2.11.0 T10 review round 2 — the vector equivalents of the two defects
- // fixed on the document path (put/del) were still present on put_vector/
- // del_vector: late handle-recording (Finding 2) and a same-transaction
- // existence-probe gap (Finding 3). Both are reachable under the Task 12
- // cascade design documented in the task report, which shares ONE to_cache
- // across put/del/put_vector/del_vector for a parent and its children in a
- // single transaction — dormant only because nothing but the self-contained
- // no-txn wrappers calls the txn-accepting vector overloads yet.
- void test_txn_accepting_vector_writes_are_atomic() {
- TmpEnv t("txn-atomic-vec");
- LmdbDocumentStore store(t.env);
- // --- Abort path: an aborted put_vector must leave no vector behind.
- // Proven the same non-vacuous way as the document test: get_vector()
- // on a never-committed sub-db returns nullopt purely from a cold cache
- // (try_open_for_read is cache-only), so the follow-up write under the
- // SAME collection is what makes the absence-of-'a1' check real - if the
- // aborted key had survived, get_vector("vecsA", "a1") would find it via
- // the now-warm cache, not report absent. ---
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- store.put_vector(wtxn, "vecsA", "a1", {1.0f, 2.0f, 3.0f}, to_cache);
- wtxn.abort();
- }
- bool ok_put = true;
- try {
- store.put_vector("vecsA", "a2", {9.0f, 9.0f, 9.0f}); // warms the cache for real
- } catch (const std::exception&) {
- ok_put = false;
- }
- check(ok_put, "a later put_vector on 'vecsA' after the abort still works - "
- "not poisoned by the aborted transaction");
- check(!store.get_vector("vecsA", "a1").has_value(),
- "aborted txn: 'a1' does not exist, checked against a genuinely "
- "warm cache (via 'a2'), not a cold-cache false negative");
- auto v2 = store.get_vector("vecsA", "a2");
- check(v2.has_value() && v2->size() == 3 && (*v2)[0] == 9.0f,
- "the post-abort vector write is readable");
- // --- Finding 2 (put_vector): the handle must be recorded in to_cache
- // BEFORE mdb_put runs, not after - so a throw from mdb_put does not
- // silently drop the collection's own handle from what the caller was
- // told to cache. Reproduced with the same oversized-key technique as
- // test_aborted_write_does_not_poison_the_collection: a key past LMDB's
- // 511-byte limit makes mdb_put fail MDB_BAD_VALSIZE. ---
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- const std::string huge_id(600, 'v');
- bool threw = false;
- try {
- store.put_vector(wtxn, "vecsB", huge_id, {1.0f}, to_cache);
- } catch (const std::exception&) {
- threw = true;
- }
- check(threw, "an oversized key really does fail put_vector's mdb_put");
- check(!to_cache.empty(),
- "put_vector(wtxn,...) recorded the sub-db handle BEFORE the "
- "failing mdb_put ran, not after - so to_cache is not silently "
- "missing it");
- wtxn.abort();
- }
- bool ok_put_b = true;
- try {
- store.put_vector("vecsB", "b1", {4.0f, 5.0f});
- } catch (const std::exception&) {
- ok_put_b = false;
- }
- check(ok_put_b, "'vecsB' is not poisoned by the failed put_vector either");
- check(store.get_vector("vecsB", "b1").has_value(),
- "and the post-failure vector write is readable");
- // --- Finding 3 (del_vector): a vector sub-db opened by put_vector(wtxn,
- // ...) earlier in the SAME still-uncommitted transaction must be
- // deletable by del_vector(wtxn, ...) sharing that to_cache - cachedDbi()
- // alone only sees already-committed sub-dbs, so without the to_cache
- // fallback this would silently no-op instead of deleting. ---
- bool existed = false;
- {
- WriteTxn wtxn(t.env);
- std::vector<std::pair<std::string, unsigned int>> to_cache;
- store.put_vector(wtxn, "vecsC", "c1", {7.0f, 7.0f}, to_cache);
- existed = store.del_vector(wtxn, "vecsC", "c1", to_cache);
- wtxn.commit();
- for (const auto& [sub, d] : to_cache) {
- // Stand-in for the caller's post-commit caching step - not
- // strictly needed for this assertion (get_vector below would
- // read as absent for a deleted key from a cold cache too, same
- // as a genuinely-deleted one), but exercised for parity with
- // how a real caller (Task 12) must use this.
- (void)sub;
- (void)d;
- }
- }
- check(existed,
- "del_vector(wtxn, ...) found and deleted 'c1' within the SAME "
- "transaction that created it, rather than treating the "
- "not-yet-committed sub-db as nonexistent and no-op'ing");
- store.prime_dbi_cache();
- check(!store.get_vector("vecsC", "c1").has_value(),
- "'c1' is genuinely gone after the commit, not merely unreported");
- }
- } // namespace
- int main() {
- std::cout << "=== test_subdb_identity ===\n";
- test_sentinel_roundtrip();
- test_misbound_handle_is_refused();
- test_unstamped_subdb_is_permitted();
- test_identity_key_predicate();
- test_sentinel_invisible_through_store();
- test_vector_subdb_sentinel();
- test_scan_limit_zero_reports_total();
- test_scan_fast_path_matches_general_path();
- test_aborted_write_does_not_poison_the_collection();
- test_txn_accepting_writes_are_atomic_across_collections();
- test_txn_accepting_vector_writes_are_atomic();
- test_filtered_scan_operator_matrix();
- test_concurrent_reads_do_not_rebind_cached_handles();
- test_existing_collection_readable_without_writing_first();
- test_index_tracks_documents_through_every_write();
- test_build_index_over_existing_rows();
- test_index_numeric_equality_matches_scan();
- test_indexed_and_unindexed_plans_agree();
- test_empty_id_reads_as_absent();
- test_range_contains_and_intersection();
- test_index_supplies_ordering();
- test_index_in_and_exists();
- test_counted_plan();
- test_range_ordered_plan();
- test_index_values();
- std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
- return g_fail == 0 ? 0 : 1;
- }
|