test_subdb_identity.cpp 87 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016
  1. // v2.4.4 — sub-db identity sentinel tests.
  2. //
  3. // Regression cover for the production incident in which an invalidated
  4. // MDB_dbi was reused after LMDB reassigned its slot, so writes aimed at
  5. // `image_hashes` landed in `executions` and succeeded silently. 31 documents
  6. // across smartbotic-automation ended up in a sub-db other than the one they
  7. // declared. See storage/subdb_identity.hpp for the full mechanism.
  8. //
  9. // The core test is `misbound_handle_is_refused`: it reproduces the misbinding
  10. // directly by handing the verifier a handle for a different sub-db, which is
  11. // what a stale cache entry amounts to.
  12. #include <cassert>
  13. #include <atomic>
  14. #include <filesystem>
  15. #include <iostream>
  16. #include <cstdio>
  17. #include <algorithm>
  18. #include <functional>
  19. #include <thread>
  20. #include <set>
  21. #include <string>
  22. #include <unistd.h>
  23. #include <lmdb.h>
  24. #include <nlohmann/json.hpp>
  25. #include "document.hpp"
  26. #include "storage/document_store_lmdb.hpp"
  27. #include "storage/lmdb_env.hpp"
  28. #include "storage/lmdb_txn.hpp"
  29. #include "storage/subdb_identity.hpp"
  30. namespace fs = std::filesystem;
  31. using smartbotic::database::Document;
  32. using smartbotic::db::storage::is_identity_key;
  33. using smartbotic::db::storage::kSubdbIdentityKey;
  34. using smartbotic::db::storage::LmdbDocumentStore;
  35. using smartbotic::db::storage::LmdbEnv;
  36. using smartbotic::db::storage::LmdbEnvOpts;
  37. using smartbotic::db::storage::read_subdb_identity;
  38. using smartbotic::db::storage::ReadTxn;
  39. using smartbotic::db::storage::RelationRef;
  40. using smartbotic::db::storage::verify_subdb_identity;
  41. using smartbotic::db::storage::write_subdb_identity;
  42. using smartbotic::db::storage::WriteTxn;
  43. namespace {
  44. int g_pass = 0;
  45. int g_fail = 0;
  46. void check(bool cond, const char* msg) {
  47. if (cond) {
  48. ++g_pass;
  49. } else {
  50. ++g_fail;
  51. std::cerr << "FAIL: " << msg << "\n";
  52. }
  53. }
  54. std::string make_tmpdir(const char* tag) {
  55. static std::atomic<int> counter{0};
  56. std::string path = "/tmp/subdb-identity-test-" + std::to_string(::getpid()) +
  57. "-" + std::to_string(counter.fetch_add(1)) + "-" + tag;
  58. std::error_code ec;
  59. fs::remove_all(path, ec);
  60. return path;
  61. }
  62. struct TmpEnv {
  63. std::string path;
  64. LmdbEnv env;
  65. explicit TmpEnv(const char* tag)
  66. : path(make_tmpdir(tag)),
  67. env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
  68. ~TmpEnv() {
  69. std::error_code ec;
  70. fs::remove_all(path, ec);
  71. }
  72. TmpEnv(const TmpEnv&) = delete;
  73. TmpEnv& operator=(const TmpEnv&) = delete;
  74. };
  75. // Open (creating) a named sub-db inside a write txn and return its handle.
  76. unsigned int open_subdb(WriteTxn& txn, const char* name) {
  77. MDB_dbi dbi = 0;
  78. int rc = mdb_dbi_open(txn.raw(), name, MDB_CREATE, &dbi);
  79. assert(rc == MDB_SUCCESS);
  80. (void)rc;
  81. return dbi;
  82. }
  83. Document make_doc(const std::string& id, const std::string& collection) {
  84. Document d;
  85. d.id = id;
  86. d.collection = collection;
  87. d.set_data(nlohmann::json{{"seenCount", 1}, {"who", collection}});
  88. return d;
  89. }
  90. // -------------------------------------------------------------------------
  91. void test_sentinel_roundtrip() {
  92. TmpEnv t("roundtrip");
  93. {
  94. WriteTxn w(t.env);
  95. unsigned int dbi = open_subdb(w, "image_hashes");
  96. write_subdb_identity(w, dbi, "image_hashes");
  97. w.commit();
  98. }
  99. {
  100. WriteTxn w(t.env);
  101. unsigned int dbi = open_subdb(w, "image_hashes");
  102. bool threw = false;
  103. try {
  104. verify_subdb_identity(w, dbi, "image_hashes");
  105. } catch (const std::exception&) {
  106. threw = true;
  107. }
  108. check(!threw, "matching sentinel must verify without throwing");
  109. w.commit();
  110. }
  111. {
  112. ReadTxn r(t.env);
  113. MDB_dbi dbi = 0;
  114. mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  115. check(read_subdb_identity(r, dbi) == "image_hashes",
  116. "read_subdb_identity returns the stamped name");
  117. }
  118. }
  119. // THE regression test. A stale cache entry is, in effect, a handle that
  120. // addresses someone else's sub-db. Hand the verifier exactly that.
  121. void test_misbound_handle_is_refused() {
  122. TmpEnv t("misbound");
  123. unsigned int executions_dbi = 0;
  124. {
  125. WriteTxn w(t.env);
  126. unsigned int ih = open_subdb(w, "image_hashes");
  127. write_subdb_identity(w, ih, "image_hashes");
  128. executions_dbi = open_subdb(w, "executions");
  129. write_subdb_identity(w, executions_dbi, "executions");
  130. w.commit();
  131. }
  132. WriteTxn w(t.env);
  133. // Re-open so the handle is valid in this txn, then deliberately verify it
  134. // under the WRONG name — the production misbinding, reproduced.
  135. unsigned int exec = open_subdb(w, "executions");
  136. bool threw = false;
  137. std::string msg;
  138. try {
  139. verify_subdb_identity(w, exec, "image_hashes");
  140. } catch (const std::exception& e) {
  141. threw = true;
  142. msg = e.what();
  143. }
  144. check(threw, "handle for 'executions' verified as 'image_hashes' must throw");
  145. check(msg.find("image_hashes") != std::string::npos &&
  146. msg.find("executions") != std::string::npos,
  147. "misbinding error names both the requested and actual sub-db");
  148. w.abort();
  149. }
  150. // Existing deployments have sub-dbs with no sentinel. Those must keep working.
  151. void test_unstamped_subdb_is_permitted() {
  152. TmpEnv t("unstamped");
  153. WriteTxn w(t.env);
  154. unsigned int dbi = open_subdb(w, "legacy");
  155. bool threw = false;
  156. try {
  157. verify_subdb_identity(w, dbi, "legacy");
  158. } catch (const std::exception&) {
  159. threw = true;
  160. }
  161. check(!threw, "sub-db without a sentinel must verify (absence is unknown, not wrong)");
  162. w.commit();
  163. }
  164. void test_identity_key_predicate() {
  165. check(is_identity_key(kSubdbIdentityKey), "sentinel key recognised");
  166. check(!is_identity_key("__subdb_identity__"),
  167. "same text without the leading NUL is NOT the sentinel");
  168. check(!is_identity_key("e63c1b90"), "a document id is not the sentinel");
  169. check(kSubdbIdentityKey[0] == '\0',
  170. "sentinel must start with NUL so it cannot collide with a doc id");
  171. }
  172. // The sentinel is an implementation detail: it must never surface through the
  173. // DocumentStore API as a document, nor inflate a count.
  174. void test_sentinel_invisible_through_store() {
  175. TmpEnv t("invisible");
  176. LmdbDocumentStore store(t.env);
  177. store.put("image_hashes", "aaa", make_doc("aaa", "image_hashes"));
  178. store.put("image_hashes", "bbb", make_doc("bbb", "image_hashes"));
  179. check(store.count("image_hashes") == 2,
  180. "count() must exclude the identity sentinel");
  181. smartbotic::database::Query q;
  182. q.limit = 100;
  183. auto res = store.scan("image_hashes", q);
  184. check(res.documents.size() == 2, "scan() must exclude the identity sentinel");
  185. check(res.total_matched == 2, "scan() total_matched must exclude the sentinel");
  186. for (const auto& d : res.documents) {
  187. check(d.id == "aaa" || d.id == "bbb",
  188. "scan() must not surface the sentinel as a document");
  189. }
  190. // And it really is on disk.
  191. ReadTxn r(t.env);
  192. MDB_dbi dbi = 0;
  193. int rc = mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  194. check(rc == MDB_SUCCESS, "sub-db exists");
  195. check(read_subdb_identity(r, dbi) == "image_hashes",
  196. "store.put() stamps the sentinel on first write");
  197. }
  198. // A vector sub-db gets stamped with its own (prefixed) name, and scan_vectors
  199. // must skip the sentinel rather than trying to read it as float32 bytes.
  200. void test_vector_subdb_sentinel() {
  201. TmpEnv t("vectors");
  202. LmdbDocumentStore store(t.env);
  203. store.put_vector("emb", "v1", {1.0f, 2.0f, 3.0f});
  204. store.put_vector("emb", "v2", {4.0f, 5.0f, 6.0f});
  205. int seen = 0;
  206. bool bad = false;
  207. store.scan_vectors("emb", [&](std::string_view id, const float*, size_t n) {
  208. ++seen;
  209. if (n != 3) bad = true;
  210. if (is_identity_key(id)) bad = true;
  211. });
  212. check(seen == 2, "scan_vectors must skip the sentinel");
  213. check(!bad, "scan_vectors must not decode the sentinel as float data");
  214. ReadTxn r(t.env);
  215. MDB_dbi dbi = 0;
  216. mdb_dbi_open(r.raw(), "_vectors_emb", 0, &dbi);
  217. check(read_subdb_identity(r, dbi) == "_vectors_emb",
  218. "vector sub-db is stamped with its prefixed name");
  219. }
  220. // v2.4.4 Count is LMDB-first. Unfiltered it uses count(); filtered it uses
  221. // scan() with limit=0 and reads total_matched. Pin that contract: total_matched
  222. // is computed BEFORE pagination, so limit=0 must still report the true total
  223. // while returning no documents.
  224. void test_scan_limit_zero_reports_total() {
  225. TmpEnv t("counting");
  226. LmdbDocumentStore store(t.env);
  227. for (int i = 0; i < 5; ++i) {
  228. Document d;
  229. d.id = "id" + std::to_string(i);
  230. d.collection = "things";
  231. d.set_data(nlohmann::json{{"kind", i < 3 ? "alpha" : "beta"}});
  232. store.put("things", d.id, d);
  233. }
  234. smartbotic::database::Query q;
  235. q.limit = 0;
  236. auto all = store.scan("things", q);
  237. check(all.total_matched == 5, "limit=0 reports the full total");
  238. check(all.documents.empty(), "limit=0 returns no documents");
  239. check(store.count("things") == 5, "unfiltered count matches");
  240. smartbotic::database::Query fq;
  241. fq.limit = 0;
  242. smartbotic::database::Filter f;
  243. f.field = "kind";
  244. f.op = smartbotic::database::FilterOp::EQ;
  245. f.value = "alpha";
  246. fq.filters.push_back(f);
  247. auto filtered = store.scan("things", fq);
  248. check(filtered.total_matched == 3,
  249. "filtered limit=0 reports the matching total, not the collection size");
  250. }
  251. // v2.7.1 — the unfiltered/unsorted fast path in scan() must agree with the
  252. // general path exactly. It exists because the general path decoded every
  253. // document in the collection to return `limit` of them, so cost tracked total
  254. // bytes rather than page size (382ms to return one 510-byte document from a
  255. // 414 MB collection on a live instance). Any divergence here is a paging bug.
  256. void test_scan_fast_path_matches_general_path() {
  257. TmpEnv t("fastpath");
  258. LmdbDocumentStore store(t.env);
  259. for (int i = 0; i < 25; ++i) {
  260. Document d;
  261. char buf[16];
  262. std::snprintf(buf, sizeof(buf), "id%02d", i);
  263. d.id = buf;
  264. d.collection = "things";
  265. d.set_data(nlohmann::json{{"n", i}, {"kind", i % 2 ? "odd" : "even"}});
  266. store.put("things", d.id, d);
  267. }
  268. // total_matched and has_more must match what a full count says.
  269. smartbotic::database::Query page;
  270. page.limit = 10;
  271. page.offset = 0;
  272. auto p0 = store.scan("things", page);
  273. check(p0.documents.size() == 10, "fast path returns exactly `limit` docs");
  274. check(p0.total_matched == 25, "fast path total_matched excludes the sentinel");
  275. check(p0.has_more, "has_more true when more remain");
  276. page.offset = 20;
  277. auto p2 = store.scan("things", page);
  278. check(p2.documents.size() == 5, "final page returns the remainder");
  279. check(p2.total_matched == 25, "total_matched stable across pages");
  280. check(!p2.has_more, "has_more false on the last page");
  281. page.offset = 25;
  282. auto p3 = store.scan("things", page);
  283. check(p3.documents.empty(), "offset past the end returns nothing");
  284. check(p3.total_matched == 25, "and still reports the true total");
  285. // Paging must cover every document exactly once, in a stable order.
  286. std::set<std::string> seen;
  287. for (uint32_t off = 0; off < 25; off += 7) {
  288. smartbotic::database::Query q;
  289. q.limit = 7;
  290. q.offset = off;
  291. for (const auto& d : store.scan("things", q).documents) seen.insert(d.id);
  292. }
  293. check(seen.size() == 25, "paging the whole collection yields every document once");
  294. // A filter forces the general path; it must still be correct.
  295. smartbotic::database::Query fq;
  296. fq.limit = 100;
  297. smartbotic::database::Filter f;
  298. f.field = "kind";
  299. f.op = smartbotic::database::FilterOp::EQ;
  300. f.value = "odd";
  301. fq.filters.push_back(f);
  302. auto filtered = store.scan("things", fq);
  303. check(filtered.total_matched == 12, "filtered path still counts matches, not rows");
  304. // A sort also forces the general path.
  305. smartbotic::database::Query sq;
  306. sq.limit = 3;
  307. sq.sort = smartbotic::database::Sort{"n", true};
  308. auto sorted = store.scan("things", sq);
  309. check(sorted.documents.size() == 3, "sorted path paginates");
  310. check(sorted.total_matched == 25, "sorted path totals all rows");
  311. check(sorted.documents[0].data().value("n", -1) == 24,
  312. "descending sort really sorted (fast path must not swallow sorts)");
  313. // limit=0 keeps meaning "no documents, but a true total" - the contract
  314. // Count depends on (see test_scan_limit_zero_reports_total).
  315. smartbotic::database::Query zq;
  316. zq.limit = 0;
  317. auto z = store.scan("things", zq);
  318. check(z.documents.empty(), "limit=0 returns no documents on the fast path");
  319. check(z.total_matched == 25, "limit=0 still reports the true total");
  320. }
  321. // v2.8.0 — a WRITE that aborts must not poison the collection.
  322. //
  323. // This is the v2.4.3 EINVAL bug in a third failure mode, observed live in
  324. // production on 2.7.1: `find` on smartbotic-automation:workflows failed with
  325. // "LMDB cursor_open: Invalid argument" on every attempt while every other
  326. // collection was fine, and a restart was the only cure.
  327. //
  328. // Cause: open_for_write() cached the MDB_dbi immediately after mdb_dbi_open,
  329. // BEFORE the caller committed. LMDB keeps a handle private to the opening
  330. // transaction until it commits and CLOSES it if that transaction aborts - so any
  331. // write that threw after the handle was cached (a failed mdb_put, a sentinel
  332. // mismatch, a WriteTxn destructing uncommitted) left a closed handle in the
  333. // cache, and every later operation on that collection failed EINVAL for the rest
  334. // of the process's life.
  335. //
  336. // The abort is induced honestly here, with a key past LMDB's 511-byte limit, so
  337. // the test exercises the same path a real failed write takes.
  338. void test_aborted_write_does_not_poison_the_collection() {
  339. TmpEnv t("abortpoison");
  340. LmdbDocumentStore store(t.env);
  341. // Force a write that opens the sub-db and then fails: an oversized key makes
  342. // mdb_put return MDB_BAD_VALSIZE, which throws, so the WriteTxn aborts.
  343. const std::string huge_id(600, 'k');
  344. bool threw = false;
  345. try {
  346. store.put("poisoned", huge_id, make_doc(huge_id, "poisoned"));
  347. } catch (const std::exception&) {
  348. threw = true;
  349. }
  350. check(threw, "an oversized key really does fail the write");
  351. // The collection must still be usable. Before the fix, every one of these
  352. // failed with EINVAL because the cache held a handle LMDB had closed.
  353. bool ok_put = true;
  354. try {
  355. store.put("poisoned", "good", make_doc("good", "poisoned"));
  356. } catch (const std::exception&) {
  357. ok_put = false;
  358. }
  359. check(ok_put, "a later WRITE to the same collection still works");
  360. bool ok_read = true;
  361. try {
  362. smartbotic::database::Query q;
  363. q.limit = 10;
  364. auto res = store.scan("poisoned", q);
  365. check(res.documents.size() == 1, "and the document written after the abort is there");
  366. } catch (const std::exception&) {
  367. ok_read = false;
  368. }
  369. check(ok_read, "a later SCAN of the same collection still works (cursor_open)");
  370. bool ok_count = true;
  371. try {
  372. check(store.count("poisoned") == 1, "count is right after the abort");
  373. } catch (const std::exception&) {
  374. ok_count = false;
  375. }
  376. check(ok_count, "and count() does not throw");
  377. check(store.get("poisoned", "good").has_value(), "get() works after the abort");
  378. }
  379. // v2.8.0 — the two-pass filtered scan must agree with the old row-at-a-time path
  380. // on every operator, not just the common ones.
  381. //
  382. // scan() now evaluates predicates against a yyjson tree via a field resolver and
  383. // materialises only the returned page, because building a Document per row was
  384. // 88% of a filtered query's cost (3168ms vs 369ms for the parse alone over 193 MB
  385. // of real rows). A resolver that mishandles one operator returns silently wrong
  386. // data, so this walks the matrix.
  387. void test_filtered_scan_operator_matrix() {
  388. TmpEnv t("filtermatrix");
  389. LmdbDocumentStore store(t.env);
  390. auto put = [&](const std::string& id, const nlohmann::json& data) {
  391. Document d;
  392. d.id = id;
  393. d.collection = "m";
  394. d.version = 3;
  395. d.createdAt = 1000;
  396. d.updatedAt = 2000;
  397. d.set_data(data);
  398. store.put("m", id, d);
  399. };
  400. put("a", {{"n", 1}, {"kind", "odd"}, {"tags", {"x", "y"}}, {"nest", {{"deep", "hit"}}}});
  401. put("b", {{"n", 2}, {"kind", "even"}, {"tags", {"y"}}, {"nest", {{"deep", "miss"}}}});
  402. put("c", {{"n", 3}, {"kind", "odd"}, {"tags", nlohmann::json::array()}});
  403. put("d", {{"n", 4}, {"kind", "even"}, {"extra", "present"}});
  404. auto ids = [&](const smartbotic::database::Query& q) {
  405. std::vector<std::string> out;
  406. for (const auto& d : store.scan("m", q).documents) out.push_back(d.id);
  407. std::sort(out.begin(), out.end());
  408. return out;
  409. };
  410. auto q1 = [&](const char* field, smartbotic::database::FilterOp op,
  411. const nlohmann::json& val) {
  412. smartbotic::database::Query q;
  413. q.limit = 100;
  414. smartbotic::database::Filter f;
  415. f.field = field; f.op = op; f.value = val;
  416. q.filters.push_back(f);
  417. return q;
  418. };
  419. using Op = smartbotic::database::FilterOp;
  420. check(ids(q1("kind", Op::EQ, "odd")) == (std::vector<std::string>{"a", "c"}),
  421. "EQ on a data field");
  422. check(ids(q1("kind", Op::NE, "odd")) == (std::vector<std::string>{"b", "d"}),
  423. "NE on a data field");
  424. check(ids(q1("n", Op::GT, 2)) == (std::vector<std::string>{"c", "d"}), "GT numeric");
  425. check(ids(q1("n", Op::GTE, 3)) == (std::vector<std::string>{"c", "d"}), "GTE numeric");
  426. check(ids(q1("n", Op::LT, 2)) == (std::vector<std::string>{"a"}), "LT numeric");
  427. check(ids(q1("n", Op::LTE, 2)) == (std::vector<std::string>{"a", "b"}), "LTE numeric");
  428. check(ids(q1("n", Op::IN, nlohmann::json::array({1, 4}))) ==
  429. (std::vector<std::string>{"a", "d"}), "IN");
  430. check(ids(q1("tags", Op::CONTAINS, "x")) == (std::vector<std::string>{"a"}),
  431. "CONTAINS descends into an array value");
  432. check(ids(q1("extra", Op::EXISTS, true)) == (std::vector<std::string>{"d"}),
  433. "EXISTS true");
  434. check(ids(q1("extra", Op::EXISTS, false)) ==
  435. (std::vector<std::string>{"a", "b", "c"}), "EXISTS false");
  436. check(ids(q1("kind", Op::REGEX, "^od")) == (std::vector<std::string>{"a", "c"}),
  437. "REGEX");
  438. check(ids(q1("nest.deep", Op::EQ, "hit")) == (std::vector<std::string>{"a"}),
  439. "dotted path descends into data");
  440. check(ids(q1("nest.missing", Op::EXISTS, true)).empty(),
  441. "a dotted path that does not resolve matches nothing");
  442. // Document metadata, which lives at the top level of the stored JSON rather
  443. // than inside "data".
  444. check(ids(q1("_id", Op::EQ, "b")) == (std::vector<std::string>{"b"}), "_id");
  445. check(ids(q1("_version", Op::EQ, 3)).size() == 4, "_version");
  446. check(ids(q1("_created_at", Op::GTE, 1000)).size() == 4, "_created_at");
  447. check(ids(q1("_updated_at", Op::LT, 2000)).empty(), "_updated_at");
  448. // SEARCH must still work - it needs the whole document, so it takes the old
  449. // path.
  450. check(ids(q1("", Op::SEARCH, "present")) == (std::vector<std::string>{"d"}),
  451. "SEARCH still matches (routed to the whole-document path)");
  452. check(ids(q1("", Op::SEARCH, "nothinghere")).empty(), "SEARCH non-match");
  453. // Sorting, pagination and total_matched over a filtered set.
  454. {
  455. smartbotic::database::Query q;
  456. q.limit = 1;
  457. smartbotic::database::Filter f;
  458. f.field = "kind"; f.op = Op::EQ; f.value = "odd";
  459. q.filters.push_back(f);
  460. q.sort = smartbotic::database::Sort{"n", true}; // descending
  461. auto page0 = store.scan("m", q);
  462. check(page0.total_matched == 2, "total_matched counts matches, not rows");
  463. check(page0.documents.size() == 1, "limit honoured");
  464. check(page0.documents[0].id == "c", "descending sort picks the highest first");
  465. check(page0.has_more, "has_more true mid-set");
  466. q.offset = 1;
  467. auto page1 = store.scan("m", q);
  468. check(page1.documents.size() == 1 && page1.documents[0].id == "a",
  469. "second page continues the sort order");
  470. check(!page1.has_more, "has_more false on the last page");
  471. q.offset = 5;
  472. check(store.scan("m", q).documents.empty(), "offset past the end is empty");
  473. }
  474. // Ascending, and a sort field that is missing from some documents.
  475. {
  476. smartbotic::database::Query q;
  477. q.limit = 10;
  478. q.sort = smartbotic::database::Sort{"extra", false};
  479. auto res = store.scan("m", q);
  480. check(res.total_matched == 4, "no filter plus a sort still totals every row");
  481. check(res.documents.size() == 4, "and returns them all");
  482. // sort_documents returns `descending` when the LEFT value is missing, so
  483. // ascending puts documents that HAVE the field first and the ones missing
  484. // it last. The two-pass path copies that rule rather than inventing one.
  485. check(res.documents.front().id == "d",
  486. "ascending: the document that has the sort field comes first");
  487. std::vector<std::string> tail;
  488. for (size_t i = 1; i < res.documents.size(); ++i) tail.push_back(res.documents[i].id);
  489. check(tail == (std::vector<std::string>{"a", "b", "c"}),
  490. "and the ones missing it follow, tie-broken by id");
  491. }
  492. }
  493. // v2.8.1 — concurrent reads must not rebind a cached handle.
  494. //
  495. // lmdb.h: "This function [mdb_dbi_open] must not be called from multiple
  496. // concurrent transactions in the same process. A transaction that uses this
  497. // function must finish (either commit or abort) before any other transaction in
  498. // the process may use this function."
  499. //
  500. // The old read path violated that on every read of a collection this process had
  501. // not yet written, from every gRPC thread at once. MDB_dbi is an index into the
  502. // env's shared handle table, so churning it silently REBOUND handles that
  503. // committed writes had already cached. Live on 2.8.0-3 that produced 41 refusals
  504. // in 45 minutes, all "caller asked for 'image_hashes' but the handle addresses
  505. // 'nsfw_images'" - and because each refusal bumped mirror drift, every read in
  506. // the process fell back to MemoryStore permanently.
  507. //
  508. // The fix primes all handles in one committed write txn at construction, so only
  509. // write txns ever call mdb_dbi_open and LMDB serialises those itself.
  510. //
  511. // HONEST LIMITATION: this test does NOT reproduce the race. Reverting the fix
  512. // leaves it green apart from the primed_count assertion - the torn slot-table
  513. // update needs an interleaving of concurrent mdb_dbi_open calls that 8 threads
  514. // over 10 collections does not reliably hit. What the test does pin is the
  515. // invariant the race violates (no read returns another collection's document, no
  516. // write is refused by the sentinel) plus the mechanism that removes the race:
  517. // handles are opened up front, so the read path has no mdb_dbi_open left to call.
  518. // The deterministic evidence is the documented contract in lmdb.h and the
  519. // production log.
  520. void test_concurrent_reads_do_not_rebind_cached_handles() {
  521. const std::string path = make_tmpdir("dbi-concurrent");
  522. const LmdbEnvOpts opts{path, 64ULL << 20, 256, 126, false};
  523. struct Cleanup {
  524. const std::string& p;
  525. ~Cleanup() { std::error_code ec; fs::remove_all(p, ec); }
  526. } cleanup{path};
  527. // Enough collections that slot churn has somewhere to go.
  528. const std::vector<std::string> colls = {
  529. "alpha", "beta", "gamma", "delta", "epsilon",
  530. "zeta", "eta", "theta", "iota", "kappa"};
  531. {
  532. LmdbEnv env(opts);
  533. LmdbDocumentStore writer(env);
  534. for (const auto& c : colls) {
  535. Document d;
  536. d.id = "seed";
  537. d.collection = c;
  538. d.set_data(nlohmann::json{{"who", c}});
  539. writer.put(c, "seed", d);
  540. }
  541. }
  542. // Restart: fresh env, so the shared handle table starts empty and the store
  543. // must prime it.
  544. LmdbEnv env2(opts);
  545. LmdbDocumentStore store(env2);
  546. check(store.prime_error().empty(), "priming succeeded on reopen");
  547. check(store.primed_count() >= colls.size(),
  548. "priming opened a handle for every existing sub-db");
  549. // Hammer reads from many threads. Every one of these used to call
  550. // mdb_dbi_open inside its own read txn.
  551. std::atomic<int> read_failures{0};
  552. std::atomic<int> wrong_data{0};
  553. {
  554. std::vector<std::thread> threads;
  555. for (int t = 0; t < 8; ++t) {
  556. threads.emplace_back([&, t]() {
  557. for (int i = 0; i < 40; ++i) {
  558. const auto& c = colls[(t + i) % colls.size()];
  559. try {
  560. auto got = store.get(c, "seed");
  561. if (!got) { ++read_failures; continue; }
  562. // A rebound handle reads a STRANGER's sub-db, so the
  563. // document that comes back belongs to another collection.
  564. if (got->data().value("who", std::string{}) != c) ++wrong_data;
  565. } catch (const std::exception&) {
  566. ++read_failures;
  567. }
  568. }
  569. });
  570. }
  571. for (auto& th : threads) th.join();
  572. }
  573. check(read_failures.load() == 0, "concurrent reads all succeeded");
  574. check(wrong_data.load() == 0,
  575. "no read returned another collection's document - a rebound handle "
  576. "addresses whichever sub-db now occupies its slot");
  577. // Now write to every collection through the cached handles. This is where
  578. // the live failure surfaced: the sentinel refused the write.
  579. int write_failures = 0;
  580. for (const auto& c : colls) {
  581. try {
  582. Document d;
  583. d.id = "after";
  584. d.collection = c;
  585. d.set_data(nlohmann::json{{"who", c}});
  586. store.put(c, "after", d);
  587. } catch (const std::exception&) {
  588. ++write_failures;
  589. }
  590. }
  591. check(write_failures == 0,
  592. "writes through primed handles are not refused by the identity "
  593. "sentinel - the refusal is what production saw");
  594. for (const auto& c : colls) {
  595. check(store.count(c) == 2, ("both documents readable in " + c).c_str());
  596. }
  597. }
  598. // A collection that exists on disk but has NOT been written by this process must
  599. // still be readable. The read path no longer opens handles on demand, so if
  600. // priming missed anything a read would report the collection as EMPTY - a
  601. // silent-wrong-data failure worse than the bug being fixed.
  602. void test_existing_collection_readable_without_writing_first() {
  603. const std::string path = make_tmpdir("dbi-prime-read");
  604. const LmdbEnvOpts opts{path, 64ULL << 20, 256, 126, false};
  605. struct Cleanup {
  606. const std::string& p;
  607. ~Cleanup() { std::error_code ec; fs::remove_all(p, ec); }
  608. } cleanup{path};
  609. {
  610. LmdbEnv env(opts);
  611. LmdbDocumentStore writer(env);
  612. Document d;
  613. d.id = "only";
  614. d.collection = "archive";
  615. d.set_data(nlohmann::json{{"kept", true}});
  616. writer.put("archive", "only", d);
  617. }
  618. LmdbEnv env2(opts);
  619. LmdbDocumentStore reader(env2);
  620. // Read-only access, never a write on this collection in this process.
  621. auto got = reader.get("archive", "only");
  622. check(got.has_value(), "a never-written-here collection is still readable");
  623. check(reader.count("archive") == 1, "count sees it");
  624. smartbotic::database::Query q; q.limit = 10;
  625. check(reader.scan("archive", q).documents.size() == 1, "scan sees it");
  626. // And a collection that genuinely does not exist still reads as absent.
  627. check(!reader.get("nosuch", "x").has_value(),
  628. "a missing collection is still absent, not an error");
  629. check(reader.count("nosuch") == 0, "and counts zero");
  630. }
  631. // v2.9.0 — a secondary index must stay exactly in step with the documents.
  632. //
  633. // The index is a second copy of a fact already stored in the row. Every way the
  634. // two can diverge is a silent-wrong-data bug: a stale entry returns a row that
  635. // no longer matches, a missing entry hides a row that does. So this walks the
  636. // full lifecycle - insert, update the indexed field, update something else,
  637. // delete, re-insert - and after every step asserts the index agrees with a
  638. // brute-force scan of the collection.
  639. void test_index_tracks_documents_through_every_write() {
  640. TmpEnv t("idx-maint");
  641. LmdbDocumentStore store(t.env);
  642. store.set_indexed_fields("execs", {"workflowId"});
  643. auto put = [&](const std::string& id, const std::string& wf, int n) {
  644. Document d;
  645. d.id = id;
  646. d.collection = "execs";
  647. d.set_data(nlohmann::json{{"workflowId", wf}, {"n", n}});
  648. store.put("execs", id, d);
  649. };
  650. // What the index SHOULD say, computed by scanning every row - the oracle.
  651. auto truth = [&](const std::string& wf) {
  652. smartbotic::database::Query q;
  653. q.limit = 10000;
  654. std::vector<std::string> ids;
  655. for (const auto& d : store.scan("execs", q).documents) {
  656. if (d.data().value("workflowId", std::string{}) == wf) ids.push_back(d.id);
  657. }
  658. std::sort(ids.begin(), ids.end());
  659. return ids;
  660. };
  661. auto indexed = [&](const std::string& wf) {
  662. auto got = store.index_lookup_eq("execs", "workflowId", nlohmann::json(wf));
  663. std::vector<std::string> ids = got.value_or(std::vector<std::string>{});
  664. std::sort(ids.begin(), ids.end());
  665. return ids;
  666. };
  667. auto agree = [&](const std::string& wf, const char* stage) {
  668. const bool ok = indexed(wf) == truth(wf);
  669. const std::string msg = "index agrees with a full scan for " + wf +
  670. " after " + stage;
  671. check(ok, msg.c_str());
  672. };
  673. // Insert
  674. put("e1", "wf-a", 1);
  675. put("e2", "wf-a", 2);
  676. put("e3", "wf-b", 3);
  677. agree("wf-a", "inserts");
  678. agree("wf-b", "inserts");
  679. check(indexed("wf-a").size() == 2, "two rows under wf-a");
  680. // Update the INDEXED field: the old posting must go, the new one appear.
  681. put("e2", "wf-b", 2);
  682. agree("wf-a", "moving e2 to wf-b");
  683. agree("wf-b", "moving e2 to wf-b");
  684. check(indexed("wf-a").size() == 1, "wf-a lost e2");
  685. check(indexed("wf-b").size() == 2, "wf-b gained it");
  686. // Update an UNindexed field: the index must be untouched, not duplicated.
  687. put("e1", "wf-a", 99);
  688. agree("wf-a", "updating an unindexed field");
  689. check(indexed("wf-a").size() == 1,
  690. "no duplicate posting from re-writing the same indexed value");
  691. // Delete
  692. check(store.del("execs", "e3"), "deleted e3");
  693. agree("wf-b", "deleting e3");
  694. check(indexed("wf-b").size() == 1, "e3 is gone from the index");
  695. // Re-insert the same id
  696. put("e3", "wf-b", 7);
  697. agree("wf-b", "re-inserting e3");
  698. check(indexed("wf-b").size() == 2, "e3 is back exactly once");
  699. // A value with no rows is an empty result, NOT "no index".
  700. auto none = store.index_lookup_eq("execs", "workflowId", nlohmann::json("wf-zzz"));
  701. check(none.has_value() && none->empty(),
  702. "an indexed field with no matching rows returns empty, not nullopt - "
  703. "nullopt means 'no index' and would send the caller to a scan");
  704. // An undeclared field has no index, and must say so rather than say 'none'.
  705. auto unindexed = store.index_lookup_eq("execs", "n", nlohmann::json(1));
  706. check(!unindexed.has_value(),
  707. "an unindexed field returns nullopt so the caller falls back to a scan "
  708. "instead of concluding there are no matches");
  709. }
  710. // A collection with no declared index must behave exactly as before, and pay
  711. // nothing. Also: declaring an index later must pick up the rows already there.
  712. void test_build_index_over_existing_rows() {
  713. TmpEnv t("idx-build");
  714. LmdbDocumentStore store(t.env);
  715. // Write BEFORE declaring the index.
  716. for (int i = 0; i < 20; ++i) {
  717. Document d;
  718. d.id = "d" + std::to_string(i);
  719. d.collection = "c";
  720. d.set_data(nlohmann::json{{"grp", i % 4 == 0 ? "hot" : "cold"}});
  721. store.put("c", d.id, d);
  722. }
  723. check(!store.index_lookup_eq("c", "grp", nlohmann::json("hot")).has_value(),
  724. "no index exists before it is declared");
  725. const uint64_t built = store.build_index("c", "grp");
  726. check(built == 20, "the backfill indexed every existing row");
  727. store.set_indexed_fields("c", {"grp"});
  728. auto hot = store.index_lookup_eq("c", "grp", nlohmann::json("hot"));
  729. check(hot.has_value() && hot->size() == 5,
  730. "the backfilled index finds the pre-existing rows (d0,d4,d8,d12,d16)");
  731. // Idempotent: a second build must not double the postings.
  732. store.build_index("c", "grp");
  733. hot = store.index_lookup_eq("c", "grp", nlohmann::json("hot"));
  734. check(hot.has_value() && hot->size() == 5,
  735. "re-running the backfill does not duplicate postings");
  736. // The count guard must see the same number without reading the ids.
  737. auto n = store.index_count_eq("c", "grp", nlohmann::json("hot"));
  738. check(n.has_value() && *n == 5, "index_count_eq agrees with the lookup");
  739. auto cold = store.index_count_eq("c", "grp", nlohmann::json("cold"));
  740. check(cold.has_value() && *cold == 15, "and counts the larger group");
  741. check(store.drop_index("c", "grp"), "the index drops");
  742. check(!store.index_lookup_eq("c", "grp", nlohmann::json("hot")).has_value(),
  743. "after dropping, lookups report no index rather than no rows");
  744. }
  745. // Numbers are where an index most easily disagrees with a scan, because the scan
  746. // compares across numeric subtypes. A doc stored with 5 must be found by a
  747. // filter asking for 5.0 through EITHER path.
  748. void test_index_numeric_equality_matches_scan() {
  749. TmpEnv t("idx-num");
  750. LmdbDocumentStore store(t.env);
  751. store.set_indexed_fields("m", {"code"});
  752. Document a;
  753. a.id = "a"; a.collection = "m";
  754. a.set_data(nlohmann::json{{"code", 5}}); // integer
  755. store.put("m", "a", a);
  756. Document b;
  757. b.id = "b"; b.collection = "m";
  758. b.set_data(nlohmann::json{{"code", 5.0}}); // integral double
  759. store.put("m", "b", b);
  760. auto by_int = store.index_lookup_eq("m", "code", nlohmann::json(5));
  761. auto by_dbl = store.index_lookup_eq("m", "code", nlohmann::json(5.0));
  762. check(by_int.has_value() && by_int->size() == 2,
  763. "asking for 5 finds BOTH the int and the integral-double row");
  764. check(by_dbl == by_int,
  765. "and asking for 5.0 returns exactly the same rows - the scan's EQ "
  766. "compares numbers across subtypes, so the index must too");
  767. // A big integer must not collide with its neighbour via double precision.
  768. Document c;
  769. c.id = "c"; c.collection = "m";
  770. c.set_data(nlohmann::json{{"code", 1786263002080195076LL}});
  771. store.put("m", "c", c);
  772. auto near = store.index_lookup_eq("m", "code",
  773. nlohmann::json(1786263002080195077LL));
  774. check(near.has_value() && near->empty(),
  775. "a neighbouring ns-scale integer does not collide - these two ARE "
  776. "equal as doubles, so routing through double would false-match");
  777. }
  778. // v2.9.0 — an indexed plan must return EXACTLY what the unindexed plan returns.
  779. //
  780. // This is the whole safety argument for indexing. The index is an optimisation,
  781. // so any observable difference is a bug, and the interesting failures are silent:
  782. // a missing posting drops a row, a stale one adds a row that no longer matches,
  783. // and a different code path can disagree about ordering or total_matched.
  784. //
  785. // The test runs each query twice against the same data - once with the field
  786. // declared indexed, once not - and compares the complete result: ids in order,
  787. // total_matched, and has_more.
  788. void test_indexed_and_unindexed_plans_agree() {
  789. TmpEnv t("idx-equiv");
  790. LmdbDocumentStore store(t.env);
  791. // 300 rows: `grp` is selective enough to use the index (10 groups of 30 =
  792. // 10%), `bucket` deliberately is NOT (2 values, 50% each) so the guard has
  793. // something to decline.
  794. for (int i = 0; i < 300; ++i) {
  795. Document d;
  796. d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) +
  797. std::to_string(i);
  798. d.collection = "c";
  799. d.set_data(nlohmann::json{
  800. {"grp", "g" + std::to_string(i % 10)},
  801. {"bucket", (i % 2 == 0) ? "even" : "odd"},
  802. {"n", i},
  803. {"nest", {{"deep", "d" + std::to_string(i % 10)}}},
  804. });
  805. store.put("c", d.id, d);
  806. }
  807. using Op = smartbotic::database::FilterOp;
  808. struct Case {
  809. const char* name;
  810. std::vector<smartbotic::database::Filter> filters;
  811. std::optional<smartbotic::database::Sort> sort;
  812. uint32_t limit;
  813. uint32_t offset;
  814. };
  815. auto F = [](const char* f, Op op, const nlohmann::json& v) {
  816. smartbotic::database::Filter x;
  817. x.field = f; x.op = op; x.value = v;
  818. return x;
  819. };
  820. const std::vector<Case> cases = {
  821. {"eq indexed field", {F("grp", Op::EQ, "g3")}, std::nullopt, 100, 0},
  822. {"eq + second predicate", {F("grp", Op::EQ, "g3"), F("bucket", Op::EQ, "even")}, std::nullopt, 100, 0},
  823. {"eq + range on another field", {F("grp", Op::EQ, "g3"), F("n", Op::GT, 100)}, std::nullopt, 100, 0},
  824. {"eq with sort asc", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", false}, 100, 0},
  825. {"eq with sort desc", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", true}, 100, 0},
  826. {"eq paginated", {F("grp", Op::EQ, "g3")}, smartbotic::database::Sort{"n", false}, 7, 10},
  827. {"eq offset past end", {F("grp", Op::EQ, "g3")}, std::nullopt, 10, 999},
  828. {"eq matching nothing", {F("grp", Op::EQ, "nope")}, std::nullopt, 100, 0},
  829. {"eq on unselective field", {F("bucket", Op::EQ, "even")}, std::nullopt, 100, 0},
  830. {"eq plus SEARCH", {F("grp", Op::EQ, "g3"), F("", Op::SEARCH, "g3")}, std::nullopt, 100, 0},
  831. {"ne on indexed field", {F("grp", Op::NE, "g3")}, std::nullopt, 100, 0},
  832. {"eq on nested path", {F("nest.deep", Op::EQ, "d4")}, std::nullopt, 100, 0},
  833. {"limit zero", {F("grp", Op::EQ, "g3")}, std::nullopt, 0, 0},
  834. };
  835. auto run = [&](const Case& c) {
  836. smartbotic::database::Query q;
  837. q.filters = c.filters;
  838. q.sort = c.sort;
  839. q.limit = c.limit;
  840. q.offset = c.offset;
  841. auto r = store.scan("c", q);
  842. std::string sig = "total=" + std::to_string(r.total_matched) +
  843. " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
  844. for (const auto& d : r.documents) { sig += d.id; sig += ","; }
  845. sig += "]";
  846. return sig;
  847. };
  848. for (const auto& c : cases) {
  849. store.set_indexed_fields("c", {}); // no index
  850. const std::string without = run(c);
  851. store.set_indexed_fields("c", {"grp", "bucket", "nest.deep"});
  852. store.build_index("c", "grp");
  853. store.build_index("c", "bucket");
  854. store.build_index("c", "nest.deep");
  855. const std::string with = run(c);
  856. const std::string msg = std::string("indexed and unindexed plans agree: ")
  857. + c.name;
  858. if (with != without) {
  859. std::cerr << " without index: " << without << "\n"
  860. << " with index: " << with << "\n";
  861. }
  862. check(with == without, msg.c_str());
  863. }
  864. // The agreement above is only meaningful if the index plan was actually
  865. // TAKEN for the selective cases. Otherwise the planner declined every time
  866. // and the test compared the scan against itself.
  867. store.set_indexed_fields("c", {"grp", "bucket", "nest.deep"});
  868. {
  869. store.reset_index_plan_stats();
  870. smartbotic::database::Query q;
  871. q.limit = 100;
  872. q.filters.push_back(F("grp", Op::EQ, "g3"));
  873. auto r = store.scan("c", q);
  874. auto st = store.index_plan_stats();
  875. check(r.total_matched == 30, "the selective query matches 30 of 300 rows");
  876. check(st.counted_scans == 1 && st.full_scans == 0,
  877. "a single unsorted EQ is answered by the COUNTED plan - total from "
  878. "the index, only the page's rows read");
  879. }
  880. {
  881. store.reset_index_plan_stats();
  882. smartbotic::database::Query q;
  883. q.limit = 100;
  884. q.filters.push_back(F("bucket", Op::EQ, "even"));
  885. auto r = store.scan("c", q);
  886. auto st = store.index_plan_stats();
  887. check(r.total_matched == 150, "the unselective query matches half the rows");
  888. check(st.counted_scans == 1 && st.full_scans == 0,
  889. "an UNSELECTIVE EQ is now cheap too. The old selectivity guard "
  890. "declined it, but only because counting 150 matches meant visiting "
  891. "them; with the count read from the index, breadth costs nothing");
  892. }
  893. {
  894. // An index on a field the query does not filter on must not be consulted.
  895. store.reset_index_plan_stats();
  896. smartbotic::database::Query q;
  897. q.limit = 100;
  898. q.filters.push_back(F("n", Op::GT, 250));
  899. store.scan("c", q);
  900. auto st = store.index_plan_stats();
  901. check(st.full_scans == 1 && st.indexed_scans == 0 && st.counted_scans == 0,
  902. "a query whose predicates name no indexed field scans");
  903. }
  904. {
  905. // The selectivity guard still governs the shapes the counted plan cannot
  906. // serve. A SORT rules it out (postings come in id order, not sort order),
  907. // so an unselective EQ plus a sort must still decline to the scan.
  908. store.reset_index_plan_stats();
  909. smartbotic::database::Query q;
  910. q.limit = 10;
  911. q.filters.push_back(F("bucket", Op::EQ, "even"));
  912. q.sort = smartbotic::database::Sort{"n", true};
  913. store.scan("c", q);
  914. auto st = store.index_plan_stats();
  915. check(st.counted_scans == 0 && st.declined_unselective == 1,
  916. "unselective EQ + a sort still declines - the counted plan cannot "
  917. "order, and the guard is what stops the index pessimising it");
  918. }
  919. auto n = store.index_count_eq("c", "bucket", nlohmann::json("even"));
  920. check(n.has_value() && *n == 150,
  921. "the unselective index does exist and holds 150 of 300 rows");
  922. }
  923. // An empty document id is a zero-length LMDB key, which mdb_get rejects with
  924. // MDB_BAD_VALSIZE. That surfaced in production as a gRPC INTERNAL and an ERROR
  925. // log line every time a consumer asked for one - noise that masks real failures.
  926. // A key that cannot exist is absent, not an error.
  927. void test_empty_id_reads_as_absent() {
  928. TmpEnv t("empty-id");
  929. LmdbDocumentStore store(t.env);
  930. Document d;
  931. d.id = "real";
  932. d.collection = "c";
  933. d.set_data(nlohmann::json{{"x", 1}});
  934. store.put("c", "real", d);
  935. bool threw = false;
  936. try {
  937. check(!store.get("c", "").has_value(), "an empty id reads as absent");
  938. } catch (const std::exception&) {
  939. threw = true;
  940. }
  941. check(!threw, "and does NOT throw - MDB_BAD_VALSIZE became a gRPC INTERNAL");
  942. threw = false;
  943. try {
  944. check(!store.del("c", ""), "deleting an empty id is a no-op");
  945. } catch (const std::exception&) {
  946. threw = true;
  947. }
  948. check(!threw, "and does not throw either");
  949. check(store.get("c", "real").has_value(), "real ids still work");
  950. check(store.count("c") == 1, "and nothing was disturbed");
  951. }
  952. // v2.9.1 — ranges, CONTAINS and intersection must agree with the scan too, and
  953. // must actually be USED. Each is a new way for the index to disagree with a
  954. // brute-force answer, and each disagreement would be silent.
  955. void test_range_contains_and_intersection() {
  956. TmpEnv t("idx-v291");
  957. LmdbDocumentStore store(t.env);
  958. // n mixes integers and reals on purpose: v2.9.0 encoded those under separate
  959. // type tags, so a range spanning both could not be served at all.
  960. for (int i = 0; i < 400; ++i) {
  961. Document d;
  962. d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
  963. d.collection = "c";
  964. nlohmann::json data{
  965. {"n", (i % 2 == 0) ? nlohmann::json(i) : nlohmann::json(i + 0.5)},
  966. {"grp", "g" + std::to_string(i % 20)},
  967. {"other", "o" + std::to_string(i % 20)},
  968. // Deliberately COARSE: 4 and 5 values, so each matches 25% and 20% of
  969. // 400 rows - both above the 10% budget alone - while their pair
  970. // narrows to i%20, i.e. 20 rows (5%). That is the only shape where
  971. // intersecting two posting lists earns its keep.
  972. {"q4", i % 4},
  973. {"q5", i % 5},
  974. {"tags", nlohmann::json::array({"t" + std::to_string(i % 25), "all"})},
  975. };
  976. d.set_data(data);
  977. store.put("c", d.id, d);
  978. }
  979. using Op = smartbotic::database::FilterOp;
  980. auto F = [](const char* f, Op op, const nlohmann::json& v) {
  981. smartbotic::database::Filter x;
  982. x.field = f; x.op = op; x.value = v;
  983. return x;
  984. };
  985. auto sig = [&](const std::vector<smartbotic::database::Filter>& fs,
  986. std::optional<smartbotic::database::Sort> so = std::nullopt) {
  987. smartbotic::database::Query q;
  988. q.filters = fs;
  989. q.sort = so;
  990. q.limit = 1000;
  991. auto r = store.scan("c", q);
  992. std::string out = "total=" + std::to_string(r.total_matched) + " [";
  993. std::vector<std::string> ids;
  994. for (const auto& d : r.documents) ids.push_back(d.id);
  995. std::sort(ids.begin(), ids.end());
  996. for (const auto& i : ids) { out += i; out += ","; }
  997. return out + "]";
  998. };
  999. const std::vector<std::pair<const char*, std::vector<smartbotic::database::Filter>>> cases = {
  1000. {"GT on a mixed int/real field", {F("n", Op::GT, 380)}},
  1001. {"GTE on a mixed field", {F("n", Op::GTE, 380)}},
  1002. {"LT on a mixed field", {F("n", Op::LT, 12)}},
  1003. {"LTE on a mixed field", {F("n", Op::LTE, 12)}},
  1004. {"GT with a real bound", {F("n", Op::GT, 380.5)}},
  1005. {"range that matches nothing", {F("n", Op::GT, 100000)}},
  1006. {"range that matches everything", {F("n", Op::GT, -1)}},
  1007. {"CONTAINS an array element", {F("tags", Op::CONTAINS, "t3")}},
  1008. {"CONTAINS a common element", {F("tags", Op::CONTAINS, "all")}},
  1009. {"CONTAINS a missing element", {F("tags", Op::CONTAINS, "nope")}},
  1010. {"two broad EQs, narrow together", {F("q4", Op::EQ, 1), F("q5", Op::EQ, 2)}},
  1011. {"two broad EQs, disjoint result", {F("q4", Op::EQ, 1), F("q5", Op::EQ, 2), F("grp", Op::EQ, "g19")}},
  1012. {"range plus EQ", {F("n", Op::GT, 300), F("grp", Op::EQ, "g3")}},
  1013. {"EQ on an array field (whole)", {F("tags", Op::EQ, nlohmann::json::array({"t3", "all"}))}},
  1014. };
  1015. for (const auto& [name, filters] : cases) {
  1016. store.set_indexed_fields("c", {});
  1017. const std::string without = sig(filters);
  1018. store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
  1019. for (const char* f : {"n", "grp", "other", "tags", "q4", "q5"}) {
  1020. store.build_index("c", f);
  1021. }
  1022. const std::string with = sig(filters);
  1023. if (with != without) {
  1024. std::cerr << " without: " << without.substr(0, 200) << "\n"
  1025. << " with: " << with.substr(0, 200) << "\n";
  1026. }
  1027. const std::string msg = std::string("indexed == unindexed: ") + name;
  1028. check(with == without, msg.c_str());
  1029. }
  1030. // Now prove each new plan is actually taken.
  1031. store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
  1032. {
  1033. store.reset_index_plan_stats();
  1034. (void)sig({F("n", Op::GT, 380)});
  1035. auto st = store.index_plan_stats();
  1036. check(st.range_scans == 1 && st.indexed_scans == 1,
  1037. "a selective range WALKS the index - impossible before the numeric "
  1038. "encoding was unified");
  1039. }
  1040. {
  1041. store.reset_index_plan_stats();
  1042. (void)sig({F("n", Op::GT, -1)}); // matches everything
  1043. auto st = store.index_plan_stats();
  1044. check(st.range_scans == 0 && st.full_scans == 1,
  1045. "an unselective range gives up and scans - and must return no-plan "
  1046. "rather than a truncated candidate list");
  1047. }
  1048. {
  1049. store.reset_index_plan_stats();
  1050. (void)sig({F("tags", Op::CONTAINS, "t3")});
  1051. auto st = store.index_plan_stats();
  1052. check(st.indexed_scans == 1,
  1053. "CONTAINS is served from the per-element postings an array writes");
  1054. }
  1055. {
  1056. store.reset_index_plan_stats();
  1057. (void)sig({F("q4", Op::EQ, 1), F("q5", Op::EQ, 2)});
  1058. auto st = store.index_plan_stats();
  1059. check(st.intersected_scans == 1 && st.indexed_scans == 1,
  1060. "q4 matches 25% and q5 20% - each too broad alone - so their posting "
  1061. "lists are INTERSECTED down to 5% instead of scanning");
  1062. }
  1063. // An array-valued field: EQ on a scalar may over-return from the index, which
  1064. // is safe only because every candidate is re-filtered. Pin that.
  1065. {
  1066. const std::string indexed = sig({F("tags", Op::EQ, "t3")});
  1067. store.set_indexed_fields("c", {});
  1068. const std::string scanned = sig({F("tags", Op::EQ, "t3")});
  1069. store.set_indexed_fields("c", {"n", "grp", "other", "tags", "q4", "q5"});
  1070. check(indexed == scanned,
  1071. "EQ for a scalar on an array field agrees - the index may name rows "
  1072. "whose array merely CONTAINS it, and re-filtering drops them");
  1073. }
  1074. }
  1075. // v2.9.2 — the index supplies the ORDER, not just filter candidates.
  1076. //
  1077. // "newest N, unfiltered" walked the whole collection to discover what to sort by:
  1078. // 341ms to return one row from 592 MB of real data, with the index declared and
  1079. // unused, because a Sort is not a filter. Walking the sort field's index reads the
  1080. // page and nothing else.
  1081. //
  1082. // The dangerous part is not speed, it is that sort_documents places rows MISSING
  1083. // the sort field FIRST when descending, while an index holds no posting for them.
  1084. // A walk would silently omit them from the first page - wrong rows, not slow ones.
  1085. // So every case here compares the ordered plan against the plain scan.
  1086. void test_index_supplies_ordering() {
  1087. using Op = smartbotic::database::FilterOp;
  1088. auto make = [](LmdbDocumentStore& store, int n,
  1089. const std::function<nlohmann::json(int)>& body) {
  1090. for (int i = 0; i < n; ++i) {
  1091. Document d;
  1092. d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) +
  1093. std::to_string(i);
  1094. d.collection = "c";
  1095. d.set_data(body(i));
  1096. store.put("c", d.id, d);
  1097. }
  1098. };
  1099. auto sig = [](LmdbDocumentStore& store, const smartbotic::database::Query& q) {
  1100. auto r = store.scan("c", q);
  1101. std::string out = "total=" + std::to_string(r.total_matched) +
  1102. " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
  1103. for (const auto& d : r.documents) { out += d.id; out += ","; }
  1104. return out + "]";
  1105. };
  1106. auto Q = [](const char* field, bool desc, uint32_t limit, uint32_t offset) {
  1107. smartbotic::database::Query q;
  1108. q.sort = smartbotic::database::Sort{field, desc};
  1109. q.limit = limit;
  1110. q.offset = offset;
  1111. return q;
  1112. };
  1113. // ---- every row carries the sort field: the ordered plan applies ----
  1114. {
  1115. TmpEnv t("ord-total");
  1116. LmdbDocumentStore store(t.env);
  1117. // Deliberate duplicate keys (i/3) so tie-breaking by id is exercised in
  1118. // both directions.
  1119. make(store, 300, [](int i) {
  1120. return nlohmann::json{{"seq", i}, {"dup", i / 3}};
  1121. });
  1122. const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
  1123. {"desc, first page", Q("seq", true, 10, 0)},
  1124. {"asc, first page", Q("seq", false, 10, 0)},
  1125. {"desc, deep offset", Q("seq", true, 10, 250)},
  1126. {"asc, deep offset", Q("seq", false, 7, 33)},
  1127. {"desc, last partial page", Q("seq", true, 10, 295)},
  1128. {"offset past the end", Q("seq", true, 10, 999)},
  1129. {"limit=0", Q("seq", true, 0, 0)},
  1130. {"whole collection", Q("seq", false, 1000, 0)},
  1131. {"desc with tied keys", Q("dup", true, 12, 0)},
  1132. {"asc with tied keys", Q("dup", false, 12, 0)},
  1133. {"tied keys, deep offset", Q("dup", true, 5, 40)},
  1134. };
  1135. uint64_t ordered_used = 0;
  1136. for (const auto& [name, q] : cases) {
  1137. store.set_indexed_fields("c", {});
  1138. const std::string without = sig(store, q);
  1139. store.set_indexed_fields("c", {"seq", "dup"});
  1140. store.build_index("c", "seq");
  1141. store.build_index("c", "dup");
  1142. store.reset_index_plan_stats();
  1143. const std::string with = sig(store, q);
  1144. ordered_used += store.index_plan_stats().ordered_scans;
  1145. if (with != without) {
  1146. std::cerr << " scan: " << without.substr(0, 180) << "\n"
  1147. << " ordered: " << with.substr(0, 180) << "\n";
  1148. }
  1149. const std::string msg = std::string("ordered plan == scan: ") + name;
  1150. check(with == without, msg.c_str());
  1151. }
  1152. // EVERY case above must have used the ordered plan, not just one. The
  1153. // first version of this test asserted only a single query and so passed
  1154. // while the plan was silently never taken (the collection's row count
  1155. // included the identity sentinel, so entries never equalled rows).
  1156. check(ordered_used == cases.size(),
  1157. ("the index SUPPLIED THE ORDER in all " + std::to_string(cases.size()) +
  1158. " cases (got " + std::to_string(ordered_used) + ") - otherwise the "
  1159. "comparisons above are scan against scan").c_str());
  1160. }
  1161. // ---- THE trap: one row lacks the sort field ----
  1162. {
  1163. TmpEnv t("ord-missing");
  1164. LmdbDocumentStore store(t.env);
  1165. make(store, 50, [](int i) {
  1166. nlohmann::json j{{"other", i}};
  1167. if (i != 7) j["seq"] = i; // r007 has no seq
  1168. return j;
  1169. });
  1170. store.set_indexed_fields("c", {});
  1171. const std::string without = sig(store, Q("seq", true, 5, 0));
  1172. store.set_indexed_fields("c", {"seq"});
  1173. store.build_index("c", "seq");
  1174. const std::string with = sig(store, Q("seq", true, 5, 0));
  1175. check(with == without,
  1176. "one row missing the sort field: DESCENDING puts it FIRST, and the "
  1177. "index has no posting for it - the ordered plan must decline");
  1178. store.reset_index_plan_stats();
  1179. (void)sig(store, Q("seq", true, 5, 0));
  1180. check(store.index_plan_stats().ordered_scans == 0,
  1181. "and it does decline - entries != rows is the guard");
  1182. }
  1183. // ---- an array-valued sort field must also decline ----
  1184. {
  1185. TmpEnv t("ord-array");
  1186. LmdbDocumentStore store(t.env);
  1187. make(store, 40, [](int i) {
  1188. return nlohmann::json{{"seq", nlohmann::json::array({i})}};
  1189. });
  1190. store.set_indexed_fields("c", {});
  1191. const std::string without = sig(store, Q("seq", true, 5, 0));
  1192. store.set_indexed_fields("c", {"seq"});
  1193. store.build_index("c", "seq");
  1194. const std::string with = sig(store, Q("seq", true, 5, 0));
  1195. check(with == without,
  1196. "a one-element-array sort field agrees - it satisfies entries==rows, "
  1197. "so the fetched-page array check is what catches it");
  1198. }
  1199. }
  1200. // v2.9.2 — IN as a union of posting lists, EXISTS as all postings.
  1201. void test_index_in_and_exists() {
  1202. using Op = smartbotic::database::FilterOp;
  1203. TmpEnv t("in-exists");
  1204. LmdbDocumentStore store(t.env);
  1205. for (int i = 0; i < 400; ++i) {
  1206. Document d;
  1207. d.id = "r" + std::to_string(1000 + i);
  1208. d.collection = "c";
  1209. nlohmann::json j{{"grp", "g" + std::to_string(i % 40)}};
  1210. // `rare` exists on 8 of 400 rows, so EXISTS=true is highly selective.
  1211. if (i % 50 == 0) j["rare"] = i;
  1212. d.set_data(j);
  1213. store.put("c", d.id, d);
  1214. }
  1215. auto F = [](const char* f, Op op, const nlohmann::json& v) {
  1216. smartbotic::database::Filter x;
  1217. x.field = f; x.op = op; x.value = v;
  1218. return x;
  1219. };
  1220. auto sig = [&](const std::vector<smartbotic::database::Filter>& fs) {
  1221. smartbotic::database::Query q;
  1222. q.filters = fs;
  1223. q.limit = 1000;
  1224. auto r = store.scan("c", q);
  1225. std::vector<std::string> ids;
  1226. for (const auto& d : r.documents) ids.push_back(d.id);
  1227. std::sort(ids.begin(), ids.end());
  1228. std::string out = "total=" + std::to_string(r.total_matched) + " [";
  1229. for (const auto& i : ids) { out += i; out += ","; }
  1230. return out + "]";
  1231. };
  1232. const std::vector<std::pair<const char*, std::vector<smartbotic::database::Filter>>> cases = {
  1233. {"IN over three values", {F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"}))}},
  1234. {"IN with a missing value", {F("grp", Op::IN, nlohmann::json::array({"g1","nope"}))}},
  1235. {"IN over one value", {F("grp", Op::IN, nlohmann::json::array({"g5"}))}},
  1236. {"IN matching nothing", {F("grp", Op::IN, nlohmann::json::array({"x","y"}))}},
  1237. {"IN too broad to help", {F("grp", Op::IN, nlohmann::json::array(
  1238. {"g0","g1","g2","g3","g4","g5","g6","g7","g8","g9","g10","g11"}))}},
  1239. {"EXISTS true on a sparse field", {F("rare", Op::EXISTS, true)}},
  1240. {"EXISTS false on a sparse field", {F("rare", Op::EXISTS, false)}},
  1241. {"EXISTS true on a dense field", {F("grp", Op::EXISTS, true)}},
  1242. };
  1243. for (const auto& [name, filters] : cases) {
  1244. store.set_indexed_fields("c", {});
  1245. const std::string without = sig(filters);
  1246. store.set_indexed_fields("c", {"grp", "rare"});
  1247. store.build_index("c", "grp");
  1248. store.build_index("c", "rare");
  1249. const std::string with = sig(filters);
  1250. if (with != without) {
  1251. std::cerr << " scan: " << without.substr(0, 160) << "\n"
  1252. << " indexed: " << with.substr(0, 160) << "\n";
  1253. }
  1254. const std::string msg = std::string("indexed == scan: ") + name;
  1255. check(with == without, msg.c_str());
  1256. }
  1257. store.set_indexed_fields("c", {"grp", "rare"});
  1258. {
  1259. store.reset_index_plan_stats();
  1260. (void)sig({F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"}))});
  1261. auto st = store.index_plan_stats();
  1262. check(st.counted_scans == 1,
  1263. "a single unsorted IN is answered by the COUNTED plan: the per-value "
  1264. "counts are disjoint on a non-multivalued field, so their sum is the "
  1265. "exact total");
  1266. }
  1267. {
  1268. store.reset_index_plan_stats();
  1269. (void)sig({F("grp", Op::IN, nlohmann::json::array(
  1270. {"g0","g1","g2","g3","g4","g5","g6","g7","g8","g9","g10","g11"}))});
  1271. auto st = store.index_plan_stats();
  1272. check(st.counted_scans == 1 && st.full_scans == 0,
  1273. "even a BROAD IN is served now - the total is a sum of counts and "
  1274. "only ids are merged, so no document outside the page is touched. "
  1275. "The union plan remains for the sorted case");
  1276. }
  1277. {
  1278. // The union plan still exists for the shape the counted plan declines.
  1279. store.reset_index_plan_stats();
  1280. smartbotic::database::Query q;
  1281. q.limit = 1000;
  1282. q.filters.push_back(F("grp", Op::IN, nlohmann::json::array({"g1","g2","g3"})));
  1283. q.sort = smartbotic::database::Sort{"grp", false};
  1284. store.scan("c", q);
  1285. auto st = store.index_plan_stats();
  1286. check(st.union_scans == 1,
  1287. "a SORTED IN still unions posting lists as filter candidates");
  1288. }
  1289. {
  1290. store.reset_index_plan_stats();
  1291. (void)sig({F("rare", Op::EXISTS, true)});
  1292. check(store.index_plan_stats().exists_scans == 1,
  1293. "EXISTS=true on a sparse field is served from all its postings");
  1294. }
  1295. {
  1296. store.reset_index_plan_stats();
  1297. (void)sig({F("rare", Op::EXISTS, false)});
  1298. auto st = store.index_plan_stats();
  1299. check(st.exists_scans == 0 && st.full_scans == 1,
  1300. "EXISTS=false cannot be served - rows WITHOUT a posting are not "
  1301. "enumerable from the index, so it must scan");
  1302. }
  1303. {
  1304. store.reset_index_plan_stats();
  1305. (void)sig({F("grp", Op::EXISTS, true)});
  1306. auto st = store.index_plan_stats();
  1307. check(st.exists_scans == 0 && st.full_scans == 1,
  1308. "EXISTS=true on a field every row has is not selective, so it scans");
  1309. }
  1310. }
  1311. // v2.10.0 — the total and the page come from the index, so nothing outside the
  1312. // page is read. The risk moves from "are the rows right" to "is the TOTAL right",
  1313. // and a wrong total silently breaks paging rather than erroring.
  1314. void test_counted_plan() {
  1315. using Op = smartbotic::database::FilterOp;
  1316. TmpEnv t("counted");
  1317. LmdbDocumentStore store(t.env);
  1318. // 200 rows, 100 of them state="done" - deliberately unselective, the case the
  1319. // old guard declined.
  1320. for (int i = 0; i < 200; ++i) {
  1321. Document d;
  1322. d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
  1323. d.collection = "c";
  1324. d.set_data(nlohmann::json{{"state", (i % 2 == 0) ? "done" : "todo"},
  1325. {"grp", "g" + std::to_string(i % 5)}});
  1326. store.put("c", d.id, d);
  1327. }
  1328. auto F = [](const char* f, Op op, const nlohmann::json& v) {
  1329. smartbotic::database::Filter x;
  1330. x.field = f; x.op = op; x.value = v;
  1331. return x;
  1332. };
  1333. auto sig = [&](const smartbotic::database::Query& q) {
  1334. auto r = store.scan("c", q);
  1335. std::string out = "total=" + std::to_string(r.total_matched) +
  1336. " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
  1337. for (const auto& d : r.documents) { out += d.id; out += ","; }
  1338. return out + "]";
  1339. };
  1340. auto Q = [&](std::vector<smartbotic::database::Filter> fs, uint32_t limit,
  1341. uint32_t offset) {
  1342. smartbotic::database::Query q;
  1343. q.filters = std::move(fs);
  1344. q.limit = limit;
  1345. q.offset = offset;
  1346. return q;
  1347. };
  1348. const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
  1349. {"EQ first page", Q({F("state", Op::EQ, "done")}, 10, 0)},
  1350. {"EQ deep offset", Q({F("state", Op::EQ, "done")}, 10, 80)},
  1351. {"EQ last partial page", Q({F("state", Op::EQ, "done")}, 10, 95)},
  1352. {"EQ offset past the end", Q({F("state", Op::EQ, "done")}, 10, 500)},
  1353. {"EQ limit=0", Q({F("state", Op::EQ, "done")}, 0, 0)},
  1354. {"EQ whole match set", Q({F("state", Op::EQ, "done")}, 1000, 0)},
  1355. {"EQ matching nothing", Q({F("state", Op::EQ, "nope")}, 10, 0)},
  1356. {"IN two values", Q({F("grp", Op::IN, nlohmann::json::array({"g1","g3"}))}, 10, 0)},
  1357. {"IN paged", Q({F("grp", Op::IN, nlohmann::json::array({"g1","g3"}))}, 7, 55)},
  1358. {"IN one missing value", Q({F("grp", Op::IN, nlohmann::json::array({"g1","zz"}))}, 10, 0)},
  1359. };
  1360. uint64_t counted_used = 0;
  1361. for (const auto& [name, q] : cases) {
  1362. store.set_indexed_fields("c", {});
  1363. const std::string without = sig(q);
  1364. store.set_indexed_fields("c", {"state", "grp"});
  1365. store.build_index("c", "state");
  1366. store.build_index("c", "grp");
  1367. store.reset_index_plan_stats();
  1368. const std::string with = sig(q);
  1369. counted_used += store.index_plan_stats().counted_scans;
  1370. if (with != without) {
  1371. std::cerr << " scan: " << without.substr(0, 200) << "\n"
  1372. << " counted: " << with.substr(0, 200) << "\n";
  1373. }
  1374. const std::string msg = std::string("counted plan == scan: ") + name;
  1375. check(with == without, msg.c_str());
  1376. }
  1377. check(counted_used == cases.size(),
  1378. ("the counted plan ran in all " + std::to_string(cases.size()) +
  1379. " cases (got " + std::to_string(counted_used) + ")").c_str());
  1380. // ---- an ARRAY-valued field must NOT be counted from the index ----
  1381. //
  1382. // EQ compares the whole array, but the index holds one posting per element, so
  1383. // a key's duplicate count is the number of rows CONTAINING that element - not
  1384. // the number whose value equals it. Counting from the index there would report
  1385. // a total for rows that do not match.
  1386. {
  1387. TmpEnv t2("counted-multi");
  1388. LmdbDocumentStore s2(t2.env);
  1389. for (int i = 0; i < 60; ++i) {
  1390. Document d;
  1391. d.id = "m" + std::to_string(i);
  1392. d.collection = "c";
  1393. d.set_data(nlohmann::json{{"tags", nlohmann::json::array(
  1394. {"t" + std::to_string(i % 3), "all"})}});
  1395. s2.put("c", d.id, d);
  1396. }
  1397. auto sig2 = [&](const smartbotic::database::Query& q) {
  1398. auto r = s2.scan("c", q);
  1399. return "total=" + std::to_string(r.total_matched) +
  1400. " rows=" + std::to_string(r.documents.size());
  1401. };
  1402. smartbotic::database::Query q;
  1403. q.limit = 100;
  1404. q.filters.push_back(F("tags", Op::EQ, "t1"));
  1405. s2.set_indexed_fields("c", {});
  1406. const std::string without = sig2(q);
  1407. s2.set_indexed_fields("c", {"tags"});
  1408. s2.build_index("c", "tags");
  1409. s2.reset_index_plan_stats();
  1410. const std::string with = sig2(q);
  1411. check(with == without,
  1412. "EQ on an array-valued field agrees with the scan (both find 0 - EQ "
  1413. "compares the WHOLE array)");
  1414. check(s2.index_plan_stats().counted_scans == 0,
  1415. "and the counted plan DECLINES, because the index is marked "
  1416. "multivalued: a key's duplicate count counts rows CONTAINING the "
  1417. "element, which is not the EQ match count");
  1418. // CONTAINS on the same data still works, via candidates.
  1419. smartbotic::database::Query qc;
  1420. qc.limit = 100;
  1421. qc.filters.push_back(F("tags", Op::CONTAINS, "t1"));
  1422. s2.set_indexed_fields("c", {});
  1423. const std::string cwithout = sig2(qc);
  1424. s2.set_indexed_fields("c", {"tags"});
  1425. const std::string cwith = sig2(qc);
  1426. check(cwith == cwithout, "CONTAINS on the same array field still agrees");
  1427. }
  1428. }
  1429. // v2.10.0 — a range on the field being sorted. The walk's order already IS the
  1430. // sort order, so no sorting pass runs and only the page is decoded. The subtle
  1431. // part is descending ties: reversing an ascending walk must reproduce
  1432. // sort_documents' reverse-id tie-break exactly.
  1433. void test_range_ordered_plan() {
  1434. using Op = smartbotic::database::FilterOp;
  1435. TmpEnv t("range-ordered");
  1436. LmdbDocumentStore store(t.env);
  1437. for (int i = 0; i < 250; ++i) {
  1438. Document d;
  1439. d.id = "r" + std::string(i < 10 ? "00" : (i < 100 ? "0" : "")) + std::to_string(i);
  1440. d.collection = "c";
  1441. // `dup` deliberately ties three rows per value so the tie-break matters.
  1442. store.put("c", d.id, [&]{
  1443. d.set_data(nlohmann::json{{"n", i}, {"dup", i / 3}});
  1444. return d;
  1445. }());
  1446. }
  1447. auto F = [](const char* f, Op op, const nlohmann::json& v) {
  1448. smartbotic::database::Filter x;
  1449. x.field = f; x.op = op; x.value = v;
  1450. return x;
  1451. };
  1452. auto sig = [&](const smartbotic::database::Query& q) {
  1453. auto r = store.scan("c", q);
  1454. std::string out = "total=" + std::to_string(r.total_matched) +
  1455. " more=" + std::to_string(r.has_more ? 1 : 0) + " [";
  1456. for (const auto& d : r.documents) { out += d.id; out += ","; }
  1457. return out + "]";
  1458. };
  1459. auto Q = [&](smartbotic::database::Filter f, const char* sortField, bool desc,
  1460. uint32_t limit, uint32_t offset) {
  1461. smartbotic::database::Query q;
  1462. q.filters.push_back(std::move(f));
  1463. q.sort = smartbotic::database::Sort{sortField, desc};
  1464. q.limit = limit;
  1465. q.offset = offset;
  1466. return q;
  1467. };
  1468. const std::vector<std::pair<const char*, smartbotic::database::Query>> cases = {
  1469. {"GT sorted desc on the same field", Q(F("n", Op::GT, 200), "n", true, 10, 0)},
  1470. {"GT sorted asc on the same field", Q(F("n", Op::GT, 200), "n", false, 10, 0)},
  1471. {"GTE sorted desc", Q(F("n", Op::GTE, 200), "n", true, 10, 0)},
  1472. {"LT sorted asc", Q(F("n", Op::LT, 40), "n", false, 10, 0)},
  1473. {"LTE sorted desc", Q(F("n", Op::LTE, 40), "n", true, 10, 0)},
  1474. {"paged inside the range", Q(F("n", Op::GT, 100), "n", true, 7, 20)},
  1475. {"offset past the range", Q(F("n", Op::GT, 240), "n", true, 10, 99)},
  1476. {"limit=0 over a range", Q(F("n", Op::GT, 100), "n", true, 0, 0)},
  1477. {"range matching nothing", Q(F("n", Op::GT, 9999), "n", true, 10, 0)},
  1478. {"range over the whole collection", Q(F("n", Op::GTE, 0), "n", true, 5, 0)},
  1479. {"TIED keys, desc", Q(F("dup", Op::GT, 40), "dup", true, 12, 0)},
  1480. {"TIED keys, asc", Q(F("dup", Op::GT, 40), "dup", false, 12, 0)},
  1481. {"TIED keys, desc, paged", Q(F("dup", Op::GTE, 20), "dup", true, 5, 13)},
  1482. // A range on one field but sorted by ANOTHER must NOT take this plan.
  1483. {"range sorted by a different field", Q(F("n", Op::GT, 200), "dup", true, 10, 0)},
  1484. };
  1485. uint64_t used = 0;
  1486. for (const auto& [name, q] : cases) {
  1487. store.set_indexed_fields("c", {});
  1488. const std::string without = sig(q);
  1489. store.set_indexed_fields("c", {"n", "dup"});
  1490. store.build_index("c", "n");
  1491. store.build_index("c", "dup");
  1492. store.reset_index_plan_stats();
  1493. const std::string with = sig(q);
  1494. used += store.index_plan_stats().range_ordered_scans;
  1495. if (with != without) {
  1496. std::cerr << " scan: " << without.substr(0, 200) << "\n"
  1497. << " rangeord: " << with.substr(0, 200) << "\n";
  1498. }
  1499. const std::string msg = std::string("range-ordered == scan: ") + name;
  1500. check(with == without, msg.c_str());
  1501. }
  1502. // All but the last case (sorted by a different field) should use the plan.
  1503. check(used == cases.size() - 1,
  1504. ("the range-ordered plan ran in " + std::to_string(used) + " of " +
  1505. std::to_string(cases.size() - 1) + " applicable cases").c_str());
  1506. {
  1507. store.set_indexed_fields("c", {"n", "dup"});
  1508. store.reset_index_plan_stats();
  1509. (void)sig(Q(F("n", Op::GT, 200), "dup", true, 10, 0));
  1510. auto st = store.index_plan_stats();
  1511. check(st.range_ordered_scans == 0,
  1512. "a range on one field sorted by another does NOT take the plan - the "
  1513. "walk's order is not the requested order");
  1514. }
  1515. }
  1516. // v2.10.0 — distinct values and min/max, answered from the index.
  1517. void test_index_values() {
  1518. TmpEnv t("idx-values");
  1519. LmdbDocumentStore store(t.env);
  1520. for (int i = 0; i < 120; ++i) {
  1521. Document d;
  1522. d.id = "r" + std::to_string(100 + i);
  1523. d.collection = "c";
  1524. d.set_data(nlohmann::json{
  1525. {"state", (i % 3 == 0) ? "done" : ((i % 3 == 1) ? "todo" : "wip")},
  1526. {"n", i},
  1527. {"tags", nlohmann::json::array({"t" + std::to_string(i % 2)})},
  1528. });
  1529. store.put("c", d.id, d);
  1530. }
  1531. store.build_index("c", "state");
  1532. store.build_index("c", "n");
  1533. store.build_index("c", "tags");
  1534. auto vals = store.index_values("c", "state", 20, true);
  1535. check(vals.size() == 3, "three distinct states");
  1536. check(!vals.empty() && vals[0].value == "done", "ascending: 'done' sorts first");
  1537. uint64_t sum = 0;
  1538. for (const auto& v : vals) sum += v.count;
  1539. check(sum == 120, "the counts add up to every row - none double-counted");
  1540. auto desc = store.index_values("c", "state", 20, false);
  1541. check(desc.size() == 3 && desc[0].value == "wip",
  1542. "descending walks from the highest value");
  1543. // limit=1 in each direction is min and max.
  1544. auto mn = store.index_values("c", "n", 1, true);
  1545. auto mx = store.index_values("c", "n", 1, false);
  1546. check(mn.size() == 1 && mn[0].value == 0, "ascending limit=1 is the MINIMUM");
  1547. check(mx.size() == 1 && mx[0].value == 119, "descending limit=1 is the MAXIMUM");
  1548. check(store.index_values("c", "n", 5, true).size() == 5, "limit is honoured");
  1549. check(store.index_values("c", "n", 0, true).empty(), "limit=0 returns nothing");
  1550. check(store.index_values("c", "nosuch", 5, true).empty(),
  1551. "an unindexed field returns nothing rather than erroring");
  1552. // An array field's keys are ELEMENTS, so reporting the row's value (the whole
  1553. // array) would be misleading. It reports nothing instead.
  1554. check(store.index_values("c", "tags", 5, true).empty(),
  1555. "an array-valued field reports no values - its keys are elements, not "
  1556. "values, and returning the array would misstate what the key means");
  1557. }
  1558. // v2.11.0 T10 — the whole point of the txn-accepting overloads: one
  1559. // transaction spanning TWO DIFFERENT collections (plus an index sub-db) must
  1560. // be all-or-nothing. Task 12's atomic cascade depends on this - a parent
  1561. // delete and its children's cleanup have to share one commit/abort, and the
  1562. // no-txn put()/del() each open and commit their own WriteTxn, so they cannot
  1563. // compose into one atomic unit at all.
  1564. //
  1565. // Written BEFORE the overloads exist, per the brief: this must fail to
  1566. // compile/link until put(WriteTxn&, ...) and friends are added.
  1567. void test_txn_accepting_writes_are_atomic_across_collections() {
  1568. TmpEnv t("txn-atomic");
  1569. LmdbDocumentStore store(t.env);
  1570. store.set_indexed_fields("parents", {"name"});
  1571. // A relation declared on 'children' as the CHILD side, so put(wtxn, ...)
  1572. // on 'children' also maintains a reverse-index posting - the brief's "no
  1573. // relation entry survives" half needs one declared to be testable at
  1574. // all, and it shares the identical to_cache mechanism as the index path.
  1575. store.set_relations("children", {RelationRef{"par_child", "parentId"}});
  1576. Document pd;
  1577. pd.id = "p1";
  1578. pd.collection = "parents";
  1579. pd.set_data(nlohmann::json{{"name", "alice"}});
  1580. Document cd;
  1581. cd.id = "c1";
  1582. cd.collection = "children";
  1583. cd.set_data(nlohmann::json{{"parentId", "p1"}});
  1584. // --- Abort path: neither document, nor the index entry, nor the
  1585. // relation posting, may survive. ---
  1586. {
  1587. WriteTxn wtxn(t.env);
  1588. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1589. store.put(wtxn, "parents", "p1", pd, to_cache);
  1590. store.put(wtxn, "children", "c1", cd, to_cache);
  1591. wtxn.abort();
  1592. // to_cache is deliberately NOT applied to the process-wide dbi cache -
  1593. // see the header comment: caching before commit is exactly the v2.8.0
  1594. // bug. Nothing here should call cacheCommittedDbi.
  1595. }
  1596. check(!store.get("parents", "p1").has_value(),
  1597. "aborted txn: the parent document does not exist");
  1598. check(!store.get("children", "c1").has_value(),
  1599. "aborted txn: the child document does not exist");
  1600. // --- The collection must not be poisoned by the aborted transaction: a
  1601. // later write, scan, count and get on EITHER collection must still work.
  1602. // This is precisely the v2.8.0 failure mode - caching a handle before
  1603. // commit leaves a closed handle behind an aborted caller transaction.
  1604. // It also DOUBLES as the only way to make the index/relation-rollback
  1605. // checks below non-vacuous: before either collection has ever committed
  1606. // successfully, index_lookup_eq()/relation_index_children() report
  1607. // nullopt/empty purely because their sub-db was never cached - that
  1608. // would pass whether or not the aborted posting actually rolled back.
  1609. // Writing a NEW row under the SAME key values the aborted write used
  1610. // (name="alice", parentId="p1") warms those caches for real, so a
  1611. // survived posting from the aborted write would show up alongside it. ---
  1612. bool ok_put = true;
  1613. try {
  1614. store.put("parents", "p2", pd); // same name: "alice" as the aborted p1
  1615. } catch (const std::exception&) {
  1616. ok_put = false;
  1617. }
  1618. check(ok_put, "a later write to 'parents' after the abort still works");
  1619. bool ok_put2 = true;
  1620. try {
  1621. store.put("children", "c2", cd); // same parentId: "p1" as the aborted c1
  1622. } catch (const std::exception&) {
  1623. ok_put2 = false;
  1624. }
  1625. check(ok_put2, "a later write to 'children' after the abort still works");
  1626. check(store.get("parents", "p2").has_value(), "get() on 'parents' works after the abort");
  1627. check(store.get("children", "c2").has_value(), "get() on 'children' works after the abort");
  1628. smartbotic::database::Query q;
  1629. q.limit = 10;
  1630. check(store.scan("parents", q).documents.size() == 1,
  1631. "scan() on 'parents' works after the abort and sees only the post-abort write");
  1632. check(store.scan("children", q).documents.size() == 1,
  1633. "scan() on 'children' works after the abort and sees only the post-abort write");
  1634. check(store.count("parents") == 1, "count() on 'parents' is right after the abort");
  1635. check(store.count("children") == 1, "count() on 'children' is right after the abort");
  1636. // Now the real proof: the index for "alice" holds ONLY p2, and the
  1637. // relation's postings for parent "p1" hold ONLY c2. If the aborted
  1638. // write's postings (p1 under "alice", c1 under "p1") had survived, either
  1639. // of these would report two ids instead of one - unlike the vacuous
  1640. // "nullopt/empty" checks a cold cache would also produce.
  1641. auto idx = store.index_lookup_eq("parents", "name", nlohmann::json("alice"));
  1642. check(idx.has_value() && idx->size() == 1 && (*idx)[0] == "p2",
  1643. "aborted txn: the index under 'alice' holds only the post-abort "
  1644. "write (p2) - the aborted p1 posting did not survive");
  1645. auto children_of_p1 = store.relation_index_children("par_child", "p1", 10);
  1646. check(children_of_p1.size() == 1 && children_of_p1[0] == "c2",
  1647. "aborted txn: parent 'p1' has only the post-abort child (c2) in the "
  1648. "relation index - the aborted c1 posting did not survive");
  1649. // --- Commit path: both documents AND the index entry land together,
  1650. // once the caller re-primes to pick up the handles it chose not to
  1651. // cache itself (see the next block for why that step is mandatory in
  1652. // real callers). ---
  1653. {
  1654. WriteTxn wtxn(t.env);
  1655. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1656. Document pd2;
  1657. pd2.id = "p3";
  1658. pd2.collection = "parents";
  1659. pd2.set_data(nlohmann::json{{"name", "bob"}});
  1660. Document cd2;
  1661. cd2.id = "c3";
  1662. cd2.collection = "children";
  1663. cd2.set_data(nlohmann::json{{"parentId", "p3"}});
  1664. store.put(wtxn, "parents", "p3", pd2, to_cache);
  1665. store.put(wtxn, "children", "c3", cd2, to_cache);
  1666. wtxn.commit();
  1667. check(to_cache.size() >= 2,
  1668. "put(wtxn,...) reports every handle it opened (collection dbis, "
  1669. "plus the index dbi since 'parents' declares one) for the caller "
  1670. "to cache after its own commit");
  1671. }
  1672. store.prime_dbi_cache(); // stand-in for the caching step a real caller does
  1673. check(store.get("parents", "p3").has_value(), "committed txn: parent document exists");
  1674. check(store.get("children", "c3").has_value(), "committed txn: child document exists");
  1675. auto idx2 = store.index_lookup_eq("parents", "name", nlohmann::json("bob"));
  1676. check(idx2.has_value() && idx2->size() == 1 && (*idx2)[0] == "p3",
  1677. "committed txn: the index entry for the new parent is there");
  1678. // --- The contract that makes handle-return necessary: after a commit
  1679. // through the txn-accepting overload, the caller MUST cache the handles
  1680. // it was given, because reads are cache-only (see try_open_for_read's
  1681. // comment: a cache miss reads as "no such collection" by design, so
  1682. // that read transactions never have to call mdb_dbi_open). Skip that
  1683. // step and freshly-committed data becomes unreadable - not corrupted,
  1684. // just invisible - for the life of the process. Demonstrated on a
  1685. // brand-new collection name never touched before this point. ---
  1686. {
  1687. WriteTxn wtxn(t.env);
  1688. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1689. Document nd;
  1690. nd.id = "n1";
  1691. nd.collection = "brandnew";
  1692. nd.set_data(nlohmann::json{{"x", 1}});
  1693. store.put(wtxn, "brandnew", "n1", nd, to_cache);
  1694. wtxn.commit();
  1695. check(!to_cache.empty(),
  1696. "put(wtxn,...) hands back the handle it opened for 'brandnew'");
  1697. // Deliberately do NOT cache it - that is the point of this block.
  1698. }
  1699. check(!store.get("brandnew", "n1").has_value(),
  1700. "without caching after commit, the freshly-committed row reads as "
  1701. "absent - a cold cache, not lost data (proven next)");
  1702. store.prime_dbi_cache();
  1703. check(store.get("brandnew", "n1").has_value(),
  1704. "after priming (which caches it), the same row is visible - "
  1705. "confirming the earlier miss was purely a cold cache");
  1706. }
  1707. // v2.11.0 T10 review round 2 — the vector equivalents of the two defects
  1708. // fixed on the document path (put/del) were still present on put_vector/
  1709. // del_vector: late handle-recording (Finding 2) and a same-transaction
  1710. // existence-probe gap (Finding 3). Both are reachable under the Task 12
  1711. // cascade design documented in the task report, which shares ONE to_cache
  1712. // across put/del/put_vector/del_vector for a parent and its children in a
  1713. // single transaction — dormant only because nothing but the self-contained
  1714. // no-txn wrappers calls the txn-accepting vector overloads yet.
  1715. void test_txn_accepting_vector_writes_are_atomic() {
  1716. TmpEnv t("txn-atomic-vec");
  1717. LmdbDocumentStore store(t.env);
  1718. // --- Abort path: an aborted put_vector must leave no vector behind.
  1719. // Proven the same non-vacuous way as the document test: get_vector()
  1720. // on a never-committed sub-db returns nullopt purely from a cold cache
  1721. // (try_open_for_read is cache-only), so the follow-up write under the
  1722. // SAME collection is what makes the absence-of-'a1' check real - if the
  1723. // aborted key had survived, get_vector("vecsA", "a1") would find it via
  1724. // the now-warm cache, not report absent. ---
  1725. {
  1726. WriteTxn wtxn(t.env);
  1727. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1728. store.put_vector(wtxn, "vecsA", "a1", {1.0f, 2.0f, 3.0f}, to_cache);
  1729. wtxn.abort();
  1730. }
  1731. bool ok_put = true;
  1732. try {
  1733. store.put_vector("vecsA", "a2", {9.0f, 9.0f, 9.0f}); // warms the cache for real
  1734. } catch (const std::exception&) {
  1735. ok_put = false;
  1736. }
  1737. check(ok_put, "a later put_vector on 'vecsA' after the abort still works - "
  1738. "not poisoned by the aborted transaction");
  1739. check(!store.get_vector("vecsA", "a1").has_value(),
  1740. "aborted txn: 'a1' does not exist, checked against a genuinely "
  1741. "warm cache (via 'a2'), not a cold-cache false negative");
  1742. auto v2 = store.get_vector("vecsA", "a2");
  1743. check(v2.has_value() && v2->size() == 3 && (*v2)[0] == 9.0f,
  1744. "the post-abort vector write is readable");
  1745. // --- Finding 2 (put_vector): the handle must be recorded in to_cache
  1746. // BEFORE mdb_put runs, not after - so a throw from mdb_put does not
  1747. // silently drop the collection's own handle from what the caller was
  1748. // told to cache. Reproduced with the same oversized-key technique as
  1749. // test_aborted_write_does_not_poison_the_collection: a key past LMDB's
  1750. // 511-byte limit makes mdb_put fail MDB_BAD_VALSIZE. ---
  1751. {
  1752. WriteTxn wtxn(t.env);
  1753. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1754. const std::string huge_id(600, 'v');
  1755. bool threw = false;
  1756. try {
  1757. store.put_vector(wtxn, "vecsB", huge_id, {1.0f}, to_cache);
  1758. } catch (const std::exception&) {
  1759. threw = true;
  1760. }
  1761. check(threw, "an oversized key really does fail put_vector's mdb_put");
  1762. check(!to_cache.empty(),
  1763. "put_vector(wtxn,...) recorded the sub-db handle BEFORE the "
  1764. "failing mdb_put ran, not after - so to_cache is not silently "
  1765. "missing it");
  1766. wtxn.abort();
  1767. }
  1768. bool ok_put_b = true;
  1769. try {
  1770. store.put_vector("vecsB", "b1", {4.0f, 5.0f});
  1771. } catch (const std::exception&) {
  1772. ok_put_b = false;
  1773. }
  1774. check(ok_put_b, "'vecsB' is not poisoned by the failed put_vector either");
  1775. check(store.get_vector("vecsB", "b1").has_value(),
  1776. "and the post-failure vector write is readable");
  1777. // --- Finding 3 (del_vector): a vector sub-db opened by put_vector(wtxn,
  1778. // ...) earlier in the SAME still-uncommitted transaction must be
  1779. // deletable by del_vector(wtxn, ...) sharing that to_cache - cachedDbi()
  1780. // alone only sees already-committed sub-dbs, so without the to_cache
  1781. // fallback this would silently no-op instead of deleting. ---
  1782. bool existed = false;
  1783. {
  1784. WriteTxn wtxn(t.env);
  1785. std::vector<std::pair<std::string, unsigned int>> to_cache;
  1786. store.put_vector(wtxn, "vecsC", "c1", {7.0f, 7.0f}, to_cache);
  1787. existed = store.del_vector(wtxn, "vecsC", "c1", to_cache);
  1788. wtxn.commit();
  1789. for (const auto& [sub, d] : to_cache) {
  1790. // Stand-in for the caller's post-commit caching step - not
  1791. // strictly needed for this assertion (get_vector below would
  1792. // read as absent for a deleted key from a cold cache too, same
  1793. // as a genuinely-deleted one), but exercised for parity with
  1794. // how a real caller (Task 12) must use this.
  1795. (void)sub;
  1796. (void)d;
  1797. }
  1798. }
  1799. check(existed,
  1800. "del_vector(wtxn, ...) found and deleted 'c1' within the SAME "
  1801. "transaction that created it, rather than treating the "
  1802. "not-yet-committed sub-db as nonexistent and no-op'ing");
  1803. store.prime_dbi_cache();
  1804. check(!store.get_vector("vecsC", "c1").has_value(),
  1805. "'c1' is genuinely gone after the commit, not merely unreported");
  1806. }
  1807. } // namespace
  1808. int main() {
  1809. std::cout << "=== test_subdb_identity ===\n";
  1810. test_sentinel_roundtrip();
  1811. test_misbound_handle_is_refused();
  1812. test_unstamped_subdb_is_permitted();
  1813. test_identity_key_predicate();
  1814. test_sentinel_invisible_through_store();
  1815. test_vector_subdb_sentinel();
  1816. test_scan_limit_zero_reports_total();
  1817. test_scan_fast_path_matches_general_path();
  1818. test_aborted_write_does_not_poison_the_collection();
  1819. test_txn_accepting_writes_are_atomic_across_collections();
  1820. test_txn_accepting_vector_writes_are_atomic();
  1821. test_filtered_scan_operator_matrix();
  1822. test_concurrent_reads_do_not_rebind_cached_handles();
  1823. test_existing_collection_readable_without_writing_first();
  1824. test_index_tracks_documents_through_every_write();
  1825. test_build_index_over_existing_rows();
  1826. test_index_numeric_equality_matches_scan();
  1827. test_indexed_and_unindexed_plans_agree();
  1828. test_empty_id_reads_as_absent();
  1829. test_range_contains_and_intersection();
  1830. test_index_supplies_ordering();
  1831. test_index_in_and_exists();
  1832. test_counted_plan();
  1833. test_range_ordered_plan();
  1834. test_index_values();
  1835. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  1836. return g_fail == 0 ? 0 : 1;
  1837. }