test_subdb_identity.cpp 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  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 <set>
  19. #include <string>
  20. #include <unistd.h>
  21. #include <lmdb.h>
  22. #include <nlohmann/json.hpp>
  23. #include "document.hpp"
  24. #include "storage/document_store_lmdb.hpp"
  25. #include "storage/lmdb_env.hpp"
  26. #include "storage/lmdb_txn.hpp"
  27. #include "storage/subdb_identity.hpp"
  28. namespace fs = std::filesystem;
  29. using smartbotic::database::Document;
  30. using smartbotic::db::storage::is_identity_key;
  31. using smartbotic::db::storage::kSubdbIdentityKey;
  32. using smartbotic::db::storage::LmdbDocumentStore;
  33. using smartbotic::db::storage::LmdbEnv;
  34. using smartbotic::db::storage::LmdbEnvOpts;
  35. using smartbotic::db::storage::read_subdb_identity;
  36. using smartbotic::db::storage::ReadTxn;
  37. using smartbotic::db::storage::verify_subdb_identity;
  38. using smartbotic::db::storage::write_subdb_identity;
  39. using smartbotic::db::storage::WriteTxn;
  40. namespace {
  41. int g_pass = 0;
  42. int g_fail = 0;
  43. void check(bool cond, const char* msg) {
  44. if (cond) {
  45. ++g_pass;
  46. } else {
  47. ++g_fail;
  48. std::cerr << "FAIL: " << msg << "\n";
  49. }
  50. }
  51. std::string make_tmpdir(const char* tag) {
  52. static std::atomic<int> counter{0};
  53. std::string path = "/tmp/subdb-identity-test-" + std::to_string(::getpid()) +
  54. "-" + std::to_string(counter.fetch_add(1)) + "-" + tag;
  55. std::error_code ec;
  56. fs::remove_all(path, ec);
  57. return path;
  58. }
  59. struct TmpEnv {
  60. std::string path;
  61. LmdbEnv env;
  62. explicit TmpEnv(const char* tag)
  63. : path(make_tmpdir(tag)),
  64. env(LmdbEnvOpts{path, 64ULL << 20, 256, 126, false}) {}
  65. ~TmpEnv() {
  66. std::error_code ec;
  67. fs::remove_all(path, ec);
  68. }
  69. TmpEnv(const TmpEnv&) = delete;
  70. TmpEnv& operator=(const TmpEnv&) = delete;
  71. };
  72. // Open (creating) a named sub-db inside a write txn and return its handle.
  73. unsigned int open_subdb(WriteTxn& txn, const char* name) {
  74. MDB_dbi dbi = 0;
  75. int rc = mdb_dbi_open(txn.raw(), name, MDB_CREATE, &dbi);
  76. assert(rc == MDB_SUCCESS);
  77. (void)rc;
  78. return dbi;
  79. }
  80. Document make_doc(const std::string& id, const std::string& collection) {
  81. Document d;
  82. d.id = id;
  83. d.collection = collection;
  84. d.set_data(nlohmann::json{{"seenCount", 1}, {"who", collection}});
  85. return d;
  86. }
  87. // -------------------------------------------------------------------------
  88. void test_sentinel_roundtrip() {
  89. TmpEnv t("roundtrip");
  90. {
  91. WriteTxn w(t.env);
  92. unsigned int dbi = open_subdb(w, "image_hashes");
  93. write_subdb_identity(w, dbi, "image_hashes");
  94. w.commit();
  95. }
  96. {
  97. WriteTxn w(t.env);
  98. unsigned int dbi = open_subdb(w, "image_hashes");
  99. bool threw = false;
  100. try {
  101. verify_subdb_identity(w, dbi, "image_hashes");
  102. } catch (const std::exception&) {
  103. threw = true;
  104. }
  105. check(!threw, "matching sentinel must verify without throwing");
  106. w.commit();
  107. }
  108. {
  109. ReadTxn r(t.env);
  110. MDB_dbi dbi = 0;
  111. mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  112. check(read_subdb_identity(r, dbi) == "image_hashes",
  113. "read_subdb_identity returns the stamped name");
  114. }
  115. }
  116. // THE regression test. A stale cache entry is, in effect, a handle that
  117. // addresses someone else's sub-db. Hand the verifier exactly that.
  118. void test_misbound_handle_is_refused() {
  119. TmpEnv t("misbound");
  120. unsigned int executions_dbi = 0;
  121. {
  122. WriteTxn w(t.env);
  123. unsigned int ih = open_subdb(w, "image_hashes");
  124. write_subdb_identity(w, ih, "image_hashes");
  125. executions_dbi = open_subdb(w, "executions");
  126. write_subdb_identity(w, executions_dbi, "executions");
  127. w.commit();
  128. }
  129. WriteTxn w(t.env);
  130. // Re-open so the handle is valid in this txn, then deliberately verify it
  131. // under the WRONG name — the production misbinding, reproduced.
  132. unsigned int exec = open_subdb(w, "executions");
  133. bool threw = false;
  134. std::string msg;
  135. try {
  136. verify_subdb_identity(w, exec, "image_hashes");
  137. } catch (const std::exception& e) {
  138. threw = true;
  139. msg = e.what();
  140. }
  141. check(threw, "handle for 'executions' verified as 'image_hashes' must throw");
  142. check(msg.find("image_hashes") != std::string::npos &&
  143. msg.find("executions") != std::string::npos,
  144. "misbinding error names both the requested and actual sub-db");
  145. w.abort();
  146. }
  147. // Existing deployments have sub-dbs with no sentinel. Those must keep working.
  148. void test_unstamped_subdb_is_permitted() {
  149. TmpEnv t("unstamped");
  150. WriteTxn w(t.env);
  151. unsigned int dbi = open_subdb(w, "legacy");
  152. bool threw = false;
  153. try {
  154. verify_subdb_identity(w, dbi, "legacy");
  155. } catch (const std::exception&) {
  156. threw = true;
  157. }
  158. check(!threw, "sub-db without a sentinel must verify (absence is unknown, not wrong)");
  159. w.commit();
  160. }
  161. void test_identity_key_predicate() {
  162. check(is_identity_key(kSubdbIdentityKey), "sentinel key recognised");
  163. check(!is_identity_key("__subdb_identity__"),
  164. "same text without the leading NUL is NOT the sentinel");
  165. check(!is_identity_key("e63c1b90"), "a document id is not the sentinel");
  166. check(kSubdbIdentityKey[0] == '\0',
  167. "sentinel must start with NUL so it cannot collide with a doc id");
  168. }
  169. // The sentinel is an implementation detail: it must never surface through the
  170. // DocumentStore API as a document, nor inflate a count.
  171. void test_sentinel_invisible_through_store() {
  172. TmpEnv t("invisible");
  173. LmdbDocumentStore store(t.env);
  174. store.put("image_hashes", "aaa", make_doc("aaa", "image_hashes"));
  175. store.put("image_hashes", "bbb", make_doc("bbb", "image_hashes"));
  176. check(store.count("image_hashes") == 2,
  177. "count() must exclude the identity sentinel");
  178. smartbotic::database::Query q;
  179. q.limit = 100;
  180. auto res = store.scan("image_hashes", q);
  181. check(res.documents.size() == 2, "scan() must exclude the identity sentinel");
  182. check(res.total_matched == 2, "scan() total_matched must exclude the sentinel");
  183. for (const auto& d : res.documents) {
  184. check(d.id == "aaa" || d.id == "bbb",
  185. "scan() must not surface the sentinel as a document");
  186. }
  187. // And it really is on disk.
  188. ReadTxn r(t.env);
  189. MDB_dbi dbi = 0;
  190. int rc = mdb_dbi_open(r.raw(), "image_hashes", 0, &dbi);
  191. check(rc == MDB_SUCCESS, "sub-db exists");
  192. check(read_subdb_identity(r, dbi) == "image_hashes",
  193. "store.put() stamps the sentinel on first write");
  194. }
  195. // A vector sub-db gets stamped with its own (prefixed) name, and scan_vectors
  196. // must skip the sentinel rather than trying to read it as float32 bytes.
  197. void test_vector_subdb_sentinel() {
  198. TmpEnv t("vectors");
  199. LmdbDocumentStore store(t.env);
  200. store.put_vector("emb", "v1", {1.0f, 2.0f, 3.0f});
  201. store.put_vector("emb", "v2", {4.0f, 5.0f, 6.0f});
  202. int seen = 0;
  203. bool bad = false;
  204. store.scan_vectors("emb", [&](std::string_view id, const float*, size_t n) {
  205. ++seen;
  206. if (n != 3) bad = true;
  207. if (is_identity_key(id)) bad = true;
  208. });
  209. check(seen == 2, "scan_vectors must skip the sentinel");
  210. check(!bad, "scan_vectors must not decode the sentinel as float data");
  211. ReadTxn r(t.env);
  212. MDB_dbi dbi = 0;
  213. mdb_dbi_open(r.raw(), "_vectors_emb", 0, &dbi);
  214. check(read_subdb_identity(r, dbi) == "_vectors_emb",
  215. "vector sub-db is stamped with its prefixed name");
  216. }
  217. // v2.4.4 Count is LMDB-first. Unfiltered it uses count(); filtered it uses
  218. // scan() with limit=0 and reads total_matched. Pin that contract: total_matched
  219. // is computed BEFORE pagination, so limit=0 must still report the true total
  220. // while returning no documents.
  221. void test_scan_limit_zero_reports_total() {
  222. TmpEnv t("counting");
  223. LmdbDocumentStore store(t.env);
  224. for (int i = 0; i < 5; ++i) {
  225. Document d;
  226. d.id = "id" + std::to_string(i);
  227. d.collection = "things";
  228. d.set_data(nlohmann::json{{"kind", i < 3 ? "alpha" : "beta"}});
  229. store.put("things", d.id, d);
  230. }
  231. smartbotic::database::Query q;
  232. q.limit = 0;
  233. auto all = store.scan("things", q);
  234. check(all.total_matched == 5, "limit=0 reports the full total");
  235. check(all.documents.empty(), "limit=0 returns no documents");
  236. check(store.count("things") == 5, "unfiltered count matches");
  237. smartbotic::database::Query fq;
  238. fq.limit = 0;
  239. smartbotic::database::Filter f;
  240. f.field = "kind";
  241. f.op = smartbotic::database::FilterOp::EQ;
  242. f.value = "alpha";
  243. fq.filters.push_back(f);
  244. auto filtered = store.scan("things", fq);
  245. check(filtered.total_matched == 3,
  246. "filtered limit=0 reports the matching total, not the collection size");
  247. }
  248. // v2.7.1 — the unfiltered/unsorted fast path in scan() must agree with the
  249. // general path exactly. It exists because the general path decoded every
  250. // document in the collection to return `limit` of them, so cost tracked total
  251. // bytes rather than page size (382ms to return one 510-byte document from a
  252. // 414 MB collection on a live instance). Any divergence here is a paging bug.
  253. void test_scan_fast_path_matches_general_path() {
  254. TmpEnv t("fastpath");
  255. LmdbDocumentStore store(t.env);
  256. for (int i = 0; i < 25; ++i) {
  257. Document d;
  258. char buf[16];
  259. std::snprintf(buf, sizeof(buf), "id%02d", i);
  260. d.id = buf;
  261. d.collection = "things";
  262. d.set_data(nlohmann::json{{"n", i}, {"kind", i % 2 ? "odd" : "even"}});
  263. store.put("things", d.id, d);
  264. }
  265. // total_matched and has_more must match what a full count says.
  266. smartbotic::database::Query page;
  267. page.limit = 10;
  268. page.offset = 0;
  269. auto p0 = store.scan("things", page);
  270. check(p0.documents.size() == 10, "fast path returns exactly `limit` docs");
  271. check(p0.total_matched == 25, "fast path total_matched excludes the sentinel");
  272. check(p0.has_more, "has_more true when more remain");
  273. page.offset = 20;
  274. auto p2 = store.scan("things", page);
  275. check(p2.documents.size() == 5, "final page returns the remainder");
  276. check(p2.total_matched == 25, "total_matched stable across pages");
  277. check(!p2.has_more, "has_more false on the last page");
  278. page.offset = 25;
  279. auto p3 = store.scan("things", page);
  280. check(p3.documents.empty(), "offset past the end returns nothing");
  281. check(p3.total_matched == 25, "and still reports the true total");
  282. // Paging must cover every document exactly once, in a stable order.
  283. std::set<std::string> seen;
  284. for (uint32_t off = 0; off < 25; off += 7) {
  285. smartbotic::database::Query q;
  286. q.limit = 7;
  287. q.offset = off;
  288. for (const auto& d : store.scan("things", q).documents) seen.insert(d.id);
  289. }
  290. check(seen.size() == 25, "paging the whole collection yields every document once");
  291. // A filter forces the general path; it must still be correct.
  292. smartbotic::database::Query fq;
  293. fq.limit = 100;
  294. smartbotic::database::Filter f;
  295. f.field = "kind";
  296. f.op = smartbotic::database::FilterOp::EQ;
  297. f.value = "odd";
  298. fq.filters.push_back(f);
  299. auto filtered = store.scan("things", fq);
  300. check(filtered.total_matched == 12, "filtered path still counts matches, not rows");
  301. // A sort also forces the general path.
  302. smartbotic::database::Query sq;
  303. sq.limit = 3;
  304. sq.sort = smartbotic::database::Sort{"n", true};
  305. auto sorted = store.scan("things", sq);
  306. check(sorted.documents.size() == 3, "sorted path paginates");
  307. check(sorted.total_matched == 25, "sorted path totals all rows");
  308. check(sorted.documents[0].data().value("n", -1) == 24,
  309. "descending sort really sorted (fast path must not swallow sorts)");
  310. // limit=0 keeps meaning "no documents, but a true total" - the contract
  311. // Count depends on (see test_scan_limit_zero_reports_total).
  312. smartbotic::database::Query zq;
  313. zq.limit = 0;
  314. auto z = store.scan("things", zq);
  315. check(z.documents.empty(), "limit=0 returns no documents on the fast path");
  316. check(z.total_matched == 25, "limit=0 still reports the true total");
  317. }
  318. // v2.8.0 — a WRITE that aborts must not poison the collection.
  319. //
  320. // This is the v2.4.3 EINVAL bug in a third failure mode, observed live in
  321. // production on 2.7.1: `find` on smartbotic-automation:workflows failed with
  322. // "LMDB cursor_open: Invalid argument" on every attempt while every other
  323. // collection was fine, and a restart was the only cure.
  324. //
  325. // Cause: open_for_write() cached the MDB_dbi immediately after mdb_dbi_open,
  326. // BEFORE the caller committed. LMDB keeps a handle private to the opening
  327. // transaction until it commits and CLOSES it if that transaction aborts - so any
  328. // write that threw after the handle was cached (a failed mdb_put, a sentinel
  329. // mismatch, a WriteTxn destructing uncommitted) left a closed handle in the
  330. // cache, and every later operation on that collection failed EINVAL for the rest
  331. // of the process's life.
  332. //
  333. // The abort is induced honestly here, with a key past LMDB's 511-byte limit, so
  334. // the test exercises the same path a real failed write takes.
  335. void test_aborted_write_does_not_poison_the_collection() {
  336. TmpEnv t("abortpoison");
  337. LmdbDocumentStore store(t.env);
  338. // Force a write that opens the sub-db and then fails: an oversized key makes
  339. // mdb_put return MDB_BAD_VALSIZE, which throws, so the WriteTxn aborts.
  340. const std::string huge_id(600, 'k');
  341. bool threw = false;
  342. try {
  343. store.put("poisoned", huge_id, make_doc(huge_id, "poisoned"));
  344. } catch (const std::exception&) {
  345. threw = true;
  346. }
  347. check(threw, "an oversized key really does fail the write");
  348. // The collection must still be usable. Before the fix, every one of these
  349. // failed with EINVAL because the cache held a handle LMDB had closed.
  350. bool ok_put = true;
  351. try {
  352. store.put("poisoned", "good", make_doc("good", "poisoned"));
  353. } catch (const std::exception&) {
  354. ok_put = false;
  355. }
  356. check(ok_put, "a later WRITE to the same collection still works");
  357. bool ok_read = true;
  358. try {
  359. smartbotic::database::Query q;
  360. q.limit = 10;
  361. auto res = store.scan("poisoned", q);
  362. check(res.documents.size() == 1, "and the document written after the abort is there");
  363. } catch (const std::exception&) {
  364. ok_read = false;
  365. }
  366. check(ok_read, "a later SCAN of the same collection still works (cursor_open)");
  367. bool ok_count = true;
  368. try {
  369. check(store.count("poisoned") == 1, "count is right after the abort");
  370. } catch (const std::exception&) {
  371. ok_count = false;
  372. }
  373. check(ok_count, "and count() does not throw");
  374. check(store.get("poisoned", "good").has_value(), "get() works after the abort");
  375. }
  376. // v2.8.0 — the two-pass filtered scan must agree with the old row-at-a-time path
  377. // on every operator, not just the common ones.
  378. //
  379. // scan() now evaluates predicates against a yyjson tree via a field resolver and
  380. // materialises only the returned page, because building a Document per row was
  381. // 88% of a filtered query's cost (3168ms vs 369ms for the parse alone over 193 MB
  382. // of real rows). A resolver that mishandles one operator returns silently wrong
  383. // data, so this walks the matrix.
  384. void test_filtered_scan_operator_matrix() {
  385. TmpEnv t("filtermatrix");
  386. LmdbDocumentStore store(t.env);
  387. auto put = [&](const std::string& id, const nlohmann::json& data) {
  388. Document d;
  389. d.id = id;
  390. d.collection = "m";
  391. d.version = 3;
  392. d.createdAt = 1000;
  393. d.updatedAt = 2000;
  394. d.set_data(data);
  395. store.put("m", id, d);
  396. };
  397. put("a", {{"n", 1}, {"kind", "odd"}, {"tags", {"x", "y"}}, {"nest", {{"deep", "hit"}}}});
  398. put("b", {{"n", 2}, {"kind", "even"}, {"tags", {"y"}}, {"nest", {{"deep", "miss"}}}});
  399. put("c", {{"n", 3}, {"kind", "odd"}, {"tags", nlohmann::json::array()}});
  400. put("d", {{"n", 4}, {"kind", "even"}, {"extra", "present"}});
  401. auto ids = [&](const smartbotic::database::Query& q) {
  402. std::vector<std::string> out;
  403. for (const auto& d : store.scan("m", q).documents) out.push_back(d.id);
  404. std::sort(out.begin(), out.end());
  405. return out;
  406. };
  407. auto q1 = [&](const char* field, smartbotic::database::FilterOp op,
  408. const nlohmann::json& val) {
  409. smartbotic::database::Query q;
  410. q.limit = 100;
  411. smartbotic::database::Filter f;
  412. f.field = field; f.op = op; f.value = val;
  413. q.filters.push_back(f);
  414. return q;
  415. };
  416. using Op = smartbotic::database::FilterOp;
  417. check(ids(q1("kind", Op::EQ, "odd")) == (std::vector<std::string>{"a", "c"}),
  418. "EQ on a data field");
  419. check(ids(q1("kind", Op::NE, "odd")) == (std::vector<std::string>{"b", "d"}),
  420. "NE on a data field");
  421. check(ids(q1("n", Op::GT, 2)) == (std::vector<std::string>{"c", "d"}), "GT numeric");
  422. check(ids(q1("n", Op::GTE, 3)) == (std::vector<std::string>{"c", "d"}), "GTE numeric");
  423. check(ids(q1("n", Op::LT, 2)) == (std::vector<std::string>{"a"}), "LT numeric");
  424. check(ids(q1("n", Op::LTE, 2)) == (std::vector<std::string>{"a", "b"}), "LTE numeric");
  425. check(ids(q1("n", Op::IN, nlohmann::json::array({1, 4}))) ==
  426. (std::vector<std::string>{"a", "d"}), "IN");
  427. check(ids(q1("tags", Op::CONTAINS, "x")) == (std::vector<std::string>{"a"}),
  428. "CONTAINS descends into an array value");
  429. check(ids(q1("extra", Op::EXISTS, true)) == (std::vector<std::string>{"d"}),
  430. "EXISTS true");
  431. check(ids(q1("extra", Op::EXISTS, false)) ==
  432. (std::vector<std::string>{"a", "b", "c"}), "EXISTS false");
  433. check(ids(q1("kind", Op::REGEX, "^od")) == (std::vector<std::string>{"a", "c"}),
  434. "REGEX");
  435. check(ids(q1("nest.deep", Op::EQ, "hit")) == (std::vector<std::string>{"a"}),
  436. "dotted path descends into data");
  437. check(ids(q1("nest.missing", Op::EXISTS, true)).empty(),
  438. "a dotted path that does not resolve matches nothing");
  439. // Document metadata, which lives at the top level of the stored JSON rather
  440. // than inside "data".
  441. check(ids(q1("_id", Op::EQ, "b")) == (std::vector<std::string>{"b"}), "_id");
  442. check(ids(q1("_version", Op::EQ, 3)).size() == 4, "_version");
  443. check(ids(q1("_created_at", Op::GTE, 1000)).size() == 4, "_created_at");
  444. check(ids(q1("_updated_at", Op::LT, 2000)).empty(), "_updated_at");
  445. // SEARCH must still work - it needs the whole document, so it takes the old
  446. // path.
  447. check(ids(q1("", Op::SEARCH, "present")) == (std::vector<std::string>{"d"}),
  448. "SEARCH still matches (routed to the whole-document path)");
  449. check(ids(q1("", Op::SEARCH, "nothinghere")).empty(), "SEARCH non-match");
  450. // Sorting, pagination and total_matched over a filtered set.
  451. {
  452. smartbotic::database::Query q;
  453. q.limit = 1;
  454. smartbotic::database::Filter f;
  455. f.field = "kind"; f.op = Op::EQ; f.value = "odd";
  456. q.filters.push_back(f);
  457. q.sort = smartbotic::database::Sort{"n", true}; // descending
  458. auto page0 = store.scan("m", q);
  459. check(page0.total_matched == 2, "total_matched counts matches, not rows");
  460. check(page0.documents.size() == 1, "limit honoured");
  461. check(page0.documents[0].id == "c", "descending sort picks the highest first");
  462. check(page0.has_more, "has_more true mid-set");
  463. q.offset = 1;
  464. auto page1 = store.scan("m", q);
  465. check(page1.documents.size() == 1 && page1.documents[0].id == "a",
  466. "second page continues the sort order");
  467. check(!page1.has_more, "has_more false on the last page");
  468. q.offset = 5;
  469. check(store.scan("m", q).documents.empty(), "offset past the end is empty");
  470. }
  471. // Ascending, and a sort field that is missing from some documents.
  472. {
  473. smartbotic::database::Query q;
  474. q.limit = 10;
  475. q.sort = smartbotic::database::Sort{"extra", false};
  476. auto res = store.scan("m", q);
  477. check(res.total_matched == 4, "no filter plus a sort still totals every row");
  478. check(res.documents.size() == 4, "and returns them all");
  479. // sort_documents returns `descending` when the LEFT value is missing, so
  480. // ascending puts documents that HAVE the field first and the ones missing
  481. // it last. The two-pass path copies that rule rather than inventing one.
  482. check(res.documents.front().id == "d",
  483. "ascending: the document that has the sort field comes first");
  484. std::vector<std::string> tail;
  485. for (size_t i = 1; i < res.documents.size(); ++i) tail.push_back(res.documents[i].id);
  486. check(tail == (std::vector<std::string>{"a", "b", "c"}),
  487. "and the ones missing it follow, tie-broken by id");
  488. }
  489. }
  490. } // namespace
  491. int main() {
  492. std::cout << "=== test_subdb_identity ===\n";
  493. test_sentinel_roundtrip();
  494. test_misbound_handle_is_refused();
  495. test_unstamped_subdb_is_permitted();
  496. test_identity_key_predicate();
  497. test_sentinel_invisible_through_store();
  498. test_vector_subdb_sentinel();
  499. test_scan_limit_zero_reports_total();
  500. test_scan_fast_path_matches_general_path();
  501. test_aborted_write_does_not_poison_the_collection();
  502. test_filtered_scan_operator_matrix();
  503. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  504. return g_fail == 0 ? 0 : 1;
  505. }