test_subdb_identity.cpp 74 KB

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